diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index a828b35af64..c9559efc2ac 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -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. diff --git a/tests/brainstorm-server/lifecycle.test.js b/tests/brainstorm-server/lifecycle.test.js index c43d8c0de2c..865e4f0a723 100644 --- a/tests/brainstorm-server/lifecycle.test.js +++ b/tests/brainstorm-server/lifecycle.test.js @@ -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); }