mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
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.
This commit is contained in:
1 parent
db1d125f83
commit
3569297b6d
2 files changed
+97
-12
No files matched your search
@@ -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
|
* The bar.json port (if present) is checked first so a previously-used port
|
||||||
* gets priority. Short timeouts prevent hanging the launch flow.
|
* gets priority. Short timeouts prevent hanging the launch flow.
|
||||||
*/
|
*/
|
||||||
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
||||||
const { request } = await import('undici');
|
const { request } = await import('undici');
|
||||||
|
|
||||||
const barJsonPort = resolveBarPort(ccsDir);
|
/**
|
||||||
const base = [3000, 3001, 3002, 8000, 8080];
|
* Probe a single URL. Returns true on HTTP 200, false on any other status
|
||||||
const candidates: number[] =
|
* or error (connection refused, timeout, etc.).
|
||||||
barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base;
|
*/
|
||||||
|
async function probe(url: string): Promise<boolean> {
|
||||||
for (const port of candidates) {
|
|
||||||
try {
|
try {
|
||||||
const url = `http://127.0.0.1:${port}/api/bar/summary`;
|
|
||||||
const { statusCode, body } = await request(url, {
|
const { statusCode, body } = await request(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headersTimeout: 1500,
|
headersTimeout: 1500,
|
||||||
@@ -101,11 +106,26 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
|||||||
// body.dump() is not available in Bun's undici shim; body.text() works
|
// body.dump() is not available in Bun's undici shim; body.text() works
|
||||||
// cross-runtime and fully consumes the response stream.
|
// cross-runtime and fully consumes the response stream.
|
||||||
await body.text();
|
await body.text();
|
||||||
if (statusCode === 200) {
|
return statusCode === 200;
|
||||||
return { port, baseUrl: `http://127.0.0.1:${port}` };
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Connection refused, timeout, or any other error → try next candidate.
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
return null;
|
||||||
|
|||||||
@@ -1738,6 +1738,71 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
expect(result).toBeNull();
|
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<void>((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<void>((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<void>((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}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user