Skip to content

Fix/init and timeouts - #19

Closed
SimonSchick wants to merge 7 commits into
Cellivar:mainfrom
ConventionCatCorp:fix/init-and-timeouts
Closed

Fix/init and timeouts#19
SimonSchick wants to merge 7 commits into
Cellivar:mainfrom
ConventionCatCorp:fix/init-and-timeouts

Conversation

@SimonSchick

Copy link
Copy Markdown

HIGHLY VIP, EXTREMELY VIBE CODED, HERE MOSTLY TO KEEP TRACK OF THINGS, FIXES AND IMPROVEMENTS WILL BE UPSTREAMED.

SimonSchick and others added 7 commits September 5, 2026 19:02
…e buffer

Three defects in the receive path combined to explain both reported
symptoms: printer init failing on Windows, and every operation timing out
on macOS after the first hiccup.

parseRaw's candidate filter had no return on its non-match path, so the
implicit undefined removed any awaited command that did not match the
byte in hand. An unsolicited ASB frame arriving mid-query was enough to
strand the query until its timeout.

parseRaw also ignored the parser's remainder whenever it reported the
message as incomplete. The ESC/POS "not prepared yet" reply is a complete
but empty two-byte packet that the parser consumes before asking for
more, so those bytes stayed at the head of the buffer forever. Every
later parse re-read them and reported incomplete again, which timed out
not just that command but every operation after it.

callMessageHandler threw when a reply arrived with no matching command -
routine after a timeout clears the awaited list. The throw escaped
through the input listener's read loop, which stopped permanently while
the channel stayed open and connected kept reporting true.

parseRaw now offers the buffer head to each candidate without ever
dropping one, checks incompleteness before matching so a split reply is
not handed to the unsolicited path, always honours a reported remainder,
falls back to the unsolicited path for anything unclaimed, and refuses to
loop when a handler consumes nothing. Unparseable input resynchronizes by
one byte instead of throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
Verified against the Epson ESC/POS reference for GS a and GS r as printed
in the TM-T20 quick reference.

The ASB online bit was inverted. The spec reads "bit 3 = 1: in Offline,
0: in Online", but a set bit was reported as PrinterOnline, so the
library announced the printer as healthy at exactly the moment it went
offline. Adds a distinct PrinterOffline status rather than reporting
neither.

The invalid-trailer branch pushed a parse error and then fell through and
decoded the garbage bytes anyway, emitting fabricated paper-out and
unrecoverable-error events. It also consumed four bytes on a desync,
which shifts the desync along instead of correcting it, so a single lost
byte corrupted every following frame. It now discards the frame and
advances by one byte to let the next real header line up.

