fix(devices): USB printing dropped the job tail — chunked write loop

Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-06 12:36:14 +02:00
parent 7ef332999e
commit 81bc2e357c
3 changed files with 140 additions and 6 deletions
@@ -10,6 +10,7 @@ import {
probeUsb, probeUsb,
sendRawUsb, sendRawUsb,
transportFromConfig, transportFromConfig,
writeAllUsb,
stamp, stamp,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -183,3 +184,63 @@ describe("stamp (Albanian date format)", () => {
expect(stamp("not-a-date")).toBe("not-a-date"); 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");
});
});
+55 -5
View File
@@ -600,10 +600,60 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
}); });
} }
/** 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<void>((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<void> {
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 /** 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 * is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
* FIN/half-close dance (that was a TCP concern, where an early destroy() could * but delivery must go through the chunked loop above (see its doc for why). We
* truncate the stream). We always close the handle (even on a failed write). */ * always close the handle (even on a failed write). */
export async function sendRawUsb( export async function sendRawUsb(
devicePath: string, devicePath: string,
payload: Buffer, payload: Buffer,
@@ -615,7 +665,7 @@ export async function sendRawUsb(
"usb open timeout", "usb open timeout",
); );
try { try {
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout"); await writeAllUsb(handle, payload, Date.now() + timeoutMs);
} finally { } finally {
await handle.close(); await handle.close();
} }
@@ -701,7 +751,7 @@ export const transportField: ConfigField = {
required: true, required: true,
default: "tcp-ip", default: "tcp-ip",
options: [ 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)" }, { 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.", help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
+24 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, device, printer, transport, usb, escpos, provisioning] tags: [parking, device, printer, transport, usb, escpos, provisioning]
sources: [] sources: []
updated: 2026-06-24 updated: 2026-07-06
status: settled 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 to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
are pending (open-questions #14). 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]], Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]]. [[appliance-provisioning]], [[network-isolation]], [[technology-stack]].