mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 06:46:49 +08:00
codegraph index told you to run `codegraph daemon stop`, which the CLI
rejects ("too many arguments"), whenever it could not verify the process
named in .codegraph/daemon.pid. In that state `codegraph daemon` lists
nothing, because it lists only daemons that answer their socket. And
`codegraph unlock` kept an old-format lock whose process was alive (the
rule from issue #1834), so nothing the CLI offered got past it. The
writer-lock message gave the same wrong advice.
Root cause: a live pid in the lock was the only evidence, and a pid
proves nothing once the OS gives it to another program. Every CodeGraph
daemon since the first one (v0.9.5) runs
`<runtime> ... codegraph.js serve --mcp --path <root>`, so the holder's
command line settles the question.
Fix:
- New src/mcp/process-identity.ts reads a process's command line and
executable name from the OS: Win32_Process through PowerShell (tasklist
as fallback) on Windows, /proc on Linux, ps elsewhere. Each lookup is
capped at 5 s and never throws. A holder is a CodeGraph MCP server,
provably something else, or unknown, and unknown is never evidence.
- stopDaemonAt treats an unverified live holder that is provably not a
CodeGraph server like a dead one. Its lock is cleared, under the same
writer-slot claim, and the process is never signalled.
clearStaleDaemonArtifacts (codegraph unlock) does the same for an
old-format lock. A holder that may be a daemon keeps its lock, as
before (issues #1553 and #1834).
- daemonStopFailureMessage builds index's error from what remains: the
pid, what it runs, and a command that clears it (taskkill /PID N /F or
kill N), or which lock file to delete when only that will do.
- writerLockHeldMessage names `codegraph daemon` instead of the missing
`codegraph daemon stop`.
Validation (Windows 11):
- New daemon-lock-holder.test.ts (11 tests) and a daemon-registry test.
With main's daemon-registry.ts, writer-lock.ts and codegraph.ts put
back, 11 tests fail, including all 8 CLI cases and the writer-lock
wording; with the fix all pass. Four existing tests used generic
processes to stand in for a daemon that doesn't answer. Those stand-ins
now carry a CodeGraph server's command line, and pass on main and here.
- Real CLI with a private HOME, 4 cases: an old-format lock naming a live
PING.EXE; an old-format lock naming a live Node process that isn't
CodeGraph; a current-format lock naming that kind of Node process with
a pipe nobody listens on; an old-format lock naming a Node process whose
command line reads `codegraph.js serve --mcp`. On main all 4 index runs
exit 1 with the old advice, `daemon stop` is rejected, `daemon` lists
nothing, and unlock clears only the third. With the fix the first 3
rebuild (exit 0), each holder stays alive, and unlock clears the first
too. The 4th exits 1, naming the pid and `taskkill /PID <pid> /F`;
after that command, index exits 0.
Contributor PR #2373 (@danusha2345) changes the two strings only; I read
it for guidance.
Fixes #2770
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
/**
|
|
* Issue #2770: `codegraph index` could not stop a project's daemon whose lock
|
|
* named a live process it couldn't verify, and told the user to run
|
|
* `codegraph daemon stop`, a command the CLI rejects. `codegraph daemon` lists
|
|
* nothing in that state and `codegraph unlock` kept the lock, so nothing the
|
|
* CLI offered got past it.
|
|
*
|
|
* A lock whose live holder the OS shows is not a CodeGraph server (a pid the
|
|
* OS gave to another program, or a lock written by hand) is now treated as
|
|
* stale, the way a dead holder's is: `index` clears it and rebuilds, and
|
|
* `unlock` clears it, without signalling the process. A holder that does run a
|
|
* CodeGraph server keeps its lock, and the message names that process and a
|
|
* command that stops it.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';
|
|
import { isProcessAlive } from '../src/mcp/daemon-registry';
|
|
import {
|
|
classifyLockHolder,
|
|
describeProcess,
|
|
isCodeGraphServerCommandLine,
|
|
} from '../src/mcp/process-identity';
|
|
import { tryAcquireWriterLock, releaseWriterLock } from '../src/mcp/writer-lock';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
const windows = process.platform === 'win32';
|
|
const holders: number[] = [];
|
|
let root: string;
|
|
|
|
/**
|
|
* Start a long-lived process to be named by the lock: `native` is no
|
|
* JavaScript runtime, `node` is Node running something else, and
|
|
* `codegraph-server` is Node with a CodeGraph MCP server's command line.
|
|
*/
|
|
function startHolder(kind: 'native' | 'node' | 'codegraph-server'): number {
|
|
const idle = ['-e', 'setInterval(() => {}, 1000)'];
|
|
const [command, args] = kind === 'native'
|
|
? windows
|
|
? [path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'PING.EXE'), ['-n', '600', '127.0.0.1']]
|
|
: ['sleep', ['600']]
|
|
: kind === 'node'
|
|
? [process.execPath, idle]
|
|
: [process.execPath, [...idle, path.join(root, 'install', 'dist', 'bin', 'codegraph.js'), 'serve', '--mcp', '--path', root]];
|
|
const child = spawn(command, args as string[], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
child.unref();
|
|
if (!child.pid) throw new Error(`could not start a ${kind} holder`);
|
|
holders.push(child.pid);
|
|
return child.pid;
|
|
}
|
|
|
|
function runCli(args: string[]) {
|
|
return spawnSync(process.execPath, [BIN, ...args], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
timeout: 60_000,
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_PROMPT_HOOK: '1' },
|
|
});
|
|
}
|
|
|
|
function writeLegacyLock(pid: number): string {
|
|
const lockPath = getDaemonPidPath(root);
|
|
fs.writeFileSync(lockPath, `${pid}\n`);
|
|
return lockPath;
|
|
}
|
|
|
|
/** A current-format lock whose socket no daemon listens on. */
|
|
function writeLock(pid: number): string {
|
|
const lockPath = getDaemonPidPath(root);
|
|
fs.writeFileSync(lockPath, encodeLockInfo({
|
|
pid,
|
|
version: '1.6.2',
|
|
socketPath: windows ? '\\\\.\\pipe\\codegraph-2770-nobody-listens' : path.join(root, '.codegraph', 'nobody-listens.sock'),
|
|
startedAt: Date.now() - 60_000,
|
|
}));
|
|
return lockPath;
|
|
}
|
|
|
|
function stopCommand(pid: number): string {
|
|
return windows ? `taskkill /PID ${pid} /F` : `kill ${pid}`;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lock-holder-')));
|
|
fs.writeFileSync(path.join(root, 'app.ts'), 'export function originalSymbol() { return 1; }\n');
|
|
const cg = await CodeGraph.init(root);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const pid of holders.splice(0)) {
|
|
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
}
|
|
releaseWriterLock(root);
|
|
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
});
|
|
|
|
describe('what a lock holder is (#2770)', () => {
|
|
it('recognizes a CodeGraph MCP server by its command line', () => {
|
|
expect(isCodeGraphServerCommandLine(
|
|
'"C:\\Program Files\\nodejs\\node.exe" --liftoff-only --disable-warning=ExperimentalWarning ' +
|
|
'"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@colbymchenry\\codegraph\\lib\\dist\\bin\\codegraph.js" serve --mcp --path D:\\proj',
|
|
)).toBe(true);
|
|
expect(isCodeGraphServerCommandLine('/opt/codegraph/node /opt/codegraph/lib/dist/bin/codegraph.js serve --mcp --path /src/app')).toBe(true);
|
|
expect(isCodeGraphServerCommandLine('node /usr/lib/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js index')).toBe(false);
|
|
expect(isCodeGraphServerCommandLine('node server.js serve --mcp')).toBe(false);
|
|
expect(isCodeGraphServerCommandLine('C:\\Windows\\Explorer.EXE')).toBe(false);
|
|
});
|
|
|
|
it('counts only what the OS shows as evidence', () => {
|
|
expect(classifyLockHolder({ name: 'explorer.exe', commandLine: 'C:\\Windows\\Explorer.EXE' })).toBe('other');
|
|
expect(classifyLockHolder({ name: 'node.exe', commandLine: 'node.exe -e "setInterval(() => {}, 1000)"' })).toBe('other');
|
|
expect(classifyLockHolder({ name: 'node', commandLine: 'node codegraph.js serve --mcp --path /p' })).toBe('codegraph-server');
|
|
// No command line (another user's or an elevated process): only an
|
|
// executable that is no JavaScript runtime rules the holder out.
|
|
expect(classifyLockHolder({ name: 'svchost.exe', commandLine: null })).toBe('other');
|
|
expect(classifyLockHolder({ name: 'node.exe', commandLine: null })).toBe('unknown');
|
|
expect(classifyLockHolder({ name: '/usr/local/bin/node', commandLine: null })).toBe('unknown');
|
|
expect(classifyLockHolder({ name: null, commandLine: null })).toBe('unknown');
|
|
});
|
|
|
|
it('reads a running process from the OS', async () => {
|
|
const native = startHolder('native');
|
|
const server = startHolder('codegraph-server');
|
|
const nativeInfo = await describeProcess(native);
|
|
expect(nativeInfo.commandLine ?? '').toMatch(windows ? /ping/i : /sleep/);
|
|
expect(classifyLockHolder(nativeInfo)).toBe('other');
|
|
expect(classifyLockHolder(await describeProcess(server))).toBe('codegraph-server');
|
|
process.kill(native, 'SIGKILL');
|
|
const end = Date.now() + 10_000;
|
|
while (isProcessAlive(native) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
|
|
expect(classifyLockHolder(await describeProcess(native))).toBe('unknown');
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('codegraph index and unlock with a daemon lock naming a live process (#2770)', () => {
|
|
it.each([
|
|
['an old-format lock naming a program that is not a JavaScript runtime', 'native', writeLegacyLock],
|
|
['an old-format lock naming a Node process that is not CodeGraph', 'node', writeLegacyLock],
|
|
['a lock naming a Node process that is not CodeGraph', 'node', writeLock],
|
|
] as const)('index clears %s and rebuilds, leaving the process alone', (_, kind, write) => {
|
|
const holder = startHolder(kind);
|
|
const lockPath = write(holder);
|
|
fs.writeFileSync(path.join(root, 'app.ts'), 'export function replacementSymbol() { return 2; }\n');
|
|
|
|
const index = runCli(['index', '--quiet', root]);
|
|
|
|
expect(index.stderr + index.stdout).not.toContain('daemon stop');
|
|
expect(index.status, index.stderr).toBe(0);
|
|
expect(fs.existsSync(lockPath)).toBe(false);
|
|
expect(isProcessAlive(holder)).toBe(true);
|
|
const cg = CodeGraph.openSync(root);
|
|
try { expect(cg.searchNodes('replacementSymbol').length).toBeGreaterThan(0); }
|
|
finally { cg.close(); }
|
|
}, 60_000);
|
|
|
|
it('unlock clears an old-format lock naming a process that is not CodeGraph, leaving the process alone', () => {
|
|
const holder = startHolder('native');
|
|
const lockPath = writeLegacyLock(holder);
|
|
|
|
const unlock = runCli(['unlock', root]);
|
|
|
|
expect(unlock.status, unlock.stderr).toBe(0);
|
|
expect(unlock.stdout).toContain('Removed stale lock artifacts');
|
|
expect(fs.existsSync(lockPath)).toBe(false);
|
|
expect(isProcessAlive(holder)).toBe(true);
|
|
}, 60_000);
|
|
|
|
it.each([
|
|
['an old-format lock', writeLegacyLock],
|
|
['a lock', writeLock],
|
|
] as const)('keeps %s held by a CodeGraph server that does not answer, and names a command that clears it', async (_, write) => {
|
|
const holder = startHolder('codegraph-server');
|
|
const lockPath = write(holder);
|
|
|
|
const index = runCli(['index', '--quiet', root]);
|
|
|
|
expect(index.status).toBe(1);
|
|
const message = index.stderr + index.stdout;
|
|
expect(message).not.toContain('daemon stop');
|
|
expect(message).toContain(`Process ${holder} runs a CodeGraph server`);
|
|
expect(message).toContain(`\`${stopCommand(holder)}\``);
|
|
expect(isProcessAlive(holder)).toBe(true);
|
|
expect(fs.existsSync(lockPath)).toBe(true);
|
|
|
|
// The advice: stop that process, then retry `codegraph index`.
|
|
process.kill(holder, 'SIGKILL');
|
|
const end = Date.now() + 10_000;
|
|
while (isProcessAlive(holder) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
|
|
const retry = runCli(['index', '--quiet', root]);
|
|
expect(retry.status, retry.stderr).toBe(0);
|
|
}, 90_000);
|
|
|
|
it('names the stale writer lock when the same process that is not CodeGraph holds it too', () => {
|
|
const holder = startHolder('native');
|
|
writeLock(holder);
|
|
// Both of a crashed daemon's lock files, once the OS has reused its pid.
|
|
const writerPath = path.join(root, '.codegraph', 'writer.pid');
|
|
fs.writeFileSync(writerPath, JSON.stringify({ pid: holder, mode: 'daemon', startedAt: Date.now() - 60_000, ready: true }) + '\n');
|
|
|
|
const index = runCli(['index', '--quiet', root]);
|
|
|
|
expect(index.status).toBe(1);
|
|
const message = index.stderr + index.stdout;
|
|
expect(message).toContain(`names process ${holder}, which is not a CodeGraph process, so it is stale: delete ${writerPath}`);
|
|
fs.rmSync(writerPath);
|
|
const retry = runCli(['index', '--quiet', root]);
|
|
expect(retry.status, retry.stderr).toBe(0);
|
|
expect(isProcessAlive(holder)).toBe(true);
|
|
}, 90_000);
|
|
|
|
it('the writer-lock message names `codegraph daemon`, a command the CLI accepts', () => {
|
|
expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');
|
|
try {
|
|
const index = runCli(['index', '--quiet', root]);
|
|
expect(index.status).toBe(1);
|
|
const message = index.stderr + index.stdout;
|
|
expect(message).toContain('writer lock held');
|
|
expect(message).not.toContain('daemon stop');
|
|
expect(message).toContain('pick it in `codegraph daemon`');
|
|
} finally {
|
|
releaseWriterLock(root);
|
|
}
|
|
const daemon = runCli(['daemon']);
|
|
expect(daemon.stderr).not.toContain('too many arguments');
|
|
expect(daemon.status, daemon.stderr).toBe(0);
|
|
}, 60_000);
|
|
});
|