From 9024a2a2173c48565b9dc2109c1a2167cf975fbd Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 14:39:34 +0200 Subject: [PATCH] Pithos: s3d and its one-shot commands never open the database at once After adding another account, opening a page failed with "failed to open database: table users already exists": a users list ran while s3d was starting, and both set up the fresh database. A start now waits for running commands to finish, and commands wait while s3d is starting. --- core/daemon.js | 48 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/core/daemon.js b/core/daemon.js index 6fd4235..2c1c334 100644 --- a/core/daemon.js +++ b/core/daemon.js @@ -62,12 +62,24 @@ export class Daemon extends EventEmitter { 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) return this.status(); + 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, }); @@ -91,6 +103,7 @@ export class Daemon extends EventEmitter { 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)); @@ -103,15 +116,32 @@ export class Daemon extends EventEmitter { // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a // non-zero exit so callers can surface s3d's own error text. - run(args, { timeout = 30_000 } = {}) { - if (!this.binary) return Promise.reject(new Error('s3d binary not found')); - return 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, '') }); + 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); }); } }