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,