feat(brainstorm-server): reuse the same port on session restart

When the companion idle-shuts-down and the agent restarts it, a fresh random
port meant the user's open browser tab pointed at a dead URL. Persist the bound
port per project and prefer it on the next start, so the restarted server comes
up on the same port and the open tab's reconnect just works.

- start-server.sh exports BRAINSTORM_PORT_FILE=<project>/.superpowers/brainstorm/
  .last-port for project sessions (not /tmp).
- server.cjs prefers an explicit BRAINSTORM_PORT, else the recorded port, else
  random; writes the actually-bound port back; and on EADDRINUSE (preferred port
  still in use) falls back to a random port once instead of crashing.

lifecycle.test.js: restart reuses the recorded port; a taken preferred port
falls back to a random one without crashing.

Refs #1237
This commit is contained in:
Jesse Vincent
2026-06-09 15:22:23 -07:00
parent e6cf11f68c
commit b53c62eba8
3 changed files with 80 additions and 2 deletions

View File

@@ -82,7 +82,21 @@ function decodeFrame(buffer) {
// ========== Configuration ==========
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
// Prefer an explicit port, else the port this session last bound (so a restart
// reuses it and an already-open browser tab reconnects), else a random high port.
function preferredPort() {
if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
if (PORT_FILE) {
try {
const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
} catch (e) { /* no prior port recorded */ }
}
return randomPort();
}
let PORT = preferredPort();
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
@@ -361,7 +375,11 @@ function startServer() {
}
}
server.listen(PORT, HOST, () => {
function onListen() {
// Record the bound port so the next restart of this session can reuse it.
if (PORT_FILE) {
try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ }
}
const info = JSON.stringify({
type: 'server-started', port: Number(PORT), host: HOST,
url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
@@ -369,7 +387,22 @@ function startServer() {
});
console.log(info);
fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');
}
// If the preferred port is already taken (e.g. a previous server is still
// alive), fall back to a random port once instead of failing.
let triedFallback = false;
server.on('error', (err) => {
if (err.code === 'EADDRINUSE' && !triedFallback) {
triedFallback = true;
PORT = randomPort();
server.listen(PORT, HOST, onListen);
} else {
console.error('Server failed to bind:', err.message);
process.exit(1);
}
});
server.listen(PORT, HOST, onListen);
}
if (require.main === module) {

View File

@@ -93,6 +93,9 @@ SESSION_ID="$$-$(date +%s)"
if [[ -n "$PROJECT_DIR" ]]; then
SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
# Persist the bound port per project so a restart reuses it and an already-open
# browser tab reconnects to the same URL.
export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port"
else
SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
fi

View File

@@ -82,6 +82,48 @@ async function runTests() {
}
});
await test('persists the bound port and restores it on restart', async () => {
const dir = fs.mkdtempSync('/tmp/bs-port-');
const portFile = path.join(dir, '.last-port');
const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
const a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
let outA = ''; a.stdout.on('data', d => outA += d.toString());
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
const portA = firstServerStarted(outA).port;
assert(fs.existsSync(portFile), 'should write the port file');
assert.strictEqual(Number(fs.readFileSync(portFile, 'utf8').trim()), portA, 'port file holds the bound port');
a.kill(); await sleep(400); // free the port
const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
let outB = ''; b.stdout.on('data', d => outB += d.toString());
for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
const portB = firstServerStarted(outB).port;
b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
assert.strictEqual(portB, portA, 'restart should reuse the same port');
});
await test('falls back to a random port when the preferred port is taken', async () => {
const dir = fs.mkdtempSync('/tmp/bs-port-');
const portFile = path.join(dir, '.last-port');
const a = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'a'), BRAINSTORM_PORT: 3415, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
let outA = ''; a.stdout.on('data', d => outA += d.toString());
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
fs.writeFileSync(portFile, '3415'); // preferred port, but it's taken by A
const b = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'b'), BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
let outB = ''; b.stdout.on('data', d => outB += d.toString());
for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
const portB = firstServerStarted(outB).port;
a.kill(); b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
assert.notStrictEqual(portB, 3415, 'must not bind the already-taken port');
assert(portB >= 49152, 'should fall back to a random high port');
});
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
if (failed > 0) process.exit(1);
}