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> => {
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(', ')
)
const previousBays = bays
const previousInitData = dispenserInitData
// Rebuild bays first so subsequent dispense calls see the new layout
// even if the dispenser re-init is slow / fails.
bays = cassettes
.slice()
.sort((a, b) => a.position - b.position)
@ -389,40 +391,31 @@ 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. 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.
// 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.
try {
await dispenser.close()
await dispenser.init(dispenserInitData)
dispenser.close()
} catch (err) {
bays = previousBays
dispenserInitData = previousInitData
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
throw err
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
}
await dispenser.init(dispenserInitData)
console.log('[HAL] Dispenser re-initialized with new cassettes')
},
cleanup: async () => {
return new Promise<void>((resolve) => {
validator?.disable()
validator?.lightOff()
await dispenser.close()
if (!validator) return
await new Promise<void>((resolve) => {
validator?.close((err?: Error) => {
dispenser.close()
if (validator) {
validator.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
} else {
resolve()
}
})
},
}

View file

@ -240,25 +240,9 @@ fsm.on('send', (data: Buffer) => {
serial?.write(data)
})
/**
* 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.
export function close(): void {
serial?.close()
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 {

View file

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

View file

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

View file

@ -13,9 +13,6 @@
*/
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 type { CassetteConfig, DispenseResult } from '../../types.js'
@ -241,32 +238,13 @@ export class PuloonRs232 {
}
/** Close the serial port */
/**
* Close the serial port, resolving only once the OS handle is really gone.
*
* 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.
close(): void {
if (this.serial) {
setTimeout(() => {
this.serial?.close()
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()
})
})
}, 100)
}
}
/** Send a command and wait for a single response */

View file

@ -179,13 +179,8 @@ export interface BillDispenser {
error?: Error
}>
/**
* 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>
/** Close the connection */
close(): void
/**
* Check if bills are present at the dispense outlet