Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions skills/brainstorming/scripts/server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -613,15 +613,26 @@ function startServer() {
});
watcher.on('error', (err) => console.error('fs.watch error:', err.message));

let shuttingDown = false;
function shutdown(reason) {
if (shuttingDown) return;
shuttingDown = true;
console.log(JSON.stringify({ type: 'server-stopped', reason }));
const infoFile = path.join(STATE_DIR, 'server-info');
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
fs.writeFileSync(
path.join(STATE_DIR, 'server-stopped'),
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
);
watcher.close();
// State-dir bookkeeping is best effort. The session directory can be removed
// while the server sits idle, and a missing or read-only state dir must never
// abort shutdown -- throwing here would skip every cleanup step below and kill
// the process with an uncaught exception instead of exiting 0.
try {
if (fs.existsSync(STATE_DIR)) {
const infoFile = path.join(STATE_DIR, 'server-info');
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
fs.writeFileSync(
path.join(STATE_DIR, 'server-stopped'),
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
);
}
} catch (e) { /* best effort */ }
try { watcher.close(); } catch (e) { /* content dir may be gone */ }
clearInterval(lifecycleCheck);
// Close any upgraded WebSocket sockets so server.close() can complete and
// the process actually exits instead of lingering on an open connection.
Expand Down
21 changes: 21 additions & 0 deletions tests/brainstorm-server/lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,27 @@ async function runTests() {
assert(exited, 'idle shutdown must still fire despite a flood of unauthenticated requests');
});

await test('idle shutdown exits 0 when the session dir was removed', async () => {
const dir = fs.mkdtempSync('/tmp/bs-life-');
const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3420, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 400, BRAINSTORM_LIFECYCLE_CHECK_MS: 100 } });
let out = ''; srv.stdout.on('data', d => out += d.toString());
let err = ''; srv.stderr.on('data', d => err += d.toString());
let code = null; srv.on('exit', c => { code = c; });
for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);

// The session dir can be cleaned up while the server sits idle. Shutdown
// bookkeeping is best effort: if writing state/server-stopped throws, the
// cleanup below it never runs and the process dies on an uncaught exception.
fs.rmSync(dir, { recursive: true, force: true });

for (let i = 0; i < 40 && code === null; i++) await sleep(100);
if (code === null) await killAndWait(srv);
fs.rmSync(dir, { recursive: true, force: true });

assert.strictEqual(code, 0, `idle shutdown must exit 0 with the session dir gone, got ${code}: ${err}`);
assert(!/ENOENT/.test(err), `shutdown must not throw ENOENT, got: ${err}`);
});

console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
if (failed > 0) process.exit(1);
}
Expand Down