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:
+448
-155
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user