fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:
- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
completes);
- usblp_release() — our close() — KILLS in-flight URBs.
The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.
Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -185,11 +185,12 @@ describe("stamp (Albanian date format)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => {
|
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
|
||||||
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write.
|
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
|
||||||
// The old single-write path dropped everything past the first buffer — the ICS
|
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
|
||||||
// XP-K200L printed the ticket's text head but lost the barcode and the cut. A
|
// loop must deliver every byte AND certify delivery before the caller may close
|
||||||
// regular file can't reproduce that, so these drive the loop with a fake handle.
|
// (final byte written alone; its acceptance proves all prior bytes landed). A
|
||||||
|
// regular file can't reproduce any of that, so these drive a fake handle.
|
||||||
|
|
||||||
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||||
function slowHandle(cap: number) {
|
function slowHandle(cap: number) {
|
||||||
@@ -207,10 +208,18 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
|||||||
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||||
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
const h = slowHandle(100); // way smaller than the job → many partial writes
|
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
|
||||||
|
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
|
||||||
|
});
|
||||||
|
|
||||||
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||||
const payload = Buffer.from("x".repeat(300));
|
const payload = Buffer.from("x".repeat(300));
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
@@ -228,7 +237,7 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
|||||||
return Promise.resolve({ bytesWritten: n });
|
return Promise.resolve({ bytesWritten: n });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,12 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
* so jobs are pushed in chunks safely under that. */
|
* so jobs are pushed in chunks safely under that. */
|
||||||
const USB_WRITE_CHUNK = 4096;
|
const USB_WRITE_CHUNK = 4096;
|
||||||
|
|
||||||
|
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
|
||||||
|
* proves everything before it is physically in the printer (see writeAllUsb); this
|
||||||
|
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
|
||||||
|
* immediately (it just freed buffer space by ACKing the previous chunk). */
|
||||||
|
const USB_DRAIN_MS = 300;
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||||
@@ -613,31 +619,45 @@ export interface UsbWriteHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push the WHOLE payload through a non-blocking usblp fd. On O_NONBLOCK the kernel
|
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
|
||||||
* takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN
|
* physically received it before the caller may close. TWO field-verified truncation
|
||||||
* when full) — a single fire-and-forget write() silently drops the tail of any job
|
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
|
||||||
* bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over
|
* lost; TCP fine):
|
||||||
* USB): the text head printed, but the barcode mid-payload and the CUT at the end
|
*
|
||||||
* were in the dropped tail — "prints, but no barcode and no cut", while the same
|
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
|
||||||
* bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN
|
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
|
||||||
* and zero-byte writes with a short pause, bounded by the caller's deadline.
|
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
|
||||||
|
* returns at URB *submission*, only ONE write URB is in flight at a time, and
|
||||||
|
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
|
||||||
|
* data at PRINT speed (tiny internal buffer), so closing right after the last
|
||||||
|
* accepted write cancels the still-transferring tail — which is exactly where
|
||||||
|
* the feed + GS V cut live ("have to press the feed button to see the text").
|
||||||
|
*
|
||||||
|
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
|
||||||
|
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
|
||||||
|
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
|
||||||
|
* that 1-byte write is accepted, every byte before it is physically in the printer.
|
||||||
|
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
|
||||||
|
* close is safe. `drainMs` is parameterised only for tests.
|
||||||
*/
|
*/
|
||||||
export async function writeAllUsb(
|
export async function writeAllUsb(
|
||||||
handle: UsbWriteHandle,
|
handle: UsbWriteHandle,
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
deadlineMs: number,
|
deadlineMs: number,
|
||||||
|
drainMs: number = USB_DRAIN_MS,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (payload.length === 0) return;
|
||||||
|
const lastByteAt = payload.length - 1;
|
||||||
let off = 0;
|
let off = 0;
|
||||||
while (off < payload.length) {
|
while (off < payload.length) {
|
||||||
if (Date.now() > deadlineMs) {
|
if (Date.now() > deadlineMs) {
|
||||||
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { bytesWritten } = await handle.write(
|
// Never let the final byte ride a bigger chunk: it is written ALONE so its
|
||||||
payload,
|
// acceptance certifies delivery of everything before it (see doc above).
|
||||||
off,
|
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
|
||||||
Math.min(USB_WRITE_CHUNK, payload.length - off),
|
const { bytesWritten } = await handle.write(payload, off, len);
|
||||||
);
|
|
||||||
off += bytesWritten;
|
off += bytesWritten;
|
||||||
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -648,6 +668,9 @@ export async function writeAllUsb(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
|
||||||
|
// moment to land before the caller closes (close would cancel it).
|
||||||
|
await delay(drainMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (
|
|||||||
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||||
reproduce the char device's behaviour.
|
reproduce the char device's behaviour.
|
||||||
|
|
||||||
|
**Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07).** The
|
||||||
|
chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut —
|
||||||
|
"press the feed button to see the text"). Verified against `drivers/usb/class/usblp.c`: `write()`
|
||||||
|
returns at URB *submission* (not completion), only ONE write URB is in flight at a time (the next
|
||||||
|
write EAGAINs until it completes), and `usblp_release()` — i.e. our `close()` — **kills in-flight
|
||||||
|
URBs**. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so
|
||||||
|
closing right after the last accepted write cancels the still-transferring tail — exactly where
|
||||||
|
the feed + `GS V` cut bytes live. Kernel-accepted ≠ printer-received.
|
||||||
|
|
||||||
|
Fix: the one-URB rule makes acceptance of write N a **completion certificate for write N−1**. So
|
||||||
|
`writeAllUsb` now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every
|
||||||
|
byte before it is physically in the printer; a short drain pause (`USB_DRAIN_MS` 300 ms) covers
|
||||||
|
the lone final-byte packet, then close is safe. (usblp also implements `poll(POLLOUT)` as the true
|
||||||
|
completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the
|
||||||
|
hold-back + drain gets the same guarantee for all but the final byte, whose packet the printer
|
||||||
|
ACKs immediately after having just freed its buffer.)
|
||||||
|
|
||||||
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
||||||
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
||||||
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
||||||
|
|||||||
+11
@@ -2485,3 +2485,14 @@ Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupl
|
|||||||
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
||||||
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
||||||
A controller-less box can configure + test a printer.
|
A controller-less box can configure + test a printer.
|
||||||
|
|
||||||
|
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
|
||||||
|
|
||||||
|
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
|
||||||
|
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
|
||||||
|
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
|
||||||
|
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
|
||||||
|
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
|
||||||
|
acceptance a completion certificate for everything before it — then drains 300ms for that single
|
||||||
|
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
|
||||||
|
the full kernel-level account.
|
||||||
|
|||||||
Reference in New Issue
Block a user