From 47d3d0b237ca9b3be3d57392a0355a011733c969 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 23:55:48 +0200 Subject: [PATCH] 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 --- apps/machine/electron/hal-service.ts | 51 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 2ec863b..1259c77 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -376,12 +376,10 @@ export async function initializeHal(config: HalConfig): Promise { setCassettes: async (cassettes: CassetteConfig[]): Promise => { 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 { 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() + await dispenser.close() + await dispenser.init(dispenserInitData) } catch (err) { - console.warn('[HAL] Dispenser close during setCassettes raised:', err) + bays = previousBays + dispenserInitData = previousInitData + console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err) + throw err } - await dispenser.init(dispenserInitData) console.log('[HAL] Dispenser re-initialized with new cassettes') }, cleanup: async () => { - return new Promise((resolve) => { - validator?.disable() - validator?.lightOff() - dispenser.close() - if (validator) { - validator.close((err?: Error) => { - if (err) console.error('[HAL] Validator close error:', err) - resolve() - }) - } else { + validator?.disable() + validator?.lightOff() + await dispenser.close() + if (!validator) return + await new Promise((resolve) => { + validator?.close((err?: Error) => { + if (err) console.error('[HAL] Validator close error:', err) resolve() - } + }) }) }, }