fix(docker): bound blocking command execution

- add default local and remote sync-command timeouts with clearer timeout diagnostics

- remove extra synchronous fs and process.exit patterns from docker assets/bootstrap helpers

- register the docker help handler in CLAUDE.md for the repo help-location reference
This commit is contained in:
Tam Nhu Tran committed 2026-03-27 15:05:17 -04:00
1 parent 6ac8f59a78
commit 5ad4303345
5 files changed
+156 -48

No files matched your search

+1
View File
@@ -156,6 +156,7 @@ bun run validate # Step 3: Final check (must pass)
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` |
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
| `ccs docker --help` | `src/commands/docker/help-subcommand.ts` → `showHelp()` |
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` |
| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` |
+30 -12
View File
@@ -1,4 +1,3 @@
import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
import type { DockerConfigSummary } from './docker-types'; import type { DockerConfigSummary } from './docker-types';
@@ -22,10 +21,33 @@ export interface DockerAssetPaths {
entrypoint: string; entrypoint: string;
} }
let hasWarnedAboutVersionFallback = false;
function getPackageRoot(): string { function getPackageRoot(): string {
return path.resolve(__dirname, '..', '..'); return path.resolve(__dirname, '..', '..');
} }
function ensureBundledAsset(assetPath: string): void {
try {
require.resolve(assetPath);
} catch {
throw new Error(
`Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.`
);
}
}
function warnAboutVersionFallback(error: unknown): void {
if (hasWarnedAboutVersionFallback) {
return;
}
hasWarnedAboutVersionFallback = true;
const detail = error instanceof Error ? error.message : String(error);
console.error(
`[!] Failed to determine the installed CCS version from package metadata; falling back to latest.\n${detail}`
);
}
export function getDockerAssetPaths(): DockerAssetPaths { export function getDockerAssetPaths(): DockerAssetPaths {
const packageRoot = getPackageRoot(); const packageRoot = getPackageRoot();
const dockerDir = path.join(packageRoot, 'docker'); const dockerDir = path.join(packageRoot, 'docker');
@@ -37,13 +59,10 @@ export function getDockerAssetPaths(): DockerAssetPaths {
entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'), entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'),
}; };
for (const assetPath of Object.values(assets)) { ensureBundledAsset(assets.composeFile);
if (!fs.existsSync(assetPath)) { ensureBundledAsset(assets.dockerfile);
throw new Error( ensureBundledAsset(assets.supervisordConfig);
`Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.` ensureBundledAsset(assets.entrypoint);
);
}
}
return assets; return assets;
} }
@@ -51,11 +70,10 @@ export function getDockerAssetPaths(): DockerAssetPaths {
export function getInstalledCcsVersion(): string { export function getInstalledCcsVersion(): string {
const packageJsonPath = path.join(getPackageRoot(), 'package.json'); const packageJsonPath = path.join(getPackageRoot(), 'package.json');
try { try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { const packageJson = require(packageJsonPath) as { version?: string };
version?: string;
};
return packageJson.version?.trim() || 'latest'; return packageJson.version?.trim() || 'latest';
} catch { } catch (error) {
warnAboutVersionFallback(error);
return 'latest'; return 'latest';
} }
} }
+16 -13
View File
@@ -21,9 +21,9 @@ async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configP
return { binaryPath, configPath }; return { binaryPath, configPath };
} }
async function runCliproxy(): Promise<void> { async function runCliproxy(): Promise<number> {
const { binaryPath, configPath } = await prepareIntegratedRuntime(); const { binaryPath, configPath } = await prepareIntegratedRuntime();
await new Promise<void>((resolve, reject) => { return new Promise<number>((resolve, reject) => {
const child = spawn(binaryPath, ['--config', configPath], { const child = spawn(binaryPath, ['--config', configPath], {
stdio: 'inherit', stdio: 'inherit',
env: { env: {
@@ -34,27 +34,30 @@ async function runCliproxy(): Promise<void> {
child.on('error', reject); child.on('error', reject);
child.on('close', (code) => { child.on('close', (code) => {
process.exit(code ?? 1); resolve(code ?? 1);
}); });
child.on('spawn', () => resolve());
}); });
} }
async function main(): Promise<void> { async function main(): Promise<number> {
const command = process.argv[2]; const command = process.argv[2];
if (command !== 'run-cliproxy') { if (command !== 'run-cliproxy') {
console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy'); console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy');
process.exit(1); return 1;
} }
await runCliproxy(); return runCliproxy();
} }
if (require.main === module) { if (require.main === module) {
void main().catch((error) => { void main()
console.error( .then((exitCode) => {
`[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}` process.exitCode = exitCode;
); })
process.exit(1); .catch((error) => {
}); console.error(
`[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}`
);
process.exitCode = 1;
});
} }
+77 -13
View File
@@ -18,6 +18,9 @@ import type {
DockerUpOptions, DockerUpOptions,
} from './docker-types'; } from './docker-types';
const LOCAL_DOCKER_SYNC_TIMEOUT_MS = 10_000;
const REMOTE_DOCKER_SYNC_TIMEOUT_MS = 30_000;
function quotePosix(value: string): string { function quotePosix(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`; return `'${value.replace(/'/g, `'\"'\"'`)}'`;
} }
@@ -35,6 +38,37 @@ interface DockerSyncRunOptions {
cwd?: string; cwd?: string;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
remote?: boolean; remote?: boolean;
timeoutMs?: number;
}
function formatTimeout(timeoutMs: number): string {
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
}
function buildTimeoutMessage(
command: string,
args: string[],
timeoutMs: number,
remote: boolean
): string {
return [
`Command timed out after ${formatTimeout(timeoutMs)} while running a ${
remote ? 'remote' : 'local'
} Docker command.`,
`Command: ${renderCommand(command, args)}`,
remote
? 'Check SSH reachability and the remote Docker host, then try again.'
: 'Check Docker availability on this machine, then try again.',
].join('\n');
}
function applyDefaultTimeouts(options: DockerSyncRunOptions): DockerSyncRunOptions {
return {
...options,
timeoutMs:
options.timeoutMs ??
(options.remote ? REMOTE_DOCKER_SYNC_TIMEOUT_MS : LOCAL_DOCKER_SYNC_TIMEOUT_MS),
};
} }
function runSync( function runSync(
@@ -42,21 +76,32 @@ function runSync(
args: string[], args: string[],
options: DockerSyncRunOptions = {} options: DockerSyncRunOptions = {}
): DockerCommandResult { ): DockerCommandResult {
const normalizedOptions = applyDefaultTimeouts(options);
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
cwd: options.cwd, cwd: normalizedOptions.cwd,
env: options.env, env: normalizedOptions.env,
encoding: 'utf8', encoding: 'utf8',
stdio: 'pipe', stdio: 'pipe',
timeout: normalizedOptions.timeoutMs,
windowsHide: true, windowsHide: true,
}); });
const stderr = [normalizeOutput(result.stderr), result.error?.message].filter(Boolean).join('\n'); const errorMessage =
result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT'
? buildTimeoutMessage(
command,
args,
normalizedOptions.timeoutMs ?? LOCAL_DOCKER_SYNC_TIMEOUT_MS,
normalizedOptions.remote ?? false
)
: result.error?.message;
const stderr = [normalizeOutput(result.stderr), errorMessage].filter(Boolean).join('\n');
return { return {
command: renderCommand(command, args), command: renderCommand(command, args),
exitCode: result.status ?? 1, exitCode: result.status ?? 1,
stdout: normalizeOutput(result.stdout), stdout: normalizeOutput(result.stdout),
stderr, stderr,
remote: options.remote ?? false, remote: normalizedOptions.remote ?? false,
}; };
} }
@@ -78,12 +123,27 @@ function runStreaming(command: string, args: string[]): Promise<void> {
}); });
} }
let cachedLocalComposePrefix: string[] | undefined;
function resolveLocalComposePrefix(): string[] { function resolveLocalComposePrefix(): string[] {
if (runSync('docker', ['compose', 'version']).exitCode === 0) { if (cachedLocalComposePrefix) {
return ['docker', 'compose']; return [...cachedLocalComposePrefix];
} }
if (runSync('docker-compose', ['version']).exitCode === 0) { if (
return ['docker-compose']; runSync('docker', ['compose', 'version'], {
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
}).exitCode === 0
) {
cachedLocalComposePrefix = ['docker', 'compose'];
return [...cachedLocalComposePrefix];
}
if (
runSync('docker-compose', ['version'], {
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
}).exitCode === 0
) {
cachedLocalComposePrefix = ['docker-compose'];
return [...cachedLocalComposePrefix];
} }
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.'); throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
} }
@@ -129,7 +189,7 @@ export class DockerExecutor {
return createDockerConfigSummary(options); return createDockerConfigSummary(options);
} }
async up(options: DockerUpOptions): Promise<void> { up(options: DockerUpOptions): void {
if (options.host) { if (options.host) {
this.stageRemoteAssets(options.host); this.stageRemoteAssets(options.host);
} }
@@ -144,11 +204,11 @@ export class DockerExecutor {
); );
} }
async down(options: DockerCommandTarget): Promise<void> { down(options: DockerCommandTarget): void {
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options); this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
} }
async status(options: DockerCommandTarget): Promise<DockerStatusResult> { status(options: DockerCommandTarget): DockerStatusResult {
const compose = this.runCompose(['ps'], options); const compose = this.runCompose(['ps'], options);
this.ensureSuccess(compose, 'Docker status', options); this.ensureSuccess(compose, 'Docker status', options);
let supervisor: DockerCommandResult | undefined; let supervisor: DockerCommandResult | undefined;
@@ -161,7 +221,7 @@ export class DockerExecutor {
return { compose, supervisor }; return { compose, supervisor };
} }
async update(options: DockerCommandTarget): Promise<void> { update(options: DockerCommandTarget): void {
const script = const script =
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy'; 'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
this.ensureSuccess( this.ensureSuccess(
@@ -268,7 +328,11 @@ export class DockerExecutor {
args: string[], args: string[],
options: DockerSyncRunOptions = {} options: DockerSyncRunOptions = {}
): DockerCommandResult { ): DockerCommandResult {
return this.deps.runSync?.(command, args, options) ?? runSync(command, args, options); const normalizedOptions = applyDefaultTimeouts(options);
return (
this.deps.runSync?.(command, args, normalizedOptions) ??
runSync(command, args, normalizedOptions)
);
} }
private async runStreaming(command: string, args: string[]): Promise<void> { private async runStreaming(command: string, args: string[]): Promise<void> {
+32 -10
View File
@@ -17,6 +17,7 @@ type SyncCall = {
cwd?: string; cwd?: string;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
remote?: boolean; remote?: boolean;
timeoutMs?: number;
}; };
}; };
@@ -47,18 +48,12 @@ describe('docker executor', () => {
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0].command).toBe('docker'); expect(calls[0].command).toBe('docker');
expect(calls[0].args).toEqual([ expect(calls[0].args).toEqual(['compose', '-f', fakeAssets.composeFile, 'up', '-d', '--build']);
'compose',
'-f',
fakeAssets.composeFile,
'up',
'-d',
'--build',
]);
expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir); expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir);
expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0'); expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0');
expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000');
expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317');
expect(calls[0].options?.timeoutMs).toBe(10_000);
}); });
it('stages bundled assets before remote compose startup', async () => { it('stages bundled assets before remote compose startup', async () => {
@@ -78,7 +73,7 @@ describe('docker executor', () => {
expect(calls[0]).toEqual({ expect(calls[0]).toEqual({
command: 'ssh', command: 'ssh',
args: ['docker', 'mkdir -p ~/.ccs/docker'], args: ['docker', 'mkdir -p ~/.ccs/docker'],
options: { remote: true }, options: { remote: true, timeoutMs: 30_000 },
}); });
expect(calls[1].command).toBe('scp'); expect(calls[1].command).toBe('scp');
expect(calls[1].args).toEqual([ expect(calls[1].args).toEqual([
@@ -88,13 +83,14 @@ describe('docker executor', () => {
fakeAssets.entrypoint, fakeAssets.entrypoint,
'docker:~/.ccs/docker/', 'docker:~/.ccs/docker/',
]); ]);
expect(calls[1].options).toEqual({ remote: true }); expect(calls[1].options).toEqual({ remote: true, timeoutMs: 30_000 });
expect(calls[2].command).toBe('ssh'); expect(calls[2].command).toBe('ssh');
expect(calls[2].args[0]).toBe('docker'); expect(calls[2].args[0]).toBe('docker');
expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'"); expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'");
expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'"); expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'");
expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'"); expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'");
expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1');
expect(calls[2].options?.timeoutMs).toBe(30_000);
}); });
it('uses npm install latest rather than npm update during in-container updates', async () => { it('uses npm install latest rather than npm update during in-container updates', async () => {
@@ -162,4 +158,30 @@ describe('docker executor', () => {
expect(status.supervisor?.exitCode).toBe(7); expect(status.supervisor?.exitCode).toBe(7);
expect(status.supervisor?.stderr).toContain('supervisor.sock'); expect(status.supervisor?.stderr).toContain('supervisor.sock');
}); });
it('surfaces a clear timeout message for blocked sync commands', () => {
const executor = new DockerExecutor({
assets: fakeAssets,
});
const result = (
executor as unknown as {
runSync: (
command: string,
args: string[],
options?: { remote?: boolean; timeoutMs?: number }
) => DockerCommandResult;
}
).runSync(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], {
remote: true,
timeoutMs: 25,
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'Command timed out after 25ms while running a remote Docker command.'
);
expect(result.stderr).toContain(`Command: ${process.execPath}`);
expect(result.stderr).toContain('Check SSH reachability and the remote Docker host');
});
}); });