| … | … | @@ -118,7 +118,15 @@ if (cfg.swarm) { |
| 118 | 118 | // MULTI-DID (multi-tenant) daemon: ONE process serves N self-custody DIDs over ONE shared |
| 119 | 119 | // Corestore/Hyperswarm/ProtomuxWakeup (each DID a namespace, one storage lock). A user's own DID |
| 120 | 120 | // + 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 }) | |
| 122 | 130 | console.log(JSON.stringify({ msg: 'multi-did daemon serving', dids: daemon.nodes.length, storage: cfg.storage })) |
| 123 | 131 | const shutdownM = async () => { try { await daemon.close() } catch {} ; process.exit(0) } |
| 124 | 132 | process.on('SIGINT', shutdownM) |
| … | … | @@ -110,7 +110,7 @@ export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemoni |
| 110 | 110 | // So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner |
| 111 | 111 | // sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had |
| 112 | 112 | // to solve exactly this for a creator rejoining from a fresh store. |
| 113 | export 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 } = {}) { | |
| 113 | export 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 } = {}) { | |
| 114 | 114 | if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required') |
| 115 | 115 | if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required") |
| 116 | 116 | 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 |
| 509 | 509 | udsServing = await serveDeviceUds(device, { |
| 510 | 510 | uds: join(ctlDir, 'control.sock'), |
| 511 | 511 | 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 | } | |
| 513 | 524 | }) |
| 514 | 525 | console.log(JSON.stringify({ msg: 'device UDS control surface', uds: join(ctlDir, 'control.sock') })) |
| 515 | 526 | } 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) { |
| 628 | 639 | // per-community scripts. Foundation proven in atproto-room/test/multi-did.test.js. |
| 629 | 640 | // dids = [{ deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, members, |
| 630 | 641 | // writers, adopt?, resetFirehose?, appPasswordHash?, jwtSecret? }]. |
| 631 | export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, signIntervalMs = 2000 } = {}) { | |
| 642 | export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) { | |
| 632 | 643 | if (typeof storage !== 'string') throw new Error('bootMultiDevice: storage path required') |
| 633 | 644 | if (!Array.isArray(dids) || dids.length === 0) throw new Error('bootMultiDevice: at least one DID required') |
| 634 | 645 | |
| … | … | @@ -707,7 +718,7 @@ export async function bootMultiDevice ({ storage, dids = [], swarmOpts = null, s |
| 707 | 718 | if (d.adoptCar && !d.adopt) { |
| 708 | 719 | 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 })) } |
| 709 | 720 | } |
| 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 }) | |
| 711 | 722 | swarm.join(node.sb.base.discoveryKey, { server: true, client: true }) // daemon joins each DID's StateBase topic |
| 712 | 723 | // resync side store: replicate on connections already open (future ones via the connection handler). |
| 713 | 724 | if (node._sideStore) for (const conn of swarm.connections) { try { node._sideStore.replicate(conn) } catch {} } |
| … | … | @@ -25,7 +25,13 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n |
| 25 | 25 | svc.handle('release-ready', async (ctx, params = {}) => { |
| 26 | 26 | const ver = (params && params.version) || '?' |
| 27 | 27 | 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 | }) | |
| 29 | 35 | return { ok: true, willRespawn: true } |
| 30 | 36 | }) |
| 31 | 37 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.105", | |
| 3 | "version": "0.3.106", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/peer.js", |
| … | … | @@ -53,3 +53,4 @@ import './fresh-account-initial-commit.test.js' |
| 53 | 53 | import './swarm-device-forward-only-sign.test.mjs' |
| 54 | 54 | import './delete-tip-wedge.repro.test.js' |
| 55 | 55 | import './resync-fresh-corestore.test.js' |
| 56 | import './ota-respawn-wiring.test.mjs' |
| … | … | @@ -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 | |
| 19 | import { serveDeviceUds } from '../lib/serve-device-uds.js' | |
| 20 | import { Client } from 'hyper-svc' | |
| 21 | import fs from 'bare-fs' | |
| 22 | import process from 'bare-process' | |
| 23 | import { join } from 'bare-path' | |
| 24 | ||
| 25 | const assert = (cond, msg) => { if (!cond) { console.error('FAIL:', msg); process.exit(1) } console.log(' ok -', msg) } | |
| 26 | const tmp = join(process.env.TMPDIR || '/tmp', 'hpp-ota-wiring-' + process.pid) | |
| 27 | fs.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. | |
| 31 | const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: 'did:plc:test' } | |
| 32 | ||
| 33 | let sock, served | |
| 34 | const connect = async () => Client.connectUds(sock) | |
| 35 | ||
| 36 | console.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 | ||
| 50 | console.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 | ||
| 68 | console.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 | ||
| 94 | try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {} | |
| 95 | console.log('\nall ota-respawn-wiring assertions passed') |