fix(cli): verify process identity when validating server pid files
After an ungraceful shutdown removePidFile never runs, so a stale run/openchamber-<port>.pid outlives the process. The kernel can recycle that PID to an unrelated process, and a liveness-only `process.kill(pid, 0)` check then reports OpenChamber as "already running" and aborts startup — an infinite crashloop under systemd Restart=always while the port is actually free (issue #1721). Verify identity, not just liveness, but only where it belongs: - Add isOpenchamberProcessRunning(pid) = liveness + command-line identity, and use it ONLY at the two sites that validate a PID read from a pid file (the "already running" guard and the stale pid-file cleanup sweep). isProcessRunning stays liveness-only for PIDs we know are ours (a freshly spawned daemon child, processes we are stopping), so those paths cannot get a false negative. - Identity works on Linux (/proc/<pid>/cmdline) and macOS (ps -o command=); on Windows or where the command line can't be read it falls back to liveness, so behaviour is unchanged there with no false negatives. - Match the "openchamber" install-path segment (present for both @openchamber/web and a source checkout, foreground and daemon entrypoints alike) so a recycled stranger such as npm-cli.js or agentmemory is not mistaken for us. - Clear the stale pid file once its recorded PID is no longer our process. Adds unit tests for isOpenchamberCmdline and isOpenchamberProcessRunning, covering the recycled-PID cases and a live non-OpenChamber process.
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
||||
import { assertAuthenticatedNetworkExposure, parseArgs } from './cli.js';
|
||||
import {
|
||||
assertAuthenticatedNetworkExposure,
|
||||
isOpenchamberCmdline,
|
||||
isOpenchamberProcessRunning,
|
||||
parseArgs,
|
||||
} from './cli.js';
|
||||
|
||||
describe('cli args', () => {
|
||||
it('accepts legacy daemon flags as no-ops', () => {
|
||||
@@ -155,3 +161,40 @@ describe('cli entry detection', () => {
|
||||
expect(normalizeCliEntryPath(unresolvedPath, realpath)).toBe(path.resolve(unresolvedPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenchamberCmdline', () => {
|
||||
it('accepts OpenChamber CLI and daemon cmdlines', () => {
|
||||
expect(isOpenchamberCmdline('node /x/@openchamber/web/bin/cli.js serve')).toBe(true);
|
||||
expect(isOpenchamberCmdline('node /x/@openchamber/web/server/index.js --port 9090')).toBe(true);
|
||||
expect(isOpenchamberCmdline('bun /home/u/projects/openchamber/packages/web/server/index.js --port 3001')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects recycled and unrelated processes (issue #1721)', () => {
|
||||
expect(isOpenchamberCmdline('node /home/herjarsa/npm-global/bin/agentmemory')).toBe(false);
|
||||
expect(isOpenchamberCmdline('node /usr/lib/node_modules/npm/bin/npm-cli.js install')).toBe(false);
|
||||
expect(isOpenchamberCmdline('')).toBe(false);
|
||||
expect(isOpenchamberCmdline(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenchamberProcessRunning', () => {
|
||||
it('returns false for a dead PID', () => {
|
||||
expect(isOpenchamberProcessRunning(2147483646)).toBe(false);
|
||||
});
|
||||
|
||||
// Identity verification is available on Linux (/proc) and macOS (ps); on those
|
||||
// platforms a live but unrelated process (a recycled stale PID) must read as
|
||||
// not-running so it can't trip the "already running" guard (issue #1721).
|
||||
it.skipIf(process.platform !== 'linux' && process.platform !== 'darwin')(
|
||||
'returns false for a live non-OpenChamber PID',
|
||||
async () => {
|
||||
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(isOpenchamberProcessRunning(child.pid)).toBe(false);
|
||||
} finally {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user