Cassette hot-reload always fails on douro (serial close race), and the app reports success anyway #118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two defects, one of which has money attached.
1. The reopen races the close
packages/hal/src/dispensers/puloon/puloon-rs232.ts:241The close is deferred 100 ms and returns immediately with no completion signal.
hal-service.ts:397(setCassettes) then doesdispenser.close()followed straight byawait dispenser.init(…), so the reopen of/dev/ttyS1hits a handle that is still open and serialport's exclusive lock rejects it:On douro this fails 100% of the time — the overlap is the full 100 ms. Observed on both operator ops this afternoon (15:47:01 and 15:47:14).
There is a second ordering in the same code: if the reopen ever wins, the pending timer fires afterwards, closes the new handle and nulls
this.serial. That gives a silently dead dispenser instead of a loud error.Same family as #46 — an arbitrary 100 ms timer standing in for a real completion signal in the HAL.
2. Failure is reported as success
setCassettesupdates the in-memorybaysanddispenserInitDatabefore attempting the device re-init, deliberately ("even if the dispenser re-init is slow / fails"). When the re-init then always throws, the app keeps the new layout,[OperatorConfig] Applied ops:is logged, and acassettes-stateevent is published to the operator advertising a layout the hardware never received.Right now the douro's operator view says
bay1:100×60, bay2:200×0while the device is still initialized with the boot-time100×50, 200×50. A dispense in that state picks bays by a layout the device doesn't share. That is the dangerous half, and it is independent of defect 1 — any re-init failure produces it.Blast radius
f56-rs232.ts:243has no 100 ms timer, butserial?.close()is still asynchronous and is not awaited beforeiniteither, so the same race exists with a much narrower window — intermittent rather than deterministic. The timer-kills-the-new-handle variant is puloon-only.The root enabler is
types.ts:183, where theDispenserinterface declaresclose(): voidand so cannot express completion at all — note the validator interface right above it (types.ts:78) takes a callback.Workaround
sudo systemctl restart bitspire. Operator ops do persist tostate.db(cassette_ops, schema v13), and boot-time init reads from there, so a restart applies the operator's layout through the normal init path and device and app agree again.Fix
Make
close()return a promise that resolves once the port is really closed, await it beforeinit, and roll backbays/dispenserInitDatawhen re-init throws so the app and the operator event never claim a layout the device didn't take. PR to follow.