From 7776715ebadc5821167c0bf721266f43a228b3cb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 11 Apr 2026 17:50:11 -0400 Subject: [PATCH] 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 --- src/docker/supervisord-lifecycle.ts | 36 +++++++++++++++++++ .../routes/cliproxy-stats-routes.ts | 13 ++++++- .../cliproxy-dashboard-install-service.ts | 18 ++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/docker/supervisord-lifecycle.ts diff --git a/src/docker/supervisord-lifecycle.ts b/src/docker/supervisord-lifecycle.ts new file mode 100644 index 00000000..21ca5d0f --- /dev/null +++ b/src/docker/supervisord-lifecycle.ts @@ -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' }; + } +} diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a6bd9d44..a3fc3b05 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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 => { */ router.post('/restart', async (_req: Request, res: Response): Promise => { 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 diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 7091965f..8ea78bb5 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -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 {