OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli commits / 728071d

mirall-cli 0.1.4: durable restart (SEV-1) + persistent publish-space (SEV-2)
SEV-1 — spaces "not persistent" was actually a restart race: the daemon had no graceful shutdown, so on stop the worker (which holds the single- writer RocksDB lock) only exited via IPC pipe-close up to ~4s later, and never closed the store. A fast pgh stop→start / OTA respawn then raced the lock — the new worker couldn't open the store, worker-ready never fired, and the daemon appeared to have lost all spaces (data was intact on disk). - bin/mirall.js: catch SIGTERM/SIGINT (bare-signals) and drain the worker via host.close() BEFORE exiting, so the lock is released first. - cli/host.js: close() now resolves only when the worker PROCESS exits (waits for sc 'exit'/'close'), with a force-kill fallback + hang cap, instead of a flat 300ms delay. - bin/mirall.js: boot-retry loop (6× / 12s) so a fresh worker retries if the store is momentarily locked, instead of dying — belt-and-suspenders for hard-kill / crash recovery. Adds host readyTimeout option. SEV-2 — publish-space designation was in-memory only (reset to null every restart), so `dial --name` silently returned "no invite" until re-run. Persist it beside the store and restore on boot. Verified: 9/9 restart-loop cycles (graceful SIGTERM + hard process-group SIGKILL) recover in 1-2s with the space intact; publish-space survives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
728071d6799d697540a99a508df0e5131cfe38f3
by plankatron <fleeky@gmail.com> · 2026-07-24T18:02:45.000Z

Changed files (3) +77 -13

