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
This commit is contained in:
Bohdan Triapitsyn
2026-06-25 12:00:09 +03:00
parent 9a2012c94e
commit b173fd3f09
2 changed files with 983 additions and 155 deletions
+448 -155
View File
@@ -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,
+535
View File
@@ -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();
}
});
});
});