fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s

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:
2026-07-07 10:50:47 +02:00
parent 6f3f6ca596
commit 011fe5a4c4
4 changed files with 80 additions and 20 deletions
+36 -13
View File
@@ -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. */
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));
/** 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<void> {
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