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.
This commit is contained in:
Local Dev 2026-10-04 14:39:34 +02:00
parent 47c0c27f3d
commit 9024a2a217

View file

@ -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);
});
}
}