diff --git a/packages/devices/src/drivers/printer-escpos.test.ts b/packages/devices/src/drivers/printer-escpos.test.ts index 983573f..0bee112 100644 --- a/packages/devices/src/drivers/printer-escpos.test.ts +++ b/packages/devices/src/drivers/printer-escpos.test.ts @@ -185,11 +185,12 @@ describe("stamp (Albanian date format)", () => { }); }); -describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => { - // 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 - // XP-K200L printed the ticket's text head but lost the barcode and the cut. A - // regular file can't reproduce that, so these drive the loop with a fake handle. +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, + // write() returns at URB submission, and close() KILLS the in-flight URB — so the + // loop must deliver every byte AND certify delivery before the caller may close + // (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. */ 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 () => { const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" }); 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); }); + 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 () => { const payload = Buffer.from("x".repeat(300)); let calls = 0; @@ -228,7 +237,7 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2 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); }); diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index 30cc3ce..25763b9 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -604,6 +604,12 @@ function withTimeout(p: Promise, ms: number, msg: string): Promise { * so jobs are pushed in chunks safely under that. */ 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((r) => setTimeout(r, ms)); /** 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 - * takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN - * when full) — a single fire-and-forget write() silently drops the tail of any job - * bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over - * 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 - * bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN - * and zero-byte writes with a short pause, bounded by the caller's deadline. + * Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has + * physically received it before the caller may close. TWO field-verified truncation + * modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail + * lost; TCP fine): + * + * 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what + * the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted. + * 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( handle: UsbWriteHandle, payload: Buffer, deadlineMs: number, + drainMs: number = USB_DRAIN_MS, ): Promise { + if (payload.length === 0) return; + const lastByteAt = payload.length - 1; let off = 0; while (off < payload.length) { if (Date.now() > deadlineMs) { throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`); } try { - const { bytesWritten } = await handle.write( - payload, - off, - Math.min(USB_WRITE_CHUNK, payload.length - off), - ); + // Never let the final byte ride a bigger chunk: it is written ALONE so its + // acceptance certifies delivery of everything before it (see doc above). + const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off); + const { bytesWritten } = await handle.write(payload, off, len); off += bytesWritten; if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe } 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 diff --git a/wiki/concepts/printer-usb-transport.md b/wiki/concepts/printer-usb-transport.md index cc041de..24e31d3 100644 --- a/wiki/concepts/printer-usb-transport.md +++ b/wiki/concepts/printer-usb-transport.md @@ -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 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` > 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` diff --git a/wiki/log.md b/wiki/log.md index 557771b..d79e78f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 — 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. + +## [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.