diff --git a/packages/devices/src/drivers/printer-escpos.test.ts b/packages/devices/src/drivers/printer-escpos.test.ts index 54db740..983573f 100644 --- a/packages/devices/src/drivers/printer-escpos.test.ts +++ b/packages/devices/src/drivers/printer-escpos.test.ts @@ -10,6 +10,7 @@ import { probeUsb, sendRawUsb, transportFromConfig, + writeAllUsb, stamp, } from "./printer-escpos.js"; @@ -183,3 +184,63 @@ describe("stamp (Albanian date format)", () => { expect(stamp("not-a-date")).toBe("not-a-date"); }); }); + +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. + + /** Accepts at most `cap` bytes per call; records everything accepted in order. */ + function slowHandle(cap: number) { + const chunks: Buffer[] = []; + return { + chunks, + write(buffer: Buffer, offset: number, length: number) { + const n = Math.min(cap, length); + chunks.push(Buffer.from(buffer.subarray(offset, offset + n))); + return Promise.resolve({ bytesWritten: n }); + }, + }; + } + + 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); + expect(Buffer.concat(h.chunks).equals(payload)).toBe(true); + }); + + it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => { + const payload = Buffer.from("x".repeat(300)); + let calls = 0; + const accepted: Buffer[] = []; + const h = { + write(buffer: Buffer, offset: number, length: number) { + calls++; + if (calls % 2 === 0) { + const err = new Error("EAGAIN") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + return Promise.reject(err); + } + const n = Math.min(120, length); + accepted.push(Buffer.from(buffer.subarray(offset, offset + n))); + return Promise.resolve({ bytesWritten: n }); + }, + }; + await writeAllUsb(h, payload, Date.now() + 2000); + expect(Buffer.concat(accepted).equals(payload)).toBe(true); + }); + + it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => { + const h = { write: () => Promise.resolve({ bytesWritten: 0 }) }; + await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/); + }); + + it("a non-EAGAIN error surfaces immediately", async () => { + const err = new Error("EIO") as NodeJS.ErrnoException; + err.code = "EIO"; + const h = { write: () => Promise.reject(err) }; + await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO"); + }); +}); diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index c83050f..30cc3ce 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -600,10 +600,60 @@ function withTimeout(p: Promise, ms: number, msg: string): Promise { }); } +/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd, + * so jobs are pushed in chunks safely under that. */ +const USB_WRITE_CHUNK = 4096; + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** The slice of FileHandle the USB write loop needs (injectable for tests — a real + * regular file can't reproduce the char device's partial writes / EAGAIN). */ +export interface UsbWriteHandle { + write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>; +} + +/** + * 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. + */ +export async function writeAllUsb( + handle: UsbWriteHandle, + payload: Buffer, + deadlineMs: number, +): Promise { + 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), + ); + off += bytesWritten; + if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EAGAIN") { + await delay(10); // printer draining its buffer — retry until the deadline + } else { + throw err; + } + } + } +} + /** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp - * is a RAW character device: a single open + write delivers the job — there is no - * FIN/half-close dance (that was a TCP concern, where an early destroy() could - * truncate the stream). We always close the handle (even on a failed write). */ + * is a RAW character device — no FIN/half-close dance (that was a TCP concern) — + * but delivery must go through the chunked loop above (see its doc for why). We + * always close the handle (even on a failed write). */ export async function sendRawUsb( devicePath: string, payload: Buffer, @@ -615,7 +665,7 @@ export async function sendRawUsb( "usb open timeout", ); try { - await withTimeout(handle.write(payload), timeoutMs, "usb write timeout"); + await writeAllUsb(handle, payload, Date.now() + timeoutMs); } finally { await handle.close(); } @@ -701,7 +751,7 @@ export const transportField: ConfigField = { required: true, default: "tcp-ip", options: [ - { value: "tcp-ip", label: "Network (raw TCP, port 9100)" }, + { value: "tcp-ip", label: "Network (raw TCP)" }, { value: "usb", label: "USB (local /dev/usb/lp0)" }, ], help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.", diff --git a/wiki/concepts/printer-usb-transport.md b/wiki/concepts/printer-usb-transport.md index beb9b5a..cc041de 100644 --- a/wiki/concepts/printer-usb-transport.md +++ b/wiki/concepts/printer-usb-transport.md @@ -2,7 +2,7 @@ type: concept tags: [parking, device, printer, transport, usb, escpos, provisioning] sources: [] -updated: 2026-06-24 +updated: 2026-07-06 status: settled --- @@ -87,5 +87,28 @@ The setup UI offers a **Connection** select (Network / USB) + a **USB device** p to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning are pending (open-questions #14). +## Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06) + +First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over +USB it printed the ticket's TEXT but **no barcode and no cut**. Root cause was in OUR transport, +not the printer: `sendRawUsb` opened the node with `O_NONBLOCK` and issued ONE `write()` for the +whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer +(~8 KB) and returns a **short write**; the old code never checked `bytesWritten`, closed the +handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the +CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test +stand-in can't short-write, so tests never caught it. + +Fix: `writeAllUsb` — chunked loop (4 KB, safely under the usblp buffer) that continues after +partial writes, retries `EAGAIN`/zero-byte writes with a short pause, and fails at the caller's +deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (short writes, +EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't +reproduce the char device's behaviour. + +> 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` +> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers +> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]]. + Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]], [[appliance-provisioning]], [[network-isolation]], [[technology-stack]].