mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 00:14:58 +00:00
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:
1 parent
67d93e6722
commit
af1a2a0f5d
2 files changed
+323
-10
No files matched your search
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user