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
This commit is contained in:
padreug 2026-09-29 21:58:31 +00:00
commit 92d5fdb98b
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()
} catch (err) {
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
}
await dispenser.init(dispenserInitData) 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') 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()
dispenser.close() await dispenser.close()
if (validator) { if (!validator) return
validator.close((err?: Error) => { await new Promise<void>((resolve) => {
validator?.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err) if (err) console.error('[HAL] Validator close error:', err)
resolve() resolve()
}) })
} else {
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
* immediately, with no way for a caller to know when the port was free. A
* 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 this.serial = null
}, 100) 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