feat(machine): connectivity auto-recovery + on-screen Retry
A connectivity-type init failure (e.g. "No connected relays" when the box boots before the network) landed on "ATM Unavailable" permanently: init is one-shot and the nostr reconnect only helps after a first successful connect, so a machine never self-healed when internet returned. Recover by reloading the renderer, which re-runs init from a clean JS context (no leaked actors/subscriptions) while the main process keeps HAL: - main.ts: new `app:recover` IPC → reloadRenderer() (resets secretsConsumed). - hal:init is now idempotent (reuse the existing instance) so the reload — and the pre-existing watchdog crash-reload — can't double-open serial ports. - App.vue: when initError is a connectivity type (not the operator/ self-clearing states unpaired/awaiting-fees/maintenance), watch for the `online` event (recover immediately) plus a 45s backoff safety net, and render a kiosk-sized Retry button for a person at the machine. Preserves pairing + /var/lib state (renderer reload, not a process restart). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
44f5c0dbcf
commit
6a833d357e
4 changed files with 104 additions and 1 deletions
|
|
@ -404,6 +404,17 @@ ipcMain.handle('app:relaunch', (): void => {
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Connectivity recovery: reload the renderer to re-run init from a clean slate
|
||||||
|
// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in
|
||||||
|
// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets
|
||||||
|
// works again, and hal:init is idempotent). The renderer calls this when it's
|
||||||
|
// stuck on a connectivity-type "ATM Unavailable" and the network returns, or
|
||||||
|
// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04).
|
||||||
|
ipcMain.handle('app:recover', (): void => {
|
||||||
|
console.log('[Recovery] Reloading renderer to re-attempt initialization')
|
||||||
|
reloadRenderer()
|
||||||
|
})
|
||||||
|
|
||||||
// State persistence IPC handlers
|
// State persistence IPC handlers
|
||||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||||
|
|
@ -480,6 +491,17 @@ let pendingBillDenomination: number | null = null
|
||||||
|
|
||||||
ipcMain.handle('hal:init', async (_event, config) => {
|
ipcMain.handle('hal:init', async (_event, config) => {
|
||||||
try {
|
try {
|
||||||
|
// Idempotent: HAL lives in this (long-lived) main process, but the renderer
|
||||||
|
// re-runs full init on every reload — the watchdog's crash-recovery reload
|
||||||
|
// and the connectivity-recovery reload (app:recover) both re-invoke this.
|
||||||
|
// initializeHal opens serial ports without closing prior handles, so
|
||||||
|
// re-entering it would double-open the validator/dispenser. Reuse the
|
||||||
|
// existing instance instead; its validator event wiring already targets the
|
||||||
|
// (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events.
|
||||||
|
if (halInstance) {
|
||||||
|
console.log('[Electron] HAL already initialized — reusing existing instance')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
// Override cassette config with DB values (operator may have changed them via atm-tui
|
// Override cassette config with DB values (operator may have changed them via atm-tui
|
||||||
// or via an operator-config publish from satmachineadmin). Pass per-position so the
|
// or via an operator-config publish from satmachineadmin). Pass per-position so the
|
||||||
// HAL knows about every bay including duplicates of the same denomination — real
|
// HAL knows about every bay including duplicates of the same denomination — real
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// then relaunch so the normal boot flow pairs it.
|
// then relaunch so the normal boot flow pairs it.
|
||||||
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
|
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
|
||||||
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
||||||
|
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||||
|
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
||||||
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -243,6 +245,7 @@ declare global {
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
recoverApp: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
@ -158,8 +158,71 @@ onMounted(async () => {
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
atmStore.stopPricePolling()
|
atmStore.stopPricePolling()
|
||||||
|
stopRecoveryWatch()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Connectivity recovery (ADR-002 amendment 2026-08-04) ──────────────────
|
||||||
|
// A connectivity-type init failure lands on "ATM Unavailable" and, without
|
||||||
|
// this, stays there forever (init is one-shot; the nostr reconnect only helps
|
||||||
|
// AFTER a first successful connect). We recover by reloading the renderer —
|
||||||
|
// which re-runs this whole init from a clean JS context while the main process
|
||||||
|
// keeps HAL (see main.ts app:recover). Not for the operator/self-clearing
|
||||||
|
// states: `unpaired` shows the pairing wizard, `awaiting-fees` clears itself on
|
||||||
|
// the operator's fee-config event, `maintenance` is operator-set.
|
||||||
|
const NON_RECOVERABLE = new Set(['maintenance', 'awaiting-fees', 'unpaired'])
|
||||||
|
const isRecoverable = computed(
|
||||||
|
() => !!atmStore.initError && !NON_RECOVERABLE.has(atmStore.initError)
|
||||||
|
)
|
||||||
|
const recovering = ref(false)
|
||||||
|
const RECOVERY_RETRY_MS = 45_000
|
||||||
|
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function triggerRecovery() {
|
||||||
|
if (recovering.value) return
|
||||||
|
recovering.value = true
|
||||||
|
console.log('[App] Attempting connectivity recovery (renderer reload)')
|
||||||
|
if (window.electronAPI?.recoverApp) {
|
||||||
|
void window.electronAPI.recoverApp() // main reloads renderer → fresh init
|
||||||
|
} else {
|
||||||
|
location.reload() // browser-dev fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnline() {
|
||||||
|
// Network came back — recover immediately rather than waiting for the timer.
|
||||||
|
triggerRecovery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecoveryWatch() {
|
||||||
|
stopRecoveryWatch()
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
// Safety net for the online-but-relay-unreachable case (navigator.onLine only
|
||||||
|
// reflects a local route, not relay reachability).
|
||||||
|
recoveryTimer = setInterval(triggerRecovery, RECOVERY_RETRY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecoveryWatch() {
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
if (recoveryTimer !== null) {
|
||||||
|
clearInterval(recoveryTimer)
|
||||||
|
recoveryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator-facing "Retry" button on the maintenance screen. */
|
||||||
|
function retryNow() {
|
||||||
|
triggerRecovery()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isRecoverable,
|
||||||
|
(recoverable) => {
|
||||||
|
if (recoverable) startRecoveryWatch()
|
||||||
|
else stopRecoveryWatch()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
function toggleLiveServices() {
|
function toggleLiveServices() {
|
||||||
if (atmStore.useLiveServices) {
|
if (atmStore.useLiveServices) {
|
||||||
// Switch to mock
|
// Switch to mock
|
||||||
|
|
@ -211,6 +274,19 @@ function toggleLiveServices() {
|
||||||
>
|
>
|
||||||
{{ atmStore.initError }}
|
{{ atmStore.initError }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Manual recovery for a connectivity failure; auto-recovery also runs
|
||||||
|
in the background (online event + backoff). Not shown for operator/
|
||||||
|
self-clearing states (maintenance / awaiting-fees / unpaired). -->
|
||||||
|
<Button
|
||||||
|
v-if="isRecoverable"
|
||||||
|
size="kiosk"
|
||||||
|
:disabled="recovering"
|
||||||
|
class="mt-4"
|
||||||
|
@click="retryNow"
|
||||||
|
>
|
||||||
|
{{ recovering ? 'Retrying…' : 'Retry' }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
|
||||||
2
apps/machine/src/types/electron.d.ts
vendored
2
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -101,6 +101,8 @@ declare global {
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
||||||
|
recoverApp: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue