Skip to content

Fix/init and timeouts - #8

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

Fix/init and timeouts#8
SimonSchick wants to merge 4 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 4 commits September 5, 2026 19:12
connectDevice hardcoded selectConfiguration(1) and claimInterface(0), then
read endpoints out of configurations[0].interfaces[0]. Those are two
different addressing spaces: selectConfiguration takes a
configurationValue and claimInterface an interfaceNumber, neither of which
is an array index. A device whose configuration is numbered differently
rejects outright, and a composite device whose bulk interface is not
number 0 - the usual shape for a printer bound to WinUSB on Windows - gets
one interface claimed while transfers are issued on another's endpoints,
so every write fails with "endpoint not part of a claimed interface".

It now selects by configurationValue, picks the interface that actually
exposes a bulk output endpoint, claims it by interfaceNumber, and reads
the endpoints back from the alternate that is active after the claim.

Driver conflicts were only mapped to DriverAccessDeniedError for open().
On Windows the device usually opens fine and it is selectConfiguration or
claimInterface that the OS refuses, because usbprint.sys or a vendor
driver already owns the interface, so users got a bare DOMException
instead of the guidance that error exists to provide.

transferOut's result was discarded, so a stalled endpoint or a short write
silently swallowed the job and the caller then waited out its full timeout
for a reply that could never come. A transfer that is an exact multiple of
the endpoint packet size now gets a terminating zero length packet, without
which many devices keep waiting for more data.

Also: dispose no longer throws, since it runs from three callers that
cannot handle a rejection - notably setup()'s failure path, where a close
error replaced the real diagnosis; getTransferLength can no longer round
down to a zero-length read; a stalled or babbling read returns no data
rather than a torn frame; clearHalt is awaited instead of left floating;
and receive copies out of the transfer buffer rather than aliasing it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
…ever

Any single failure from the data provider stopped the read loop
permanently. The channel stayed open, the interface stayed claimed, and
connected kept reporting true, so the device looked healthy but could
never receive another byte and every subsequent operation waited out its
full timeout - forever. One transient NetworkError from a bus reset, a hub
renegotiation or a sleep/wake cycle was enough to trigger it, which is the
"works, then everything times out" report from macOS.

Transient faults are now retried with a short backoff before the listener
gives up, and a throw from the consumer's input handler no longer takes
the loop down with it.

Teardown is no longer reported as a fault. Disposing closes the device,
which rejects the in-flight transfer; reporting that made every clean
disconnect look like a failure and misfired consumer reconnect logic.

The 500ms poll timer was never cleared, so timers piled up as fast as the
loop turned over - enough to exhaust memory against a device that answers
quickly. start() is now idempotent, since two loops would race on the same
endpoint and interleave their reads, and it no longer starts a loop on an
already-disposed listener. An unexpected exception type used to reject the
promise executor itself, which nothing awaited, so the listener stopped
with no error reported at all.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
handleConnect and handleDisconnect were registered directly as async
listeners on navigator.usb events, so any rejection escaped into the
browser's event dispatch unhandled. A device that failed to set up raised
no event, was never tracked, and could not be retried: from the user's
point of view the device was plugged in and the page did nothing. Both
handlers now report through a new deviceError event.

handleConnect also checked its device map and then awaited the device
constructor before writing back. Two connect events for the same device
ran two constructors concurrently against the same handle; the loser
usually failed and the winner's entry was overwritten, leaving a fully
opened channel that nothing referenced and nothing could dispose. Setups
in progress are now registered synchronously so a second event joins the
first attempt.

A dispose failure during disconnect suppressed the disconnectedDevice
event entirely. The device is gone either way, and swallowing the event
leaks consumer state.

Exclusion filters OR'd their fields together, so
{ vendorId, productId } excluded an entire vendor rather than the one
product named. Matching now follows the WebUSB algorithm: every field a
filter specifies must agree, and an empty filter matches everything.

Also adds a dispose() so the manager's listeners can actually be detached
- binding them inline made them unremovable, leaking across SPA
navigation and hot reload - switches forceReconnect to allSettled so one
device cannot abandon the rest, forwards the dropped innerException on
DeviceNotReadyError, and stops deriving error names from
this.constructor.name, which a minifier rewrites.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018BxSJm7tBc8v6f7MTHjbhP
TypeScript 5.7 to 6.0, eslint 9 to 10, vitest 2 to 5, vite 6 to 8, and
typescript-eslint to 8.69. TypeScript is held at 6.0 rather than 7,
because typescript-eslint 8.69 declares <6.1.0 and TS 7 would silently
disable every type-aware rule.

eslint.config.js is replaced with a stripped strict flat config: recommended
plus strictTypeChecked and stylisticTypeChecked with projectService, so the
type-aware rules actually run. No react, prettier, import sorting or custom
rule packs. The rules that map to bugs already found in this codebase -
no-floating-promises, no-misused-promises, require-await, await-thenable,
no-unnecessary-condition, consistent-return, switch-exhaustiveness-check
and the no-unsafe-* family - are pinned to error. Purely aesthetic rules are
off so they cannot drown the signal.

tsconfig gains noImplicitReturns, noUncheckedIndexedAccess,
exactOptionalPropertyTypes and noFallthroughCasesInSwitch. The first of
those would have caught the parseRaw candidate-dropping bug on its own.

The remaining source and test fixes here are what the new rules surfaced:
descriptor strings became string | null in @types/w3c-web-usb, dispose was
left floating in a test, and a few redundant conditions.

Co-Authored-By: Claude Opus 5 <[email protected]>
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