fix(hal): await the serial close, and stop reporting a cassette layout the dispenser refused #119

Merged
padreug merged 2 commits from fix/dispenser-close-race into dev 2026-09-29 21:58:32 +00:00
Owner

Closes #118.

Two commits, matching the two defects.

1. close() resolves once the port is actually closed. The Dispenser interface declared close(): void, so no caller could know when the port was free, and neither driver was anywhere near done when it returned. puloon deferred serial.close() behind a 100 ms setTimeout; f56 called serialport's async close() and returned immediately. Both now resolve on the close callback and claim the handle up front so concurrent calls can't double-close. puloon keeps its 100 ms drain — an in-flight write should land before the port goes away — but awaits it.

2. A refused layout is no longer kept. setCassettes updated bays and dispenserInitData before re-initialising the device, on purpose, so dispense calls would see the new layout "even if the dispenser re-init is slow / fails". Since the re-init failed every time on douro, the app held a layout the hardware never took, [OperatorConfig] Applied ops was logged, and a cassettes-state event advertised it to the operator. This reverses that choice: roll back on failure, let the error propagate. A stale but honest layout beats a fresh but fictional one when the difference is which cassette pays out.

Also awaits dispenser.close() in setCassettes and cleanup().

Verified: tsc --noEmit clean on both apps/machine/electron and packages/hal, the 91 electron tests pass, prettier clean. packages/hal has no test files, so the close paths are unverified by tests — worth knowing given #21 (test HAL drivers on physical hardware) is still open.

Not yet verified on hardware. The douro is the deterministic reproducer: after this lands, an operator cassette op should log [HAL] Dispenser re-initialized with new cassettes instead of Cannot lock port. I'd rather confirm that on the box before this reaches sintra and batm3, since the f56 half changes a code path that four machines use and is not reproducible on demand.

Same family as #46 — arbitrary 100 ms timers standing in for real completion signals in the HAL. Worth a sweep at some point; this PR only fixes the dispenser close.

Closes #118. Two commits, matching the two defects. **1. `close()` resolves once the port is actually closed.** The `Dispenser` interface declared `close(): void`, so no caller could know when the port was free, and neither driver was anywhere near done when it returned. puloon deferred `serial.close()` behind a 100 ms `setTimeout`; f56 called serialport's async `close()` and returned immediately. Both now resolve on the close callback and claim the handle up front so concurrent calls can't double-close. puloon keeps its 100 ms drain — an in-flight write should land before the port goes away — but awaits it. **2. A refused layout is no longer kept.** `setCassettes` updated `bays` and `dispenserInitData` before re-initialising the device, on purpose, so dispense calls would see the new layout "even if the dispenser re-init is slow / fails". Since the re-init failed every time on douro, the app held a layout the hardware never took, `[OperatorConfig] Applied ops` was logged, and a `cassettes-state` event advertised it to the operator. This reverses that choice: roll back on failure, let the error propagate. A stale but honest layout beats a fresh but fictional one when the difference is which cassette pays out. Also awaits `dispenser.close()` in `setCassettes` and `cleanup()`. Verified: `tsc --noEmit` clean on both `apps/machine/electron` and `packages/hal`, the 91 electron tests pass, prettier clean. `packages/hal` has no test files, so the close paths are unverified by tests — worth knowing given #21 (test HAL drivers on physical hardware) is still open. Not yet verified on hardware. The douro is the deterministic reproducer: after this lands, an operator cassette op should log `[HAL] Dispenser re-initialized with new cassettes` instead of `Cannot lock port`. I'd rather confirm that on the box before this reaches sintra and batm3, since the f56 half changes a code path that four machines use and is not reproducible on demand. Same family as #46 — arbitrary 100 ms timers standing in for real completion signals in the HAL. Worth a sweep at some point; this PR only fixes the dispenser close.
The Dispenser interface declared close(): void, so no caller could know when
the port was free — and both drivers returned well before it was.

puloon deferred serial.close() behind a 100 ms setTimeout and returned
immediately. A close-then-reopen caller (setCassettes -> init) therefore
raced a handle that was still open and got EAGAIN "Cannot lock port" every
single time, the overlap being the full 100 ms. Worse, had the reopen ever
won, the pending timer would then have closed the *new* handle and nulled
the field, leaving a silently dead dispenser rather than a loud error.

f56 has no timer but serialport's close() is asynchronous regardless, so it
had the same race with a much narrower window — intermittent rather than
deterministic, on sintra/tejo/gaia/batm3.

Both now resolve on serialport's close callback, claiming the handle up
front so concurrent calls can't double-close. puloon keeps its 100 ms drain
(it lets an in-flight write land) but awaits it instead of firing and
forgetting.
setCassettes updated bays and dispenserInitData before re-initialising the
device, deliberately, so that "subsequent dispense calls see the new layout
even if the dispenser re-init is slow / fails". With the re-init failing
every time on douro, that meant the app kept a layout the hardware had
never taken, the operator-config consumer logged "Applied ops", and a
cassettes-state event went out to the operator advertising it. The douro
spent the afternoon reporting bay1:100x60 bay2:200x0 while the device was
still running the boot-time 100x50/200x50. A dispense in that state picks
bays by a layout the device does not share.

Roll the in-memory layout back when the re-init throws, and let the error
propagate as before. Reversing that earlier choice deliberately: a stale
but honest layout beats a fresh but fictional one when the difference is
which cassette pays out.

Also await dispenser.close() here and in cleanup(), now that close()
reports completion.

Closes #118
padreug deleted branch fix/dispenser-close-race 2026-09-29 21:58:32 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!119
No description provided.