Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- VB.NET designer code such as `New System.Drawing.Point(…)` or `Me.Size = New System.Drawing.Size(…)` is no longer linked to a project type or member that only shares the name, and a class nested inside another is matched by its bare name only from inside that class or one that inherits it.
- Upgrading CodeGraph while an agent session is open no longer leaves the old version's background server in charge of your project: the first session started from the new install stops it and starts a current one in its place, even while sessions opened before the upgrade are still running. That old server could no longer load the language parsers the upgrade removed, so it saved every file it re-indexed with no symbols, while new sessions could only read the index beside it without keeping it up to date. A background server from a newer install is never stopped, and sessions opened before the upgrade keep the old version until you restart them. Thanks @lipchey for the report. (#2335)
- A file is no longer saved with no symbols when its language parser can't be loaded, which is what happened to every file a background server re-indexed after an upgrade removed its install: the file keeps what it had and is indexed again once the parser loads, and files an earlier version emptied this way are re-indexed by the next sync. A background server also exits on its own once its install is upgraded or removed, so the next session starts one from the current install. Thanks @lipchey for the report. (#2335)
- `codegraph index` no longer stops with advice to run `codegraph daemon stop`, a command that doesn't exist, when the project's background-server lock names a process that isn't CodeGraph, such as a lock left by a crash whose process number the system has since given to another program: it now clears that stale lock, leaving the process alone, and rebuilds, and `codegraph unlock` clears it too. When a CodeGraph process that doesn't answer holds the lock, such as an older background server, the message names that process and the command that stops it, and the message about another server already serving a project now points to `codegraph daemon`. Thanks @inth3shadows for the report and @danusha2345 for the first fix. (#2770)
- The shared background server is no longer killed with "Main thread unresponsive" while it brings another project's index up to date, such as one a session names with `projectPath`: only its own project's index counted as progress, so a long update of any other looked like a hang, and the server was stopped and restarted again and again. Every index the server keeps up to date now counts, and so does the one a session updates on its own when the background server is unavailable. Thanks @mradwankhalil for the report and the first fix. (#2404)
- An index left incomplete by an indexing run that was stopped partway, by a crash, an out-of-memory kill or the server's own hang check, no longer gets the background server killed with "Main thread unresponsive" every time it starts: on a large project, restoring the database indexes the run had set aside took longer than the hang check allows, so each restart died at the same point and the index was never repaired. They are now restored in the background, and the project is served once they are back. Thanks @cbeaulieu-gt for the report and the first fix. (#1887)
- A new agent session no longer waits up to about 12 minutes for its first answer when the background server is stuck: a server whose process still holds its connection open but never responds made the session wait out a long series of retries. The session now gives up within seconds and answers on its own, and switches back to the background server once that has been restarted.
Expand Down
22 changes: 15 additions & 7 deletions __tests__/cli-unlock.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { execFile, execFileSync } from 'child_process';
import { execFile, execFileSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
Expand Down Expand Up @@ -101,14 +101,22 @@ describe('codegraph unlock — daemon artifact recovery (#1553)', () => {
});

it('preserves a live legacy lock whose daemon identity cannot be probed', () => {
const pidPath = getDaemonPidPath(tempDir);
fs.writeFileSync(pidPath, `${process.pid}\n`);
// The holder's command line reads like a CodeGraph MCP server's, so it may
// be the daemon. One that is plainly another program leaves a stale lock,
// which unlock clears (#2770).
const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'codegraph.js', 'serve', '--mcp'], { stdio: 'ignore' });
try {
const pidPath = getDaemonPidPath(tempDir);
fs.writeFileSync(pidPath, `${holder.pid}\n`);

const output = runCodegraph(['unlock', tempDir], tempDir);
const output = runCodegraph(['unlock', tempDir], tempDir);

expect(output).toContain('No stale lock files found');
expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
expect(() => process.kill(process.pid, 0)).not.toThrow();
expect(output).toContain('No stale lock files found');
expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${holder.pid}\n`);
expect(() => process.kill(holder.pid!, 0)).not.toThrow();
} finally {
holder.kill('SIGKILL');
}
});

it('removes a legacy lock whose PID is dead', () => {
Expand Down
233 changes: 233 additions & 0 deletions __tests__/daemon-lock-holder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* 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);
});
33 changes: 32 additions & 1 deletion __tests__/daemon-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ function waitForExit(child: ReturnType<typeof spawn>): Promise<void> {
: new Promise((resolve) => child.once('exit', () => resolve()));
}

/**
* A live process standing in for a daemon. Its command line reads like a
* CodeGraph MCP server's: one that is plainly another program would make its
* lock stale (#2770).
*/
function startDetachedProcess(): number {
const source = [
"const { spawn } = require('child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'codegraph.js', 'serve', '--mcp'], { detached: true, stdio: 'ignore' });",
'child.unref();',
'console.log(child.pid);',
].join(' ');
Expand Down Expand Up @@ -101,6 +106,32 @@ describe('daemon-registry', () => {
}
});

it('clears, without signalling it, the lock of a live process that is not a CodeGraph server (#2770)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-foreign-holder-'));
fs.mkdirSync(path.join(root, '.codegraph'));
const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
const pid = holder.pid!;
try {
fs.writeFileSync(
getDaemonPidPath(root),
encodeLockInfo({ pid, version: 'test', socketPath: path.join(root, '.codegraph', 'missing.sock'), startedAt: Date.now() }),
);
expect((await stopDaemonAt(root, { preserveUnverified: true })).outcome).toBe('not-running');
expect(fs.existsSync(getDaemonPidPath(root))).toBe(false);
expect(isProcessAlive(pid)).toBe(true);

// An old-format lock has no socket to ask; the command line still tells.
fs.writeFileSync(getDaemonPidPath(root), `${pid}\n`);
expect(await clearStaleDaemonArtifacts(root)).toBe(true);
expect(fs.existsSync(getDaemonPidPath(root))).toBe(false);
expect(isProcessAlive(pid)).toBe(true);
} finally {
holder.kill('SIGKILL');
await waitForExit(holder);
fs.rmSync(root, { recursive: true, force: true });
}
}, 30_000);

it.each([false, true])('confirms daemon termination (refused: %s)', async (refused) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-daemon-identity-'));
fs.mkdirSync(path.join(root, '.codegraph'));
Expand Down
10 changes: 8 additions & 2 deletions __tests__/index-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ function isAlive(pid: number): boolean {
}
}

/**
* A live process standing in for a daemon that does not answer its socket.
* Its command line reads like a CodeGraph MCP server's: one that is plainly
* some other program would make the lock stale, and `index` would clear it
* (#2770).
*/
function startDetachedProcess(): number {
const source = [
"const { spawn } = require('child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'codegraph.js', 'serve', '--mcp'], { detached: true, stdio: 'ignore' });",
'child.unref();',
'console.log(child.pid);',
].join(' ');
Expand Down Expand Up @@ -150,7 +156,7 @@ describe('codegraph index — full re-index keeps the graph populated (#874)', (
);

try {
expect(() => runCodegraph(['index', '--quiet'], tempDir)).toThrow(/could not verify.*daemon/i);
expect(() => runCodegraph(['index', '--quiet'], tempDir)).toThrow(/did not answer as its daemon, so it was left running/);
expect(isAlive(daemonPid)).toBe(true);
expect(fs.existsSync(lockPath)).toBe(true);
} finally {
Expand Down
Loading