diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index d1ac4e58..30b92b10 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -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) { diff --git a/skills/brainstorming/scripts/start-server.sh b/skills/brainstorming/scripts/start-server.sh index 358e1842..59d3ff2f 100755 --- a/skills/brainstorming/scripts/start-server.sh +++ b/skills/brainstorming/scripts/start-server.sh @@ -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 diff --git a/tests/brainstorm-server/lifecycle.test.js b/tests/brainstorm-server/lifecycle.test.js index aaeb346c..f264f32a 100644 --- a/tests/brainstorm-server/lifecycle.test.js +++ b/tests/brainstorm-server/lifecycle.test.js @@ -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); }