| … | … | @@ -157,7 +157,7 @@ nothing on this side can send one yet. The bridge half is built; see hyper-socia |
| 157 | 157 | |
| 158 | 158 | ## Status |
| 159 | 159 | |
| 160 | Working. `npm test` — 407 tests, 961 asserts, passing (`brittle-bare test/*.test.js`, 53 files, | |
| 160 | Working. `npm test` — 409 tests, 971 asserts, passing (`brittle-bare test/*.test.js`, 54 files, | |
| 161 | 161 | ~43 s). |
| 162 | 162 | |
| 163 | 163 | Deployed as a [pgh](../pgh) app. Measured 2026-09-20: 0.0.57 installed and running on tabertron and |
| … | … | @@ -11,9 +11,16 @@ import Hyperswarm from 'hyperswarm' |
| 11 | 11 | import { FavelaNode } from './lib/node.js' |
| 12 | 12 | import { SwarmTransport } from './lib/transport.js' |
| 13 | 13 | import { createServer } from './lib/rpc.js' |
| 14 | import { startControl } from './lib/control.js' | |
| 14 | 15 | |
| 15 | 16 | const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela') |
| 16 | 17 | const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock') |
| 18 | // The pgh control socket (lib/control.js), separate from favela.sock: the sidecar speaks hyper-svc. | |
| 19 | const svcSock = process.env.FAVELA_SVC_SOCK || join(storage, 'svc.sock') | |
| 20 | ||
| 21 | function pkgVersion () { | |
| 22 | try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')).version } catch { return null } | |
| 23 | } | |
| 17 | 24 | |
| 18 | 25 | // ⛔ Refuse to start if a daemon already holds the socket. Removing a live socket would leave the |
| 19 | 26 | // running daemon unreachable while this one looked healthy — one process, two owners, no error. |
| … | … | @@ -55,13 +62,35 @@ async function main () { |
| 55 | 62 | msg: 'favela-peer up', feed: node.key.toString('hex'), storage, sock |
| 56 | 63 | })) |
| 57 | 64 | |
| 58 | const shutdown = async () => { | |
| 65 | // Idempotent, and BOUNDED: a close that never settles must not keep an OTA from respawning (the | |
| 66 | // supervisor only respawns once we exit). The first caller's exit code wins: 75 for release-ready, | |
| 67 | // which the supervisor respawns immediately, 0 for a signal. | |
| 68 | let control = null | |
| 69 | let stopping = false | |
| 70 | const shutdown = async (code = 0) => { | |
| 71 | if (stopping) return | |
| 72 | stopping = true | |
| 73 | const deadline = setTimeout(() => { | |
| 74 | console.log(JSON.stringify({ msg: 'shutdown: deadline reached, exiting anyway', code })) | |
| 75 | process.exit(code) | |
| 76 | }, 15000) | |
| 77 | try { if (control) await control.close() } catch {} | |
| 59 | 78 | try { await node.close() } catch {} |
| 60 | 79 | try { fs.unlinkSync(sock) } catch {} |
| 61 | process.exit(0) | |
| 80 | clearTimeout(deadline) | |
| 81 | process.exit(code) | |
| 62 | 82 | } |
| 63 | process.on('SIGINT', shutdown) | |
| 64 | process.on('SIGTERM', shutdown) | |
| 83 | process.on('SIGINT', () => shutdown(0)) | |
| 84 | process.on('SIGTERM', () => shutdown(0)) | |
| 85 | ||
| 86 | // OTA: registers svc.sock with pgh-sidecar so a published release reaches THIS process. Fails soft. | |
| 87 | control = await startControl({ | |
| 88 | udsPath: svcSock, | |
| 89 | name: process.env.PGH_APP_NAME || 'favela-peer', | |
| 90 | version: pkgVersion(), | |
| 91 | status: () => ({ ok: true, name: 'favela-peer', version: pkgVersion(), feed: node.key.toString('hex') }), | |
| 92 | onRelease: () => shutdown(75) | |
| 93 | }) | |
| 65 | 94 | } |
| 66 | 95 | |
| 67 | 96 | main().catch((err) => { console.error(err); process.exit(1) }) |
| … | … | @@ -0,0 +1,103 @@ |
| 1 | // control.js — the pgh control surface, which is what makes OTA updates reach a RUNNING daemon. | |
| 2 | // | |
| 3 | // WHY THIS EXISTS (2026-10-12). favela-peer had no control surface, so the sidecar had nowhere to | |
| 4 | // push `release-ready`: an update installed (staged, because the daemon was running) and the daemon | |
| 5 | // kept running the old code until someone restarted it by hand. 0.0.61 was published and installed | |
| 6 | // while tabertron and stortron both kept running 0.0.57. | |
| 7 | // | |
| 8 | // THE OTA CONTRACT (pgh-sidecar lib/index.js `_notifyAppsOfNewReleases`; the same contract every | |
| 9 | // pgh daemon with a control surface follows): | |
| 10 | // 1. The sidecar does the FULL install: tree, dep closure, materialize. A running app's install is | |
| 11 | // staged as <name>.next. | |
| 12 | // 2. It pushes `release-ready` to the udsPath the app REGISTERED with `app-register`. An app that | |
| 13 | // never registered is never told. | |
| 14 | // 3. We answer ok FIRST, then shut down and exit 75. | |
| 15 | // 4. The supervisor applies the staged tree and respawns immediately (75 skips its crash backoff). | |
| 16 | // | |
| 17 | // ⚠ This is NOT favela.sock. That socket speaks favela's own line-JSON RPC for the CLI and the | |
| 18 | // surface; the sidecar speaks hyper-svc. Two protocols, two sockets. | |
| 19 | // | |
| 20 | // FAILS SOFT. Holding and serving feeds matters more than being remotely manageable: if the socket | |
| 21 | // cannot bind, the daemon logs it and keeps running. Registration is best-effort too, because a | |
| 22 | // standalone `bare index.js` has no sidecar at all. | |
| 23 | ||
| 24 | import fs from '#node:fs' | |
| 25 | import os from '#node:os' | |
| 26 | import { join } from '#node:path' | |
| 27 | import process from '#node:process' | |
| 28 | ||
| 29 | export function sidecarSockPath () { | |
| 30 | return process.env.PGH_SIDECAR_SOCK || join(os.homedir(), '.pgh', 'svc.sock') | |
| 31 | } | |
| 32 | ||
| 33 | async function registerWithSidecar ({ udsPath, name, version }) { | |
| 34 | const sidecarSock = sidecarSockPath() | |
| 35 | if (!fs.existsSync(sidecarSock)) return false | |
| 36 | try { | |
| 37 | const { Client } = await import('hyper-svc') | |
| 38 | const c = await Client.connectUds(sidecarSock) | |
| 39 | try { await c.call('app-register', { name, udsPath, pid: process.pid, runtime: 'bare', version }) } finally { await c.close() } | |
| 40 | return true | |
| 41 | } catch { | |
| 42 | return false | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | // Register now, then again on an interval: the sidecar keeps its app registry IN MEMORY, so a | |
| 47 | // sidecar restart silently drops us and the next release is never pushed. The sidecar socket can | |
| 48 | // also appear after us (its cold start indexes a large store), which the interval covers too. | |
| 49 | function maintainRegistration ({ udsPath, name, version, intervalMs = 30000 }) { | |
| 50 | registerWithSidecar({ udsPath, name, version }) | |
| 51 | const timer = setInterval(() => { registerWithSidecar({ udsPath, name, version }) }, intervalMs) | |
| 52 | if (timer.unref) timer.unref() | |
| 53 | return () => clearInterval(timer) | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Stand up the control socket and register it with the sidecar. | |
| 58 | * | |
| 59 | * @param {object} opts | |
| 60 | * @param {string} opts.udsPath | |
| 61 | * @param {string} opts.name | |
| 62 | * @param {string} [opts.version] | |
| 63 | * @param {Function} [opts.status] returns the `status` reply | |
| 64 | * @param {Function} opts.onRelease called with the version AFTER the reply is queued; owns the | |
| 65 | * shutdown and the exit code | |
| 66 | * @param {Function} [opts.log] | |
| 67 | * @returns {Promise<{close: Function}|null>} null when the surface could not come up | |
| 68 | */ | |
| 69 | export async function startControl ({ udsPath, name, version = null, status = null, onRelease, log = defaultLog }) { | |
| 70 | try { | |
| 71 | const { Service } = await import('hyper-svc') | |
| 72 | // A stale socket from a killed process blocks bind. index.js only gets here after refusing to | |
| 73 | // start beside a live daemon, so an existing file is orphaned. | |
| 74 | try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {} | |
| 75 | const svc = new Service({ name, uds: udsPath }) | |
| 76 | svc.handle('status', async () => (status ? status() : { ok: true, name, version })) | |
| 77 | svc.handle('release-ready', async (ctx, params = {}) => { | |
| 78 | const ver = (params && params.version) || '?' | |
| 79 | log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver }) | |
| 80 | // After the reply is queued: exiting first drops the connection, and the sidecar cannot tell | |
| 81 | // "accepted, respawning" from "crashed". | |
| 82 | setImmediate(() => { onRelease(ver) }) | |
| 83 | return { ok: true, willRespawn: true } | |
| 84 | }) | |
| 85 | await svc.listen() | |
| 86 | const stop = maintainRegistration({ udsPath, name, version }) | |
| 87 | log({ msg: 'control surface up — OTA respawn armed', uds: udsPath }) | |
| 88 | return { | |
| 89 | async close () { | |
| 90 | stop() | |
| 91 | try { await svc.close() } catch {} | |
| 92 | try { fs.unlinkSync(udsPath) } catch {} | |
| 93 | } | |
| 94 | } | |
| 95 | } catch (err) { | |
| 96 | log({ msg: 'control surface unavailable (continuing to serve)', error: (err && err.message) || String(err) }) | |
| 97 | return null | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | function defaultLog (o) { console.log(JSON.stringify(o)) } | |
| 102 | ||
| 103 | export default startControl |
| … | … | @@ -31,7 +31,8 @@ |
| 31 | 31 | "keet-identity-key": "3.2.0", |
| 32 | 32 | "protomux": "^3.11.0", |
| 33 | 33 | "protomux-rpc": "^1.10.0", |
| 34 | "sodium-hmac": "^2.0.1" | |
| 34 | "sodium-hmac": "^2.0.1", | |
| 35 | "hyper-svc": "file:../hyper-svc" | |
| 35 | 36 | }, |
| 36 | 37 | "bin": { |
| 37 | 38 | "favela": "bin/favela.js", |
| … | … | @@ -1992,6 +1993,37 @@ |
| 1992 | 1993 | "dependencies": { |
| 1993 | 1994 | "b4a": "^1.5.3" |
| 1994 | 1995 | } |
| 1996 | }, | |
| 1997 | "node_modules/hyper-svc": { | |
| 1998 | "resolved": "../hyper-svc", | |
| 1999 | "link": true | |
| 2000 | }, | |
| 2001 | "../hyper-svc": { | |
| 2002 | "version": "0.1.9", | |
| 2003 | "dependencies": { | |
| 2004 | "b4a": "^1.6.0", | |
| 2005 | "bare-events": "^2.0.0", | |
| 2006 | "bare-fs": "^4.7.2", | |
| 2007 | "bare-http1": "^4.0.0", | |
| 2008 | "bare-net": "^1.0.0", | |
| 2009 | "bare-os": "^3.0.0", | |
| 2010 | "bare-path": "^3.0.0", | |
| 2011 | "bare-process": "^4.0.0", | |
| 2012 | "bare-stream": "^2.0.0", | |
| 2013 | "bare-url": "^2.0.0", | |
| 2014 | "compact-encoding": "^3.2.0", | |
| 2015 | "hypercore-crypto": "^3.0.0", | |
| 2016 | "hyperswarm": "^4.0.0", | |
| 2017 | "protomux": "^3.0.0", | |
| 2018 | "ready-resource": "^1.0.0", | |
| 2019 | "bare-abstract-socket": "^1.0.0" | |
| 2020 | }, | |
| 2021 | "devDependencies": { | |
| 2022 | "@hyperswarm/testnet": "^3.0.0", | |
| 2023 | "brittle": "^3.0.0", | |
| 2024 | "corestore": "^7.0.0", | |
| 2025 | "hyper-rooms": "file:../hyper-rooms" | |
| 2026 | } | |
| 1995 | 2027 | } |
| 1996 | 2028 | } |
| 1997 | 2029 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.61", | |
| 3 | "version": "0.0.62", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -53,7 +53,8 @@ |
| 53 | 53 | "protomux": "^3.11.0", |
| 54 | 54 | "sodium-hmac": "^2.0.1", |
| 55 | 55 | "hyperident": "file:../repos/hyperident", |
| 56 | "protomux-rpc": "^1.10.0" | |
| 56 | "protomux-rpc": "^1.10.0", | |
| 57 | "hyper-svc": "file:../hyper-svc" | |
| 57 | 58 | }, |
| 58 | 59 | "devDependencies": { |
| 59 | 60 | "brittle": "^4.0.2", |
| … | … | @@ -0,0 +1,64 @@ |
| 1 | // favela-peer answers the sidecar's OTA push. | |
| 2 | // | |
| 3 | // ⛔ WHY (2026-10-12). favela-peer had no control surface, so the sidecar never knew where to send | |
| 4 | // `release-ready`: an update installed (staged, since the daemon was running) and the daemon kept | |
| 5 | // running the old code until someone restarted it by hand. tabertron and stortron were both still on | |
| 6 | // 0.0.57 after 0.0.61 was published. INSTALLED != RUNNING, with nothing to close the gap. | |
| 7 | // | |
| 8 | // The contract (pgh-sidecar lib/index.js `_notifyAppsOfNewReleases`): the sidecar installs, then | |
| 9 | // pushes `release-ready` ONLY to an app that registered a udsPath with `app-register`. The app | |
| 10 | // answers, shuts down, and exits 75; the supervisor applies the staged tree and respawns. | |
| 11 | ||
| 12 | import test from 'brittle' | |
| 13 | import fs from '#node:fs' | |
| 14 | import os from '#node:os' | |
| 15 | import process from '#node:process' | |
| 16 | import { join } from '#node:path' | |
| 17 | import { Service, Client } from 'hyper-svc' | |
| 18 | import { startControl } from '../lib/control.js' | |
| 19 | ||
| 20 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ctl-')) | |
| 21 | ||
| 22 | // A stand-in for pgh-sidecar: records every app-register it receives. | |
| 23 | async function fakeSidecar () { | |
| 24 | const dir = tmp() | |
| 25 | const sock = join(dir, 's.sock') | |
| 26 | const registered = [] | |
| 27 | const svc = new Service({ name: 'pgh-sidecar', uds: sock }) | |
| 28 | svc.handle('app-register', async (ctx, params) => { registered.push(params); return { ok: true } }) | |
| 29 | await svc.listen() | |
| 30 | return { sock, registered, close: () => svc.close() } | |
| 31 | } | |
| 32 | ||
| 33 | async function until (fn, ms = 10000) { | |
| 34 | const end = Date.now() + ms | |
| 35 | while (Date.now() < end) { const v = await fn(); if (v) return v; await new Promise((r) => setTimeout(r, 25)) } | |
| 36 | return null | |
| 37 | } | |
| 38 | ||
| 39 | test('★ registers its control socket with the sidecar, and answers release-ready', async (t) => { | |
| 40 | const sidecar = await fakeSidecar() | |
| 41 | // Bare's process.env cannot delete a key, so the previous value is put back instead. | |
| 42 | const prev = process.env.PGH_SIDECAR_SOCK || join(os.homedir(), '.pgh', 'svc.sock') | |
| 43 | process.env.PGH_SIDECAR_SOCK = sidecar.sock | |
| 44 | const udsPath = join(tmp(), 'svc.sock') | |
| 45 | let released = null | |
| 46 | const control = await startControl({ udsPath, name: 'favela-peer', version: '0.0.0-test', onRelease: (v) => { released = v }, log: () => {} }) | |
| 47 | t.teardown(async () => { await control.close(); await sidecar.close(); process.env.PGH_SIDECAR_SOCK = prev }) | |
| 48 | ||
| 49 | t.ok(control, 'the control surface came up') | |
| 50 | const reg = await until(() => sidecar.registered.find((r) => r.udsPath === udsPath)) | |
| 51 | t.ok(reg, 'the sidecar was told where to push release-ready — without this it never calls us') | |
| 52 | t.is(reg && reg.name, 'favela-peer') | |
| 53 | ||
| 54 | const c = await Client.connectUds(udsPath) | |
| 55 | const reply = await c.call('release-ready', { name: 'favela-peer', version: '9.9.9' }) | |
| 56 | await c.close() | |
| 57 | t.alike(reply, { ok: true, willRespawn: true }, 'answered before exiting, so the sidecar sees ok, not a dropped connection') | |
| 58 | t.is(await until(() => released), '9.9.9', 'and the daemon was asked to shut down for the respawn') | |
| 59 | }) | |
| 60 | ||
| 61 | test('fails soft: no control socket is not a reason to stop serving the feed', async (t) => { | |
| 62 | const control = await startControl({ udsPath: join(tmp(), 'no-such-dir', 'svc.sock'), name: 'favela-peer', onRelease: () => {}, log: () => {} }) | |
| 63 | t.is(control, null, 'returns null instead of throwing') | |
| 64 | }) |