// Owns the s3d process: start/stop, a log ring buffer, and one-shot CLI runs // (users/keys/version) against the same config file the daemon uses. import { spawn, execFile } from 'node:child_process'; import { EventEmitter } from 'node:events'; import path from 'node:path'; const LOG_LINES = 2000; const ANSI = /\x1b\[[0-9;]*m/g; export class Daemon extends EventEmitter { constructor({ binary, configFile }) { super(); this.binary = binary; this.configFile = configFile; this.child = null; this.state = 'stopped'; // stopped | starting | running | stopping | crashed this.exitCode = null; this.startedAt = null; this.logs = []; this.seq = 0; } env() { // S3D_CONFIG_FILE pins every invocation to the config this GUI edits; the // cwd is the config's folder because s3d also searches ./s3d.yml first. return { ...process.env, S3D_CONFIG_FILE: this.configFile }; } cwd() { return path.dirname(this.configFile); } status() { return { state: this.state, pid: this.child?.pid ?? null, exitCode: this.exitCode, startedAt: this.startedAt, binary: this.binary, needsLogin: !!this.needsLogin, configFile: this.configFile, }; } setState(state) { this.state = state; this.emit('status', this.status()); } pushLog(stream, chunk) { for (const raw of chunk.toString('utf8').split(/\r?\n/)) { const line = raw.replace(ANSI, ''); if (!line) continue; const entry = { seq: ++this.seq, t: Date.now(), stream, line }; // s3d logs "server started" once every listener is up. if (this.state === 'starting' && /server started/.test(line)) this.setState('running'); if (/No app key found/.test(line)) this.needsLogin = true; this.logs.push(entry); if (this.logs.length > LOG_LINES) this.logs.shift(); this.emit('log', entry); } } logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); } // s3d migrates its database when it starts, and so does every one-shot // command (users list, keys…) that opens it. Two of them at once failed // with "table users already exists", so a start waits for running // commands to finish, and commands wait while s3d is starting. start() { if (!this.binary) throw new Error('s3d binary not found'); if (this.child || this.pendingStart) return this.status(); this.exitCode = null; this.needsLogin = false; this.setState('starting'); if (this.cliRuns > 0) { this.pendingStart = true; this.once('cli-idle', () => { this.pendingStart = false; if (this.state === 'starting' && !this.child) this.spawnServer(); }); } else this.spawnServer(); return this.status(); } spawnServer() { const child = spawn(this.binary, [], { cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); this.child = child; this.startedAt = Date.now(); child.stdout.on('data', (d) => this.pushLog('out', d)); child.stderr.on('data', (d) => this.pushLog('err', d)); child.on('error', (err) => { this.pushLog('err', `failed to start s3d: ${err.message}`); // A spawn failure never emits 'exit'. if (child.pid === undefined) { this.child = null; this.setState('crashed'); } }); child.on('exit', (code, signal) => { this.child = null; this.exitCode = code; this.pushLog('sys', `s3d exited (${signal || `code ${code}`})`); this.setState(this.state === 'stopping' ? 'stopped' : 'crashed'); }); return this.status(); } async stop() { const child = this.child; if (!child && this.pendingStart) { this.pendingStart = false; this.setState('stopped'); return this.status(); } if (!child) return this.status(); this.setState('stopping'); const exited = new Promise((r) => child.once('exit', r)); child.kill(); // SIGTERM on unix; TerminateProcess on Windows const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 10_000); await exited; clearTimeout(timer); return this.status(); } // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a // non-zero exit so callers can surface s3d's own error text. async run(args, { timeout = 30_000 } = {}) { if (!this.binary) throw new Error('s3d binary not found'); if (this.state === 'starting') await this.settled(20_000); this.cliRuns = (this.cliRuns || 0) + 1; try { return await new Promise((resolve, reject) => { execFile(this.binary, args, { cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, }, (err, stdout, stderr) => { if (err && typeof err.code !== 'number') return reject(err); resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); }); }); } finally { if (--this.cliRuns === 0) this.emit('cli-idle'); } } // Resolves once s3d is no longer starting (or after ms). settled(ms) { return new Promise((resolve) => { if (this.state !== 'starting') return resolve(); const done = () => { clearTimeout(t); this.off('status', on); resolve(); }; const on = (st) => { if (st.state !== 'starting') done(); }; const t = setTimeout(done, ms); this.on('status', on); }); } }