OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 2e5c8ea

0.0.62: answer the sidecar's release-ready, so an update reaches the running daemon
favela-peer had no control surface, so pgh-sidecar never knew where to push release-ready. An update installed (staged, the daemon being up) and the daemon kept running the old code: 0.0.61 was published and installed while tabertron and stortron still ran 0.0.57. lib/control.js serves status and release-ready on svc.sock (hyper-svc, separate from favela.sock's line-JSON RPC), registers it with the sidecar and re-registers every 30 s (the sidecar's registry is in memory), and fails soft. index.js now shuts down idempotently, bounded by 15 s, with the caller's exit code: 75 on release-ready, 0 on a signal. hyper-svc is a new file: sibling, symlinked; CI vendors it from package.json. Verified on the real entrypoint against a fake sidecar: registered as 0.0.62, release-ready -> exit 75 in 1.8 s, SIGTERM -> exit 0 in 2.4 s. Suite 409/409 (971 asserts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2e5c8eac0b83b136e654e2f8c7aa9949f2f3d7af
by plankatron <fleeky@gmail.com> · 2026-10-13T01:31:48.000Z

Changed files (6) +237 -8

README.md +1 -1
……@@ -157,7 +157,7 @@ nothing on this side can send one yet. The bridge half is built; see hyper-socia
157157
158158## Status
159159
160Working. `npm test` — 407 tests, 961 asserts, passing (`brittle-bare test/*.test.js`, 53 files,
160Working. `npm test` — 409 tests, 971 asserts, passing (`brittle-bare test/*.test.js`, 54 files,
161161~43 s).
162162
163163Deployed as a [pgh](../pgh) app. Measured 2026-09-20: 0.0.57 installed and running on tabertron and
index.js +33 -4
……@@ -11,9 +11,16 @@ import Hyperswarm from 'hyperswarm'
1111import { FavelaNode } from './lib/node.js'
1212import { SwarmTransport } from './lib/transport.js'
1313import { createServer } from './lib/rpc.js'
14import { startControl } from './lib/control.js'
1415
1516const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela')
1617const 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.
19const svcSock = process.env.FAVELA_SVC_SOCK || join(storage, 'svc.sock')
20
21function pkgVersion () {
22 try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')).version } catch { return null }
23}
1724
1825// ⛔ Refuse to start if a daemon already holds the socket. Removing a live socket would leave the
1926// running daemon unreachable while this one looked healthy — one process, two owners, no error.
……@@ -55,13 +62,35 @@ async function main () {
5562 msg: 'favela-peer up', feed: node.key.toString('hex'), storage, sock
5663 }))
5764
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 {}
5978 try { await node.close() } catch {}
6079 try { fs.unlinkSync(sock) } catch {}
61 process.exit(0)
80 clearTimeout(deadline)
81 process.exit(code)
6282 }
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 })
6594}
6695
6796main().catch((err) => { console.error(err); process.exit(1) })
lib/control.js +103 -0
……@@ -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
24import fs from '#node:fs'
25import os from '#node:os'
26import { join } from '#node:path'
27import process from '#node:process'
28
29export function sidecarSockPath () {
30 return process.env.PGH_SIDECAR_SOCK || join(os.homedir(), '.pgh', 'svc.sock')
31}
32
33async 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.
49function 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 */
69export 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
101function defaultLog (o) { console.log(JSON.stringify(o)) }
102
103export default startControl
package-lock.json +33 -1
……@@ -31,7 +31,8 @@
3131 "keet-identity-key": "3.2.0",
3232 "protomux": "^3.11.0",
3333 "protomux-rpc": "^1.10.0",
34 "sodium-hmac": "^2.0.1"
34 "sodium-hmac": "^2.0.1",
35 "hyper-svc": "file:../hyper-svc"
3536 },
3637 "bin": {
3738 "favela": "bin/favela.js",
……@@ -1992,6 +1993,37 @@
19921993 "dependencies": {
19931994 "b4a": "^1.5.3"
19941995 }
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 }
19952027 }
19962028 }
19972029}
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.61",
3 "version": "0.0.62",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
……@@ -53,7 +53,8 @@
5353 "protomux": "^3.11.0",
5454 "sodium-hmac": "^2.0.1",
5555 "hyperident": "file:../repos/hyperident",
56 "protomux-rpc": "^1.10.0"
56 "protomux-rpc": "^1.10.0",
57 "hyper-svc": "file:../hyper-svc"
5758 },
5859 "devDependencies": {
5960 "brittle": "^4.0.2",
test/control.test.js +64 -0
……@@ -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
12import test from 'brittle'
13import fs from '#node:fs'
14import os from '#node:os'
15import process from '#node:process'
16import { join } from '#node:path'
17import { Service, Client } from 'hyper-svc'
18import { startControl } from '../lib/control.js'
19
20const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ctl-'))
21
22// A stand-in for pgh-sidecar: records every app-register it receives.
23async 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
33async 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
39test('★ 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
61test('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})