| … | … | @@ -10,6 +10,7 @@ const fs = require('bare-fs') |
| 10 | 10 | const os = require('bare-os') |
| 11 | 11 | const path = require('bare-path') |
| 12 | 12 | const b4a = require('b4a') |
| 13 | const Signal = require('bare-signals') |
| 13 | 14 | const { createHost } = require('../cli/host.js') |
| 14 | 15 | |
| 15 | 16 | // ---- tiny arg parser (positionals + --flags; paparam can replace this later) ---- |
| … | … | @@ -113,10 +114,34 @@ async function runDaemon () { |
| 113 | 114 | // (deterministic multi-node testing); omit for the public DHT. |
| 114 | 115 | const dhtBootstrap = flags.bootstrap ? JSON.parse(flags.bootstrap) : null |
| 115 | 116 | const { Service } = await import('hyper-svc') |
| 116 | | const host = createHost({ storage, downloads, dhtBootstrap, debug: !!flags.debug }) |
| 117 | | await host.ready |
| 118 | 117 | |
| 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') |
| 119 | 139 | 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 | |
| 120 | 145 | const svc = new Service({ name: 'mirall', uds: sock }) |
| 121 | 146 | // Thin passthrough: the CLI owns the command surface, the daemon just relays to the worker. |
| 122 | 147 | svc.handle('rpc', async (ctx, params) => host.request(params.type, params.args || {})) |
| … | … | @@ -125,8 +150,15 @@ async function runDaemon () { |
| 125 | 150 | spaces: await host.request('spaces:list'), |
| 126 | 151 | network: await host.request('network:status:get'), |
| 127 | 152 | })) |
| 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 | }) |
| 130 | 162 | await svc.listen() |
| 131 | 163 | |
| 132 | 164 | // hyperident-keyed invite RENDEZVOUS: serve `get-invite` on topic = hash(ownHyperidentPubkey |
| … | … | @@ -139,7 +171,7 @@ async function runDaemon () { |
| 139 | 171 | const [{ default: Hyperswarm }, { default: Protomux }, { default: crypto }, { default: b4a }, { attachRpcServer }] = |
| 140 | 172 | await Promise.all([import('hyperswarm'), import('protomux'), import('hypercore-crypto'), import('b4a'), import('hyper-svc')]) |
| 141 | 173 | const topic = crypto.hash(b4a.concat([b4a.from(pub, 'hex'), b4a.from('mirall-invite/v1')])) |
| 142 | | const rvSwarm = new Hyperswarm() |
| 174 | rvSwarm = new Hyperswarm() |
| 143 | 175 | rvSwarm.on('connection', (conn) => { |
| 144 | 176 | const mux = Protomux.from(conn) |
| 145 | 177 | attachRpcServer(mux, async (method) => { |
| … | … | @@ -167,7 +199,7 @@ async function runDaemon () { |
| 167 | 199 | const line = 'data: ' + b4a.toString(chunk, 'base64') + '\n\n' |
| 168 | 200 | for (const res of sseClients) { try { res.write(line) } catch {} } |
| 169 | 201 | }) |
| 170 | | const server = http.createServer((req, res) => { |
| 202 | webServer = http.createServer((req, res) => { |
| 171 | 203 | const url = (req.url || '/').split('?')[0] |
| 172 | 204 | if (url === '/worker/events') { // SSE: stream worker output to the browser |
| 173 | 205 | res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) |
| … | … | @@ -204,7 +236,25 @@ async function runDaemon () { |
| 204 | 236 | res.end(data) |
| 205 | 237 | } catch { res.writeHead(404); res.end('not found') } |
| 206 | 238 | }) |
| 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 {} |
| 208 | 258 | } |
| 209 | 259 | |
| 210 | 260 | console.log('mirall daemon up') |