Compare commits

..

No commits in common. "47d3d0b237ca9b3be3d57392a0355a011733c969" and "7000720ae9217c204a54622cde456464699bfa13" have entirely different histories.

6 changed files with 40 additions and 90 deletions

View file

@ -376,10 +376,12 @@ 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.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ') cassettes
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
.join(', ')
) )
const previousBays = bays // Rebuild bays first so subsequent dispense calls see the new layout
const previousInitData = dispenserInitData // even if the dispenser re-init is slow / fails.
bays = cassettes bays = cassettes
.slice() .slice()
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
@ -389,40 +391,31 @@ 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 the // Close + re-init the dispenser so its internal per-bay state matches
// new layout. close() now resolves only once the port is really closed, // the new layout. Errors here surface to the caller (operator-config
// so the re-open below cannot race it (aiolabs/bitspire#118). // consumer) — the renderer can decide whether to retry.
//
// 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 {
await dispenser.close() dispenser.close()
await dispenser.init(dispenserInitData)
} catch (err) { } catch (err) {
bays = previousBays console.warn('[HAL] Dispenser close during setCassettes raised:', err)
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() resolve()
}) })
} else {
resolve()
}
}) })
}, },
} }

View file

@ -240,25 +240,9 @@ fsm.on('send', (data: Buffer) => {
serial?.write(data) serial?.write(data)
}) })
/** export function close(): void {
* Close the serial port, resolving once the OS handle is really gone. serial?.close()
*
* 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) {
await this.close() 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) {
await this.close() 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 {
} }
} }
async close(): Promise<void> { close(): void {
await f56.close() 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) {
await this.close() 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 }
} }
async close(): Promise<void> { close(): void {
await this.device.close() this.device.close()
this.initialized = false this.initialized = false
} }

View file

@ -13,9 +13,6 @@
*/ */
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'
@ -241,32 +238,13 @@ export class PuloonRs232 {
} }
/** Close the serial port */ /** Close the serial port */
/** close(): void {
* Close the serial port, resolving only once the OS handle is really gone. if (this.serial) {
* setTimeout(() => {
* This used to defer `serial.close()` behind a 100 ms setTimeout and return this.serial?.close()
* 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
await new Promise((resolve) => setTimeout(resolve, SERIAL_DRAIN_MS)) }, 100)
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,13 +179,8 @@ export interface BillDispenser {
error?: Error error?: Error
}> }>
/** /** Close the connection */
* Close the connection. close(): void
*
* 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