From b173fd3f09069eb5815ccc48c4f8f5b47fffb60b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 25 Jun 2026 12:00:02 +0300 Subject: [PATCH] fix: align CLI lifecycle detection with live port checks (#1830) Prevents status/stop/restart from deleting valid PID registries on transient probe failures Adds explicit stop recovery for unresponsive PID-file instances Covers unmanaged, stale PID, host, and desktop edge cases with CLI tests --- packages/web/bin/cli.js | 603 ++++++++++++++++++++++++++--------- packages/web/bin/cli.test.js | 535 +++++++++++++++++++++++++++++++ 2 files changed, 983 insertions(+), 155 deletions(-) diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index d7fd5fdc..b63fb47b 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -152,6 +152,10 @@ function resolveConfiguredBindHost(hostOverride) { return configured || '127.0.0.1'; } +function resolveServeHost(hostOverride) { + return resolveConfiguredBindHost(hostOverride); +} + function resolveApiHost(hostOverride) { const configured = resolveConfiguredBindHost(hostOverride); @@ -2452,14 +2456,100 @@ function isOpenchamberCmdline(cmdline) { // Where identity can't be determined (Windows, unreadable /proc or ps), we fall // back to liveness so there are no false negatives on those platforms. function isOpenchamberProcessRunning(pid) { - if (!isProcessRunning(pid)) { - return false; + const state = getOpenchamberProcessState(pid); + return state === 'matched' || state === 'unknown'; +} + +function getOpenchamberProcessState(pid, options = {}) { + const checkProcessRunning = typeof options.isProcessRunning === 'function' + ? options.isProcessRunning + : isProcessRunning; + if (!Number.isFinite(pid) || pid <= 0 || !checkProcessRunning(pid)) { + return 'dead'; } - const cmdline = readProcessCmdline(pid); + + const readCmdline = typeof options.readProcessCmdline === 'function' + ? options.readProcessCmdline + : readProcessCmdline; + const cmdline = readCmdline(pid); if (cmdline === null) { - return true; + return 'unknown'; } - return isOpenchamberCmdline(cmdline); + return isOpenchamberCmdline(cmdline) ? 'matched' : 'mismatched'; +} + +function hasOpenchamberRuntimeInfo(info) { + return Boolean(info && typeof info.runtime === 'string' && info.runtime.length > 0); +} + +function createLivePortInstance(port, info, host) { + if (!hasOpenchamberRuntimeInfo(info)) return null; + return { + port, + pid: Number.isFinite(info.pid) ? info.pid : null, + pidFilePath: path.join(getRunDir(), `openchamber-${port}.pid`), + instanceFilePath: path.join(getRunDir(), `openchamber-${port}.json`), + mtime: 0, + startedAt: 0, + launchMode: 'daemon', + runtime: info.runtime, + source: 'probe', + host: typeof host === 'string' && host.length > 0 ? host : undefined, + }; +} + +function normalizeProbeHost(host) { + return typeof host === 'string' && host.trim().length > 0 ? host.trim() : undefined; +} + +function isWildcardProbeHost(host) { + const normalized = normalizeProbeHost(host); + return normalized === '0.0.0.0' || normalized === '::' || normalized === '[::]'; +} + +function isLoopbackProbeHost(host) { + const normalized = normalizeProbeHost(host); + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]'; +} + +function isConcreteProbeHost(host) { + const normalized = normalizeProbeHost(host); + return Boolean(normalized && !isWildcardProbeHost(normalized) && !isLoopbackProbeHost(normalized)); +} + +function getSystemInfoProbeHosts(...hosts) { + const out = []; + const hasConcreteAuthoritativeHost = hosts.some(isConcreteProbeHost); + const pushHost = (host, requiresPidMatch = false) => { + const normalized = normalizeProbeHost(host); + const key = resolveApiHost(normalized); + if (!out.some((entry) => resolveApiHost(entry.host) === key)) { + out.push({ host: normalized, requiresPidMatch }); + } + }; + + for (const host of hosts) { + if (normalizeProbeHost(host)) { + pushHost(host, false); + } + } + + pushHost(undefined, hasConcreteAuthoritativeHost); + pushHost('127.0.0.1', hasConcreteAuthoritativeHost); + return out; +} + +async function fetchSystemInfoFromPortCandidates(port, fetchImpl, hosts, expectedPid) { + for (const { host, requiresPidMatch } of hosts) { + const info = await fetchSystemInfoFromPort(port, fetchImpl, host); + if (hasOpenchamberRuntimeInfo(info)) { + if (requiresPidMatch && info.pid !== expectedPid) { + continue; + } + return { info, host }; + } + } + return { info: null, host: null }; } function waitForProcessExit(pid, timeoutMs) { @@ -2564,12 +2654,12 @@ async function stopInstanceProcess(pid, options = {}) { return terminateProcessTree(pid, options); } -async function requestServerShutdown(port) { +async function requestServerShutdown(port, hostOverride) { if (!Number.isFinite(port) || port <= 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1500); try { - const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown'), { + const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown', hostOverride), { method: 'POST', signal: controller.signal, }); @@ -2754,9 +2844,13 @@ async function resolveDoctorPortStatuses(options = {}) { return { statuses, availableEntries: runningEntries }; } -async function discoverRunningInstances() { +async function discoverRunningInstances(options = {}) { const instances = []; const runDir = getRunDir(); + const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; + const getProcessState = typeof options.getOpenchamberProcessState === 'function' + ? options.getOpenchamberProcessState + : (pid) => getOpenchamberProcessState(pid, options); try { const files = fs.readdirSync(runDir); const pidFiles = files.filter((file) => file.startsWith('openchamber-') && file.endsWith('.pid')); @@ -2765,24 +2859,71 @@ async function discoverRunningInstances() { if (!Number.isFinite(port) || port <= 0) continue; const pidFilePath = path.join(runDir, file); const pid = readPidFile(pidFilePath); - if (!pid || !isOpenchamberProcessRunning(pid)) { + if (!pid) { removePidFile(pidFilePath); removeInstanceFile(path.join(runDir, `openchamber-${port}.json`)); continue; } + const instanceFilePath = path.join(runDir, `openchamber-${port}.json`); + const storedOptions = readInstanceOptions(instanceFilePath); + const processState = getProcessState(pid); + if (processState === 'dead') { + removePidFile(pidFilePath); + removeInstanceFile(instanceFilePath); + continue; + } + + // A live PID-file is only the right instance if the recorded port also + // confirms OpenChamber. Cmdline identity alone can match a recycled PID + // from another OpenChamber process on a different port. Try all plausible + // hosts first; if matched/unknown identity still can't be confirmed, keep + // the registry files but don't claim the instance is running. + const { info: liveInfo, host: confirmedHost } = await fetchSystemInfoFromPortCandidates( + port, + fetchImpl, + getSystemInfoProbeHosts(storedOptions?.host, options.host), + pid, + ); + const livePid = Number.isFinite(liveInfo?.pid) ? liveInfo.pid : null; + if (!hasOpenchamberRuntimeInfo(liveInfo)) { + if (processState === 'mismatched') { + removePidFile(pidFilePath); + removeInstanceFile(instanceFilePath); + } + continue; + } + + if (liveInfo.runtime === 'desktop') { + removePidFile(pidFilePath); + removeInstanceFile(instanceFilePath); + continue; + } + let mtime = 0; let startedAt = 0; try { mtime = fs.statSync(pidFilePath).mtimeMs; } catch { } - const storedOptions = readInstanceOptions(instanceFilePath); if (Number.isFinite(storedOptions?.startedAt)) { startedAt = storedOptions.startedAt; } const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon'; - instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt, launchMode }); + instances.push({ + port, + pid: livePid || (processState === 'matched' ? pid : null), + pidFilePath, + instanceFilePath, + mtime, + startedAt, + launchMode, + runtime: liveInfo.runtime, + source: 'registry+probe', + host: typeof confirmedHost === 'string' && confirmedHost.length > 0 + ? confirmedHost + : (typeof storedOptions?.host === 'string' && storedOptions.host.length > 0 ? storedOptions.host : undefined), + }); } } catch { } @@ -2790,6 +2931,78 @@ async function discoverRunningInstances() { return instances; } +async function discoverOpenChamberInstanceOnPort(port, options = {}) { + if (!Number.isFinite(port) || port <= 0) return null; + const runningInstances = Array.isArray(options.runningInstances) + ? options.runningInstances + : await discoverRunningInstances(options); + const registryMatch = runningInstances.find((entry) => entry.port === port); + if (registryMatch) return registryMatch; + + const info = await fetchSystemInfoFromPort( + port, + typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch, + options.host, + ); + return createLivePortInstance(port, info, options.host); +} + +async function discoverLifecycleInstances(options = {}, deps = {}) { + const runningInstances = await discoverRunningInstances({ ...deps, host: options.host }); + if (!options.explicitPort) { + return runningInstances; + } + const found = runningInstances.find((entry) => entry.port === options.port); + if (found) return [found]; + const liveInstance = await discoverOpenChamberInstanceOnPort(options.port, { + ...deps, + host: options.host, + runningInstances, + }); + return liveInstance ? [liveInstance] : []; +} + +async function discoverUnconfirmedRegistryInstanceOnPort(port, options = {}) { + if (!Number.isFinite(port) || port <= 0) return null; + + const pidFilePath = await getPidFilePath(port); + const pid = readPidFile(pidFilePath); + if (!pid) return null; + + const instanceFilePath = await getInstanceFilePath(port); + const storedOptions = readInstanceOptions(instanceFilePath); + const processState = getOpenchamberProcessState(pid); + if (processState === 'dead') { + removePidFile(pidFilePath); + removeInstanceFile(instanceFilePath); + return null; + } + + if (processState !== 'matched') { + return null; + } + + const host = storedOptions?.host || options.host; + if (await isPortAvailable(port, host)) { + removePidFile(pidFilePath); + removeInstanceFile(instanceFilePath); + return null; + } + + return { + port, + pid, + pidFilePath, + instanceFilePath, + mtime: 0, + startedAt: Number.isFinite(storedOptions?.startedAt) ? storedOptions.startedAt : 0, + launchMode: storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon', + runtime: 'cli', + source: 'registry-unconfirmed', + host: typeof host === 'string' && host.length > 0 ? host : undefined, + }; +} + function getLatestInstance(instances) { if (!instances.length) return null; return [...instances].sort((a, b) => { @@ -2816,7 +3029,7 @@ async function fetchTunnelProvidersFromPort(port, fetchImpl = globalThis.fetch) } } -async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch) { +async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch, hostOverride) { if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') { return null; } @@ -2824,7 +3037,7 @@ async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1500); try { - const response = await fetchImpl(buildLocalUrl(port, '/api/system/info'), { + const response = await fetchImpl(buildLocalUrl(port, '/api/system/info', hostOverride), { headers: { Accept: 'application/json' }, signal: controller.signal, }); @@ -2943,7 +3156,7 @@ async function resolveTargetInstance({ } if (rejectDesktopRuntime) { - const systemInfo = await fetchSystemInfoFromPort(options.port); + const systemInfo = await fetchSystemInfoFromPort(options.port, globalThis.fetch, options.host); if (systemInfo?.runtime === 'desktop') { throw new Error( `Port ${options.port} is used by OpenChamber Desktop app. Tunnel attach requires a CLI instance from \`openchamber serve\`.` @@ -3484,6 +3697,7 @@ const commands = { } }; const explicitPort = options.explicitPort === true; + const effectiveHost = resolveServeHost(options.host); const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice); if (targetPort !== 0 && !options.suppressUnsafePortWarning) { @@ -3491,19 +3705,22 @@ const commands = { } if (targetPort !== 0) { - const pidFilePath = await getPidFilePath(targetPort); - const existingPid = readPidFile(pidFilePath); - if (existingPid) { - if (isOpenchamberProcessRunning(existingPid)) { - throw new Error(`OpenChamber is already running on port ${targetPort} (PID: ${existingPid})`); + const existingInstance = await discoverOpenChamberInstanceOnPort(targetPort, { host: effectiveHost }); + if (existingInstance?.runtime === 'desktop') { + throw new Error( + `Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.` + ); + } + if (existingInstance) { + const pidSuffix = Number.isFinite(existingInstance.pid) ? ` (PID: ${existingInstance.pid})` : ''; + if (existingInstance.source === 'probe') { + throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`); } - // Stale pid file from an ungraceful shutdown (PID dead or recycled to an - // unrelated process). Clear it so it can't trip later checks. - removePidFile(pidFilePath); + throw new Error(`OpenChamber is already running on port ${targetPort}${pidSuffix}`); } - if (explicitPort && !(await isPortAvailable(targetPort, options.host))) { - const systemInfo = await fetchSystemInfoFromPort(targetPort); + if (explicitPort && !(await isPortAvailable(targetPort, effectiveHost))) { + const systemInfo = await fetchSystemInfoFromPort(targetPort, globalThis.fetch, effectiveHost); if (systemInfo?.runtime === 'desktop') { throw new Error( `Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.` @@ -3529,11 +3746,11 @@ const commands = { const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined; assertAuthenticatedNetworkExposure({ - host: options.host, + host: effectiveHost, uiPassword: effectiveUiPassword, }); if (!effectiveUiPassword && !options.suppressUiPasswordWarning) { - const bindHost = resolveConfiguredBindHost(options.host); + const bindHost = effectiveHost; const networkExposed = isNetworkExposedBindHost(bindHost); const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set'; const warningDetail = networkExposed @@ -3572,6 +3789,7 @@ const commands = { if (effectiveUiPassword) { process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } + process.env.OPENCHAMBER_HOST = effectiveHost; // In --quiet mode, redirect stdout/stderr to the log file so that // server runtime output (console.log calls) does not pollute the @@ -3602,9 +3820,6 @@ const commands = { console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`); } - const effectiveHost = typeof options.host === 'string' && options.host.length > 0 - ? options.host : undefined; - const { startWebUiServer } = await import(pathToFileURL(serverPath).href); const controller = await startWebUiServer({ port: targetPort, @@ -3674,10 +3889,7 @@ const commands = { } const serverArgs = [serverPath, '--port', String(targetPort)]; - const effectiveHost = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined; - if (effectiveHost) { - serverArgs.push('--host', effectiveHost); - } + serverArgs.push('--host', effectiveHost); if (options.apiOnly === true) { serverArgs.push('--api-only'); } @@ -3692,7 +3904,7 @@ const commands = { ...process.env, OPENCHAMBER_PORT: String(targetPort), OPENCODE_BINARY: opencodeBinary, - ...(effectiveHost ? { OPENCHAMBER_HOST: effectiveHost } : {}), + OPENCHAMBER_HOST: effectiveHost, ...(effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), ...(options.apiOnly === true ? { OPENCHAMBER_API_ONLY: 'true' } : {}), ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), @@ -3888,100 +4100,16 @@ const commands = { clackIntro('OpenChamber Stop'); } - let runningInstances = await discoverRunningInstances(); - if (runningInstances.length === 0) { - if (isJsonMode(options)) { - printJson({ stoppedCount: 0, results: jsonResults }); - } - if (showOutput) { - logStatus('info', 'No running OpenChamber instances found'); - finish('nothing to stop'); - } - printQuietStopResults(); - return; - } - + let runningInstances = await discoverLifecycleInstances(options); if (options.explicitPort) { - runningInstances = runningInstances.filter((entry) => entry.port === options.port); if (runningInstances.length === 0) { - const systemInfo = await fetchSystemInfoFromPort(options.port); - if (systemInfo?.runtime === 'desktop') { - jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' }); - if (isJsonMode(options)) { - printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by OpenChamber Desktop and cannot be stopped with this command.` }] }); - } - if (showOutput) { - logStatus('warning', `port ${options.port} is managed by OpenChamber Desktop`, 'cannot be stopped with this command'); - finish('no changes applied'); - } - printQuietStopResults(); - return; - } - - if (systemInfo?.runtime) { - const unmanagedStopSpin = showOutput ? createSpinner(options) : null; - if (showOutput && !unmanagedStopSpin) { - logStatus('info', `found unmanaged OpenChamber instance on port ${options.port}`, 'attempting shutdown'); - } - unmanagedStopSpin?.start(`Stopping unmanaged OpenChamber on port ${options.port}...`); - const requested = await requestServerShutdown(options.port); - - if (Number.isFinite(systemInfo.pid) && isProcessRunning(systemInfo.pid)) { - await stopInstanceProcess(systemInfo.pid, { - shutdownWaitMs: requested ? 5000 : 0, - gracefulTimeoutMs: 2500, - forceTimeoutMs: 3000, - }).catch(() => false); - } - - const stopped = await isPortAvailable(options.port); - if (stopped) { - unmanagedStopSpin?.stop(`Stopped unmanaged OpenChamber on port ${options.port}`); - jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true }); - if (isJsonMode(options)) { - printJson({ stoppedCount: 1, results: jsonResults }); - } - if (showOutput && !unmanagedStopSpin) { - logStatus('success', `stopped OpenChamber on port ${options.port}`); - finish('stop complete'); - } - printQuietStopResults(); - } else if (requested) { - unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`); - jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' }); - if (isJsonMode(options)) { - printJson({ - status: 'warning', - stoppedCount: 0, - results: jsonResults, - messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }], - }); - } - if (showOutput && !unmanagedStopSpin) { - logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied'); - finish('partial stop'); - } - printQuietStopResults(); - } else { - unmanagedStopSpin?.error(`Could not stop OpenChamber on port ${options.port}`); - jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' }); - if (isJsonMode(options)) { - printJson({ - status: 'error', - stoppedCount: 0, - results: jsonResults, - messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber on port ${options.port}.` }], - }); - } - if (showOutput && !unmanagedStopSpin) { - logStatus('error', `could not stop OpenChamber on port ${options.port}`); - finish('failed'); - } - printQuietStopResults(); - } - return; + const unconfirmedInstance = await discoverUnconfirmedRegistryInstanceOnPort(options.port, options); + if (unconfirmedInstance) { + runningInstances = [unconfirmedInstance]; } + } + if (runningInstances.length === 0) { jsonResults.push({ port: options.port, stopped: false, reason: 'not-found' }); if (isJsonMode(options)) { printJson({ stoppedCount: 0, results: jsonResults }); @@ -3993,6 +4121,140 @@ const commands = { printQuietStopResults(); return; } + + const explicitInstance = runningInstances[0]; + if (explicitInstance.runtime === 'desktop') { + jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' }); + if (isJsonMode(options)) { + printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by OpenChamber Desktop and cannot be stopped with this command.` }] }); + } + if (showOutput) { + logStatus('warning', `port ${options.port} is managed by OpenChamber Desktop`, 'cannot be stopped with this command'); + finish('no changes applied'); + } + printQuietStopResults(); + return; + } + + if (explicitInstance.source === 'probe') { + const unmanagedStopSpin = showOutput ? createSpinner(options) : null; + if (showOutput && !unmanagedStopSpin) { + logStatus('info', `found unmanaged OpenChamber instance on port ${options.port}`, 'attempting shutdown'); + } + unmanagedStopSpin?.start(`Stopping unmanaged OpenChamber on port ${options.port}...`); + const requested = await requestServerShutdown(options.port, options.host); + + if (Number.isFinite(explicitInstance.pid) && isProcessRunning(explicitInstance.pid)) { + await stopInstanceProcess(explicitInstance.pid, { + shutdownWaitMs: requested ? 5000 : 0, + gracefulTimeoutMs: 2500, + forceTimeoutMs: 3000, + }).catch(() => false); + } + + const stopped = await isPortAvailable(options.port, options.host); + if (stopped) { + unmanagedStopSpin?.stop(`Stopped unmanaged OpenChamber on port ${options.port}`); + jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true }); + if (isJsonMode(options)) { + printJson({ stoppedCount: 1, results: jsonResults }); + } + if (showOutput && !unmanagedStopSpin) { + logStatus('success', `stopped OpenChamber on port ${options.port}`); + finish('stop complete'); + } + printQuietStopResults(); + } else if (requested) { + unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`); + jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' }); + if (isJsonMode(options)) { + printJson({ + status: 'warning', + stoppedCount: 0, + results: jsonResults, + messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }], + }); + } + if (showOutput && !unmanagedStopSpin) { + logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied'); + finish('partial stop'); + } + printQuietStopResults(); + } else { + unmanagedStopSpin?.error(`Could not stop OpenChamber on port ${options.port}`); + jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' }); + if (isJsonMode(options)) { + printJson({ + status: 'error', + stoppedCount: 0, + results: jsonResults, + messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber on port ${options.port}.` }], + }); + } + if (showOutput && !unmanagedStopSpin) { + logStatus('error', `could not stop OpenChamber on port ${options.port}`); + finish('failed'); + } + printQuietStopResults(); + } + return; + } + + if (explicitInstance.source === 'registry-unconfirmed') { + const unconfirmedStopSpin = showOutput ? createSpinner(options) : null; + if (showOutput && !unconfirmedStopSpin) { + logStatus('info', `found unconfirmed OpenChamber pid ${explicitInstance.pid} on port ${options.port}`, 'HTTP shutdown endpoint is unreachable; stopping by PID'); + } + unconfirmedStopSpin?.start(`Stopping unconfirmed OpenChamber on port ${options.port}...`); + const stopped = await stopInstanceProcess(explicitInstance.pid, { + shutdownWaitMs: 0, + gracefulTimeoutMs: 2500, + forceTimeoutMs: 3000, + }).catch(() => false); + + if (stopped || !isProcessRunning(explicitInstance.pid)) { + removePidFile(explicitInstance.pidFilePath); + removeInstanceFile(explicitInstance.instanceFilePath); + unconfirmedStopSpin?.stop(`Stopped OpenChamber PID ${explicitInstance.pid}`); + jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: true }); + if (isJsonMode(options)) { + printJson({ stoppedCount: 1, results: jsonResults }); + } + if (showOutput && !unconfirmedStopSpin) { + logStatus('success', `stopped pid ${explicitInstance.pid}`); + finish('stop complete'); + } + printQuietStopResults(); + return; + } + + unconfirmedStopSpin?.error(`Could not stop OpenChamber PID ${explicitInstance.pid}`); + jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: false, reason: 'stop-failed' }); + if (isJsonMode(options)) { + printJson({ + status: 'error', + stoppedCount: 0, + results: jsonResults, + messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber PID ${explicitInstance.pid}.` }], + }); + } + if (showOutput && !unconfirmedStopSpin) { + logStatus('error', `could not stop pid ${explicitInstance.pid}`); + finish('failed'); + } + printQuietStopResults(); + return; + } + } else if (runningInstances.length === 0) { + if (isJsonMode(options)) { + printJson({ stoppedCount: 0, results: jsonResults }); + } + if (showOutput) { + logStatus('info', 'No running OpenChamber instances found'); + finish('nothing to stop'); + } + printQuietStopResults(); + return; } for (const instance of runningInstances) { @@ -4002,7 +4264,7 @@ const commands = { } stopSpin?.start(`Stopping OpenChamber on port ${instance.port}...`); try { - const requested = await requestServerShutdown(instance.port); + const requested = await requestServerShutdown(instance.port, instance.host || options.host); const stopped = await stopInstanceProcess(instance.pid, { shutdownWaitMs: requested ? 5000 : 0, gracefulTimeoutMs: 2500, @@ -4052,7 +4314,7 @@ const commands = { clackIntro('OpenChamber Restart'); } - let runningInstances = await discoverRunningInstances(); + let runningInstances = await discoverLifecycleInstances(options); if (runningInstances.length === 0) { if (isJsonMode(options)) { printJson({ restartedCount: 0, results: restarted }); @@ -4066,24 +4328,31 @@ const commands = { return; } - if (options.explicitPort) { - runningInstances = runningInstances.filter((entry) => entry.port === options.port); - if (runningInstances.length === 0) { + for (const instance of runningInstances) { + if (instance.runtime === 'desktop') { + const message = `Port ${instance.port} is managed by OpenChamber Desktop and cannot be restarted with this command.`; if (isJsonMode(options)) { - printJson({ restartedCount: 0, results: restarted }); + printJson({ + status: 'warning', + restartedCount: 0, + results: [{ fromPort: instance.port, runtime: 'desktop', ok: false, reason: 'desktop-managed' }], + messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message }], + }); + return; } if (showOutput) { - logStatus('warning', `no OpenChamber instance found on port ${options.port}`); - clackOutro('nothing to restart'); + logStatus('warning', `port ${instance.port} is managed by OpenChamber Desktop`, 'cannot be restarted with this command'); + clackOutro('no changes applied'); } else if (isQuietMode(options)) { process.stdout.write('restarted 0\n'); } return; } - } - for (const instance of runningInstances) { - const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port }; + const storedOptions = instance.instanceFilePath + ? (readInstanceOptions(instance.instanceFilePath) || { port: instance.port }) + : { port: instance.port }; + const instanceHost = storedOptions.host || instance.host || options.host; const launchMode = instance.launchMode || 'daemon'; const isForeground = launchMode === 'foreground'; @@ -4098,6 +4367,7 @@ const commands = { await this.stop({ explicitPort: true, port: instance.port, + host: instanceHost, quiet: true, suppressQuietOutput: true, }); @@ -4119,10 +4389,10 @@ const commands = { const restartedPort = await this.serve({ port: restartPort, - host: storedOptions.host, + host: instanceHost, explicitPort: true, - uiPassword: options.explicitUiPassword ? options.uiPassword : storedOptions.uiPassword, - apiOnly: storedOptions.apiOnly === true, + uiPassword: options.explicitUiPassword ? options.uiPassword : (storedOptions.uiPassword || options.uiPassword), + apiOnly: storedOptions.apiOnly === true || options.apiOnly === true, suppressStartupSummary: true, quiet: true, suppressUiPasswordWarning: true, @@ -4156,10 +4426,12 @@ const commands = { }, async status(options = {}) { - const [runningInstances, desktopInstance] = await Promise.all([ - discoverRunningInstances(), - discoverDesktopInstance(), - ]); + const [runningInstances, desktopInstance] = options.explicitPort + ? [await discoverLifecycleInstances(options), null] + : await Promise.all([ + discoverLifecycleInstances(options), + discoverDesktopInstance(), + ]); const toPasswordProtectionLabel = (value) => { if (value === true) return 'yes'; @@ -4177,21 +4449,36 @@ const commands = { } : null; - const cliInstances = runningInstances.map((instance) => { - const storedOptions = readInstanceOptions(instance.instanceFilePath) || {}; - const passwordProtected = storedOptions.hasUiPassword === true - || (typeof storedOptions.uiPassword === 'string' && storedOptions.uiPassword.trim().length > 0); + const cliInstances = runningInstances + .filter((instance) => instance.runtime !== 'desktop') + .map((instance) => { + const storedOptions = instance.instanceFilePath ? (readInstanceOptions(instance.instanceFilePath) || {}) : {}; + const passwordProtected = storedOptions.hasUiPassword === true + || (typeof storedOptions.uiPassword === 'string' && storedOptions.uiPassword.trim().length > 0); - return { - runtime: 'cli', - port: instance.port, - pid: instance.pid, - launchMode: instance.launchMode || 'daemon', - passwordProtected, - }; - }); + return { + runtime: instance.source === 'probe' ? 'unmanaged' : 'cli', + port: instance.port, + pid: instance.pid, + launchMode: instance.launchMode || 'daemon', + passwordProtected: instance.source === 'probe' ? null : passwordProtected, + }; + }); + + const explicitDesktop = options.explicitPort + ? runningInstances.find((entry) => entry.runtime === 'desktop') + : null; const instances = desktopOnly ? [...cliInstances, desktopOnly] : cliInstances; + if (explicitDesktop) { + instances.push({ + runtime: 'desktop', + port: explicitDesktop.port, + pid: Number.isFinite(explicitDesktop.pid) ? explicitDesktop.pid : null, + launchMode: null, + passwordProtected: null, + }); + } const runningCount = instances.length; if (isJsonMode(options)) { @@ -5580,7 +5867,7 @@ const commands = { updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`); for (const instance of runningInstances) { try { - const requested = await requestServerShutdown(instance.port); + const requested = await requestServerShutdown(instance.port, instance.host); await stopInstanceProcess(instance.pid, { shutdownWaitMs: requested ? 5000 : 0, gracefulTimeoutMs: 2500, @@ -5797,18 +6084,24 @@ export { commands, parseArgs, assertAuthenticatedNetworkExposure, + resolveServeHost, hasUiPasswordConfigured, shouldDisplayTunnelQr, isValidTunnelDoctorResponse, readDesktopLocalPortFromSettings, getPidFilePath, + getInstanceFilePath, isProcessRunning, isOpenchamberProcessRunning, isOpenchamberCmdline, + getOpenchamberProcessState, resolveTunnelProviders, fetchTunnelProvidersFromPort, fetchSystemInfoFromPort, discoverRunningInstances, + discoverOpenChamberInstanceOnPort, + discoverLifecycleInstances, + discoverUnconfirmedRegistryInstanceOnPort, ensureTunnelProfilesMigrated, resolveToken, redactProfileForOutput, diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index c4b21afb..d0529b85 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -1,16 +1,173 @@ import { describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; +import { createServer } from 'http'; +import net from 'net'; import { spawn } from 'child_process'; import { pathToFileURL } from 'url'; import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js'; import { assertAuthenticatedNetworkExposure, + commands, + discoverLifecycleInstances, + discoverRunningInstances, + discoverUnconfirmedRegistryInstanceOnPort, + getInstanceFilePath, + getPidFilePath, isOpenchamberCmdline, isOpenchamberProcessRunning, parseArgs, + resolveServeHost, } from './cli.js'; +async function withTempOpenChamberDataDir(fn) { + const previous = process.env.OPENCHAMBER_DATA_DIR; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cli-test-')); + process.env.OPENCHAMBER_DATA_DIR = dir; + try { + return await fn(dir); + } finally { + if (typeof previous === 'string') { + process.env.OPENCHAMBER_DATA_DIR = previous; + } else { + delete process.env.OPENCHAMBER_DATA_DIR; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function createMockJsonResponse(body, ok = true) { + return { + ok, + json: async () => body, + }; +} + +async function captureStdout(fn) { + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = (chunk, encoding, callback) => { + output += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + if (typeof encoding === 'function') encoding(); + if (typeof callback === 'function') callback(); + return true; + }; + try { + await fn(); + return output; + } finally { + process.stdout.write = originalWrite; + } +} + +async function startMockOpenChamberServer(options = {}) { + const runtime = options.runtime || 'web'; + const pid = Number.isFinite(options.pid) ? options.pid : null; + let shutdownRequested = false; + let closed = false; + const server = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/api/system/info') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ runtime, pid })); + return; + } + + if (req.method === 'POST' && req.url === '/api/system/shutdown') { + shutdownRequested = true; + res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); + res.end(JSON.stringify({ ok: true })); + try { + server.close(() => { + closed = true; + }); + } catch { + closed = true; + } + return; + } + + res.writeHead(404); + res.end('not found'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + port, + get shutdownRequested() { + return shutdownRequested; + }, + close: async () => { + if (closed || !server.listening) return; + await new Promise((resolve) => { + try { + server.close(() => { + closed = true; + resolve(); + }); + } catch { + closed = true; + resolve(); + } + }); + }, + }; +} + +async function allocateLoopbackPort() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function waitForTcpPort(port, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + socket.setTimeout(250, () => { + socket.destroy(); + resolve(false); + }); + }); + if (connected) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return false; +} + +function spawnOpenChamberLikeIdleProcess() { + return spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'openchamber-idle'], { stdio: 'ignore' }); +} + +function spawnOpenChamberLikeHungServer(port) { + const script = ` + const net = require('net'); + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + server.listen(${port}, '127.0.0.1'); + setInterval(() => {}, 1000); + `; + return spawn(process.execPath, ['-e', script, 'openchamber-hung-server'], { stdio: 'ignore' }); +} + describe('cli args', () => { it('accepts legacy daemon flags as no-ops', () => { expect(parseArgs(['serve', '--daemon']).removedFlagErrors).toEqual([]); @@ -111,6 +268,36 @@ describe('network-exposed auth validation', () => { }); }); +describe('serve host resolution', () => { + it('uses OPENCHAMBER_HOST when --host is not provided', () => { + const previous = process.env.OPENCHAMBER_HOST; + process.env.OPENCHAMBER_HOST = '192.0.2.20'; + try { + expect(resolveServeHost(undefined)).toBe('192.0.2.20'); + } finally { + if (typeof previous === 'string') { + process.env.OPENCHAMBER_HOST = previous; + } else { + delete process.env.OPENCHAMBER_HOST; + } + } + }); + + it('prefers explicit --host over OPENCHAMBER_HOST', () => { + const previous = process.env.OPENCHAMBER_HOST; + process.env.OPENCHAMBER_HOST = '192.0.2.20'; + try { + expect(resolveServeHost('192.0.2.21')).toBe('192.0.2.21'); + } finally { + if (typeof previous === 'string') { + process.env.OPENCHAMBER_HOST = previous; + } else { + delete process.env.OPENCHAMBER_HOST; + } + } + }); +}); + describe('cli entry detection', () => { const modulePath = '/tmp/openchamber/bin/cli.js'; const moduleUrl = pathToFileURL(modulePath).href; @@ -198,3 +385,351 @@ describe('isOpenchamberProcessRunning', () => { } ); }); + +describe('lifecycle instance discovery', () => { + it('keeps pid and instance files when live port probe confirms a cmdline mismatch', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45123; + const pid = 12345; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon', startedAt: 123 }, null, 2)); + + const instances = await discoverRunningInstances({ + fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid }), + getOpenchamberProcessState: () => 'mismatched', + }); + + expect(instances).toEqual([ + expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), + ]); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(instanceFile)).toBe(true); + }); + }); + + it('removes stale pid and instance files when a cmdline mismatch is not confirmed by live probe', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45124; + const pid = 12346; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverRunningInstances({ + fetchImpl: async () => createMockJsonResponse(null, false), + getOpenchamberProcessState: () => 'mismatched', + }); + + expect(instances).toEqual([]); + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(instanceFile)).toBe(false); + }); + }); + + it('preserves matched pid and instance files when the recorded port probe is inconclusive', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45126; + const pid = 12347; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverRunningInstances({ + fetchImpl: async () => createMockJsonResponse(null, false), + getOpenchamberProcessState: () => 'matched', + }); + + expect(instances).toEqual([]); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(instanceFile)).toBe(true); + }); + }); + + it('preserves unknown-identity pid and instance files when the recorded port probe is inconclusive', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45129; + const pid = 12350; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverRunningInstances({ + fetchImpl: async () => createMockJsonResponse(null, false), + getOpenchamberProcessState: () => 'unknown', + }); + + expect(instances).toEqual([]); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(instanceFile)).toBe(true); + }); + }); + + it('uses the live system-info pid instead of a stale OpenChamber-looking pid-file pid', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45127; + const stalePid = 12348; + const livePid = 54321; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(stalePid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverRunningInstances({ + fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid: livePid }), + getOpenchamberProcessState: () => 'matched', + }); + + expect(instances).toEqual([ + expect.objectContaining({ port, pid: livePid, runtime: 'web', source: 'registry+probe' }), + ]); + }); + }); + + it('uses the explicit host when probing a pid-file entry without a stored host', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45128; + const pid = 12349; + const host = '192.0.2.10'; + const urls = []; + fs.writeFileSync(await getPidFilePath(port), String(pid)); + fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverLifecycleInstances( + { explicitPort: true, port, host }, + { + fetchImpl: async (url) => { + urls.push(String(url)); + return createMockJsonResponse({ runtime: 'web', pid }); + }, + getOpenchamberProcessState: () => 'matched', + }, + ); + + expect(instances).toEqual([ + expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), + ]); + expect(new URL(urls[0]).hostname).toBe(host); + }); + }); + + it('tries loopback before treating an explicit-host pid-file probe as inconclusive', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45130; + const pid = 12351; + const host = '192.0.2.11'; + const urls = []; + fs.writeFileSync(await getPidFilePath(port), String(pid)); + fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverLifecycleInstances( + { explicitPort: true, port, host }, + { + fetchImpl: async (url) => { + urls.push(String(url)); + return new URL(String(url)).hostname === '127.0.0.1' + ? createMockJsonResponse({ runtime: 'web', pid }) + : createMockJsonResponse(null, false); + }, + getOpenchamberProcessState: () => 'matched', + }, + ); + + expect(urls.map((url) => new URL(url).hostname)).toContain(host); + expect(urls.map((url) => new URL(url).hostname)).toContain('127.0.0.1'); + expect(instances).toEqual([ + expect.objectContaining({ port, pid, runtime: 'web', source: 'registry+probe' }), + ]); + }); + }); + + it('does not accept a fallback loopback probe with a different pid for a concrete host registry', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45131; + const pid = 12352; + const otherPid = 54322; + const host = '192.0.2.12'; + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + fs.writeFileSync(pidFile, String(pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, host, launchMode: 'daemon' }, null, 2)); + + const instances = await discoverLifecycleInstances( + { explicitPort: true, port, host }, + { + fetchImpl: async (url) => { + return new URL(String(url)).hostname === '127.0.0.1' + ? createMockJsonResponse({ runtime: 'web', pid: otherPid }) + : createMockJsonResponse(null, false); + }, + getOpenchamberProcessState: () => 'matched', + }, + ); + + expect(instances).toEqual([]); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(instanceFile)).toBe(true); + }); + }); + + it('discovers an explicit live OpenChamber port without a pid-file registry entry', async () => { + await withTempOpenChamberDataDir(async () => { + const port = 45125; + const instances = await discoverLifecycleInstances( + { explicitPort: true, port }, + { fetchImpl: async () => createMockJsonResponse({ runtime: 'web', pid: null }) }, + ); + + expect(instances).toEqual([ + expect.objectContaining({ port, pid: null, runtime: 'web', source: 'probe' }), + ]); + }); + }); + + it('cleans a matched pid-file entry without stopping it when the recorded port is free', async () => { + await withTempOpenChamberDataDir(async () => { + const port = await allocateLoopbackPort(); + const child = spawnOpenChamberLikeIdleProcess(); + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + try { + await new Promise((resolve) => setTimeout(resolve, 150)); + fs.writeFileSync(pidFile, String(child.pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, host: '127.0.0.1', launchMode: 'daemon' }, null, 2)); + + const instance = await discoverUnconfirmedRegistryInstanceOnPort(port, { host: '127.0.0.1' }); + + expect(instance).toBeNull(); + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(instanceFile)).toBe(false); + expect(child.exitCode).toBeNull(); + } finally { + child.kill('SIGKILL'); + } + }); + }); +}); + +describe('lifecycle commands with unmanaged explicit ports', () => { + it('serve refuses to start on a live OpenChamber port without requiring pid files', async () => { + await withTempOpenChamberDataDir(async () => { + const server = await startMockOpenChamberServer(); + try { + await expect(commands.serve({ explicitPort: true, port: server.port, quiet: true })).rejects.toThrow( + /already running on port/ + ); + } finally { + await server.close(); + } + }); + }); + + it('status --port reports a live unmanaged server when the registry is empty', async () => { + await withTempOpenChamberDataDir(async () => { + const server = await startMockOpenChamberServer(); + try { + const output = await captureStdout(() => commands.status({ explicitPort: true, port: server.port, json: true })); + const payload = JSON.parse(output); + expect(payload.state).toBe('running'); + expect(payload.runningCount).toBe(1); + expect(payload.instances).toEqual([ + expect.objectContaining({ runtime: 'unmanaged', port: server.port, pid: null }), + ]); + } finally { + await server.close(); + } + }); + }); + + it('stop --port reaches unmanaged shutdown when the registry is empty', async () => { + await withTempOpenChamberDataDir(async () => { + const server = await startMockOpenChamberServer(); + try { + await commands.stop({ explicitPort: true, port: server.port, quiet: true, suppressQuietOutput: true }); + expect(server.shutdownRequested).toBe(true); + } finally { + await server.close(); + } + }); + }); + + it('stop --port can recover a matched pid-file instance whose HTTP endpoint is unresponsive', async () => { + await withTempOpenChamberDataDir(async () => { + const port = await allocateLoopbackPort(); + const child = spawnOpenChamberLikeHungServer(port); + const pidFile = await getPidFilePath(port); + const instanceFile = await getInstanceFilePath(port); + try { + expect(await waitForTcpPort(port)).toBe(true); + fs.writeFileSync(pidFile, String(child.pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port, host: '127.0.0.1', launchMode: 'daemon' }, null, 2)); + + await commands.stop({ explicitPort: true, port, host: '127.0.0.1', quiet: true, suppressQuietOutput: true }); + + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(instanceFile)).toBe(false); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + } finally { + child.kill('SIGKILL'); + } + }); + }); + + it('plain stop ignores a stale CLI registry entry that resolves to desktop runtime', async () => { + await withTempOpenChamberDataDir(async () => { + const server = await startMockOpenChamberServer({ runtime: 'desktop' }); + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + const pidFile = await getPidFilePath(server.port); + const instanceFile = await getInstanceFilePath(server.port); + try { + await new Promise((resolve) => setTimeout(resolve, 150)); + fs.writeFileSync(pidFile, String(child.pid)); + fs.writeFileSync(instanceFile, JSON.stringify({ port: server.port, launchMode: 'daemon' }, null, 2)); + + await commands.stop({ quiet: true, suppressQuietOutput: true }); + + expect(server.shutdownRequested).toBe(false); + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(instanceFile)).toBe(false); + } finally { + child.kill('SIGKILL'); + await server.close(); + } + }); + }); + + it('restart --port restarts a live unmanaged server through the shared explicit-port discovery path', async () => { + await withTempOpenChamberDataDir(async () => { + const server = await startMockOpenChamberServer(); + const calls = []; + const host = '127.0.0.1'; + try { + const output = await captureStdout(() => commands.restart.call({ + stop: async (options) => { + calls.push(['stop', options.port, options.host]); + }, + serve: async (options) => { + calls.push(['serve', options.port, options.host]); + return options.port; + }, + }, { explicitPort: true, port: server.port, host, json: true })); + + const payload = JSON.parse(output); + expect(calls).toEqual([ + ['stop', server.port, host], + ['serve', server.port, host], + ]); + expect(payload.restartedCount).toBe(1); + expect(payload.results).toEqual([ + expect.objectContaining({ fromPort: server.port, toPort: server.port, ok: true }), + ]); + } finally { + await server.close(); + } + }); + }); +});