mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Merge pull request #1691 from poomsc/feat/bar-port-flag
feat(bar): support --port for ccs bar with sticky port persistence
This commit is contained in:
15 files changed
+1459
-182
No files matched your search
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"syncFs": {
|
||||
"totalOccurrences": 2426,
|
||||
"filesAffected": 258,
|
||||
"hotpathOccurrences": 1141,
|
||||
"hotpathFilesAffected": 152,
|
||||
"totalOccurrences": 2434,
|
||||
"filesAffected": 259,
|
||||
"hotpathOccurrences": 1149,
|
||||
"hotpathFilesAffected": 153,
|
||||
"topHotpathFiles": [
|
||||
{
|
||||
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
||||
@@ -306,8 +306,8 @@
|
||||
]
|
||||
},
|
||||
"legacyShim": {
|
||||
"totalMarkers": 458,
|
||||
"filesAffected": 173,
|
||||
"totalMarkers": 465,
|
||||
"filesAffected": 176,
|
||||
"topFiles": [
|
||||
{
|
||||
"file": "src/auth/profile-detector.ts",
|
||||
@@ -497,11 +497,11 @@
|
||||
},
|
||||
"maintainability": {
|
||||
"typedErrors": {
|
||||
"totalThrows": 452,
|
||||
"typedThrows": 80,
|
||||
"totalThrows": 454,
|
||||
"typedThrows": 82,
|
||||
"plainThrows": 312,
|
||||
"otherThrows": 60,
|
||||
"adoptionRatio": 0.177,
|
||||
"adoptionRatio": 0.1806,
|
||||
"topSubdomainsByThrows": [
|
||||
{
|
||||
"subdomain": "web-server",
|
||||
@@ -509,18 +509,18 @@
|
||||
"typed": 16,
|
||||
"plain": 45
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 38,
|
||||
"typed": 6,
|
||||
"plain": 30
|
||||
},
|
||||
{
|
||||
"subdomain": "proxy",
|
||||
"count": 38,
|
||||
"typed": 0,
|
||||
"plain": 34
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 36,
|
||||
"typed": 4,
|
||||
"plain": 30
|
||||
},
|
||||
{
|
||||
"subdomain": "codex-auth",
|
||||
"count": 35,
|
||||
@@ -579,8 +579,8 @@
|
||||
},
|
||||
"loggerCoverage": {
|
||||
"filesWithCreateLogger": 65,
|
||||
"totalSourceFiles": 759,
|
||||
"coverageRatio": 0.0856,
|
||||
"totalSourceFiles": 761,
|
||||
"coverageRatio": 0.0854,
|
||||
"subdomainsWithZeroCreateLogger": [
|
||||
"api",
|
||||
"bin",
|
||||
@@ -606,7 +606,7 @@
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 108,
|
||||
"count": 110,
|
||||
"withLogger": 2
|
||||
},
|
||||
{
|
||||
@@ -652,8 +652,8 @@
|
||||
]
|
||||
},
|
||||
"hotpathConsoleErrors": {
|
||||
"totalOccurrences": 571,
|
||||
"exemptOccurrences": 305,
|
||||
"totalOccurrences": 592,
|
||||
"exemptOccurrences": 326,
|
||||
"hotpathOccurrences": 266,
|
||||
"filesAffected": 82,
|
||||
"topFiles": [
|
||||
|
||||
@@ -6,12 +6,12 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 2426 |
|
||||
| Sync fs files affected (all) | 258 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1141 |
|
||||
| Sync fs files affected (runtime hotpaths) | 152 |
|
||||
| Legacy shim markers | 458 |
|
||||
| Legacy shim files affected | 173 |
|
||||
| Sync fs occurrences (all) | 2434 |
|
||||
| Sync fs files affected (all) | 259 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1149 |
|
||||
| Sync fs files affected (runtime hotpaths) | 153 |
|
||||
| Legacy shim markers | 465 |
|
||||
| Legacy shim files affected | 176 |
|
||||
|
||||
## Top Runtime Hotpath Sync fs Files
|
||||
|
||||
@@ -56,11 +56,11 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| typed-error adoption (typed/total throws) | 17.7% (80/452) |
|
||||
| typed-error adoption (typed/total throws) | 18.1% (82/454) |
|
||||
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
||||
| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) |
|
||||
| hotpath console.error/warn occurrences | 266 (592 total, 326 CLI-UX exempt) |
|
||||
| hotpath console.error/warn files | 82 |
|
||||
| files with createLogger | 65/759 |
|
||||
| files with createLogger | 65/761 |
|
||||
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
||||
| files > 400 LOC | 91 |
|
||||
| files > 600 LOC | 42 |
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { ConfigError } from '../../errors/error-types';
|
||||
import { getServerPidPath } from './bar-paths';
|
||||
|
||||
export interface BarServerProcessRecord {
|
||||
pid: number;
|
||||
birthIdentity: string;
|
||||
}
|
||||
|
||||
export type BarServerStopResult =
|
||||
| 'stopped'
|
||||
| 'stale'
|
||||
| 'legacy-record'
|
||||
| 'invalid-record'
|
||||
| 'identity-mismatch'
|
||||
| 'permission-denied'
|
||||
| 'signal-failed'
|
||||
| 'timeout';
|
||||
|
||||
export interface BarServerStopDeps {
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
killProcess: (pid: number, signal: 'SIGTERM') => void;
|
||||
waitForProcessExit: (
|
||||
pid: number,
|
||||
birthIdentity: string,
|
||||
timeoutMs: number
|
||||
) => Promise<'exited' | 'identity-mismatch' | 'timeout'>;
|
||||
}
|
||||
|
||||
export function parseBarServerProcessRecord(raw: string): BarServerProcessRecord | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<BarServerProcessRecord>;
|
||||
const pid = parsed.pid;
|
||||
if (!Number.isSafeInteger(pid) || (pid ?? 0) <= 0) return null;
|
||||
if (typeof parsed.birthIdentity !== 'string' || parsed.birthIdentity.trim() === '') return null;
|
||||
return { pid: pid as number, birthIdentity: parsed.birthIdentity };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLegacyServerPid(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^[1-9]\d*$/.test(trimmed)) return null;
|
||||
const pid = Number(trimmed);
|
||||
return Number.isSafeInteger(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export function serializeBarServerProcessRecord(record: BarServerProcessRecord): string {
|
||||
return JSON.stringify(record, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the OS process birth marker for PID reuse protection. CCS Bar is a
|
||||
* macOS app, where `ps lstart` is stable for a process lifetime. Linux uses the
|
||||
* same portable ps surface in development and CI.
|
||||
*/
|
||||
export function getProcessBirthIdentity(pid: number): string | null {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
||||
try {
|
||||
const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForProcessExit(
|
||||
pid: number,
|
||||
birthIdentity: string,
|
||||
timeoutMs: number
|
||||
): Promise<'exited' | 'identity-mismatch' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const currentIdentity = getProcessBirthIdentity(pid);
|
||||
if (currentIdentity === null) return 'exited';
|
||||
if (currentIdentity !== birthIdentity) return 'identity-mismatch';
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
export async function stopRecordedBarServer(
|
||||
rawRecord: string,
|
||||
deps: Partial<BarServerStopDeps> = {}
|
||||
): Promise<{ result: BarServerStopResult; record: BarServerProcessRecord | null; error?: Error }> {
|
||||
const record = parseBarServerProcessRecord(rawRecord);
|
||||
if (record === null) {
|
||||
return {
|
||||
result: parseLegacyServerPid(rawRecord) === null ? 'invalid-record' : 'legacy-record',
|
||||
record: null,
|
||||
};
|
||||
}
|
||||
|
||||
const getIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||
const waitForExit = deps.waitForProcessExit ?? waitForProcessExit;
|
||||
|
||||
// Revalidate immediately before SIGTERM. A PID alone is unsafe because the OS
|
||||
// can reuse it after the recorded CCS Bar process exits.
|
||||
const currentIdentity = getIdentity(record.pid);
|
||||
if (currentIdentity === null) return { result: 'stale', record };
|
||||
if (currentIdentity !== record.birthIdentity) return { result: 'identity-mismatch', record };
|
||||
|
||||
try {
|
||||
killProcess(record.pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') return { result: 'stale', record };
|
||||
if (code === 'EPERM') return { result: 'permission-denied', record, error };
|
||||
return { result: 'signal-failed', record, error };
|
||||
}
|
||||
|
||||
const waitResult = await waitForExit(record.pid, record.birthIdentity, 3_000);
|
||||
if (waitResult === 'exited') return { result: 'stopped', record };
|
||||
if (waitResult === 'identity-mismatch') return { result: 'identity-mismatch', record };
|
||||
return { result: 'timeout', record };
|
||||
}
|
||||
|
||||
export interface ClaimedBarServerStopOutcome {
|
||||
result: BarServerStopResult | 'no-record';
|
||||
record: BarServerProcessRecord | null;
|
||||
legacyPid?: number;
|
||||
error?: Error;
|
||||
recoveryPath?: string;
|
||||
}
|
||||
|
||||
function isErrno(err: unknown, code: string): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function unlinkIfPresent(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
if (!isErrno(err, 'ENOENT')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreClaimWithoutOverwrite(
|
||||
claimPath: string,
|
||||
canonicalPath: string
|
||||
): string | undefined {
|
||||
try {
|
||||
fs.linkSync(claimPath, canonicalPath);
|
||||
unlinkIfPresent(claimPath);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'EEXIST')) return claimPath;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim server.pid before signaling. The serve process therefore
|
||||
* sees ENOENT during its signal handler and cannot unlink a new owner's record.
|
||||
*/
|
||||
export async function stopBarServerProcessFile(
|
||||
pidPath: string,
|
||||
deps: Partial<BarServerStopDeps> = {}
|
||||
): Promise<ClaimedBarServerStopOutcome> {
|
||||
const claimPath = `${pidPath}.claim-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(pidPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return { result: 'no-record', record: null };
|
||||
throw err;
|
||||
}
|
||||
|
||||
let rawRecord: string;
|
||||
try {
|
||||
rawRecord = fs.readFileSync(claimPath, 'utf8').trim();
|
||||
} catch (err) {
|
||||
const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
return {
|
||||
result: 'invalid-record',
|
||||
record: null,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
recoveryPath,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await stopRecordedBarServer(rawRecord, deps);
|
||||
if (outcome.result === 'stopped' || outcome.result === 'stale') {
|
||||
unlinkIfPresent(claimPath);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
return {
|
||||
...outcome,
|
||||
legacyPid:
|
||||
outcome.result === 'legacy-record'
|
||||
? (parseLegacyServerPid(rawRecord) ?? undefined)
|
||||
: undefined,
|
||||
recoveryPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove server.pid only when it still belongs to this exact serve process. */
|
||||
export function removeBarServerProcessRecordIfOwned(
|
||||
pidPath: string,
|
||||
expectedRecord: BarServerProcessRecord
|
||||
): void {
|
||||
const claimPath = `${pidPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(pidPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let claimedRecord: BarServerProcessRecord | null = null;
|
||||
try {
|
||||
claimedRecord = parseBarServerProcessRecord(fs.readFileSync(claimPath, 'utf8'));
|
||||
} catch {
|
||||
// Preserve unreadable state below.
|
||||
}
|
||||
|
||||
if (
|
||||
claimedRecord?.pid === expectedRecord.pid &&
|
||||
claimedRecord.birthIdentity === expectedRecord.birthIdentity
|
||||
) {
|
||||
unlinkIfPresent(claimPath);
|
||||
return;
|
||||
}
|
||||
restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
}
|
||||
|
||||
/** Claim stale discovery state so a replacement write can never be unlinked. */
|
||||
export function removeBarDiscoveryIfNoProcess(barJsonPath: string, pidPath: string): boolean {
|
||||
const claimPath = `${barJsonPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(barJsonPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return true;
|
||||
throw err;
|
||||
}
|
||||
if (fs.existsSync(pidPath)) {
|
||||
restoreClaimWithoutOverwrite(claimPath, barJsonPath);
|
||||
return false;
|
||||
}
|
||||
unlinkIfPresent(claimPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopDetachedBarServer(ccsDir: string): Promise<void> {
|
||||
const pidPath = getServerPidPath(ccsDir);
|
||||
try {
|
||||
const outcome = await stopBarServerProcessFile(pidPath);
|
||||
if (outcome.result !== 'stopped' && outcome.result !== 'stale') {
|
||||
throw new ConfigError(
|
||||
`Safe CCS Bar stop aborted (${outcome.result}); recovery state was preserved`,
|
||||
outcome.recoveryPath ?? pidPath
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) throw err;
|
||||
throw new ConfigError(
|
||||
`Cannot safely stop CCS Bar process: ${err instanceof Error ? err.message : String(err)}`,
|
||||
pidPath
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,16 @@ export interface DashboardInfo {
|
||||
authRequired?: boolean;
|
||||
}
|
||||
|
||||
function isValidPort(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the port recorded in an existing bar.json.
|
||||
* Returns null when the file is absent or malformed.
|
||||
* Read the port recorded in an existing bar.json, falling back to the --port
|
||||
* in launch.json's args. `ccs bar stop` deletes bar.json but leaves
|
||||
* launch.json, so the fallback is what keeps the sticky port (and the probe's
|
||||
* ability to find a server on a non-default port) across a stop/start cycle.
|
||||
* Returns null when neither file records a port.
|
||||
*/
|
||||
|
||||
export function resolveBarPort(ccsDir: string): number | null {
|
||||
@@ -35,10 +42,28 @@ export function resolveBarPort(ccsDir: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<{ port: number }>;
|
||||
return typeof parsed.port === 'number' ? parsed.port : null;
|
||||
if (isValidPort(parsed.port)) return parsed.port;
|
||||
} catch {
|
||||
return null;
|
||||
/* fall through to launch.json */
|
||||
}
|
||||
|
||||
const launchJsonPath = path.join(ccsDir, 'bar', 'launch.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(launchJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<{ args: unknown[] }>;
|
||||
const args = Array.isArray(parsed.args) ? parsed.args : [];
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
const rawPort = args[idx + 1];
|
||||
if (typeof rawPort === 'string' && /^[1-9]\d{0,4}$/.test(rawPort)) {
|
||||
const n = Number(rawPort);
|
||||
if (isValidPort(n)) return n;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* absent or malformed -> null */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,18 +109,18 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
||||
// loopback service that streams forever cannot block discovery from
|
||||
// returning a higher-priority hit.
|
||||
socket.destroy();
|
||||
const authRequired = statusCode === 401 || statusCode === 403;
|
||||
const proofMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = proofMatch ? proofMatch[1].trim() : '';
|
||||
const tokenMatched = isMatchingBarAuthProof(token, nonce, proof);
|
||||
const authRequired = (statusCode === 401 || statusCode === 403) && tokenMatched;
|
||||
if (authRequired) {
|
||||
resolve({ ok: true, authRequired: true });
|
||||
return;
|
||||
}
|
||||
if (statusCode === 200) {
|
||||
// Accept only when the server includes a correct nonce-bound proof.
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ ok: isMatchingBarAuthProof(token, nonce, proof), authRequired: false });
|
||||
resolve({ ok: tokenMatched, authRequired: false });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: false, authRequired: false });
|
||||
@@ -117,12 +142,12 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
||||
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1]);
|
||||
// For non-200 we can finish on the status line alone.
|
||||
if (code !== 200) {
|
||||
// CCS-authenticated 401/403 responses also carry the nonce proof, so
|
||||
// wait for their complete headers before deciding identity.
|
||||
if (code !== 200 && code !== 401 && code !== 403) {
|
||||
finish(code, rawResponse);
|
||||
return;
|
||||
}
|
||||
// For 200 we need the headers section to extract the token.
|
||||
if (rawResponse.includes('\r\n\r\n')) {
|
||||
finish(code, rawResponse.split('\r\n\r\n')[0]);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Options:',
|
||||
[
|
||||
['--port <n>', 'Run the server on this port (persists; later launches keep the same port)'],
|
||||
['--help, -h', 'Show this help message'],
|
||||
['--version', 'Show CLI and installed app versions'],
|
||||
],
|
||||
@@ -44,6 +45,7 @@ export async function showHelp(): Promise<void> {
|
||||
'Examples:',
|
||||
[
|
||||
['ccs bar', 'Start the server detached and open CCS Bar'],
|
||||
['ccs bar --port 3999', 'Start (or move) the server on port 3999 instead of 3000'],
|
||||
['ccs bar stop', 'Stop the detached CCS Bar server'],
|
||||
['ccs bar status', 'Show server running state and PID'],
|
||||
['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { hasAnyFlag } from '../arg-extractor';
|
||||
import { validatePortArgs } from './port-arg';
|
||||
|
||||
export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
@@ -56,9 +57,18 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
},
|
||||
};
|
||||
|
||||
// Bare `ccs bar` → launch
|
||||
if (!subcommand || subcommand === 'launch') {
|
||||
await commandHandlers.launch(subcommand ? args.slice(1) : []);
|
||||
// Bare `ccs bar` → launch. Bare flags (e.g. `ccs bar --port 3999`) also go to
|
||||
// launch with the full arg list preserved (--help/--version were handled above).
|
||||
if (!subcommand || subcommand === 'launch' || subcommand.startsWith('-')) {
|
||||
const launchArgs = subcommand === 'launch' ? args.slice(1) : args;
|
||||
const argError = validatePortArgs(launchArgs);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
console.error('[i] Usage: ccs bar [--port N]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await commandHandlers.launch(launchArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface LaunchDescriptorOptions {
|
||||
runtime?: string;
|
||||
home?: string;
|
||||
ccsHome?: string;
|
||||
/** Server port; recorded in args so the Swift app self-starts on the same port. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function getLaunchShimPath(home: string = os.homedir()): string {
|
||||
@@ -84,7 +86,12 @@ export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {})
|
||||
return {
|
||||
schema: LAUNCH_JSON_SCHEMA,
|
||||
runtime: options.runtime ?? process.execPath,
|
||||
args: [entrypoint, 'bar', 'serve'],
|
||||
args: [
|
||||
entrypoint,
|
||||
'bar',
|
||||
'serve',
|
||||
...(options.port !== undefined ? ['--port', String(options.port)] : []),
|
||||
],
|
||||
home,
|
||||
...(ccsHome ? { ccsHome } : {}),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* Detached model (replaces the old in-process model):
|
||||
* 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080).
|
||||
* 2. If a live server is found → reuse it, write bar.json, open app, return.
|
||||
* 3. Else → refresh launch.json, getPort to pick a free port, spawn
|
||||
* With an explicit --port that differs from the running server's port,
|
||||
* stop that server first and fall through to a fresh start instead.
|
||||
* 3. Else → pick a port (--port exactly when given; otherwise bar.json's
|
||||
* recorded port first, then the default candidates), refresh launch.json
|
||||
* (including --port so the Swift app self-starts on the same port), spawn
|
||||
* `ccs bar serve --port N` detached with stdio → serve.log, poll
|
||||
* /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app,
|
||||
* return. The CLI process exits; the server continues as a detached child.
|
||||
@@ -31,11 +35,13 @@ import {
|
||||
import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths';
|
||||
import type { LaunchJson } from './bar-paths';
|
||||
import { createBarLaunchDescriptor } from './launch-descriptor';
|
||||
import { parsePortFlag, validatePortArgs } from './port-arg';
|
||||
import {
|
||||
defaultFindRunningServer as _defaultFindRunningServer,
|
||||
resolveBarPort as _resolveBarPort,
|
||||
} from './bar-server-probe';
|
||||
import type { DashboardInfo as _DashboardInfo } from './bar-server-probe';
|
||||
import { stopDetachedBarServer } from './bar-process-control';
|
||||
|
||||
const BAR_PROBE_TIMEOUT_MS = 1500;
|
||||
const MAX_BAR_PROBE_RESPONSE_BYTES = 8192;
|
||||
@@ -83,10 +89,21 @@ export interface LaunchDeps {
|
||||
* Returns the live baseUrl on success, throws on timeout.
|
||||
*/
|
||||
waitForServerLive: (baseUrl: string) => Promise<void>;
|
||||
/**
|
||||
* Build the launch.json descriptor (includes the chosen --port so the Swift
|
||||
* app self-starts the server on the same port).
|
||||
*/
|
||||
createLaunchDescriptor: (opts?: { port?: number }) => LaunchJson;
|
||||
/**
|
||||
* Write launch.json so the Swift app can spawn the server independently.
|
||||
*/
|
||||
writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void;
|
||||
/**
|
||||
* Stop the detached CCS Bar server recorded in server.pid and wait briefly
|
||||
* for the port to free. Used when an explicit --port differs from the port
|
||||
* the running server occupies.
|
||||
*/
|
||||
stopDetachedServer: (ccsDir: string) => Promise<void>;
|
||||
/** Open the installed .app bundle. Throws if the app is not found. */
|
||||
openApp: (appPath: string) => Promise<void>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
|
||||
@@ -169,15 +186,11 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
settled = true;
|
||||
clearTimeout(absoluteDeadline);
|
||||
socket.destroy();
|
||||
if (statusCode === 200) {
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) });
|
||||
return;
|
||||
}
|
||||
resolve({ statusCode, tokenMatched: false });
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) });
|
||||
};
|
||||
const socket = net.connect(
|
||||
{ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) },
|
||||
@@ -199,7 +212,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1]);
|
||||
if (code !== 200) {
|
||||
if (code !== 200 && code !== 401 && code !== 403) {
|
||||
finish(code, rawResponse);
|
||||
return;
|
||||
}
|
||||
@@ -221,7 +234,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
const { statusCode, tokenMatched } = await probe();
|
||||
|
||||
if (statusCode === 200 && tokenMatched) return;
|
||||
if (statusCode !== null && isAuthRequiredStatus(statusCode)) {
|
||||
if (statusCode !== null && isAuthRequiredStatus(statusCode) && tokenMatched) {
|
||||
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
||||
}
|
||||
|
||||
@@ -258,13 +271,21 @@ export async function handleBarLaunch(
|
||||
_args: string[],
|
||||
deps: Partial<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
const argError = validatePortArgs(_args);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const openApp = deps.openApp ?? defaultOpenApp;
|
||||
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
|
||||
const getPortFn = deps.getPort ?? defaultGetPort;
|
||||
const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer;
|
||||
const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive;
|
||||
const createLaunchDescriptor = deps.createLaunchDescriptor ?? createBarLaunchDescriptor;
|
||||
const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor;
|
||||
const stopDetachedServer = deps.stopDetachedServer ?? stopDetachedBarServer;
|
||||
|
||||
// Wire findRunningServer after ccsDir is resolved.
|
||||
const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir));
|
||||
@@ -272,6 +293,16 @@ export async function handleBarLaunch(
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
const launchJsonPath = getLaunchJsonPath(ccsDir);
|
||||
|
||||
// 0. Parse --port. A present-but-invalid value is a hard error (silently
|
||||
// launching on a different port than the user asked for is worse).
|
||||
const portFlag = parsePortFlag(_args);
|
||||
if (portFlag.present && portFlag.port === null) {
|
||||
console.error('[X] Invalid --port value. Use a number between 1 and 65535.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const requestedPort = portFlag.port;
|
||||
|
||||
// 1. Probe for an already-running server.
|
||||
let running: DashboardInfo | null = null;
|
||||
try {
|
||||
@@ -280,6 +311,9 @@ export async function handleBarLaunch(
|
||||
/* any probe error counts as null */
|
||||
}
|
||||
|
||||
let movingFrom: DashboardInfo | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
if (running !== null) {
|
||||
if (running.authRequired) {
|
||||
console.error(
|
||||
@@ -291,59 +325,127 @@ export async function handleBarLaunch(
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the live server — write bar.json and open the app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: running.baseUrl,
|
||||
port: running.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
if (requestedPort === null || running.port === requestedPort) {
|
||||
// Reuse the live server — write bar.json and open the app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: running.baseUrl,
|
||||
port: running.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${barJsonPath}`);
|
||||
await _openAppWithFallback(appInstallPath, openApp);
|
||||
return;
|
||||
}
|
||||
console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${barJsonPath}`);
|
||||
await _openAppWithFallback(appInstallPath, openApp);
|
||||
return;
|
||||
|
||||
// Explicit --port that differs from the running server: preflight the
|
||||
// destination before disrupting the healthy current service.
|
||||
console.log(
|
||||
`[i] CCS Bar server is running on port ${running.port}; moving to port ${requestedPort}...`
|
||||
);
|
||||
if (requestedPort === null) return;
|
||||
try {
|
||||
const availablePort = await getPortFn({ port: [requestedPort], host: '127.0.0.1' });
|
||||
if (availablePort !== requestedPort) {
|
||||
console.error(`[X] Port ${requestedPort} is already in use by another process.`);
|
||||
console.error('[i] The existing CCS Bar server was left running.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
port = requestedPort;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not preflight port ${requestedPort}: ${msg}`);
|
||||
console.error('[i] The existing CCS Bar server was left running.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopDetachedServer(ccsDir);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not safely stop the running server: ${msg}`);
|
||||
console.error('[i] Recovery state was preserved; resolve the stop error, then retry.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
movingFrom = running;
|
||||
// Fall through to the fresh-start path below.
|
||||
}
|
||||
|
||||
// 2. No live server — pick a port, write/refresh launch.json, spawn detached.
|
||||
// 2. No live server (or moving ports) — pick a port, write/refresh
|
||||
// launch.json, spawn detached.
|
||||
|
||||
// 2a. Pick a free port.
|
||||
let port: number;
|
||||
// 2a. Pick a free port. An explicit --port must be honored exactly; without
|
||||
// it, the port recorded in bar.json is preferred so the server keeps
|
||||
// coming back on the port the user last chose (sticky port).
|
||||
try {
|
||||
port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
|
||||
if (port !== null) {
|
||||
// Destination was already preflighted before stopping the prior server.
|
||||
} else if (requestedPort !== null) {
|
||||
const got = await getPortFn({ port: [requestedPort], host: '127.0.0.1' });
|
||||
if (got !== requestedPort) {
|
||||
console.error(`[X] Port ${requestedPort} is already in use by another process.`);
|
||||
console.error('[i] Choose a different port or free it, then retry.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
port = requestedPort;
|
||||
} else {
|
||||
const stickyPort = _resolveBarPort(ccsDir);
|
||||
const base = [3000, 3001, 3002, 8000, 8080];
|
||||
const candidates =
|
||||
stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base;
|
||||
port = await getPortFn({ port: candidates, host: '127.0.0.1' });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not find a free port: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2b. Write/refresh launch.json so the Swift app can self-start next time.
|
||||
try {
|
||||
const launchDescriptor = createBarLaunchDescriptor();
|
||||
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
||||
} catch (err) {
|
||||
// Non-fatal — the Swift app falls back to resolving `ccs` via PATH.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write launch.json: ${msg}`);
|
||||
if (port === null) {
|
||||
console.error('[X] Could not resolve a valid CCS Bar port.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const selectedPort = port;
|
||||
|
||||
// 2c. Spawn the detached server.
|
||||
const rollbackPriorServer = async (): Promise<void> => {
|
||||
if (movingFrom === null) return;
|
||||
try {
|
||||
spawnDetachedServer(movingFrom.port, serveLogPath);
|
||||
await waitForServerLive(movingFrom.baseUrl);
|
||||
console.log(`[OK] Restored CCS Bar server at ${movingFrom.baseUrl}.`);
|
||||
} catch (rollbackErr) {
|
||||
const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
console.error(`[X] Failed to restore CCS Bar at ${movingFrom.baseUrl}: ${message}`);
|
||||
console.error('[i] Existing discovery and launch state was preserved for manual recovery.');
|
||||
}
|
||||
};
|
||||
|
||||
// 2b. Spawn the detached server. launch.json is not replaced until the new
|
||||
// server is proven live, preserving the prior recovery path during a move.
|
||||
const serveLogPath = getServeLogPath(ccsDir);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseUrl = `http://127.0.0.1:${selectedPort}`;
|
||||
let spawnedChild: ChildProcess | void;
|
||||
try {
|
||||
fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
|
||||
spawnedChild = spawnDetachedServer(port, serveLogPath);
|
||||
spawnedChild = spawnDetachedServer(selectedPort, serveLogPath);
|
||||
} 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.');
|
||||
await rollbackPriorServer();
|
||||
if (movingFrom === null) console.error('[i] Run `ccs config` to start the dashboard manually.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -361,18 +463,30 @@ export async function handleBarLaunch(
|
||||
console.error(
|
||||
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||
spawnedChild?.kill();
|
||||
await rollbackPriorServer();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2d. Persist the proven-good recovery descriptor.
|
||||
try {
|
||||
const launchDescriptor = createLaunchDescriptor({ port: selectedPort });
|
||||
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write launch.json: ${msg}`);
|
||||
}
|
||||
|
||||
// 2e. Write bar.json.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl,
|
||||
port,
|
||||
port: selectedPort,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared `--port N` flag parsing for the `ccs bar` command family.
|
||||
*
|
||||
* `present` distinguishes "flag not given" from "flag given with a bad value"
|
||||
* so launch can reject typos loudly instead of silently falling back to the
|
||||
* default port list.
|
||||
*/
|
||||
|
||||
export interface PortFlag {
|
||||
/** True when `--port` appears in args at all. */
|
||||
present: boolean;
|
||||
/** The parsed port (1-65535), or null when absent or invalid. */
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
export function validatePortArgs(args: string[]): string | null {
|
||||
let foundPort = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg !== '--port') return `Unknown option: ${arg}`;
|
||||
if (foundPort) return 'Duplicate option: --port';
|
||||
foundPort = true;
|
||||
const raw = args[index + 1];
|
||||
if (raw === undefined) return 'Missing value for --port';
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePortFlag(args: string[]): PortFlag {
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx === -1) return { present: false, port: null };
|
||||
const raw = args[idx + 1];
|
||||
if (raw === undefined || !/^[1-9]\d{0,4}$/.test(raw)) {
|
||||
return { present: true, port: null };
|
||||
}
|
||||
const n = Number(raw);
|
||||
const valid = Number.isSafeInteger(n) && n <= 65535;
|
||||
return { present: true, port: valid ? n : null };
|
||||
}
|
||||
@@ -16,9 +16,16 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import { defaultFindRunningServer } from './bar-server-probe';
|
||||
import { defaultFindRunningServer, resolveBarPort } from './bar-server-probe';
|
||||
import { parsePortFlag, validatePortArgs } from './port-arg';
|
||||
import type { DashboardInfo } from './bar-server-probe';
|
||||
import type { BarDiscoveryJson } from './launch-subcommand';
|
||||
import {
|
||||
getProcessBirthIdentity,
|
||||
removeBarServerProcessRecordIfOwned,
|
||||
serializeBarServerProcessRecord,
|
||||
} from './bar-process-control';
|
||||
import type { BarServerProcessRecord } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps for testability
|
||||
@@ -44,10 +51,14 @@ export interface ServeDeps {
|
||||
writeFile: (filePath: string, content: string) => void;
|
||||
/** Remove a file if it exists (for cleanup on exit). */
|
||||
removeFile: (filePath: string) => void;
|
||||
/** Remove server.pid only if it still identifies this process. */
|
||||
removeProcessRecordIfOwned: (filePath: string, record: BarServerProcessRecord) => void;
|
||||
/** Register process signal handlers. */
|
||||
onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void;
|
||||
/** Exit the process. */
|
||||
exit: (code: number) => never;
|
||||
/** Read a stable OS birth marker so later stop commands cannot signal a reused PID. */
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,14 +84,6 @@ function defaultWriteFile(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function defaultRemoveFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* ignore — file may already be gone */
|
||||
}
|
||||
}
|
||||
|
||||
function defaultOnSignal(signal: 'SIGINT' | 'SIGTERM', handler: () => void): void {
|
||||
process.on(signal, handler);
|
||||
}
|
||||
@@ -93,32 +96,27 @@ function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePortArg(args: string[]): number | null {
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
const n = parseInt(args[idx + 1], 10);
|
||||
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarServe(args: string[], deps: Partial<ServeDeps> = {}): Promise<void> {
|
||||
const argError = validatePortArgs(args);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
(deps.exit ?? defaultExit)(1);
|
||||
return;
|
||||
}
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir));
|
||||
const startServerFn = deps.startServer ?? defaultStartServer;
|
||||
const getPortFn = deps.getPort ?? defaultGetPort;
|
||||
const writeFile = deps.writeFile ?? defaultWriteFile;
|
||||
const removeFile = deps.removeFile ?? defaultRemoveFile;
|
||||
const removeProcessRecordIfOwned =
|
||||
deps.removeProcessRecordIfOwned ?? removeBarServerProcessRecordIfOwned;
|
||||
const onSignal = deps.onSignal ?? defaultOnSignal;
|
||||
const exit = deps.exit ?? defaultExit;
|
||||
const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
const serverPidPath = getServerPidPath(ccsDir);
|
||||
@@ -151,12 +149,24 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
|
||||
// 2. No live server found — start one.
|
||||
// Honor --port N from the launcher (it pre-selected via getPort to avoid races).
|
||||
const requestedPort = parsePortArg(args);
|
||||
// Without it, prefer the port recorded in bar.json so the server keeps coming
|
||||
// back on the port the user last chose (sticky port).
|
||||
const portFlag = parsePortFlag(args);
|
||||
if (portFlag.present && portFlag.port === null) {
|
||||
console.error('[X] Invalid --port value. Use an integer between 1 and 65535.');
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
const requestedPort = portFlag.port;
|
||||
let port: number;
|
||||
if (requestedPort !== null) {
|
||||
port = requestedPort;
|
||||
} else {
|
||||
port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
|
||||
const stickyPort = resolveBarPort(ccsDir);
|
||||
const base = [3000, 3001, 3002, 8000, 8080];
|
||||
const candidates =
|
||||
stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base;
|
||||
port = await getPortFn({ port: candidates, host: '127.0.0.1' });
|
||||
}
|
||||
|
||||
// TypeScript cannot infer that exit(1) is `never` when it is injected as a dep,
|
||||
@@ -171,26 +181,43 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 3. Write bar.json and server.pid.
|
||||
// 3. Publish the owned process record before discovery. Stop cleanup uses
|
||||
// server.pid as the ownership barrier, so discovery must never appear first.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: dashboardInfo.baseUrl,
|
||||
port: dashboardInfo.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
const birthIdentity = readProcessBirthIdentity(process.pid);
|
||||
if (birthIdentity === null) {
|
||||
console.error('[X] Failed to record CCS Bar process identity; stopping unmanaged server.');
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const processRecord = { pid: process.pid, birthIdentity };
|
||||
try {
|
||||
writeFile(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
writeFile(serverPidPath, serializeBarServerProcessRecord(processRecord));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
console.error(`[X] Failed to write server.pid: ${msg}`);
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
writeFile(serverPidPath, String(process.pid));
|
||||
writeFile(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
// Non-fatal — stop/status will just degrade gracefully.
|
||||
try {
|
||||
removeProcessRecordIfOwned(serverPidPath, processRecord);
|
||||
} catch (cleanupErr) {
|
||||
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
||||
console.error(`[!] Failed to roll back server.pid: ${cleanupMessage}`);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Failed to write server.pid: ${msg}`);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OK] CCS Bar server started at ${dashboardInfo.baseUrl}`);
|
||||
@@ -198,7 +225,7 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
|
||||
// 4. Clean shutdown on SIGINT / SIGTERM.
|
||||
const shutdown = (): void => {
|
||||
removeFile(serverPidPath);
|
||||
removeProcessRecordIfOwned(serverPidPath, processRecord);
|
||||
// bar.json is intentionally left in place on clean shutdown so
|
||||
// the Swift app self-heal poll can detect the server is gone via
|
||||
// the liveness check, not a stale discovery file.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as fs from 'fs';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import { parseBarServerProcessRecord, parseLegacyServerPid } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps
|
||||
@@ -115,11 +116,21 @@ export async function handleBarStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = parseInt(pidRaw, 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
const processRecord = parseBarServerProcessRecord(pidRaw);
|
||||
if (processRecord === null) {
|
||||
const legacyPid = parseLegacyServerPid(pidRaw);
|
||||
if (legacyPid !== null) {
|
||||
console.log(
|
||||
`[!] CCS Bar server: legacy server.pid has unverified PID ${legacyPid}; status cannot safely identify it.`
|
||||
);
|
||||
console.log(`[i] Verify manually with: ps -p ${legacyPid} -o command=`);
|
||||
console.log('[i] If it is CCS Bar, stop it manually, remove server.pid, then restart.');
|
||||
return;
|
||||
}
|
||||
console.log(`[!] CCS Bar server: server.pid is invalid ("${pidRaw}")`);
|
||||
return;
|
||||
}
|
||||
const { pid } = processRecord;
|
||||
|
||||
// 2. Check process liveness.
|
||||
const alive = isProcessAlive(pid);
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
import * as fs from 'fs';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import {
|
||||
getProcessBirthIdentity,
|
||||
parseLegacyServerPid,
|
||||
removeBarDiscoveryIfNoProcess,
|
||||
stopBarServerProcessFile,
|
||||
stopRecordedBarServer,
|
||||
waitForProcessExit,
|
||||
} from './bar-process-control';
|
||||
import type { ClaimedBarServerStopOutcome } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps
|
||||
@@ -26,6 +35,9 @@ export interface StopDeps {
|
||||
* Throws if the signal cannot be delivered (e.g. ESRCH — no such process).
|
||||
*/
|
||||
killProcess: (pid: number, signal: 'SIGTERM') => void;
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
waitForProcessExit: typeof waitForProcessExit;
|
||||
stopProcessFile: (pidPath: string) => Promise<ClaimedBarServerStopOutcome>;
|
||||
/** Remove a file, ignoring errors if absent. */
|
||||
removeFile: (filePath: string) => void;
|
||||
}
|
||||
@@ -67,43 +79,92 @@ export async function handleBarStop(_args: string[], deps: Partial<StopDeps> = {
|
||||
const readPidFile = deps.readPidFile ?? defaultReadPidFile;
|
||||
const killProcess = deps.killProcess ?? defaultKillProcess;
|
||||
const removeFile = deps.removeFile ?? defaultRemoveFile;
|
||||
const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
const waitForExit = deps.waitForProcessExit ?? waitForProcessExit;
|
||||
|
||||
const pidPath = getServerPidPath(ccsDir);
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
|
||||
// 1. Read the PID file.
|
||||
const pidRaw = readPidFile(pidPath);
|
||||
if (pidRaw === null) {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = parseInt(pidRaw, 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
console.error(`[X] server.pid contains an invalid PID: "${pidRaw}"`);
|
||||
// Clean up the corrupted file so subsequent runs start fresh.
|
||||
removeFile(pidPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Send SIGTERM.
|
||||
try {
|
||||
killProcess(pid, 'SIGTERM');
|
||||
console.log(`[OK] Sent SIGTERM to CCS Bar server (PID ${pid}).`);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') {
|
||||
// Process no longer exists — stale PID file, clean up silently.
|
||||
console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`);
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to stop server (PID ${pid}): ${msg}`);
|
||||
// Still remove the pid file so the user is not blocked.
|
||||
let outcome: ClaimedBarServerStopOutcome;
|
||||
if (deps.stopProcessFile !== undefined || deps.readPidFile === undefined) {
|
||||
const stopProcessFile =
|
||||
deps.stopProcessFile ??
|
||||
((filePath: string) =>
|
||||
stopBarServerProcessFile(filePath, {
|
||||
getProcessBirthIdentity: readProcessBirthIdentity,
|
||||
killProcess,
|
||||
waitForProcessExit: waitForExit,
|
||||
}));
|
||||
outcome = await stopProcessFile(pidPath);
|
||||
if (outcome.result === 'no-record') {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Injected record reads remain available for isolated unit tests. Production
|
||||
// always uses the atomic file claim above.
|
||||
const pidRaw = readPidFile(pidPath);
|
||||
if (pidRaw === null) {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
const recordedOutcome = await stopRecordedBarServer(pidRaw, {
|
||||
getProcessBirthIdentity: readProcessBirthIdentity,
|
||||
killProcess,
|
||||
waitForProcessExit: waitForExit,
|
||||
});
|
||||
outcome = {
|
||||
...recordedOutcome,
|
||||
legacyPid:
|
||||
recordedOutcome.result === 'legacy-record'
|
||||
? (parseLegacyServerPid(pidRaw) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
const pid = outcome.record?.pid;
|
||||
|
||||
if (outcome.result === 'stopped' || outcome.result === 'stale') {
|
||||
if (outcome.result === 'stopped') {
|
||||
console.log(`[OK] CCS Bar server stopped (PID ${pid}).`);
|
||||
} else {
|
||||
console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`);
|
||||
}
|
||||
if (deps.readPidFile !== undefined) {
|
||||
removeFile(pidPath);
|
||||
removeFile(barJsonPath);
|
||||
console.log('[i] Removed server.pid and bar.json.');
|
||||
} else {
|
||||
const removedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath);
|
||||
if (removedDiscovery) console.log('[i] Removed server.pid and bar.json.');
|
||||
else
|
||||
console.log('[i] A replacement server record appeared; its recovery state was preserved.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Remove pid + bar.json regardless of kill result.
|
||||
removeFile(pidPath);
|
||||
removeFile(barJsonPath);
|
||||
console.log('[i] Removed server.pid and bar.json.');
|
||||
if (outcome.result === 'legacy-record') {
|
||||
const legacyPid = outcome.legacyPid;
|
||||
console.error(
|
||||
`[X] Legacy server.pid records PID ${legacyPid} without process identity; no signal was sent.`
|
||||
);
|
||||
console.error(`[i] Verify manually with: ps -p ${legacyPid} -o command=`);
|
||||
console.error(
|
||||
`[i] If it is CCS Bar, stop it manually, then remove ${outcome.recoveryPath ?? pidPath} and ${barJsonPath}.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const reason =
|
||||
outcome.result === 'invalid-record'
|
||||
? 'server.pid does not contain a verified process record'
|
||||
: outcome.result === 'identity-mismatch'
|
||||
? `PID ${pid} belongs to a different process`
|
||||
: outcome.result === 'permission-denied'
|
||||
? `permission denied while signaling PID ${pid}`
|
||||
: outcome.result === 'timeout'
|
||||
? `PID ${pid} did not exit within 3 seconds`
|
||||
: `failed to signal PID ${pid}: ${outcome.error?.message ?? 'unknown error'}`;
|
||||
console.error(`[X] Refusing to remove CCS Bar recovery state: ${reason}.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -169,6 +169,13 @@ describe('bar command dispatcher (index.ts)', () => {
|
||||
expect(calls).toEqual(['launch:']);
|
||||
});
|
||||
|
||||
it('rejects an unknown bare launch flag without dispatching launch', async () => {
|
||||
const handleBarCommand = await loadHandleBarCommand();
|
||||
await handleBarCommand(['--porrt', '3999']);
|
||||
expect(calls).toEqual([]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('dispatches `ccs bar install` to install subcommand', async () => {
|
||||
const handleBarCommand = await loadHandleBarCommand();
|
||||
await handleBarCommand(['install']);
|
||||
@@ -3040,7 +3047,7 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
|
||||
// parser correctly sets authRequired without relying on the higher-level dep
|
||||
// injection that existing tests use (which bypasses real status-line parsing).
|
||||
|
||||
function makeNetMock(statusLine: string) {
|
||||
function makeNetMock(statusLine: string, token: string) {
|
||||
return {
|
||||
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
||||
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
||||
@@ -3054,19 +3061,25 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
|
||||
void cb;
|
||||
return socket;
|
||||
},
|
||||
write() {
|
||||
write(request: string) {
|
||||
const nonce =
|
||||
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ??
|
||||
'';
|
||||
const proof = createBarAuthProof(token, nonce);
|
||||
setImmediate(() => {
|
||||
for (const cb of listeners.data ?? []) {
|
||||
cb(
|
||||
Buffer.from(`${statusLine}\r\n${BAR_AUTH_TOKEN_HEADER}: ${proof}\r\n\r\n`, 'utf8')
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
return socket;
|
||||
},
|
||||
};
|
||||
setImmediate(() => {
|
||||
onConnect();
|
||||
for (const cb of listeners.data ?? []) {
|
||||
cb(Buffer.from(`${statusLine}\r\n\r\n`, 'utf8'));
|
||||
}
|
||||
});
|
||||
setImmediate(onConnect);
|
||||
return socket;
|
||||
},
|
||||
};
|
||||
@@ -3081,7 +3094,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
|
||||
JSON.stringify({ port: 41401, baseUrl: 'http://127.0.0.1:41401', authMode: 'loopback' })
|
||||
);
|
||||
|
||||
mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized'));
|
||||
const token = getOrCreateBarAuthToken(ccsDir);
|
||||
mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized', token));
|
||||
|
||||
moduleSeq++;
|
||||
const { defaultFindRunningServer } = (await import(
|
||||
@@ -3111,7 +3125,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
|
||||
JSON.stringify({ port: 41403, baseUrl: 'http://127.0.0.1:41403', authMode: 'loopback' })
|
||||
);
|
||||
|
||||
mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden'));
|
||||
const token = getOrCreateBarAuthToken(ccsDir);
|
||||
mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden', token));
|
||||
|
||||
moduleSeq++;
|
||||
const { defaultFindRunningServer } = (await import(
|
||||
@@ -3302,6 +3317,30 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
|
||||
expect(result === 'timeout' || result === 'rejected').toBe(true);
|
||||
});
|
||||
|
||||
it('unrelated 401/403 without a CCS proof does not trigger auth-required identity', async () => {
|
||||
for (const status of [401, 403]) {
|
||||
mock.module('net', () =>
|
||||
buildNetMock(`HTTP/1.1 ${status} Unrelated Service\r\nConnection: close\r\n\r\n`)
|
||||
);
|
||||
moduleSeq++;
|
||||
const { defaultWaitForServerLive } = (await import(
|
||||
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
||||
)) as {
|
||||
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const result = await Promise.race([
|
||||
defaultWaitForServerLive(`http://127.0.0.1:${9900 + status}`).then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
),
|
||||
new Promise<'still-probing'>((resolve) => setTimeout(() => resolve('still-probing'), 300)),
|
||||
]);
|
||||
expect(result).toBe('still-probing');
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('correct-token 200 resolves defaultWaitForServerLive immediately', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -3643,3 +3682,332 @@ describe('bar install: --await-quit waits for the running app to quit (GH-1588)'
|
||||
expect(probes).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --port support: `ccs bar [launch] --port N` (user-selectable dashboard port)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bar dispatcher: bare flags route to launch', () => {
|
||||
beforeEach(() => {
|
||||
mock.module('../../../src/commands/bar/launch-subcommand', () => ({
|
||||
handleBarLaunch: async (args: string[]) => {
|
||||
calls.push(`launch:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('dispatches `ccs bar --port 3999` to launch with the flag preserved', async () => {
|
||||
const handleBarCommand = await loadHandleBarCommand();
|
||||
await handleBarCommand(['--port', '3999']);
|
||||
expect(calls).toEqual(['launch:--port 3999']);
|
||||
});
|
||||
|
||||
it('dispatches `ccs bar launch --port 3999` to launch with the flag preserved', async () => {
|
||||
const handleBarCommand = await loadHandleBarCommand();
|
||||
await handleBarCommand(['launch', '--port', '3999']);
|
||||
expect(calls).toEqual(['launch:--port 3999']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launch: --port selects the server port', () => {
|
||||
function makePortDeps(ccsDir: string) {
|
||||
const seen: {
|
||||
spawnPort: number | null;
|
||||
getPortCandidates: number[] | null;
|
||||
stopped: boolean;
|
||||
descriptorPort: number | null;
|
||||
} = { spawnPort: null, getPortCandidates: null, stopped: false, descriptorPort: null };
|
||||
|
||||
const deps = {
|
||||
findRunningServer: async () => null,
|
||||
getPort: async (opts: { port: number[]; host: string }) => {
|
||||
seen.getPortCandidates = opts.port;
|
||||
return opts.port[0];
|
||||
},
|
||||
spawnDetachedServer: (p: number) => {
|
||||
seen.spawnPort = p;
|
||||
},
|
||||
waitForServerLive: async () => {},
|
||||
createLaunchDescriptor: (opts?: { port?: number }) => {
|
||||
seen.descriptorPort = opts?.port ?? null;
|
||||
return {
|
||||
schema: 1 as const,
|
||||
runtime: '/usr/bin/node',
|
||||
args: ['/x/ccs.js', 'bar', 'serve'],
|
||||
home: '/h',
|
||||
};
|
||||
},
|
||||
writeLaunchDescriptor: () => {},
|
||||
stopDetachedServer: () => {
|
||||
seen.stopped = true;
|
||||
},
|
||||
openApp: async () => {},
|
||||
getCcsDir: () => ccsDir,
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
};
|
||||
return { deps, seen };
|
||||
}
|
||||
|
||||
it('spawns the detached server on the requested port and records it in bar.json', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
|
||||
expect(seen.spawnPort).toBe(3999);
|
||||
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
};
|
||||
expect(barJson.port).toBe(3999);
|
||||
expect(barJson.baseUrl).toBe('http://127.0.0.1:3999');
|
||||
});
|
||||
|
||||
it('persists the chosen port into the launch descriptor for app self-start', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
|
||||
expect(seen.descriptorPort).toBe(3999);
|
||||
});
|
||||
|
||||
it('errors without spawning when the requested port is busy', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
deps.getPort = async () => 4001; // get-port fell back: 3999 not free
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
|
||||
expect(seen.spawnPort).toBeNull();
|
||||
const allOutput = consoleOutput.join('\n');
|
||||
expect(allOutput).toMatch(/3999/);
|
||||
expect(allOutput.toLowerCase()).toMatch(/in use|busy|not free|unavailable/);
|
||||
});
|
||||
|
||||
it('errors on an invalid --port value', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
|
||||
await handleBarLaunch(['--port', 'banana'], deps);
|
||||
|
||||
expect(seen.spawnPort).toBeNull();
|
||||
expect(consoleOutput.join('\n').toLowerCase()).toMatch(/invalid.*port|port.*invalid/);
|
||||
});
|
||||
|
||||
it('reuses a running server already on the requested port', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
deps.findRunningServer = async () => ({ port: 3999, baseUrl: 'http://127.0.0.1:3999' });
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
|
||||
expect(seen.spawnPort).toBeNull(); // reuse, no new spawn
|
||||
expect(seen.stopped).toBe(false);
|
||||
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
||||
port: number;
|
||||
};
|
||||
expect(barJson.port).toBe(3999);
|
||||
});
|
||||
|
||||
it('stops a running server on a different port, then starts on the requested one', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
|
||||
expect(seen.stopped).toBe(true);
|
||||
expect(seen.spawnPort).toBe(3999);
|
||||
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
||||
port: number;
|
||||
};
|
||||
expect(barJson.port).toBe(3999);
|
||||
});
|
||||
|
||||
it('preflights the destination before stopping a healthy running server', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps } = makePortDeps(ccsDir);
|
||||
const events: string[] = [];
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.getPort = async () => {
|
||||
events.push('preflight');
|
||||
return 3999;
|
||||
};
|
||||
deps.stopDetachedServer = () => {
|
||||
events.push('stop');
|
||||
};
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(events).toEqual(['preflight', 'stop']);
|
||||
});
|
||||
|
||||
it('leaves the healthy server running when destination preflight is busy', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.getPort = async () => 4000;
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(seen.stopped).toBe(false);
|
||||
expect(seen.spawnPort).toBeNull();
|
||||
});
|
||||
|
||||
it('aborts the move when the verified stop fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.stopDetachedServer = () => {
|
||||
throw new Error('identity mismatch');
|
||||
};
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(seen.spawnPort).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the prior server when a check-to-bind race breaks the replacement', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps } = makePortDeps(ccsDir);
|
||||
const spawnedPorts: number[] = [];
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.spawnDetachedServer = (port: number) => {
|
||||
spawnedPorts.push(port);
|
||||
};
|
||||
deps.waitForServerLive = async (baseUrl: string) => {
|
||||
if (baseUrl.endsWith(':3999')) throw new Error('EADDRINUSE after preflight');
|
||||
};
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(spawnedPorts).toEqual([3999, 3000]);
|
||||
});
|
||||
|
||||
it('restores the prior server when replacement spawn fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps } = makePortDeps(ccsDir);
|
||||
const spawnedPorts: number[] = [];
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.spawnDetachedServer = (port: number) => {
|
||||
spawnedPorts.push(port);
|
||||
if (port === 3999) throw new Error('spawn failed');
|
||||
};
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(spawnedPorts).toEqual([3999, 3000]);
|
||||
});
|
||||
|
||||
it('preserves prior discovery state when replacement and rollback both fail', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
||||
const oldBarJson = JSON.stringify({
|
||||
baseUrl: 'http://127.0.0.1:3000',
|
||||
port: 3000,
|
||||
authMode: 'loopback',
|
||||
});
|
||||
const oldLaunchJson = JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '3000'] });
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), oldBarJson);
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar', 'launch.json'), oldLaunchJson);
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps } = makePortDeps(ccsDir);
|
||||
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
||||
deps.spawnDetachedServer = () => {
|
||||
throw new Error('spawn failed');
|
||||
};
|
||||
|
||||
await handleBarLaunch(['--port', '3999'], deps);
|
||||
expect(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')).toBe(oldBarJson);
|
||||
expect(fs.readFileSync(path.join(ccsDir, 'bar', 'launch.json'), 'utf8')).toBe(oldLaunchJson);
|
||||
});
|
||||
|
||||
it('without --port, prefers the port recorded in bar.json (sticky port)', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar.json'),
|
||||
JSON.stringify({ baseUrl: 'http://127.0.0.1:3777', port: 3777, authMode: 'loopback' })
|
||||
);
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
const { deps, seen } = makePortDeps(ccsDir);
|
||||
|
||||
await handleBarLaunch([], deps);
|
||||
|
||||
expect(seen.getPortCandidates?.[0]).toBe(3777);
|
||||
expect(seen.spawnPort).toBe(3777);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBarPort: launch.json fallback survives `ccs bar stop`', () => {
|
||||
// `ccs bar stop` deletes bar.json, so bar.json alone cannot carry the sticky
|
||||
// port across a stop/start cycle. launch.json (refreshed by launch, NOT
|
||||
// deleted by stop) records the port in its args and acts as the fallback.
|
||||
it('falls back to the --port recorded in launch.json when bar.json is absent', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar', 'launch.json'),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
runtime: '/usr/bin/node',
|
||||
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
|
||||
home: tempHome,
|
||||
})
|
||||
);
|
||||
|
||||
const { resolveBarPort } = await loadLaunchSubcommand();
|
||||
expect(resolveBarPort(ccsDir)).toBe(3456);
|
||||
});
|
||||
|
||||
it('bar.json port wins over launch.json when both exist', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar.json'),
|
||||
JSON.stringify({ baseUrl: 'http://127.0.0.1:4000', port: 4000, authMode: 'loopback' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar', 'launch.json'),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
runtime: '/usr/bin/node',
|
||||
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
|
||||
home: tempHome,
|
||||
})
|
||||
);
|
||||
|
||||
const { resolveBarPort } = await loadLaunchSubcommand();
|
||||
expect(resolveBarPort(ccsDir)).toBe(4000);
|
||||
});
|
||||
|
||||
it('returns null when launch.json has no --port and bar.json is absent', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar', 'launch.json'),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
runtime: '/usr/bin/node',
|
||||
args: ['/x/ccs.js', 'bar', 'serve'],
|
||||
home: tempHome,
|
||||
})
|
||||
);
|
||||
|
||||
const { resolveBarPort } = await loadLaunchSubcommand();
|
||||
expect(resolveBarPort(ccsDir)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import {
|
||||
defaultFindRunningServer,
|
||||
resolveBarPort,
|
||||
} from '../../../src/commands/bar/bar-server-probe';
|
||||
import {
|
||||
serializeBarServerProcessRecord,
|
||||
getProcessBirthIdentity,
|
||||
removeBarDiscoveryIfNoProcess,
|
||||
removeBarServerProcessRecordIfOwned,
|
||||
stopDetachedBarServer,
|
||||
stopBarServerProcessFile,
|
||||
stopRecordedBarServer,
|
||||
} from '../../../src/commands/bar/bar-process-control';
|
||||
import { parsePortFlag, validatePortArgs } from '../../../src/commands/bar/port-arg';
|
||||
import { handleBarServe } from '../../../src/commands/bar/serve-subcommand';
|
||||
import { handleBarStop } from '../../../src/commands/bar/stop-subcommand';
|
||||
|
||||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
const liveChildren = new Set<ReturnType<typeof spawn>>();
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CCS_HOME = path.join(tempHome, '.ccs');
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const child of liveChildren) {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
liveChildren.clear();
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = originalCcsHome;
|
||||
process.exitCode = 0;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('strict Bar port parsing', () => {
|
||||
it('rejects numeric prefixes, fractions, signs, whitespace, and out-of-range values', () => {
|
||||
for (const raw of ['3999junk', '3.5', '+3999', ' 3999', '0', '65536']) {
|
||||
expect(parsePortFlag(['--port', raw])).toEqual({ present: true, port: null });
|
||||
}
|
||||
expect(parsePortFlag(['--port', '3999'])).toEqual({ present: true, port: 3999 });
|
||||
});
|
||||
|
||||
it('rejects unknown and duplicate launch options', () => {
|
||||
expect(validatePortArgs(['--porrt', '3999'])).toBe('Unknown option: --porrt');
|
||||
expect(validatePortArgs(['--port', '3999', '--port', '4000'])).toBe('Duplicate option: --port');
|
||||
});
|
||||
|
||||
it('rejects malformed persisted ports in both discovery files', () => {
|
||||
const ccsDir = process.env.CCS_HOME!;
|
||||
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port: 3999.5 }));
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bar', 'launch.json'),
|
||||
JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '4555junk'] })
|
||||
);
|
||||
expect(resolveBarPort(ccsDir)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verified Bar process stopping', () => {
|
||||
const rawRecord = serializeBarServerProcessRecord({ pid: 4321, birthIdentity: 'birth-a' });
|
||||
|
||||
it('does not signal when the PID birth identity changed', async () => {
|
||||
let signaled = false;
|
||||
const outcome = await stopRecordedBarServer(rawRecord, {
|
||||
getProcessBirthIdentity: () => 'birth-b',
|
||||
killProcess: () => {
|
||||
signaled = true;
|
||||
},
|
||||
});
|
||||
expect(outcome.result).toBe('identity-mismatch');
|
||||
expect(signaled).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves server.pid and bar.json on mismatch, EPERM, and timeout', async () => {
|
||||
for (const failure of [
|
||||
'identity-mismatch',
|
||||
'permission-denied',
|
||||
'signal-failed',
|
||||
'timeout',
|
||||
] as const) {
|
||||
const ccsDir = path.join(tempHome, failure);
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
fs.writeFileSync(pidPath, rawRecord);
|
||||
fs.writeFileSync(barJsonPath, '{}');
|
||||
|
||||
await handleBarStop([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
getProcessBirthIdentity: () => (failure === 'identity-mismatch' ? 'birth-b' : 'birth-a'),
|
||||
killProcess: () => {
|
||||
if (failure === 'permission-denied') {
|
||||
const err = new Error('not permitted') as NodeJS.ErrnoException;
|
||||
err.code = 'EPERM';
|
||||
throw err;
|
||||
}
|
||||
if (failure === 'signal-failed') throw new Error('signal transport failed');
|
||||
},
|
||||
waitForProcessExit: async () => (failure === 'timeout' ? 'timeout' : 'exited'),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(pidPath)).toBe(true);
|
||||
expect(fs.existsSync(barJsonPath)).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = 0;
|
||||
}
|
||||
});
|
||||
|
||||
it('never signals a legacy integer PID and gives manual recovery guidance', async () => {
|
||||
const ccsDir = path.join(tempHome, 'legacy');
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
fs.writeFileSync(pidPath, '4321');
|
||||
let signaled = false;
|
||||
|
||||
await handleBarStop([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
killProcess: () => {
|
||||
signaled = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(signaled).toBe(false);
|
||||
expect(fs.existsSync(pidPath)).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('atomically preserves a replacement record written while the old process stops', async () => {
|
||||
const pidPath = path.join(tempHome, 'race', 'server.pid');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
fs.writeFileSync(pidPath, rawRecord);
|
||||
const replacement = serializeBarServerProcessRecord({
|
||||
pid: 9876,
|
||||
birthIdentity: 'replacement-birth',
|
||||
});
|
||||
|
||||
const outcome = await stopBarServerProcessFile(pidPath, {
|
||||
getProcessBirthIdentity: () => 'birth-a',
|
||||
killProcess: () => fs.writeFileSync(pidPath, replacement),
|
||||
waitForProcessExit: async () => 'exited',
|
||||
});
|
||||
|
||||
expect(outcome.result).toBe('stopped');
|
||||
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
|
||||
});
|
||||
|
||||
it('does not let an old serve cleanup unlink a replacement record', () => {
|
||||
const pidPath = path.join(tempHome, 'serve-race', 'server.pid');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
const replacement = serializeBarServerProcessRecord({
|
||||
pid: 9876,
|
||||
birthIdentity: 'replacement-birth',
|
||||
});
|
||||
fs.writeFileSync(pidPath, replacement);
|
||||
|
||||
removeBarServerProcessRecordIfOwned(pidPath, { pid: 4321, birthIdentity: 'birth-a' });
|
||||
|
||||
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
|
||||
});
|
||||
|
||||
it('stops a real recorded process through the claimed process file', async () => {
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
liveChildren.add(child);
|
||||
const birthIdentity = await waitForBirthIdentity(child.pid!);
|
||||
const pidPath = path.join(tempHome, 'real-stop', 'server.pid');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity }));
|
||||
|
||||
const outcome = await stopBarServerProcessFile(pidPath);
|
||||
|
||||
expect(outcome.result).toBe('stopped');
|
||||
expect(fs.existsSync(pidPath)).toBe(false);
|
||||
liveChildren.delete(child);
|
||||
});
|
||||
|
||||
it('stops a real recorded process through the launch-move stop path', async () => {
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
liveChildren.add(child);
|
||||
const birthIdentity = await waitForBirthIdentity(child.pid!);
|
||||
const ccsDir = path.join(tempHome, 'real-move');
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
||||
fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity }));
|
||||
await stopDetachedBarServer(ccsDir);
|
||||
|
||||
expect(fs.existsSync(pidPath)).toBe(false);
|
||||
liveChildren.delete(child);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bar serve publication ownership', () => {
|
||||
it('publishes server.pid before bar.json so old-stop cleanup preserves replacement discovery', async () => {
|
||||
const ccsDir = path.join(tempHome, 'serve-publication');
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
const publicationOrder: string[] = [];
|
||||
let oldStopRemovedDiscovery: boolean | null = null;
|
||||
|
||||
await handleBarServe(['--port', '4555'], {
|
||||
getCcsDir: () => ccsDir,
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => 4555,
|
||||
startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }),
|
||||
getProcessBirthIdentity: () => 'replacement-birth',
|
||||
writeFile: (filePath, content) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
publicationOrder.push(filePath);
|
||||
if (filePath === barJsonPath) {
|
||||
oldStopRemovedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath);
|
||||
}
|
||||
},
|
||||
onSignal: () => {},
|
||||
exit: (code) => {
|
||||
throw new Error(`unexpected exit ${code}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(publicationOrder).toEqual([pidPath, barJsonPath]);
|
||||
expect(oldStopRemovedDiscovery).toBe(false);
|
||||
expect(fs.existsSync(pidPath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(barJsonPath, 'utf8')).port).toBe(4555);
|
||||
});
|
||||
|
||||
it('conditionally rolls back its owned process record when discovery publication fails', async () => {
|
||||
const ccsDir = path.join(tempHome, 'serve-rollback');
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
|
||||
await expect(
|
||||
handleBarServe(['--port', '4555'], {
|
||||
getCcsDir: () => ccsDir,
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => 4555,
|
||||
startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }),
|
||||
getProcessBirthIdentity: () => 'replacement-birth',
|
||||
writeFile: (filePath, content) => {
|
||||
if (filePath === barJsonPath) throw new Error('discovery write failed');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
},
|
||||
onSignal: () => {},
|
||||
exit: (code) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
})
|
||||
).rejects.toThrow('exit 1');
|
||||
|
||||
expect(fs.existsSync(pidPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForBirthIdentity(pid: number): Promise<string> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const identity = getProcessBirthIdentity(pid);
|
||||
if (identity !== null) return identity;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`Process ${pid} never became observable`);
|
||||
}
|
||||
|
||||
describe('Bar server identity probe', () => {
|
||||
it('ignores unrelated services returning 401 or 403 without a CCS proof', async () => {
|
||||
for (const status of [401, 403]) {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(status);
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const ccsDir = path.join(tempHome, `status-${status}`);
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port }));
|
||||
try {
|
||||
const result = await defaultFindRunningServer(ccsDir);
|
||||
expect(result?.port).not.toBe(port);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
function processRecord(pid: number, birthIdentity = 'test-birth'): string {
|
||||
return JSON.stringify({ pid, birthIdentity }, null, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -24,6 +28,7 @@ let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleError: typeof console.error;
|
||||
let originalExitCode: number | undefined;
|
||||
|
||||
function captureConsole(): void {
|
||||
originalConsoleLog = console.log;
|
||||
@@ -113,6 +118,8 @@ beforeEach(() => {
|
||||
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
@@ -124,6 +131,7 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
process.exitCode = originalExitCode ?? 0;
|
||||
|
||||
try {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
@@ -214,6 +222,7 @@ describe('serve: start new server', () => {
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__EXIT_${code}__`);
|
||||
},
|
||||
getProcessBirthIdentity: () => 'test-birth',
|
||||
});
|
||||
|
||||
// bar.json must be written
|
||||
@@ -226,7 +235,10 @@ describe('serve: start new server', () => {
|
||||
// server.pid must be written
|
||||
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
|
||||
expect(writtenFiles[pidPath]).toBeDefined();
|
||||
expect(writtenFiles[pidPath]).toBe(String(process.pid));
|
||||
expect(JSON.parse(writtenFiles[pidPath])).toEqual({
|
||||
pid: process.pid,
|
||||
birthIdentity: 'test-birth',
|
||||
});
|
||||
|
||||
// Both SIGINT and SIGTERM handlers registered
|
||||
expect(signals).toContain('SIGINT');
|
||||
@@ -309,7 +321,7 @@ describe('stop: SIGTERM and cleanup', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const barDir = path.join(ccsDir, 'bar');
|
||||
fs.mkdirSync(barDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(barDir, 'server.pid'), '12345');
|
||||
fs.writeFileSync(path.join(barDir, 'server.pid'), processRecord(12345));
|
||||
fs.writeFileSync(path.join(ccsDir, 'bar.json'), '{"baseUrl":"http://127.0.0.1:3000"}');
|
||||
|
||||
const killed: Array<{ pid: number; signal: string }> = [];
|
||||
@@ -329,6 +341,8 @@ describe('stop: SIGTERM and cleanup', () => {
|
||||
killProcess: (pid: number, signal: string) => {
|
||||
killed.push({ pid, signal });
|
||||
},
|
||||
getProcessBirthIdentity: () => 'test-birth',
|
||||
waitForProcessExit: async () => 'exited',
|
||||
removeFile: (filePath: string) => {
|
||||
removed.push(filePath);
|
||||
},
|
||||
@@ -338,7 +352,7 @@ describe('stop: SIGTERM and cleanup', () => {
|
||||
// Both pid and bar.json must be removed
|
||||
expect(removed.some((p) => p.includes('server.pid'))).toBe(true);
|
||||
expect(removed.some((p) => p.includes('bar.json'))).toBe(true);
|
||||
expect(allOutput()).toMatch(/\[OK\].*SIGTERM/i);
|
||||
expect(allOutput()).toMatch(/\[OK\].*stopped/i);
|
||||
});
|
||||
|
||||
it('prints guidance and returns cleanly when no server.pid exists', async () => {
|
||||
@@ -370,7 +384,8 @@ describe('stop: SIGTERM and cleanup', () => {
|
||||
|
||||
await handleBarStop([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
readPidFile: () => '99999',
|
||||
readPidFile: () => processRecord(99999),
|
||||
getProcessBirthIdentity: () => null,
|
||||
killProcess: () => {
|
||||
const err = new Error('no such process') as NodeJS.ErrnoException;
|
||||
err.code = 'ESRCH';
|
||||
@@ -402,9 +417,9 @@ describe('stop: SIGTERM and cleanup', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(allOutput()).toMatch(/\[X\].*invalid/i);
|
||||
// Corrupted pid file must be cleaned up
|
||||
expect(removed.some((p) => p.includes('server.pid'))).toBe(true);
|
||||
expect(allOutput()).toMatch(/\[X\].*verified process record/i);
|
||||
// Unverified state is preserved rather than risking removal of recovery evidence.
|
||||
expect(removed.some((p) => p.includes('server.pid'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +434,7 @@ describe('status: running state reporting', () => {
|
||||
|
||||
await handleBarStatus([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
readPidFile: () => '12345',
|
||||
readPidFile: () => processRecord(12345),
|
||||
isProcessAlive: () => true,
|
||||
probeServer: async () => true,
|
||||
readBarJsonBaseUrl: () => 'http://127.0.0.1:3000',
|
||||
@@ -451,7 +466,7 @@ describe('status: running state reporting', () => {
|
||||
|
||||
await handleBarStatus([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
readPidFile: () => '99999',
|
||||
readPidFile: () => processRecord(99999),
|
||||
isProcessAlive: () => false,
|
||||
probeServer: async () => false,
|
||||
readBarJsonBaseUrl: () => null,
|
||||
@@ -461,13 +476,34 @@ describe('status: running state reporting', () => {
|
||||
expect(allOutput()).toMatch(/99999/);
|
||||
});
|
||||
|
||||
it('reports legacy integer PIDs as unverified and does not inspect their liveness', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarStatus } = await loadStatusSubcommand();
|
||||
let inspected = false;
|
||||
|
||||
await handleBarStatus([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
readPidFile: () => '12345',
|
||||
isProcessAlive: () => {
|
||||
inspected = true;
|
||||
return true;
|
||||
},
|
||||
probeServer: async () => true,
|
||||
readBarJsonBaseUrl: () => null,
|
||||
});
|
||||
|
||||
expect(inspected).toBe(false);
|
||||
expect(allOutput()).toMatch(/legacy.*unverified PID 12345/i);
|
||||
expect(allOutput()).toMatch(/ps -p 12345 -o command=/);
|
||||
});
|
||||
|
||||
it('reports alive-but-unreachable when PID is alive but HTTP probe fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const { handleBarStatus } = await loadStatusSubcommand();
|
||||
|
||||
await handleBarStatus([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
readPidFile: () => '12345',
|
||||
readPidFile: () => processRecord(12345),
|
||||
isProcessAlive: () => true,
|
||||
probeServer: async () => false,
|
||||
readBarJsonBaseUrl: () => 'http://127.0.0.1:3000',
|
||||
|
||||
Reference in new issue
Block a user