bin/mirall.js +57 -7
……@@ -10,6 +10,7 @@ const fs = require('bare-fs')
1010const os = require('bare-os')
1111const path = require('bare-path')
1212const b4a = require('b4a')
13const Signal = require('bare-signals')
1314const { createHost } = require('../cli/host.js')
1415
1516// ---- tiny arg parser (positionals + --flags; paparam can replace this later) ----
……@@ -113,10 +114,34 @@ async function runDaemon () {
113114 // (deterministic multi-node testing); omit for the public DHT.
114115 const dhtBootstrap = flags.bootstrap ? JSON.parse(flags.bootstrap) : null
115116 const { Service } = await import('hyper-svc')
116 const host = createHost({ storage, downloads, dhtBootstrap, debug: !!flags.debug })
117 await host.ready
118117
118 // Boot the worker with lock-race resilience. The store is a single-writer RocksDB; on a fast
119 // stop→start (pgh app restart / OTA respawn) the PREVIOUS worker may still hold the lock for a
120 // beat, so a fresh worker can't open the store and never emits worker-ready. Rather than die
121 // (the old "spaces vanished" symptom), retry with a fresh worker until the lock frees.
122 let host = null
123 const MAX_BOOT_ATTEMPTS = 6
124 for (let attempt = 1; ; attempt++) {
125 host = createHost({ storage, downloads, dhtBootstrap, debug: !!flags.debug, readyTimeout: 12000 })
126 try { await host.ready; break }
127 catch (err) {
128 try { await host.close({ timeoutMs: 2000 }) } catch {}
129 if (attempt >= MAX_BOOT_ATTEMPTS) throw new Error('worker did not become ready after ' + attempt + ' attempts — the store may be locked by another mirall daemon on ' + storage + ' (' + err.message + ')')
130 console.error('mirall: worker not ready (attempt ' + attempt + '/' + MAX_BOOT_ATTEMPTS + '), retrying — ' + err.message)
131 await new Promise((r) => { const t = setTimeout(r, 1500); t.unref && t.unref() })
132 }
133 }
134
135 // publish-space designation must survive restarts (SEV-2): a bare `let` reset to null on every
136 // boot made `dial --name` silently return "no invite" until publish-space was re-run. Persist it
137 // beside the store (same dir the engine writes identity.enc to) and restore it on boot.
138 const PUBLISHED_FILE = path.join(path.dirname(storage), 'published-space.json')
119139 let publishedSpaceId = null
140 try { publishedSpaceId = (JSON.parse(fs.readFileSync(PUBLISHED_FILE).toString()) || {}).spaceId || null } catch {}
141
142 let webServer = null // hoisted so the shutdown handler can close them
143 let rvSwarm = null
144
120145 const svc = new Service({ name: 'mirall', uds: sock })
121146 // Thin passthrough: the CLI owns the command surface, the daemon just relays to the worker.
122147 svc.handle('rpc', async (ctx, params) => host.request(params.type, params.args || {}))
……@@ -125,8 +150,15 @@ async function runDaemon () {
125150 spaces: await host.request('spaces:list'),
126151 network: await host.request('network:status:get'),
127152 }))
128 // Designate which space's invite the hyperident rendezvous serves (mirall publish-space).
129 svc.handle('publish-space', async (ctx, params) => { publishedSpaceId = params.spaceId || null; return { published: publishedSpaceId } })
153 // Designate which space's invite the hyperident rendezvous serves (mirall publish-space) — persisted.
154 svc.handle('publish-space', async (ctx, params) => {
155 publishedSpaceId = params.spaceId || null
156 try {
157 if (publishedSpaceId) { fs.mkdirSync(path.dirname(PUBLISHED_FILE), { recursive: true }); fs.writeFileSync(PUBLISHED_FILE, JSON.stringify({ spaceId: publishedSpaceId })) }
158 else { try { fs.unlinkSync(PUBLISHED_FILE) } catch {} }
159 } catch {}
160 return { published: publishedSpaceId }
161 })
130162 await svc.listen()
131163
132164 // hyperident-keyed invite RENDEZVOUS: serve `get-invite` on topic = hash(ownHyperidentPubkey
……@@ -139,7 +171,7 @@ async function runDaemon () {
139171 const [{ default: Hyperswarm }, { default: Protomux }, { default: crypto }, { default: b4a }, { attachRpcServer }] =
140172 await Promise.all([import('hyperswarm'), import('protomux'), import('hypercore-crypto'), import('b4a'), import('hyper-svc')])
141173 const topic = crypto.hash(b4a.concat([b4a.from(pub, 'hex'), b4a.from('mirall-invite/v1')]))
142 const rvSwarm = new Hyperswarm()
174 rvSwarm = new Hyperswarm()
143175 rvSwarm.on('connection', (conn) => {
144176 const mux = Protomux.from(conn)
145177 attachRpcServer(mux, async (method) => {
……@@ -167,7 +199,7 @@ async function runDaemon () {
167199 const line = 'data: ' + b4a.toString(chunk, 'base64') + '\n\n'
168200 for (const res of sseClients) { try { res.write(line) } catch {} }
169201 })
170 const server = http.createServer((req, res) => {
202 webServer = http.createServer((req, res) => {
171203 const url = (req.url || '/').split('?')[0]
172204 if (url === '/worker/events') { // SSE: stream worker output to the browser
173205 res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
……@@ -204,7 +236,25 @@ async function runDaemon () {
204236 res.end(data)
205237 } catch { res.writeHead(404); res.end('not found') }
206238 })
207 server.listen(Number(flags.web), '127.0.0.1')
239 webServer.listen(Number(flags.web), '127.0.0.1')
240 }
241
242 // Graceful shutdown: drain the worker so it EXITS and releases the RocksDB lock BEFORE this
243 // process dies — otherwise a quick restart races the lock (the "spaces vanished" bug). pgh app
244 // stop SIGTERMs the process group; bare-signals catches it in the real bare process.
245 let shuttingDown = false
246 async function gracefulShutdown (sig) {
247 if (shuttingDown) return
248 shuttingDown = true
249 console.log('\nmirall: ' + sig + ' — shutting down…')
250 try { if (webServer) webServer.close() } catch {}
251 try { if (rvSwarm) await rvSwarm.destroy() } catch {}
252 try { if (svc && typeof svc.close === 'function') await svc.close() } catch {}
253 try { await host.close() } catch {} // waits for the worker to exit → RocksDB lock released
254 Bare.exit(0)
255 }
256 for (const sig of ['SIGTERM', 'SIGINT']) {
257 try { new Signal(sig).on('signal', () => { gracefulShutdown(sig) }).start() } catch {}
208258 }
209259
210260 console.log('mirall daemon up')
cli/host.js +19 -5
……@@ -22,6 +22,7 @@ function createHost (opts = {}) {
2222 appVersion = '0.0.0-p1',
2323 flags = {}, // feature-flag overrides (membershipApproval, etc.)
2424 debug = false,
25 readyTimeout = 60000, // how long to wait for the worker to emit worker-ready before ready rejects
2526 } = opts
2627
2728 fs.mkdirSync(storage, { recursive: true })
……@@ -100,13 +101,26 @@ function createHost (opts = {}) {
100101 await new Promise((r) => { const t = setTimeout(r, every); t.unref && t.unref() })
101102 }
102103 }
103 async function close () {
104 try { await request('shutdown') } catch {}
105 await new Promise((r) => { const t = setTimeout(r, 300); t.unref && t.unref() })
106 try { sc.destroy() } catch {}
104 // Resolve only once the worker PROCESS has actually exited — that is what releases the
105 // single-writer RocksDB lock on `storage`. The old flat 300ms delay returned while the worker
106 // was still up, so a fast restart raced the lock (the "spaces vanished" symptom). Graceful path:
107 // ask the worker to shut down (its `shutdown` handler → safeShutdown → Bare.exit). Fallback: if
108 // it doesn't exit within timeoutMs, destroy() kills the child. A final cap so close() never hangs.
109 function close ({ timeoutMs = 8000 } = {}) {
110 return new Promise((resolve) => {
111 let done = false
112 const finish = () => { if (done) return; done = true; clearTimeout(hard); clearTimeout(cap); resolve() }
113 sc.once('exit', finish) // worker process exited → lock released
114 sc.once('close', finish)
115 try { request('shutdown').catch(() => {}) } catch {} // best-effort graceful drain
116 const hard = setTimeout(() => { try { sc.destroy() } catch {} }, timeoutMs) // force-kill the child
117 hard.unref && hard.unref()
118 const cap = setTimeout(finish, timeoutMs + 3000) // never hang forever
119 cap.unref && cap.unref()
120 })
107121 }
108122
109 const ready = waitFor('event:worker-ready')
123 const ready = waitFor('event:worker-ready', () => true, readyTimeout)
110124 sc.write(JSON.stringify({
111125 type: 'bootstrap',
112126 storage,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "mirall-cli",
3 "version": "0.1.3",
3 "version": "0.1.4",
44 "description": "Headless CLI + daemon for Mirall — secure P2P file transfer, no cloud, no Electron. A standalone bare/pgh port of Mirall's data layer.",
55 "license": "AGPL-3.0-or-later",
66 "author": "plankatron",