feat(brainstorm-server): 4h configurable idle timeout; close WS on shutdown

The companion shut down after only 30 minutes idle — too short for real
brainstorming, where a single question can sit far longer. And shutdown() never
closed upgraded WebSocket sockets, so an open browser connection could keep the
Node process alive after it was supposed to exit.

- Default idle timeout raised to 4 hours, configurable via BRAINSTORM_IDLE_TIMEOUT_MS
  and start-server.sh --idle-timeout-minutes (validated positive integer).
- Reported as idle_timeout_ms in the server-started JSON / server-info.
- shutdown() now destroys all client sockets so the process exits even with an
  open WebSocket.
- Watchdog check interval is configurable (BRAINSTORM_LIFECYCLE_CHECK_MS, default
  60s) so the lifecycle can be tested without minute-long waits.

Adds lifecycle.test.js (configured timeout reported; idle shutdown exits despite
an open WS — teeth-verified; the start-server flag). Wires ws-protocol,
lifecycle, and stop-server suites into npm test.

Closes #1237
Refs #1689
This commit is contained in:
Jesse Vincent
2026-06-09 15:08:09 -07:00
parent ddcb56c16e
commit f057b4a30b
4 changed files with 124 additions and 5 deletions

View File

@@ -255,7 +255,18 @@ function broadcast(msg) {
// ========== Activity Tracking ==========
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
// Idle timeout: shut down after this long with no activity. Default 4 hours;
// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes).
const IDLE_TIMEOUT_MS = (() => {
const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS);
return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000;
})();
// How often the watchdog checks for owner-death / idleness. Configurable mainly
// so tests can run fast; production default is 60s.
const LIFECYCLE_CHECK_MS = (() => {
const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS);
return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000;
})();
let lastActivity = Date.now();
function touchActivity() {
@@ -317,6 +328,11 @@ function startServer() {
);
watcher.close();
clearInterval(lifecycleCheck);
// Close any upgraded WebSocket sockets so server.close() can complete and
// the process actually exits instead of lingering on an open connection.
for (const socket of clients) {
try { socket.destroy(); } catch (e) { /* already gone */ }
}
server.close(() => process.exit(0));
}
@@ -325,11 +341,11 @@ function startServer() {
try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
}
// Check every 60s: exit if owner process died or idle for 30 minutes
// Periodically exit if the owner process died or we've been idle too long.
const lifecycleCheck = setInterval(() => {
if (!ownerAlive()) shutdown('owner process exited');
else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
}, 60 * 1000);
}, LIFECYCLE_CHECK_MS);
lifecycleCheck.unref();
// Validate owner PID at startup. If it's already dead, the PID resolution
@@ -349,7 +365,7 @@ function startServer() {
const info = JSON.stringify({
type: 'server-started', port: Number(PORT), host: HOST,
url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
screen_dir: CONTENT_DIR, state_dir: STATE_DIR
screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS
});
console.log(info);
fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');