Skip to content

Fix brainstorm server crashing on shutdown when the session dir is gone - #2139

Open
zigggy-stardust wants to merge 1 commit into
obra:mainfrom
zigggy-stardust:fix/brainstorm-shutdown-missing-state-dir
Open

Fix brainstorm server crashing on shutdown when the session dir is gone#2139
zigggy-stardust wants to merge 1 commit into
obra:mainfrom
zigggy-stardust:fix/brainstorm-shutdown-missing-state-dir

Conversation

@zigggy-stardust

Copy link
Copy Markdown

Problem

shutdown() in skills/brainstorming/scripts/server.cjs writes state/server-stopped without checking that STATE_DIR still exists:

function shutdown(reason) {
  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(                                   // ← throws ENOENT
    path.join(STATE_DIR, 'server-stopped'),
    JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
  );
  watcher.close();                                    // never runs
  clearInterval(lifecycleCheck);                      // never runs
  for (const socket of clients) { ... }               // never runs
  server.close(() => process.exit(0));                // never runs
}

If the session directory is removed while the server sits idle, the write throws from inside the lifecycleCheck interval. The throw is not caught, so every cleanup step below it is skipped and the process dies on an uncaught exception with exit code 1 instead of exiting 0.

This is not just a cosmetic exit code — the watcher, the interval, the WebSocket sockets and server.close() are all bypassed.

How I hit it

A brainstorming session finished, the design was committed, and I deleted the scratch .superpowers/ directory. The server had already begun a normal idle timeout shutdown, and supervision reported it as a failed process:

{"type":"server-stopped","reason":"idle timeout"}
Error: ENOENT: no such file or directory, open '.../state/server-stopped'
    at shutdown (.../skills/brainstorming/scripts/server.cjs:620:8)
    at Timeout._onTimeout (.../skills/brainstorming/scripts/server.cjs:642:59)

Minimal reproduction

D=$(mktemp -d); mkdir -p "$D/content" "$D/state"
BRAINSTORM_DIR="$D" BRAINSTORM_PORT=0 \
BRAINSTORM_IDLE_TIMEOUT_MS=1500 BRAINSTORM_LIFECYCLE_CHECK_MS=300 \
  node skills/brainstorming/scripts/server.cjs & P=$!
sleep 1.2; rm -rf "$D/state"; wait $P; echo "exit=$?"
# before: exit=1 + ENOENT
# after:  exit=0

Fix

State-dir bookkeeping becomes best effort, matching the pattern already used a few lines below for PORT_FILE (try { fs.writeFileSync(PORT_FILE, ...) } catch (e) { /* best effort */ }):

  • skip the bookkeeping when STATE_DIR is gone, wrap it in try/catch, and always fall through to the real cleanup
  • guard watcher.close() too, since CONTENT_DIR disappears in the same scenario
  • make shutdown() idempotent so a second lifecycle tick cannot call server.close() twice

The sentinel is deliberately not recreated when the directory is missing — nothing is left to consume it, and re-creating a tree the user just deleted would be surprising.

Tests

Adds one regression test to tests/brainstorm-server/lifecycle.test.js (port 3420, following the existing style in that file): remove the session dir while the server is idle, then assert a clean exit 0 with no ENOENT on stderr.

Verified both directions:

new test full npm test
without the fix FAIL (13 passed, 1 failed) fails
with the fix PASS see below

Behaviour on the normal path is unchanged — with the state dir present, the sentinel is still written and the process still exits 0.

tests/brainstorm-server $ npm test
--- Results: 32 passed, 0 failed ---     ws-protocol
--- Results: 15 passed, 0 failed ---     helper
--- Results:  3 passed, 0 failed ---     browser-launcher
--- Results: 20 passed, 0 failed ---     auth
--- Results:  7 passed, 0 failed ---     branding
--- Results: 33 passed, 0 failed ---     server
--- Results: 14 passed, 0 failed ---     lifecycle   ← +1 new test
--- Results:  4 passed, 0 failed ---     start-server.sh
--- Results:  6 passed, 1 failed ---     stop-server.sh

Note on the one remaining failure

stop-server.sh :: persistent stop writes server-stopped fails, but it is pre-existing and unrelated to this change. I reproduced it on pristine main (b36e082) in the same environment, before and after applying the patch — identical 6 passed, 1 failed both times. I left it alone rather than widen the scope of this PR.

Environment: macOS 25.6.0 (arm64), Node v26.5.1.

shutdown() wrote state/server-stopped without checking that STATE_DIR
still exists. When the session directory is removed while the server
sits idle, that write throws ENOENT from inside the lifecycle interval,
so every cleanup step below it is skipped -- watcher.close(),
clearInterval(), socket teardown and server.close() never run -- and the
process dies on an uncaught exception with exit code 1 instead of
exiting 0.

State-dir bookkeeping is now best effort, matching the pattern already
used for PORT_FILE: skip it when STATE_DIR is gone, guard it with
try/catch, and always fall through to the real cleanup. watcher.close()
is guarded too, since the content dir disappears in the same scenario.
shutdown() is also made idempotent so a second lifecycle tick cannot
call server.close() twice.

The sentinel is deliberately not recreated when the directory is
missing: nothing is left to consume it, and re-creating a tree the user
just deleted would be surprising.

Adds a lifecycle regression test that removes the session dir while the
server is idle and asserts a clean exit 0 with no ENOENT.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant