Compare commits

...

3 commits

Author SHA1 Message Date
92d5fdb98b Merge pull request 'fix(hal): await the serial close, and stop reporting a cassette layout the dispenser refused' (#119) from fix/dispenser-close-race into dev
Reviewed-on: #119
2026-09-29 21:58:31 +00:00
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
6 changed files with 90 additions and 40 deletions

View file

@ -376,12 +376,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => { setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
console.log( console.log(
'[HAL] Hot-reloading cassette layout:', '[HAL] Hot-reloading cassette layout:',
cassettes cassettes.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ')
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
.join(', ')
) )
// Rebuild bays first so subsequent dispense calls see the new layout const previousBays = bays
// even if the dispenser re-init is slow / fails. const previousInitData = dispenserInitData
bays = cassettes bays = cassettes
.slice() .slice()
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
@ -391,31 +389,40 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0, count: c.count ?? 0,
})) }))
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
// Close + re-init the dispenser so its internal per-bay state matches // Close + re-init the dispenser so its internal per-bay state matches the
// the new layout. Errors here surface to the caller (operator-config // new layout. close() now resolves only once the port is really closed,
// consumer) — the renderer can decide whether to retry. // 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 { try {
dispenser.close() await dispenser.close()
await dispenser.init(dispenserInitData)
} catch (err) { } 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') console.log('[HAL] Dispenser re-initialized with new cassettes')
}, },
cleanup: async () => { cleanup: async () => {
return new Promise<void>((resolve) => { validator?.disable()
validator?.disable() validator?.lightOff()
validator?.lightOff() await dispenser.close()
dispenser.close() if (!validator) return
if (validator) { await new Promise<void>((resolve) => {
validator.close((err?: Error) => { validator?.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err) if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
} else {
resolve() resolve()
} })
}) })
}, },
} }

View file

@ -240,9 +240,25 @@ fsm.on('send', (data: Buffer) => {
serial?.write(data) serial?.write(data)
}) })
export function close(): void { /**
serial?.close() * Close the serial port, resolving once the OS handle is really gone.
*
* serialport's close() is asynchronous, so returning before its callback fires
* let a close-then-reopen caller (setCassettes -> init) race the old handle and
* fail to take the exclusive lock. Narrower window than puloon's, which
* deferred the close behind a timer, but the same bug. See aiolabs/bitspire#118.
*/
export async function close(): Promise<void> {
const port = serial
if (!port) return
// Claim the handle up front so a concurrent close() can't double-close it.
serial = null serial = null
await new Promise<void>((resolve) => {
port.close((err?: Error | null) => {
if (err) console.warn('F56 | serial close raised:', err.message)
resolve()
})
})
} }
export default { export default {

View file

@ -56,14 +56,14 @@ export class F56Dispenser implements BillDispenser {
const { bills, error } = await f56.billCount(notes) const { bills, error } = await f56.billCount(notes)
if (error) { if (error) {
this.close() await this.close()
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
;(error as Error & { statusCode: number }).statusCode = 570 ;(error as Error & { statusCode: number }).statusCode = 570
} }
return { value: bills, error } return { value: bills, error }
} catch (err) { } catch (err) {
this.close() await this.close()
const error = err as Error const error = err as Error
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
;(error as Error & { statusCode: number }).statusCode = 570 ;(error as Error & { statusCode: number }).statusCode = 570
@ -71,8 +71,8 @@ export class F56Dispenser implements BillDispenser {
} }
} }
close(): void { async close(): Promise<void> {
f56.close() await f56.close()
this.initialized = false this.initialized = false
} }

View file

@ -50,7 +50,7 @@ export class PuloonDispenser implements BillDispenser {
const { bills, error } = await this.device.dispense(notes) const { bills, error } = await this.device.dispense(notes)
if (error) { if (error) {
this.close() await this.close()
error.name = 'PuloonDispenseError' error.name = 'PuloonDispenseError'
console.log('PULOON | dispense error', error) console.log('PULOON | dispense error', error)
} }
@ -58,8 +58,8 @@ export class PuloonDispenser implements BillDispenser {
return { value: bills, error } return { value: bills, error }
} }
close(): void { async close(): Promise<void> {
this.device.close() await this.device.close()
this.initialized = false this.initialized = false
} }

View file

@ -13,6 +13,9 @@
*/ */
import { SerialPort } from 'serialport' import { SerialPort } from 'serialport'
/** Grace period for an in-flight write to land before the port is closed. */
const SERIAL_DRAIN_MS = 100
import { billLengths, encodeBillLength } from './bills.js' import { billLengths, encodeBillLength } from './bills.js'
import type { CassetteConfig, DispenseResult } from '../../types.js' import type { CassetteConfig, DispenseResult } from '../../types.js'
@ -238,13 +241,32 @@ export class PuloonRs232 {
} }
/** Close the serial port */ /** Close the serial port */
close(): void { /**
if (this.serial) { * Close the serial port, resolving only once the OS handle is really gone.
setTimeout(() => { *
this.serial?.close() * This used to defer `serial.close()` behind a 100 ms setTimeout and return
this.serial = null * immediately, with no way for a caller to know when the port was free. A
}, 100) * caller that closed and reopened — setCassettes -> init — therefore raced a
} * handle that was still open and got EAGAIN "Cannot lock port" every time,
* since the overlap was the full 100 ms. And if the reopen ever won the
* race, the pending timer fired afterwards and closed the *new* handle,
* leaving a silently dead dispenser. See aiolabs/bitspire#118.
*
* The drain delay is kept — it lets an in-flight write land before the port
* goes away — but it is now awaited rather than fired and forgotten.
*/
async close(): Promise<void> {
const serial = this.serial
if (!serial) return
// Claim the handle up front so a concurrent close() can't double-close it.
this.serial = null
await new Promise((resolve) => setTimeout(resolve, SERIAL_DRAIN_MS))
await new Promise<void>((resolve) => {
serial.close((err?: Error | null) => {
if (err) console.warn('PULOON | serial close raised:', err.message)
resolve()
})
})
} }
/** Send a command and wait for a single response */ /** Send a command and wait for a single response */

View file

@ -179,8 +179,13 @@ export interface BillDispenser {
error?: Error error?: Error
}> }>
/** Close the connection */ /**
close(): void * Close the connection.
*
* Resolves only once the underlying port is actually closed, so a caller may
* safely reopen it straight after awaiting this (aiolabs/bitspire#118).
*/
close(): Promise<void>
/** /**
* Check if bills are present at the dispense outlet * Check if bills are present at the dispense outlet