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
Showing only changes of commit 47d3d0b237 - Show all commits

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
Padreug 2026-09-29 23:55:48 +02:00

View file

@ -376,12 +376,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
console.log(
'[HAL] Hot-reloading cassette layout:',
cassettes
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
.join(', ')
cassettes.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ')
)
// Rebuild bays first so subsequent dispense calls see the new layout
// even if the dispenser re-init is slow / fails.
const previousBays = bays
const previousInitData = dispenserInitData
bays = cassettes
.slice()
.sort((a, b) => a.position - b.position)
@ -391,31 +389,40 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0,
}))
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
// Close + re-init the dispenser so its internal per-bay state matches
// the new layout. Errors here surface to the caller (operator-config
// consumer) — the renderer can decide whether to retry.
// Close + re-init the dispenser so its internal per-bay state matches the
// new layout. close() now resolves only once the port is really closed,
// so the re-open below cannot race it (aiolabs/bitspire#118).
//
// On failure, roll the in-memory layout back. This reverses an earlier
// deliberate choice to keep the new bays "even if the dispenser re-init
// is slow / fails": with the re-init failing every time on douro, the app
// kept a layout the device had never taken and the operator-config
// consumer went on to publish a cassettes-state event advertising it. A
// subsequent dispense would then pick bays by a layout the hardware does
// not share. Better to surface the failure and stay truthful about what
// the device is actually running.
try {
dispenser.close()
} catch (err) {
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
}
await dispenser.close()
await dispenser.init(dispenserInitData)
} catch (err) {
bays = previousBays
dispenserInitData = previousInitData
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
throw err
}
console.log('[HAL] Dispenser re-initialized with new cassettes')
},
cleanup: async () => {
return new Promise<void>((resolve) => {
validator?.disable()
validator?.lightOff()
dispenser.close()
if (validator) {
validator.close((err?: Error) => {
await dispenser.close()
if (!validator) return
await new Promise<void>((resolve) => {
validator?.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
} else {
resolve()
}
})
},
}