Second-byte bits 0-2 are fixed 0 in basic ASB, so the recovery-waiting,
feed-button and recoverable-error flags mapped there could never fire.
Those belong to DLE EOT n=2 and FS ( e, not GS a.

GS r replies were claimed unconditionally, so any stray byte resolved the
awaiting command with a fabricated status. Replies are now checked
against the fixed bits the spec defines for the subcommand actually
asked about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
promiseWithTimeout never cleared its timer, leaving an armed timeout
behind on every call - dozens per connect. On timeout the awaited
commands were only dereferenced, never rejected, so anything still
holding one waited forever, and dispose() did not settle them either.

The channel reports write failures by returning an error rather than
throwing, and that value was discarded. A write that never reached the
device was then followed by a full wait for a reply that could not come,
so the user saw a timeout instead of the actual USB error.

A failed setup() left the channel open with its interface claimed and the
read loop running, with nothing referencing either. The interface could
never be released, so the next connect could not claim it and the printer
could not be re-added without a physical replug.

handleInputError only logged. Since the input listener does not restart,
the printer then looked connected but timed out every subsequent
operation forever; it now tears down so callers get a prompt
DeviceNotReadyError.

Also serializes transactions, since _awaitedCommands is a single
rendezvous slot that concurrent sends would clobber; wires up the
messageWaitTimeoutMS option, which was declared but never read; drops an
unbounded busy-wait in the pre-command-set message path; and fixes the
retry message that always reported "Tried 0 times".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
asString decoded with TextDecoder('ascii'), which the WHATWG Encoding
Standard defines as an alias for windows-1252, while asUint8Array encoded
with TextEncoder, which emits UTF-8. The two were not inverses: every
codepage byte in 0x80-0xFF came back as a different character, or as two
or three bytes. Latent over USB, which uses the raw transformer, but it
corrupts any string channel and already corrupted debug output. No
TextDecoder label round-trips these bytes, so DecodeAscii now maps them
by hand.

The CP850 table held a byte-for-byte copy of CP852. Since CP850 is in the
default candidate list, autoEncode selected it, emitted ESC t 2 to switch
the printer to real CP850, and then sent a CP852 byte - printing the
wrong glyph. Characters CP850 exists to provide and CP852 lacks were
unmappable entirely. Replaced with the canonical table.

autoEncode indexed by UTF-16 unit while advancing one unit at a time,
splitting astral characters into two lone surrogates and emitting two
replacement bytes for one character, which shifted every following
column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
setTextFormatting's alignment switch placed its default case ahead of
'Center', so resetToDefault - which arrives with alignment undefined -
emitted ESC a 1 and centred everything that followed. Left is the
printer's power-on state and what the other reset paths in this function
use.

Also makes enc() reject anything that isn't exactly one character. Every
call site passes a literal, so a bad argument is a programming mistake
rather than something to silently encode as a stray zero byte in the
middle of a command sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
Matches the WebDeviceMux change: TypeScript 6, eslint 10, vitest 5, vite 8,
typescript-eslint 8.69, with a stripped strict flat config (recommended +
strictTypeChecked + stylisticTypeChecked, projectService for type-aware
rules, no react/prettier/import-sort). The rules that map to bugs already
found here are pinned to error; purely aesthetic ones are off.

tsconfig gains noImplicitReturns, noUncheckedIndexedAccess,
exactOptionalPropertyTypes and noFallthroughCasesInSwitch. noImplicitReturns
alone would have caught the parseRaw candidate-dropping bug.

The source changes are what the new checks surfaced:

- Parser.ts read line[0] and used it unguarded in 23 places, so an empty
  line would have thrown rather than rendering nothing.
- CmdTransmitPrinterId asserted printerHardware non-null nine times; it now
  takes the hardware update object directly.
- offsetPrintPosition fell off the end for an unrecognized origin, silently
  returning undefined instead of a command.
- MessageCandidates becomes a const object rather than an enum, since its
  values are compared against raw bytes off the wire.
- UpdateFor now spells out `| undefined`, which is what an update built from
  a device descriptor that omitted a field actually looks like.
- numberInRange had bare returns mixed with value returns.

src/ReceiptLine/Parser.ts keeps a documented rule override. It is a port of
the ReceiptLine reference implementation, kept close to upstream so it can
be diffed against it; it is still fully type-checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
The demo compiles its TypeScript on the fly in a service worker, and that
worker's fetch handler returned early for any URL not starting with
https://. Served from localhost over http nothing was ever intercepted, so
no TypeScript was transpiled: the page rendered, but none of its code ran
and every button was inert with no error to explain why.

A service worker runs on http://localhost and http://127.0.0.1 because
those are secure contexts, so the check is now about the origin rather than
the scheme. Compiled output for a local origin is also no longer cached, so
editing a source file doesn't serve a stale build.

The same mistake gated the page itself: it compared location.protocol
against 'https:' to decide whether to show a "WebUSB requires HTTPS"
warning. It now reads window.isSecureContext, which is the condition WebUSB
actually gates on.

ts-browser.js posted every inline script to the worker up front while
reassigning navigator.serviceWorker.onmessage each time round the loop, so
only the last handler survived. Replies carry no id and the worker compiles
asynchronously, so they can arrive in a different order than the requests
were sent: the wrong blob could be executed, and the promise for every
script but one never settled. Scripts are now compiled one at a time.

The worker also asks for root scope where the server allows it. The import
map reaches outside demo/ for the library source, which a worker registered
at its default scope cannot intercept. It falls back to the default scope
when the server sends no Service-Worker-Allowed header, as GitHub Pages
doesn't.

Adds Wincor Nixdorf to the device filters, tested against a TH230.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
@SimonSchick SimonSchick closed this Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant