fix(bar): reuse a running CCS web-server and probe ports on the bind host

'ccs bar' always tried to start its own web-server. With a CCS server
already listening on 127.0.0.1:3000, get-port (probing the unspecified
address, which macOS allows to bind alongside a specific loopback
listener) reported 3000 as free, and the subsequent 127.0.0.1 bind
failed with EADDRINUSE, aborting launch.

Launch now probes candidate ports (bar.json port first, then the
default list) with a short-timeout GET /api/bar/summary and reuses the
first live CCS server it finds; only when none responds does it start a
new server. The get-port probe also passes host 127.0.0.1 so the
availability check matches the actual bind target. Probe failures are
treated as no-server-found and never break launch.

Closes #1500
This commit is contained in:
Tam Nhu Tran
2026-06-10 11:09:41 -04:00
parent 67d93e6722
commit af1a2a0f5d
2 changed files with 323 additions and 10 deletions
+82 -10
View File
@@ -5,6 +5,11 @@
* { baseUrl: string, port: number, authMode: "loopback" }
*
* authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1).
*
* Reuse-first behavior: before starting a new server, launch probes the candidate
* ports (bar.json port first, then 3000, 3001, 3002, 8000, 8080) for a live CCS
* server at GET /api/bar/summary. A 200 response means the server is already
* running; launch reuses it without attempting to bind a new one.
*/
import * as fs from 'fs';
@@ -28,6 +33,12 @@ export interface DashboardInfo {
}
export interface LaunchDeps {
/**
* Probe candidate ports for a running CCS server.
* Returns { port, baseUrl } of the first live server found, or null if none.
* Never throws — any error is treated as "not found".
*/
findRunningServer: () => Promise<DashboardInfo | null>;
/**
* Ensure the CCS web-server / dashboard is running.
* Returns { port, baseUrl } of the running server.
@@ -65,13 +76,52 @@ export function resolveBarPort(ccsDir: string): number | null {
// Default production dependencies
// ---------------------------------------------------------------------------
/**
* Probe candidate ports for a running CCS server on 127.0.0.1.
* The bar.json port (if present) is checked first so a previously-used port
* gets priority. Short timeouts prevent hanging the launch flow.
*/
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
const { request } = await import('undici');
const barJsonPort = resolveBarPort(ccsDir);
const base = [3000, 3001, 3002, 8000, 8080];
const candidates: number[] =
barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base;
for (const port of candidates) {
try {
const url = `http://127.0.0.1:${port}/api/bar/summary`;
const { statusCode, body } = await request(url, {
method: 'GET',
headersTimeout: 1500,
bodyTimeout: 1500,
});
// Drain the body to avoid hanging sockets regardless of status.
// body.dump() is not available in Bun's undici shim; body.text() works
// cross-runtime and fully consumes the response stream.
await body.text();
if (statusCode === 200) {
return { port, baseUrl: `http://127.0.0.1:${port}` };
}
} catch {
// Connection refused, timeout, or any other error → try next candidate.
}
}
return null;
}
async function defaultEnsureDashboard(): Promise<DashboardInfo> {
// Reuse the same startup path as `ccs config`:
// find a free port then start the web-server via startServer().
const getPort = (await import('get-port')).default;
const { startServer } = await import('../../web-server');
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080] });
// Pass host: '127.0.0.1' so getPort probes the same address that startServer
// will bind. On macOS, a wildcard bind and a specific 127.0.0.1 bind are
// independent: getPort without a host can return 3000 "free" while a live
// CCS server already holds 127.0.0.1:3000, causing EADDRINUSE on startServer.
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
// Bind IPv4 loopback explicitly. Without a host, startServer defaults to
// 'localhost', which on macOS resolves to ::1 (IPv6) — but bar.json's baseUrl
// (and the Swift app) use 127.0.0.1, so the app could not reach its own server.
@@ -112,15 +162,33 @@ export async function handleBarLaunch(
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
// 1. Ensure the web-server/dashboard is running.
let dashboardInfo: DashboardInfo;
// Wire findRunningServer after ccsDir is resolved so the default impl can
// read bar.json from the correct directory.
const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir));
// 1. Try to reuse an already-running CCS server; fall back to starting one.
// Probe errors are treated as "not found" — they never abort the launch flow.
let running: DashboardInfo | null = null;
try {
dashboardInfo = await ensureDashboard();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not start CCS web-server: ${msg}`);
console.error('[i] Run `ccs config` to start the dashboard manually.');
return;
running = await findRunningServer();
} catch {
// Any probe error counts as null; ensureDashboard() is the fallback.
}
let dashboardInfo: DashboardInfo;
let reused = false;
if (running !== null) {
dashboardInfo = running;
reused = true;
} else {
try {
dashboardInfo = await ensureDashboard();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not start CCS web-server: ${msg}`);
console.error('[i] Run `ccs config` to start the dashboard manually.');
return;
}
}
// 2. Write bar.json — this is the single source of discovery for the Swift app.
@@ -139,7 +207,11 @@ export async function handleBarLaunch(
return;
}
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
if (reused) {
console.log(`[OK] Reusing running CCS web-server at ${dashboardInfo.baseUrl}`);
} else {
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
}
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
// 3. Open the app.
+241
View File
@@ -276,6 +276,7 @@ describe('bar.json contract (launch subcommand)', () => {
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: mockEnsureDashboard,
openApp: mockOpenApp,
getCcsDir: mockGetCcsDir,
@@ -300,6 +301,7 @@ describe('bar.json contract (launch subcommand)', () => {
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }),
openApp: async () => {
/* noop */
@@ -324,6 +326,7 @@ describe('bar.json contract (launch subcommand)', () => {
const nonExistentApp = path.join(tempHome, 'Applications', 'CCS Bar.app');
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
openApp: async () => {
throw new Error('App not found');
@@ -344,6 +347,7 @@ describe('bar.json contract (launch subcommand)', () => {
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }),
openApp: async () => {
throw new Error('open failed');
@@ -364,6 +368,7 @@ describe('bar.json contract (launch subcommand)', () => {
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => {
throw new Error('port busy');
},
@@ -1499,6 +1504,242 @@ describe('bar command dispatcher: --help anywhere in args (Fix 2)', () => {
});
});
// ---------------------------------------------------------------------------
// GH-1500 — findRunningServer reuse-first behavior
// ---------------------------------------------------------------------------
describe('launch: findRunningServer reuse-first (GH-1500)', () => {
it('reuses a running server: ensureDashboard NOT called; bar.json has reused port/baseUrl', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
let ensureCalled = false;
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
ensureDashboard: async () => {
ensureCalled = true;
return { port: 9999, baseUrl: 'http://127.0.0.1:9999' };
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
expect(ensureCalled).toBe(false);
const barJson = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')
) as { port: number; baseUrl: string };
expect(barJson.port).toBe(3000);
expect(barJson.baseUrl).toBe('http://127.0.0.1:3000');
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/Reusing/);
});
it('starts a new server when findRunningServer returns null', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
let ensureCalled = false;
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => {
ensureCalled = true;
return { port: 4242, baseUrl: 'http://127.0.0.1:4242' };
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
expect(ensureCalled).toBe(true);
});
it('treats findRunningServer throw as null: ensureDashboard called; no crash', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
let ensureCalled = false;
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => {
throw new Error('probe exploded');
},
ensureDashboard: async () => {
ensureCalled = true;
return { port: 4242, baseUrl: 'http://127.0.0.1:4242' };
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
expect(ensureCalled).toBe(true);
// Should not have printed any bind error
const allOutput = consoleOutput.join('\n');
expect(allOutput).not.toMatch(/Could not start/);
});
});
// ---------------------------------------------------------------------------
// GH-1500 — existing launch tests: inject findRunningServer: null for determinism
// ---------------------------------------------------------------------------
describe('launch: bar.json contract (deterministic — GH-1500 null probe)', () => {
it('writes correct bar.json shape on start path with explicit null probe', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
findRunningServer: async () => null,
ensureDashboard: async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }),
openApp: async (_appPath: string) => {
calls.push(`open:${_appPath}`);
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
const barJson = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')
) as unknown;
expect(barJson).toMatchObject({
baseUrl: 'http://127.0.0.1:4242',
port: 4242,
authMode: 'loopback',
});
});
});
// ---------------------------------------------------------------------------
// GH-1500 — defaultFindRunningServer integration-style tests
// ---------------------------------------------------------------------------
describe('defaultFindRunningServer (GH-1500)', () => {
it('detects a real HTTP server responding 200 on /api/bar/summary', async () => {
const http = await import('http');
// Start an ephemeral server that responds 200 to /api/bar/summary.
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address() as { port: number };
const livePort = addr.port;
// Seed bar.json with the live port so it is checked first.
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'bar.json'),
JSON.stringify({ port: livePort, baseUrl: `http://127.0.0.1:${livePort}`, authMode: 'loopback' })
);
moduleSeq++;
const mod = await import(
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
);
const { defaultFindRunningServer } = mod as {
defaultFindRunningServer: (ccsDir: string) => Promise<{ port: number; baseUrl: string } | null>;
};
let result: { port: number; baseUrl: string } | null = null;
try {
result = await defaultFindRunningServer(ccsDir);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
expect(result).not.toBeNull();
expect(result?.port).toBe(livePort);
expect(result?.baseUrl).toBe(`http://127.0.0.1:${livePort}`);
});
it('returns null when no server is listening on the seeded port (port outside default candidates)', async () => {
const net = await import('net');
// We need a port that:
// 1. Is NOT in the default candidate list (3000, 3001, 3002, 8000, 8080) — otherwise a
// live server on one of those ports would be detected instead of "null".
// 2. Has nothing listening on it after we close it.
// Strategy: bind on a high ephemeral port (>= 50000), record it, close it, seed bar.json.
// defaultFindRunningServer dedupes bar.json port into the candidate list, but the default
// ports 3000/3001/3002/8000/8080 will also be probed. To guarantee null we need ALL
// candidates closed. We can't control the default ports if a live CCS server is running.
//
// Instead, use a port that's definitely not 3000/3001/3002/8000/8080 AND is closed,
// then wrap defaultFindRunningServer with a test-local variant that uses only bar.json's port.
// Since defaultFindRunningServer is not parameterizable, we test the null path by ensuring
// a server that was listening has been closed before the probe, using a high port that
// is unlikely to collide with any service on the test machine.
const closedPort = await new Promise<number>((resolve, reject) => {
const srv = net.createServer();
// High port range to avoid colliding with default candidates or live CCS server.
srv.listen(0, '127.0.0.1', () => {
const p = (srv.address() as { port: number }).port;
srv.close((err) => (err ? reject(err) : resolve(p)));
});
});
// Ephemeral ports are >= 49152 on macOS; they won't be in our default candidate list.
// If the port happens to be one of the defaults, skip — practically impossible but safe.
if ([3000, 3001, 3002, 8000, 8080].includes(closedPort)) {
return;
}
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Seed bar.json with ONLY the closed port. The probe will check this port first.
// The function also probes defaults (3000 etc.) — but we can't know if a live server
// is there. So we override: write bar.json with a port that ALSO appears as the sole
// candidate by replacing all defaults. Since we can't parameterize candidates, we test
// the "nothing on closed port" by using a port where connection is immediately refused
// and asserting the function either returns null or finds a real server on a default port.
// The definitive assertion is: the closed port itself is not returned.
fs.writeFileSync(
path.join(ccsDir, 'bar.json'),
JSON.stringify({
port: closedPort,
baseUrl: `http://127.0.0.1:${closedPort}`,
authMode: 'loopback',
})
);
moduleSeq++;
const mod = await import(
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
);
const { defaultFindRunningServer } = mod as {
defaultFindRunningServer: (ccsDir: string) => Promise<{ port: number; baseUrl: string } | null>;
};
const result = await defaultFindRunningServer(ccsDir);
// The closed port must NOT be returned (ECONNREFUSED for that port is handled).
// If a live CCS server exists on one of the default ports the function returns that
// instead of null — this is correct behavior. We assert the closed port is not the result.
if (result !== null) {
expect(result.port).not.toBe(closedPort);
} else {
expect(result).toBeNull();
}
});
});
// ---------------------------------------------------------------------------
// Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null
// ---------------------------------------------------------------------------