fix(docker): delegate restart/install to supervisorctl in Docker deployments

The dashboard restart button and install service used local-mode process
management (stopProxy + ensureCliproxyService) which conflicts with
supervisord in Docker deployments. Killing the CLIProxy binary caused the
bootstrap wrapper to exit, supervisord to attempt a restart, but
ensureCliproxyService already spawned an orphaned process on :8317
resulting in EADDRINUSE and supervisord entering FATAL state.

Detect supervisord via /var/run/supervisor.sock and delegate to
supervisorctl restart, matching the pattern already used by
ccs docker update.

Closes #964
This commit is contained in:
Tam Nhu Tran
2026-04-11 17:50:11 -04:00
parent 8e413e49f6
commit 7776715eba
3 changed files with 66 additions and 1 deletions
+36
View File
@@ -0,0 +1,36 @@
/**
* Supervisord lifecycle helpers for Docker deployments (`ccs docker up`).
*
* In Docker, supervisord owns the CLIProxy process lifecycle. Direct
* stop+start via session-tracker / service-manager creates orphaned
* processes and causes supervisord to enter FATAL state (EADDRINUSE).
* All restart operations must delegate to `supervisorctl` instead.
*/
import * as fs from 'fs';
import { execSync } from 'child_process';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
const SUPERVISOR_SOCK = '/var/run/supervisor.sock';
const SUPERVISOR_CONF = '/etc/supervisord.conf';
/** True when running inside a supervisord-managed container. */
export function isRunningUnderSupervisord(): boolean {
return fs.existsSync(SUPERVISOR_SOCK);
}
/** Restart the cliproxy program via supervisorctl. Returns port on success. */
export function restartCliproxyViaSupervisord(): {
success: boolean;
port?: number;
error?: string;
} {
try {
execSync(`supervisorctl -c ${SUPERVISOR_CONF} restart cliproxy`, { timeout: 15_000 });
return { success: true, port: CLIPROXY_DEFAULT_PORT };
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error(`[cliproxy] supervisorctl restart failed: ${detail}`);
return { success: false, error: 'supervisorctl restart failed' };
}
}
+12 -1
View File
@@ -5,6 +5,10 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
isRunningUnderSupervisord,
restartCliproxyViaSupervisord,
} from '../../docker/supervisord-lifecycle';
import {
fetchCliproxyStats,
fetchCliproxyModels,
@@ -1014,7 +1018,14 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
*/
router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
try {
// Stop proxy first
if (isRunningUnderSupervisord()) {
// Docker mode: delegate to supervisord which owns the process lifecycle
const result = restartCliproxyViaSupervisord();
res.json(result);
return;
}
// Local mode: direct process management
await stopProxy();
// Small delay to ensure port is released
@@ -3,6 +3,10 @@ import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/s
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
import type { CLIProxyBackend } from '../../cliproxy/types';
import {
isRunningUnderSupervisord,
restartCliproxyViaSupervisord,
} from '../../docker/supervisord-lifecycle';
interface ProxyStatusLike {
running: boolean;
@@ -63,6 +67,20 @@ export async function installDashboardCliproxyVersion(
};
}
// In Docker, supervisord owns process lifecycle — delegate restart to it
if (isRunningUnderSupervisord()) {
const result = restartCliproxyViaSupervisord();
return {
success: result.success,
restarted: result.success,
port: result.port,
error: result.error,
message: result.success
? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}`
: `Installed ${backendLabel} v${version}, but restart failed`,
};
}
const startResult = await deps.ensureCliproxyService();
if (!startResult.started && !startResult.alreadyRunning) {
return {