From 3569297b6d0b7d36d5ec24bb1e0daeadebd42b4e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 12:04:10 -0400 Subject: [PATCH] fix(bar): probe IPv6 loopback when detecting a running CCS server 'ccs config' starts the web-server on host 'localhost', which macOS resolves to ::1, so a reuse probe limited to 127.0.0.1 missed the most common already-running server and launch started a redundant second instance. Each candidate port is now probed on 127.0.0.1 first and then [::1]; an IPv6 hit writes the bracketed-literal baseUrl into bar.json, which URLSession in the Swift app resolves correctly. --- src/commands/bar/launch-subcommand.ts | 44 ++++++++++++----- tests/unit/commands/bar-command.test.ts | 65 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index ff43d943..bd9c1e18 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -77,21 +77,26 @@ export function resolveBarPort(ccsDir: string): number | null { // --------------------------------------------------------------------------- /** - * Probe candidate ports for a running CCS server on 127.0.0.1. + * Probe candidate ports for a running CCS server. + * + * Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for + * each port. This is necessary because `ccs config` starts the server with + * host 'localhost', which macOS resolves to ::1 — an IPv4-only probe would + * miss that server and start a redundant second instance. + * + * Probe order per port: 127.0.0.1 first, then [::1]. First 200 wins. * 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 { 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) { + /** + * Probe a single URL. Returns true on HTTP 200, false on any other status + * or error (connection refused, timeout, etc.). + */ + async function probe(url: string): Promise { try { - const url = `http://127.0.0.1:${port}/api/bar/summary`; const { statusCode, body } = await request(url, { method: 'GET', headersTimeout: 1500, @@ -101,11 +106,26 @@ export async function defaultFindRunningServer(ccsDir: string): Promise p !== barJsonPort)] : base; + + for (const port of candidates) { + // Probe IPv4 loopback first (common for explicitly-bound servers). + if (await probe(`http://127.0.0.1:${port}/api/bar/summary`)) { + return { port, baseUrl: `http://127.0.0.1:${port}` }; + } + // Probe IPv6 loopback — `ccs config` binds 'localhost' which resolves to + // ::1 on macOS, so this is the common case for a running CCS server. + if (await probe(`http://[::1]:${port}/api/bar/summary`)) { + return { port, baseUrl: `http://[::1]:${port}` }; } } return null; diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 4f270bf4..e96d14a0 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -1738,6 +1738,71 @@ describe('defaultFindRunningServer (GH-1500)', () => { expect(result).toBeNull(); } }); + + it('detects a real HTTP server responding 200 on /api/bar/summary bound to ::1 only', async () => { + const http = await import('http'); + const net = await import('net'); + + // Guard: check if IPv6 loopback is available on this runner. + // Some CI environments disable IPv6; we skip gracefully rather than fail. + let ipv6Available = false; + await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => { + // EADDRNOTAVAIL or EAFNOSUPPORT → no IPv6 on this host + console.log('[i] Skipping IPv6 loopback test: ::1 not available on this runner'); + resolve(); + }); + probe.listen(0, '::1', () => { + ipv6Available = true; + probe.close(() => resolve()); + }); + }); + + if (!ipv6Available) { + return; + } + + // Start an ephemeral HTTP server bound exclusively to ::1. + // This simulates `ccs config` starting the web-server with host 'localhost' + // on macOS, where 'localhost' resolves to ::1. + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + await new Promise((resolve) => server.listen(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://[::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((resolve) => server.close(() => resolve())); + } + + expect(result).not.toBeNull(); + expect(result?.port).toBe(livePort); + // baseUrl must use bracketed IPv6 literal — valid in URLs per RFC 2732; + // the Swift app reads this verbatim and URLSession handles bracketed IPv6 hosts. + expect(result?.baseUrl).toBe(`http://[::1]:${livePort}`); + }); }); // ---------------------------------------------------------------------------