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

2 commits

Author SHA1 Message Date
47d3d0b237 fix(hal-service): don't keep a cassette layout the dispenser refused
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
2026-09-29 23:55:48 +02:00
5fc1fbc9e8 fix(hal): close() resolves once the serial port is actually closed
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.
2026-09-29 23:55:39 +02:00