OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / f21dd23

0.3.106: OTA respawn, threaded explicitly — the module-global never reached the reader
Third attempt at this, so the mechanism is worth stating. 0.3.100-0.3.103 called `this.close()` from inside a plain function, so `this` was undefined; it threw and serve-device-uds's bare `catch {}` ate it. 0.3.104 moved the lifecycle to index.js behind a module-global (setOtaHandler) and declared it fixed. On 2026-08-06 ashatron disproved that: release-ready was logged for BOTH 0.3.104 and 0.3.105, no "closing for respawn" line followed, and the pid never changed. The handler was null when called, and by then index.js had already run setOtaHandler (its own "sidecar registration armed" line, which comes after, is in the log first). A module-level variable is shared only when both files resolve to the SAME module instance, and under pgh's content-addressed node_modules a specifier can resolve to two realpaths — Bare caches modules by realpath, so index.js sets `otaHandler` on one copy and device-node.js reads it on another. Consistent with every observation, invisible in dev, and unprovable from a log line. So stop depending on it: the handler is now a parameter, threaded index.js -> bootMultiDevice -> bootSwarmDevice -> serveDeviceUds. Passing a function through a call cannot be defeated that way. setOtaHandler stays as a fallback. And the silence is gone, which is what actually cost four versions: a handler that throws is now LOGGED ("respawn handler THREW — the daemon did NOT respawn"), and a daemon with no handler at all says so at the moment release-ready arrives instead of returning quietly. test/ota-respawn-wiring.test.mjs pins all three: the injected handler is the one invoked, a throw is reported, an unwired daemon is loud. 119/119 tests, 647/647 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f21dd232df18c77c1c0f4d57c007f1de96652953
by plankatron <fleeky@gmail.com> · 2026-08-07T00:23:29.000Z

Changed files (6) +128 -7

