mirror of
https://github.com/obra/superpowers.git
synced 2026-06-10 20:59:05 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user