import { spawn, type ChildProcess } from 'node:child_process'; import { join } from 'node:path'; import { resolveNodeBin, resolveServerEntry } from './paths'; import { buildChildEnv } from './env'; export interface DaemonHandle { url: string; token: string; stop(): Promise; } interface Handshake { token: string; url: string; } /** * Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3 * (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le * serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL. */ export function startDaemon(opts: { dataDir: string; port: number; onLog?: (line: string) => void; onExit?: (code: number | null) => void; }): Promise { const node = resolveNodeBin(); const entry = resolveServerEntry(); const dbPath = join(opts.dataDir, 'arboretum.db'); const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' }); const child: ChildProcess = spawn(node, [entry, '--port', String(opts.port), '--db', dbPath], { env, stdio: ['ignore', 'pipe', 'pipe', 'pipe'], }); child.stdout?.on('data', (d: Buffer) => opts.onLog?.(d.toString())); child.stderr?.on('data', (d: Buffer) => opts.onLog?.(d.toString())); let stopped = false; const stop = (): Promise => new Promise((resolve) => { if (stopped || child.exitCode !== null) return resolve(); stopped = true; const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000); child.once('exit', () => { clearTimeout(killTimer); resolve(); }); child.kill('SIGTERM'); }); return new Promise((resolve, reject) => { let buf = ''; let settled = false; const fd3 = child.stdio[3] as NodeJS.ReadableStream | null; const timer = setTimeout(() => { if (settled) return; settled = true; void stop(); reject(new Error('daemon handshake timeout')); }, 30000); child.once('exit', (code) => { opts.onExit?.(code); if (!settled) { settled = true; clearTimeout(timer); reject(new Error(`daemon exited before handshake (code ${code ?? 'null'})`)); } }); fd3?.on('data', (chunk: Buffer) => { if (settled) return; buf += chunk.toString(); const nl = buf.indexOf('\n'); if (nl < 0) return; settled = true; clearTimeout(timer); try { const hs = JSON.parse(buf.slice(0, nl)) as Handshake; resolve({ url: hs.url, token: hs.token, stop }); } catch (err) { void stop(); reject(err instanceof Error ? err : new Error(String(err))); } }); }); }