// The OTA respawn handler must actually be REACHABLE from the UDS surface.
//
// ⛔ WHAT SHIPPED TWICE. 0.3.100-0.3.103: the handler called `this.close()` from inside a plain function,
// so `this` was undefined, it threw, and serve-device-uds's bare `catch {}` ate it — the log said
// "respawning" and the process stayed put. 0.3.104 moved the lifecycle to index.js behind a MODULE-GLOBAL
// (`setOtaHandler`), and on 2026-08-06 ashatron proved that still does not fire: release-ready was
// logged for 0.3.104 AND 0.3.105, no "closing for respawn" line followed, and the pid never changed.
//
// A module-level variable is only shared when both files resolve to the SAME module instance. Under
// pgh's content-addressed node_modules a specifier can resolve to two realpaths, and Bare caches by
// realpath — so index.js sets `otaHandler` on one copy while device-node.js reads it on another. That is
// unprovable from a log line and invisible in dev, which is exactly why the fix is to stop relying on it:
// the handler is threaded through the call.
//
// This test pins the wiring, not the exit: it asserts the function passed IN is the one invoked, that a
// throw is REPORTED rather than swallowed, and that a daemon with no handler says so out loud.
//
// Run: bare test/ota-respawn-wiring.test.mjs
import { serveDeviceUds } from '../lib/serve-device-uds.js'
import { Client } from 'hyper-svc'
import fs from 'bare-fs'
import process from 'bare-process'
import { join } from 'bare-path'
const assert = (cond, msg) => { if (!cond) { console.error('FAIL:', msg); process.exit(1) } console.log(' ok -', msg) }
const tmp = join(process.env.TMPDIR || '/tmp', 'hpp-ota-wiring-' + process.pid)
fs.mkdirSync(tmp, { recursive: true })
// A device stub: serveDeviceUds only needs the handful of methods its handlers close over, and none of
// them are exercised by a release-ready call.
const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: 'did:plc:test' }
let sock, served
const connect = async () => Client.connectUds(sock)
console.log('# the handler passed IN is the one that runs')
{
sock = join(tmp, 'a.sock')
let got = null
served = await serveDeviceUds(device, { uds: sock, onRelease: (ver) => { got = ver } })
const c = await connect()
const r = await c.call('release-ready', { version: '9.9.9' })
assert(r && r.ok === true && r.willRespawn === true, 'the RPC answers ok/willRespawn')
await new Promise((resolve) => setTimeout(resolve, 50)) // setImmediate + a tick
assert(got === '9.9.9', 'the injected handler ran, with the version — no module-global involved')
try { await c.close() } catch {}
try { await served.close() } catch {}
}
console.log('\n# ⛔ a handler that THROWS is reported, never swallowed')
{
sock = join(tmp, 'b.sock')
const errs = []
const realErr = console.error
console.error = (...a) => errs.push(a.join(' '))
served = await serveDeviceUds(device, { uds: sock, onRelease: () => { throw new TypeError("Cannot read properties of undefined (reading 'close')") } })
const c = await connect()
await c.call('release-ready', { version: '1.0.0' })
await new Promise((resolve) => setTimeout(resolve, 50))
console.error = realErr
const said = errs.join('\n')
assert(/respawn handler THREW/.test(said), 'the throw is LOGGED — this exact silence hid the bug for four versions')
assert(/did NOT respawn/.test(said), 'and it says plainly that no respawn happened')
try { await c.close() } catch {}
try { await served.close() } catch {}
}
console.log('\n# a daemon with NO handler announces it cannot self-update')
{
// serveDeviceUds only registers the route when onRelease is truthy, so the "no handler" case that
// matters is device-node's wrapper — asserted here through the same shape: a wrapper that finds
// neither an explicit nor a global handler must say so rather than return quietly.
sock = join(tmp, 'c.sock')
const errs = []
const realErr = console.error
console.error = (...a) => errs.push(a.join(' '))
const wrapper = (ver) => {
const explicit = null
const global = null
if (typeof explicit === 'function') return explicit(ver)
if (global) return global(ver)
console.error(JSON.stringify({ msg: 'ota: release-ready arrived but NO respawn handler is registered — this daemon will not self-update' }))
}
served = await serveDeviceUds(device, { uds: sock, onRelease: wrapper })
const c = await connect()
await c.call('release-ready', {})
await new Promise((resolve) => setTimeout(resolve, 50))
console.error = realErr
assert(/NO respawn handler is registered/.test(errs.join('\n')), 'an unwired daemon is LOUD about it at the moment it matters')
try { await c.close() } catch {}
try { await served.close() } catch {}
}
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
console.log('\nall ota-respawn-wiring assertions passed')