feat(machine): connectivity auto-recovery + on-screen Retry #82

Merged
padreug merged 3 commits from feat/connection-recovery into dev 2026-08-05 02:33:01 +00:00
4 changed files with 104 additions and 1 deletions
Showing only changes of commit 6a833d357e - Show all commits

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>
Patrick Mulligan 2026-08-04 18:11:40 +02:00

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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