mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix: handle auth-protected bar launch probes (#1551)
Raw-socket probe detects auth-protected (401/403) bar servers, reuses them, and kills the orphaned detached server on auth timeout. Integrated with the non-blocking priority probe.
This commit is contained in:
1 parent
361328c788
commit
c0409862d2
4 files changed
+318
-90
No files matched your search
@@ -12,12 +12,33 @@ import * as path from 'path';
|
|||||||
export interface DashboardInfo {
|
export interface DashboardInfo {
|
||||||
port: number;
|
port: number;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
authRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the port recorded in an existing bar.json.
|
* Read the port recorded in an existing bar.json.
|
||||||
* Returns null when the file is absent or malformed.
|
* Returns null when the file is absent or malformed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function ensureLoopbackNoProxy(): void {
|
||||||
|
const loopbackHosts = ['localhost', '127.0.0.1', '::1'];
|
||||||
|
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? '';
|
||||||
|
const parts = new Set(
|
||||||
|
existing
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of loopbackHosts) {
|
||||||
|
parts.add(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = Array.from(parts).join(',');
|
||||||
|
process.env.NO_PROXY = next;
|
||||||
|
process.env.no_proxy = next;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBarPort(ccsDir: string): number | null {
|
export function resolveBarPort(ccsDir: string): number | null {
|
||||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||||
try {
|
try {
|
||||||
@@ -37,37 +58,48 @@ export function resolveBarPort(ccsDir: string): number | null {
|
|||||||
* (one timeout) rather than N × 1.5 s sequentially. Results are awaited in
|
* (one timeout) rather than N × 1.5 s sequentially. Results are awaited in
|
||||||
* priority order so a lower-priority slow or streaming response cannot block
|
* priority order so a lower-priority slow or streaming response cannot block
|
||||||
* returning an already-known higher-priority hit.
|
* returning an already-known higher-priority hit.
|
||||||
|
*
|
||||||
|
* Each probe speaks raw HTTP/1.1 over a socket and resolves on the status line,
|
||||||
|
* which lets discovery distinguish a live-but-auth-protected server (401/403)
|
||||||
|
* from a healthy one (200) without depending on a higher-level HTTP client.
|
||||||
*/
|
*/
|
||||||
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
||||||
const { request } = await import('undici');
|
ensureLoopbackNoProxy();
|
||||||
|
|
||||||
async function probe(url: string): Promise<{ ok: boolean }> {
|
async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
|
||||||
try {
|
const net = await import('net');
|
||||||
const { statusCode, body } = await request(url, {
|
const parsed = new URL(url);
|
||||||
method: 'GET',
|
const port = Number(parsed.port);
|
||||||
headersTimeout: 1500,
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
bodyTimeout: 1500,
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buffer = '';
|
||||||
|
let settled = false;
|
||||||
|
const finish = (statusCode = 0) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
// Tear down the socket the moment the status line is known. The summary
|
||||||
|
// endpoint only needs the status code for liveness, so a non-CCS
|
||||||
|
// loopback service that streams forever cannot block discovery from
|
||||||
|
// returning a higher-priority hit.
|
||||||
|
socket.destroy();
|
||||||
|
const authRequired = statusCode === 401 || statusCode === 403;
|
||||||
|
resolve({ ok: statusCode === 200 || authRequired, authRequired });
|
||||||
|
};
|
||||||
|
const socket = net.connect({ host, port }, () => {
|
||||||
|
socket.write(
|
||||||
|
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
socket.setTimeout(1500, () => finish());
|
||||||
// The summary endpoint only needs the status code for liveness. Do not
|
socket.on('data', (chunk) => {
|
||||||
// buffer the response body: a non-CCS loopback service can stream forever
|
buffer += chunk.toString('utf8');
|
||||||
// and keep discovery from returning a higher-priority hit. Release the
|
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||||
// response as soon as headers arrive.
|
if (match) finish(Number(match[1]));
|
||||||
try {
|
});
|
||||||
if (typeof body.resume === 'function') {
|
socket.on('error', () => finish());
|
||||||
body.resume();
|
socket.on('end', () => finish());
|
||||||
} else if (typeof body.destroy === 'function') {
|
});
|
||||||
body.destroy();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Closing the response is a best-effort cleanup; liveness depends only
|
|
||||||
// on the status code already received.
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: statusCode === 200 };
|
|
||||||
} catch {
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const barJsonPort = resolveBarPort(ccsDir);
|
const barJsonPort = resolveBarPort(ccsDir);
|
||||||
@@ -83,9 +115,10 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
|||||||
const probes = probeTargets.map((t) => probe(t.url));
|
const probes = probeTargets.map((t) => probe(t.url));
|
||||||
|
|
||||||
for (let i = 0; i < probeTargets.length; i++) {
|
for (let i = 0; i < probeTargets.length; i++) {
|
||||||
if ((await probes[i]).ok) {
|
const result = await probes[i];
|
||||||
|
if (result.ok) {
|
||||||
const { port, baseUrl } = probeTargets[i];
|
const { port, baseUrl } = probeTargets[i];
|
||||||
return { port, baseUrl };
|
return { port, baseUrl, authRequired: result.authRequired };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
import { getCcsDir } from '../../config/config-loader-facade';
|
import { getCcsDir } from '../../config/config-loader-facade';
|
||||||
import {
|
import {
|
||||||
getBarDir,
|
getBarDir,
|
||||||
@@ -71,7 +72,7 @@ export interface LaunchDeps {
|
|||||||
* Spawn the `ccs bar serve --port N` process detached and return immediately.
|
* Spawn the `ccs bar serve --port N` process detached and return immediately.
|
||||||
* The spawned process must be unref()ed so the launcher can exit.
|
* The spawned process must be unref()ed so the launcher can exit.
|
||||||
*/
|
*/
|
||||||
spawnDetachedServer: (port: number, logPath: string) => void;
|
spawnDetachedServer: (port: number, logPath: string) => ChildProcess | void;
|
||||||
/**
|
/**
|
||||||
* Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout.
|
* Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout.
|
||||||
* Returns the live baseUrl on success, throws on timeout.
|
* Returns the live baseUrl on success, throws on timeout.
|
||||||
@@ -104,7 +105,7 @@ async function defaultGetPort(opts: { port: number[]; host: string }): Promise<n
|
|||||||
* stdio is redirected to serve.log so server output is preserved for
|
* stdio is redirected to serve.log so server output is preserved for
|
||||||
* debugging without a terminal. unref() lets the launcher exit immediately.
|
* debugging without a terminal. unref() lets the launcher exit immediately.
|
||||||
*/
|
*/
|
||||||
function defaultSpawnDetachedServer(port: number, logPath: string): void {
|
function defaultSpawnDetachedServer(port: number, logPath: string): ChildProcess {
|
||||||
const { spawn } = require('child_process') as typeof import('child_process');
|
const { spawn } = require('child_process') as typeof import('child_process');
|
||||||
|
|
||||||
// Open (or create) the log file for appending.
|
// Open (or create) the log file for appending.
|
||||||
@@ -118,30 +119,68 @@ function defaultSpawnDetachedServer(port: number, logPath: string): void {
|
|||||||
child.unref();
|
child.unref();
|
||||||
// Close our copy of the fd — the child has its own reference.
|
// Close our copy of the fd — the child has its own reference.
|
||||||
fs.closeSync(logFd);
|
fs.closeSync(logFd);
|
||||||
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s.
|
* Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s.
|
||||||
* Resolves when the server is live. Rejects on timeout.
|
* Resolves when the server is live. Rejects on timeout.
|
||||||
*/
|
*/
|
||||||
|
export class BarServerAuthRequiredError extends Error {
|
||||||
|
constructor(baseUrl: string, statusCode: number) {
|
||||||
|
super(`CCS Bar server at ${baseUrl} requires dashboard authentication (HTTP ${statusCode})`);
|
||||||
|
this.name = 'BarServerAuthRequiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthRequiredStatus(statusCode: number): boolean {
|
||||||
|
return statusCode === 401 || statusCode === 403;
|
||||||
|
}
|
||||||
|
|
||||||
async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||||
const { request } = await import('undici');
|
const net = await import('net');
|
||||||
const INTERVAL_MS = 250;
|
const INTERVAL_MS = 250;
|
||||||
const TIMEOUT_MS = 10_000;
|
const TIMEOUT_MS = 10_000;
|
||||||
const deadline = Date.now() + TIMEOUT_MS;
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
async function probe(): Promise<number | null> {
|
||||||
try {
|
const url = new URL(`${baseUrl}/api/bar/summary`);
|
||||||
const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, {
|
return new Promise((resolve) => {
|
||||||
method: 'GET',
|
let buffer = '';
|
||||||
headersTimeout: 1500,
|
let settled = false;
|
||||||
bodyTimeout: 1500,
|
const finish = (statusCode: number | null = null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(statusCode);
|
||||||
|
};
|
||||||
|
const socket = net.connect(
|
||||||
|
{ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) },
|
||||||
|
() => {
|
||||||
|
socket.write(
|
||||||
|
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
socket.setTimeout(1500, () => finish());
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||||
|
if (match) finish(Number(match[1]));
|
||||||
});
|
});
|
||||||
await body.text();
|
socket.on('error', () => finish());
|
||||||
if (statusCode === 200) return;
|
socket.on('end', () => finish());
|
||||||
} catch {
|
});
|
||||||
/* not yet live — keep polling */
|
}
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const statusCode = await probe();
|
||||||
|
|
||||||
|
if (statusCode === 200) return;
|
||||||
|
if (statusCode !== null && isAuthRequiredStatus(statusCode)) {
|
||||||
|
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, INTERVAL_MS));
|
await new Promise<void>((resolve) => setTimeout(resolve, INTERVAL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +237,16 @@ export async function handleBarLaunch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (running !== null) {
|
if (running !== null) {
|
||||||
|
if (running.authRequired) {
|
||||||
|
console.error(
|
||||||
|
`[X] CCS Bar cannot launch while dashboard authentication protects ${running.baseUrl}.`
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse the live server — write bar.json and open the app.
|
// Reuse the live server — write bar.json and open the app.
|
||||||
const barJson: BarDiscoveryJson = {
|
const barJson: BarDiscoveryJson = {
|
||||||
baseUrl: running.baseUrl,
|
baseUrl: running.baseUrl,
|
||||||
@@ -249,9 +298,10 @@ export async function handleBarLaunch(
|
|||||||
// 2c. Spawn the detached server.
|
// 2c. Spawn the detached server.
|
||||||
const serveLogPath = getServeLogPath(ccsDir);
|
const serveLogPath = getServeLogPath(ccsDir);
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
let spawnedChild: ChildProcess | void;
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
|
fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
|
||||||
spawnDetachedServer(port, serveLogPath);
|
spawnedChild = spawnDetachedServer(port, serveLogPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
||||||
@@ -265,6 +315,16 @@ export async function handleBarLaunch(
|
|||||||
try {
|
try {
|
||||||
await waitForServerLive(baseUrl);
|
await waitForServerLive(baseUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof BarServerAuthRequiredError) {
|
||||||
|
spawnedChild?.kill();
|
||||||
|
console.error(
|
||||||
|
`[X] CCS Bar cannot launch while dashboard authentication protects ${baseUrl}.`
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||||
|
|||||||
@@ -274,10 +274,18 @@ function makeDetachedDeps(ccsDir: string, port = 4242) {
|
|||||||
return {
|
return {
|
||||||
findRunningServer: async () => null,
|
findRunningServer: async () => null,
|
||||||
getPort: async () => port,
|
getPort: async () => port,
|
||||||
spawnDetachedServer: (_p: number, _log: string) => { /* noop */ },
|
spawnDetachedServer: (_p: number, _log: string) => {
|
||||||
waitForServerLive: async (_url: string) => { /* live immediately */ },
|
/* noop */
|
||||||
writeLaunchDescriptor: () => { /* noop */ },
|
},
|
||||||
openApp: async (_appPath: string) => { /* noop */ },
|
waitForServerLive: async (_url: string) => {
|
||||||
|
/* live immediately */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async (_appPath: string) => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
};
|
};
|
||||||
@@ -501,7 +509,7 @@ describe('bar install subcommand', () => {
|
|||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -848,7 +856,7 @@ describe('bar install: compat capability handshake', () => {
|
|||||||
throw new Error('network explosion');
|
throw new Error('network explosion');
|
||||||
},
|
},
|
||||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
isBarRunning: async () => false,
|
isBarRunning: async () => false,
|
||||||
promptLaunch: async () => false,
|
promptLaunch: async () => false,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -1597,10 +1605,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
|||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
|
findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
|
||||||
getPort: async () => 9999,
|
getPort: async () => 9999,
|
||||||
spawnDetachedServer: () => { spawnCalled = true; },
|
spawnDetachedServer: () => {
|
||||||
waitForServerLive: async () => { /* noop */ },
|
spawnCalled = true;
|
||||||
writeLaunchDescriptor: () => { /* noop */ },
|
},
|
||||||
openApp: async () => { /* noop */ },
|
waitForServerLive: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
@@ -1628,10 +1644,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
|||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
findRunningServer: async () => null,
|
findRunningServer: async () => null,
|
||||||
getPort: async () => 4242,
|
getPort: async () => 4242,
|
||||||
spawnDetachedServer: () => { spawnCalled = true; },
|
spawnDetachedServer: () => {
|
||||||
waitForServerLive: async () => { /* live */ },
|
spawnCalled = true;
|
||||||
writeLaunchDescriptor: () => { /* noop */ },
|
},
|
||||||
openApp: async () => { /* noop */ },
|
waitForServerLive: async () => {
|
||||||
|
/* live */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
@@ -1651,10 +1675,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
|||||||
throw new Error('probe exploded');
|
throw new Error('probe exploded');
|
||||||
},
|
},
|
||||||
getPort: async () => 4242,
|
getPort: async () => 4242,
|
||||||
spawnDetachedServer: () => { spawnCalled = true; },
|
spawnDetachedServer: () => {
|
||||||
waitForServerLive: async () => { /* live */ },
|
spawnCalled = true;
|
||||||
writeLaunchDescriptor: () => { /* noop */ },
|
},
|
||||||
openApp: async () => { /* noop */ },
|
waitForServerLive: async () => {
|
||||||
|
/* live */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
@@ -1680,9 +1712,15 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', ()
|
|||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
findRunningServer: async () => null,
|
findRunningServer: async () => null,
|
||||||
getPort: async () => 4242,
|
getPort: async () => 4242,
|
||||||
spawnDetachedServer: () => { /* noop */ },
|
spawnDetachedServer: () => {
|
||||||
waitForServerLive: async () => { /* live */ },
|
/* noop */
|
||||||
writeLaunchDescriptor: () => { /* noop */ },
|
},
|
||||||
|
waitForServerLive: async () => {
|
||||||
|
/* live */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
openApp: async (_appPath: string) => {
|
openApp: async (_appPath: string) => {
|
||||||
calls.push(`open:${_appPath}`);
|
calls.push(`open:${_appPath}`);
|
||||||
},
|
},
|
||||||
@@ -2899,33 +2937,54 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
|||||||
JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' })
|
JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' })
|
||||||
);
|
);
|
||||||
|
|
||||||
let highPriorityBodyDestroyed = false;
|
let highPrioritySocketDestroyed = false;
|
||||||
let lowerPriorityProbeStarted = false;
|
let lowerPriorityProbeStarted = false;
|
||||||
|
|
||||||
mock.module('undici', () => ({
|
// The probe speaks raw HTTP/1.1 over a `net` socket and resolves on the
|
||||||
request: (url: string) => {
|
// status line, so mock `net.connect` rather than a higher-level client.
|
||||||
if (url === 'http://127.0.0.1:41235/api/bar/summary') {
|
// The high-priority port (41235) answers HTTP 200 immediately; the
|
||||||
return Promise.resolve({
|
// lower-priority port (3000) connects but never sends a status line,
|
||||||
statusCode: 200,
|
// emulating a non-CCS service that streams forever.
|
||||||
body: {
|
mock.module('net', () => ({
|
||||||
destroy: () => {
|
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
||||||
highPriorityBodyDestroyed = true;
|
// net.connect is called synchronously for every probe target, so the
|
||||||
},
|
// lower-priority probe is observably "started" the moment discovery
|
||||||
},
|
// fires it — even though it never receives a status line.
|
||||||
});
|
if (opts.port === 3000) lowerPriorityProbeStarted = true;
|
||||||
}
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
||||||
|
const socket = {
|
||||||
|
on(event: string, cb: (arg?: unknown) => void) {
|
||||||
|
(listeners[event] ??= []).push(cb);
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
setTimeout() {
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
write() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (opts.port === 41235) highPrioritySocketDestroyed = true;
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (url === 'http://127.0.0.1:3000/api/bar/summary') {
|
// Fire the connect callback asynchronously, mirroring net.connect.
|
||||||
lowerPriorityProbeStarted = true;
|
setImmediate(() => {
|
||||||
return new Promise(() => {
|
onConnect();
|
||||||
// Simulate a lower-priority service that never finishes responding.
|
if (opts.port === 41235) {
|
||||||
});
|
const data = listeners.data ?? [];
|
||||||
}
|
for (const cb of data) {
|
||||||
|
cb(Buffer.from('HTTP/1.1 200 OK\r\n\r\n', 'utf8'));
|
||||||
return Promise.resolve({
|
}
|
||||||
statusCode: 404,
|
}
|
||||||
body: { destroy: () => {} },
|
// Port 3000 never emits a status line: simulate an endlessly
|
||||||
|
// streaming service that must not block the higher-priority hit.
|
||||||
|
// Any other port stays silent and is settled by the 1.5s timeout,
|
||||||
|
// which the Promise.race below short-circuits.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -2933,7 +2992,9 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
|||||||
const { defaultFindRunningServer } = (await import(
|
const { defaultFindRunningServer } = (await import(
|
||||||
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
||||||
)) as {
|
)) as {
|
||||||
defaultFindRunningServer: (ccsDir: string) => Promise<{ port: number; baseUrl: string } | null>;
|
defaultFindRunningServer: (
|
||||||
|
ccsDir: string
|
||||||
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
@@ -2941,8 +3002,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
|||||||
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)),
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result).toEqual({ port: 41235, baseUrl: 'http://127.0.0.1:41235' });
|
expect(result).toEqual({
|
||||||
expect(highPriorityBodyDestroyed).toBe(true);
|
port: 41235,
|
||||||
|
baseUrl: 'http://127.0.0.1:41235',
|
||||||
|
authRequired: false,
|
||||||
|
});
|
||||||
|
expect(highPrioritySocketDestroyed).toBe(true);
|
||||||
expect(lowerPriorityProbeStarted).toBe(true);
|
expect(lowerPriorityProbeStarted).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -84,6 +84,7 @@ async function loadLaunchSubcommand() {
|
|||||||
);
|
);
|
||||||
return mod as {
|
return mod as {
|
||||||
handleBarLaunch: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
handleBarLaunch: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
||||||
|
BarServerAuthRequiredError: new (baseUrl: string, statusCode: number) => Error;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +612,75 @@ describe('launch: detached-spawn model', () => {
|
|||||||
expect(allOutput()).toMatch(/timeout|connect|server/i);
|
expect(allOutput()).toMatch(/timeout|connect|server/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stops the spawned child when the bar API is protected by dashboard auth', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
let killCalled = false;
|
||||||
|
const { handleBarLaunch, BarServerAuthRequiredError } = await loadLaunchSubcommand();
|
||||||
|
|
||||||
|
await handleBarLaunch([], {
|
||||||
|
getCcsDir: () => ccsDir,
|
||||||
|
findRunningServer: async () => null,
|
||||||
|
getPort: async () => 3000,
|
||||||
|
spawnDetachedServer: () => ({
|
||||||
|
kill: () => {
|
||||||
|
killCalled = true;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
waitForServerLive: async () => {
|
||||||
|
throw new BarServerAuthRequiredError('http://127.0.0.1:3000', 401);
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(killCalled).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false);
|
||||||
|
expect(allOutput()).toMatch(/authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not spawn a new server when an existing server is protected by dashboard auth', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
let spawnCalled = false;
|
||||||
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||||
|
|
||||||
|
await handleBarLaunch([], {
|
||||||
|
getCcsDir: () => ccsDir,
|
||||||
|
findRunningServer: async () => ({
|
||||||
|
port: 3000,
|
||||||
|
baseUrl: 'http://127.0.0.1:3000',
|
||||||
|
authRequired: true,
|
||||||
|
}),
|
||||||
|
getPort: async () => 3001,
|
||||||
|
spawnDetachedServer: () => {
|
||||||
|
spawnCalled = true;
|
||||||
|
},
|
||||||
|
waitForServerLive: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
writeLaunchDescriptor: () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spawnCalled).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false);
|
||||||
|
expect(allOutput()).toMatch(/authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('writes launch.json via writeLaunchDescriptor on the start path', async () => {
|
it('writes launch.json via writeLaunchDescriptor on the start path', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|||||||
Reference in new issue
Block a user