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 {