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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* 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.",
|
||||
|
||||
Reference in New Issue
Block a user