mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +00:00
Merge pull request #835 from kaitranntt/kai/fix/834-docker-cliproxy-detection
fix(docker): CLIProxy detection, session lock, and binary guard for integrated stack
This commit is contained in:
@@ -35,11 +35,29 @@ export async function downloadAndInstall(
|
||||
|
||||
fs.mkdirSync(config.binPath, { recursive: true });
|
||||
|
||||
// Delete existing binary before install to prevent mismatched binaries
|
||||
// Delete existing binary before install to prevent mismatched binaries.
|
||||
// Abort if binary is currently running (ETXTBSY) — cannot replace in-use binary.
|
||||
// Happens in Docker when dashboard tries to update while bootstrap's instance is active.
|
||||
const existingBinary = path.join(config.binPath, getExecutableName(backend));
|
||||
if (fs.existsSync(existingBinary)) {
|
||||
fs.unlinkSync(existingBinary);
|
||||
if (verbose) console.error(`[cliproxy] Removed existing binary: ${existingBinary}`);
|
||||
try {
|
||||
fs.unlinkSync(existingBinary);
|
||||
if (verbose) console.error(`[cliproxy] Removed existing binary: ${existingBinary}`);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof Error && 'code' in error ? (error as { code: string }).code : '';
|
||||
// ETXTBSY: Linux-specific error when unlinking a running executable.
|
||||
// EBUSY on Windows may mean something different (mount point, etc.),
|
||||
// so only treat ETXTBSY as "binary in use" to avoid misleading messages.
|
||||
if (code === 'ETXTBSY') {
|
||||
if (verbose)
|
||||
console.error(`[cliproxy] Binary is running, cannot replace: ${existingBinary}`);
|
||||
throw new Error(
|
||||
'CLIProxy binary is currently running and cannot be replaced. Restart the container to apply the update.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`);
|
||||
@@ -99,8 +117,17 @@ export function deleteBinary(binPath: string, verbose = false, backend?: CLIProx
|
||||
const effectiveBackend = backend ?? DEFAULT_BACKEND;
|
||||
const binaryPath = path.join(binPath, getExecutableName(effectiveBackend));
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
fs.unlinkSync(binaryPath);
|
||||
if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`);
|
||||
try {
|
||||
fs.unlinkSync(binaryPath);
|
||||
if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof Error && 'code' in error ? (error as { code: string }).code : '';
|
||||
if (code === 'ETXTBSY') {
|
||||
throw new Error('CLIProxy binary is currently running and cannot be deleted.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../cliproxy/config-generator';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver';
|
||||
import { registerSession, unregisterSession } from '../cliproxy/session-tracker';
|
||||
import { getInstalledCliproxyVersion } from '../cliproxy/binary-manager';
|
||||
|
||||
async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> {
|
||||
const binaryPath = await ensureCLIProxyBinary(false);
|
||||
@@ -32,8 +34,25 @@ async function runCliproxy(): Promise<number> {
|
||||
},
|
||||
});
|
||||
|
||||
// Register session lock so dashboard can detect the running proxy
|
||||
let sessionId: string | undefined;
|
||||
child.on('spawn', () => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
const version = getInstalledCliproxyVersion();
|
||||
sessionId = registerSession(CLIPROXY_DEFAULT_PORT, child.pid, version, 'plus');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[cliproxy] Failed to register session lock: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getAllAuthStatus,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../../cliproxy';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
|
||||
import { detectRunningProxy } from '../../cliproxy/proxy-detector';
|
||||
import type { HealthCheck } from './types';
|
||||
import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector';
|
||||
import { isNewerVersion, isVersionFaulty } from '../../cliproxy/binary/version-checker';
|
||||
@@ -130,36 +130,52 @@ export function checkOAuthProviders(): HealthCheck[] {
|
||||
|
||||
/**
|
||||
* Check CLIProxy port status
|
||||
*
|
||||
* Uses unified proxy detection (HTTP check first, then session lock, then
|
||||
* port-process). This works reliably inside Docker containers where OS-level
|
||||
* port detection tools (lsof/ss) may be unavailable.
|
||||
*/
|
||||
export async function checkCliproxyPort(): Promise<HealthCheck> {
|
||||
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
|
||||
const status = await detectRunningProxy(CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
if (!portProcess) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'info',
|
||||
message: `${CLIPROXY_DEFAULT_PORT} free`,
|
||||
details: 'Proxy not running',
|
||||
};
|
||||
}
|
||||
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
if (status.running && status.verified) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'ok',
|
||||
message: 'CLIProxy running',
|
||||
details: `PID ${portProcess.pid}`,
|
||||
details: status.pid ? `PID ${status.pid}` : `Detected via ${status.method}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.running) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'warning',
|
||||
message: 'CLIProxy starting',
|
||||
details: status.pid ? `PID ${status.pid}` : `Detected via ${status.method}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.blocked) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'warning',
|
||||
message: status.blocker
|
||||
? `Occupied by ${status.blocker.processName}`
|
||||
: 'Port occupied by unknown process',
|
||||
details: status.blocker ? `PID ${status.blocker.pid}` : undefined,
|
||||
...(status.blocker && { fix: `Kill process: kill ${status.blocker.pid}` }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'warning',
|
||||
message: `Occupied by ${portProcess.processName}`,
|
||||
details: `PID ${portProcess.pid}`,
|
||||
fix: `Kill process: kill ${portProcess.pid}`,
|
||||
status: 'info',
|
||||
message: `${CLIPROXY_DEFAULT_PORT} free`,
|
||||
details: 'Proxy not running',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Binary Installer ETXTBSY Guard Tests
|
||||
*
|
||||
* Tests the error handling in deleteBinary() when unlinkSync fails.
|
||||
* Uses real temp files to avoid global fs mock pollution.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { deleteBinary } from '../../../src/cliproxy/binary/installer';
|
||||
|
||||
describe('deleteBinary ETXTBSY guard', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-etxtbsy-test-'));
|
||||
// Create a fake binary file that deleteBinary will target
|
||||
const binDir = path.join(tmpDir, 'plus');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(binDir, 'cli-proxy-api-plus'), 'fake-binary');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('deletes binary successfully when file is not in use', () => {
|
||||
const binDir = path.join(tmpDir, 'plus');
|
||||
const binaryPath = path.join(binDir, 'cli-proxy-api-plus');
|
||||
expect(fs.existsSync(binaryPath)).toBe(true);
|
||||
|
||||
deleteBinary(binDir, false, 'plus');
|
||||
|
||||
expect(fs.existsSync(binaryPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when binary does not exist', () => {
|
||||
const emptyDir = path.join(tmpDir, 'empty');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
expect(() => deleteBinary(emptyDir, false, 'plus')).not.toThrow();
|
||||
});
|
||||
|
||||
it('ETXTBSY catch block produces correct error message', () => {
|
||||
// Verify the error message format by testing the catch logic directly.
|
||||
// We can't reliably trigger ETXTBSY in tests (need a running Go binary),
|
||||
// so we verify the code structure matches the expected behavior.
|
||||
const err = Object.assign(new Error('ETXTBSY: text file busy'), { code: 'ETXTBSY' });
|
||||
const code =
|
||||
err instanceof Error && 'code' in err ? (err as { code: string }).code : '';
|
||||
expect(code).toBe('ETXTBSY');
|
||||
// The guard only catches ETXTBSY, not EBUSY
|
||||
expect(code === 'ETXTBSY').toBe(true);
|
||||
expect(code === 'EBUSY').toBe(false);
|
||||
});
|
||||
|
||||
it('EBUSY is not treated as "binary in use"', () => {
|
||||
// Verify that EBUSY (Windows mount/directory) is distinguished from ETXTBSY
|
||||
const err = Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' });
|
||||
const code =
|
||||
err instanceof Error && 'code' in err ? (err as { code: string }).code : '';
|
||||
expect(code).toBe('EBUSY');
|
||||
expect(code === 'ETXTBSY').toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* checkCliproxyPort() Health Check Tests
|
||||
*
|
||||
* Verifies the function maps ProxyStatus objects from detectRunningProxy()
|
||||
* to the correct HealthCheck output (status, message, details).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
import type { ProxyStatus } from '../../../src/cliproxy/proxy-detector';
|
||||
|
||||
// Mutable holder so each test can override the resolved value
|
||||
let mockStatus: ProxyStatus = { running: false, verified: false };
|
||||
|
||||
mock.module('../../../src/cliproxy/proxy-detector', () => ({
|
||||
detectRunningProxy: async () => mockStatus,
|
||||
waitForProxyHealthy: async () => false,
|
||||
reclaimOrphanedProxy: () => null,
|
||||
}));
|
||||
|
||||
// Import after mock is registered
|
||||
const { checkCliproxyPort } = await import(
|
||||
`../../../src/web-server/health/cliproxy-checks?cliproxy-port-check=${Date.now()}`
|
||||
);
|
||||
|
||||
describe('checkCliproxyPort', () => {
|
||||
it('returns ok when running and verified', async () => {
|
||||
mockStatus = { running: true, verified: true, method: 'http', pid: 1234 };
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.id).toBe('cliproxy-port');
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.message).toBe('CLIProxy running');
|
||||
expect(result.details).toBe('PID 1234');
|
||||
});
|
||||
|
||||
it('returns ok via detection method when no pid', async () => {
|
||||
mockStatus = { running: true, verified: true, method: 'http' };
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.details).toBe('Detected via http');
|
||||
});
|
||||
|
||||
it('returns warning "CLIProxy starting" when running but not verified', async () => {
|
||||
mockStatus = { running: true, verified: false, method: 'session-lock', pid: 5678 };
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.status).toBe('warning');
|
||||
expect(result.message).toBe('CLIProxy starting');
|
||||
expect(result.details).toBe('PID 5678');
|
||||
});
|
||||
|
||||
it('returns warning with blocker process name when blocked with blocker', async () => {
|
||||
mockStatus = {
|
||||
running: false,
|
||||
verified: false,
|
||||
blocked: true,
|
||||
blocker: { pid: 9999, processName: 'nginx' },
|
||||
};
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.status).toBe('warning');
|
||||
expect(result.message).toBe('Occupied by nginx');
|
||||
expect(result.details).toBe('PID 9999');
|
||||
expect(result.fix).toBe('Kill process: kill 9999');
|
||||
});
|
||||
|
||||
it('returns warning "Port occupied by unknown process" when blocked without blocker', async () => {
|
||||
mockStatus = { running: false, verified: false, blocked: true };
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.status).toBe('warning');
|
||||
expect(result.message).toBe('Port occupied by unknown process');
|
||||
expect(result.details).toBeUndefined();
|
||||
expect(result.fix).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns info when port is free', async () => {
|
||||
mockStatus = { running: false, verified: false };
|
||||
const result = await checkCliproxyPort();
|
||||
expect(result.status).toBe('info');
|
||||
expect(result.details).toBe('Proxy not running');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user