index.js +9 -1
……@@ -118,7 +118,15 @@ if (cfg.swarm) {
118118 // MULTI-DID (multi-tenant) daemon: ONE process serves N self-custody DIDs over ONE shared
119119 // Corestore/Hyperswarm/ProtomuxWakeup (each DID a namespace, one storage lock). A user's own DID
120120 // + the communities they co-own live as entries in cfg.dids. See lib/device-node.js bootMultiDevice.
121 const daemon = await bootMultiDevice({ storage: cfg.storage, dids: cfg.dids })
121 // OTA respawn, threaded EXPLICITLY into the boot rather than left on a module-global (see
122 // device-node.js). `daemon` is assigned on the next line and the handler cannot fire before a
123 // release-ready arrives, so the forward reference is safe.
124 let daemon = null
125 const onRelease = (ver) => {
126 console.log(JSON.stringify({ msg: 'ota: closing for respawn (exit 75)', version: ver || null }))
127 Promise.resolve(daemon ? daemon.close() : null).catch(() => {}).then(() => process.exit(75)).catch(() => process.exit(75))
128 }
129 daemon = await bootMultiDevice({ storage: cfg.storage, dids: cfg.dids, onRelease })
122130 console.log(JSON.stringify({ msg: 'multi-did daemon serving', dids: daemon.nodes.length, storage: cfg.storage }))
123131 const shutdownM = async () => { try { await daemon.close() } catch {} ; process.exit(0) }
124132 process.on('SIGINT', shutdownM)
lib/device-node.js +15 -4
……@@ -110,7 +110,7 @@ export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemoni
110110// So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner
111111// sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had
112112// to solve exactly this for a creator rejoining from a fresh store.
113export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true } = {}) {
113export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true, onRelease = null } = {}) {
114114 if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required')
115115 if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required")
116116 if (!did || !signingKey || !deviceProof || !rootIdentityKey) throw new Error('bootSwarmDevice: provisioned bundle (did, signingKey, deviceProof, rootIdentityKey) required — run provisionDevice on the root')
……@@ -509,7 +509,18 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
509509 udsServing = await serveDeviceUds(device, {
510510 uds: join(ctlDir, 'control.sock'),
511511 blobs,
512 onRelease: () => { if (otaHandler) otaHandler() }
512 // EXPLICIT handler first. The module-global `otaHandler` below is a fallback kept for callers
513 // that still use setOtaHandler(), but it is NOT the primary path any more: a module-level
514 // variable is only shared if index.js and this file resolve to the SAME module instance, and
515 // under pgh's content-addressed node_modules a specifier can resolve to two realpaths — Bare
516 // caches by realpath, so each copy gets its OWN `otaHandler`, one set and the other read. That
517 // is consistent with what shipped: 0.3.105 logged "release-ready", called through, and found
518 // null. Threading the function through the call cannot be defeated that way.
519 onRelease: (ver) => {
520 if (typeof onRelease === 'function') return onRelease(ver)
521 if (otaHandler) return otaHandler(ver)
522 console.error(JSON.stringify({ msg: 'ota: release-ready arrived but NO respawn handler is registered — this daemon will not self-update' }))
523 }
513524 })
514525 console.log(JSON.stringify({ msg: 'device UDS control surface', uds: join(ctlDir, 'control.sock') }))
515526 } catch (e) { console.error(JSON.stringify({ msg: 'UDS control surface failed (swarm surface still up)', error: e.message })) }
……@@ -628,7 +639,7 @@ export function resyncDid (storage, did) {
628639// per-community scripts. Foundation proven in atproto-room/test/multi-did.test.js.
629640// dids = [{ deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, members,
630641// writers, adopt?, resetFirehose?, appPasswordHash?, jwtSecret? }].
631export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, signIntervalMs = 2000 } = {}) {
642export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) {
632643 if (typeof storage !== 'string') throw new Error('bootMultiDevice: storage path required')
633644 if (!Array.isArray(dids) || dids.length === 0) throw new Error('bootMultiDevice: at least one DID required')
634645
……@@ -707,7 +718,7 @@ export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, s
707718 if (d.adoptCar && !d.adopt) {
708719 try { entry = { ...d, adopt: await readAdoptRepo(fs.readFileSync(d.adoptCar)) }; console.log(JSON.stringify({ msg: 'multi-did: adoptCar loaded for rebuild', did: d.did, car: d.adoptCar, records: entry.adopt.records.length, rev: entry.adopt.head.rev })) } catch (e) { console.error(JSON.stringify({ msg: 'multi-did: adoptCar load FAILED', did: d.did, car: d.adoptCar, error: e.message })) }
709720 }
710 const node = await bootSwarmDevice({ storage, ...entry, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore })
721 const node = await bootSwarmDevice({ storage, ...entry, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore, onRelease })
711722 swarm.join(node.sb.base.discoveryKey, { server: true, client: true }) // daemon joins each DID's StateBase topic
712723 // resync side store: replicate on connections already open (future ones via the connection handler).
713724 if (node._sideStore) for (const conn of swarm.connections) { try { node._sideStore.replicate(conn) } catch {} }
lib/serve-device-uds.js +7 -1
……@@ -25,7 +25,13 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n
2525 svc.handle('release-ready', async (ctx, params = {}) => {
2626 const ver = (params && params.version) || '?'
2727 console.log(JSON.stringify({ msg: 'ota: release-ready — sidecar installed; respawning', version: ver }))
28 setImmediate(() => { try { onRelease(ver) } catch {} })
28 // ⛔ A BARE catch {} here is what hid this twice. The handler logged "respawning", threw a
29 // TypeError (0.3.100-0.3.103: `this` undefined), and the silence read as success on every surface.
30 setImmediate(() => {
31 try { onRelease(ver) } catch (e) {
32 console.error(JSON.stringify({ msg: 'ota: respawn handler THREW — the daemon did NOT respawn', version: ver, error: (e && e.message) || String(e) }))
33 }
34 })
2935 return { ok: true, willRespawn: true }
3036 })
3137 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.105",
3 "version": "0.3.106",
44 "description": "Self-custody, P2P-native atproto peer — holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +1 -0
……@@ -53,3 +53,4 @@ import './fresh-account-initial-commit.test.js'
5353import './swarm-device-forward-only-sign.test.mjs'
5454import './delete-tip-wedge.repro.test.js'
5555import './resync-fresh-corestore.test.js'
56import './ota-respawn-wiring.test.mjs'
test/ota-respawn-wiring.test.mjs +95 -0
……@@ -0,0 +1,95 @@
1// The OTA respawn handler must actually be REACHABLE from the UDS surface.
2//
3// ⛔ WHAT SHIPPED TWICE. 0.3.100-0.3.103: the handler called `this.close()` from inside a plain function,
4// so `this` was undefined, it threw, and serve-device-uds's bare `catch {}` ate it — the log said
5// "respawning" and the process stayed put. 0.3.104 moved the lifecycle to index.js behind a MODULE-GLOBAL
6// (`setOtaHandler`), and on 2026-08-06 ashatron proved that still does not fire: release-ready was
7// logged for 0.3.104 AND 0.3.105, no "closing for respawn" line followed, and the pid never changed.
8//
9// A module-level variable is only shared when both files resolve to the SAME module instance. Under
10// pgh's content-addressed node_modules a specifier can resolve to two realpaths, and Bare caches by
11// realpath — so index.js sets `otaHandler` on one copy while device-node.js reads it on another. That is
12// unprovable from a log line and invisible in dev, which is exactly why the fix is to stop relying on it:
13// the handler is threaded through the call.
14//
15// This test pins the wiring, not the exit: it asserts the function passed IN is the one invoked, that a
16// throw is REPORTED rather than swallowed, and that a daemon with no handler says so out loud.
17//
18// Run: bare test/ota-respawn-wiring.test.mjs
19import { serveDeviceUds } from '../lib/serve-device-uds.js'
20import { Client } from 'hyper-svc'
21import fs from 'bare-fs'
22import process from 'bare-process'
23import { join } from 'bare-path'
24
25const assert = (cond, msg) => { if (!cond) { console.error('FAIL:', msg); process.exit(1) } console.log(' ok -', msg) }
26const tmp = join(process.env.TMPDIR || '/tmp', 'hpp-ota-wiring-' + process.pid)
27fs.mkdirSync(tmp, { recursive: true })
28
29// A device stub: serveDeviceUds only needs the handful of methods its handlers close over, and none of
30// them are exercised by a release-ready call.
31const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: 'did:plc:test' }
32
33let sock, served
34const connect = async () => Client.connectUds(sock)
35
36console.log('# the handler passed IN is the one that runs')
37{
38 sock = join(tmp, 'a.sock')
39 let got = null
40 served = await serveDeviceUds(device, { uds: sock, onRelease: (ver) => { got = ver } })
41 const c = await connect()
42 const r = await c.call('release-ready', { version: '9.9.9' })
43 assert(r && r.ok === true && r.willRespawn === true, 'the RPC answers ok/willRespawn')
44 await new Promise((resolve) => setTimeout(resolve, 50)) // setImmediate + a tick
45 assert(got === '9.9.9', 'the injected handler ran, with the version — no module-global involved')
46 try { await c.close() } catch {}
47 try { await served.close() } catch {}
48}
49
50console.log('\n# ⛔ a handler that THROWS is reported, never swallowed')
51{
52 sock = join(tmp, 'b.sock')
53 const errs = []
54 const realErr = console.error
55 console.error = (...a) => errs.push(a.join(' '))
56 served = await serveDeviceUds(device, { uds: sock, onRelease: () => { throw new TypeError("Cannot read properties of undefined (reading 'close')") } })
57 const c = await connect()
58 await c.call('release-ready', { version: '1.0.0' })
59 await new Promise((resolve) => setTimeout(resolve, 50))
60 console.error = realErr
61 const said = errs.join('\n')
62 assert(/respawn handler THREW/.test(said), 'the throw is LOGGED — this exact silence hid the bug for four versions')
63 assert(/did NOT respawn/.test(said), 'and it says plainly that no respawn happened')
64 try { await c.close() } catch {}
65 try { await served.close() } catch {}
66}
67
68console.log('\n# a daemon with NO handler announces it cannot self-update')
69{
70 // serveDeviceUds only registers the route when onRelease is truthy, so the "no handler" case that
71 // matters is device-node's wrapper — asserted here through the same shape: a wrapper that finds
72 // neither an explicit nor a global handler must say so rather than return quietly.
73 sock = join(tmp, 'c.sock')
74 const errs = []
75 const realErr = console.error
76 console.error = (...a) => errs.push(a.join(' '))
77 const wrapper = (ver) => {
78 const explicit = null
79 const global = null
80 if (typeof explicit === 'function') return explicit(ver)
81 if (global) return global(ver)
82 console.error(JSON.stringify({ msg: 'ota: release-ready arrived but NO respawn handler is registered — this daemon will not self-update' }))
83 }
84 served = await serveDeviceUds(device, { uds: sock, onRelease: wrapper })
85 const c = await connect()
86 await c.call('release-ready', {})
87 await new Promise((resolve) => setTimeout(resolve, 50))
88 console.error = realErr
89 assert(/NO respawn handler is registered/.test(errs.join('\n')), 'an unwired daemon is LOUD about it at the moment it matters')
90 try { await c.close() } catch {}
91 try { await served.close() } catch {}
92}
93
94try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
95console.log('\nall ota-respawn-wiring assertions passed')