mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 06:22:38 +08:00
dea076fd1e
* fix(mcp,extraction): a daemon whose install changed exits; grammar-load failures are never stored (#2335, #2336) A daemon that outlived an upgrade loaded grammars lazily from an install that no longer existed, and stored every file its watcher re-indexed as a zero-node row under the file's new content hash: no hash-based sync ever revisited those rows, and `codegraph status` called the index up to date. - The daemon checks its own package.json every 30 s (CODEGRAPH_DAEMON_INSTALL_CHECK_MS, 0 turns it off) and exits once the file is gone or carries another version, so the next session starts a daemon from the current install. - A result whose grammar failed to load (`parser_error`) is never stored: the file keeps its previous data and the next sync or index retries it, and a row an older engine stored that way is re-indexed even though its hash matches. The tag-based CFML path reports a missing grammar with the same code. - A launcher whose own install was deleted no longer crashes on the spawn error of the daemon it tried to start. - `codegraph status` and `status --json` (filesNeedingReindex, filesWithParseErrors) report indexed files whose symbols are missing although their content is current (#2336), and `files --json` carries each file's recorded errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): a session whose own install changed never claims the writer lock (#2335) A daemon now exits once its install is upgraded or removed, and nobody holds the writer slot for a successor at that point. Its sessions run the same replaced code and serve themselves in-process the moment it goes; one that found the slot free claimed it as its writer even though its engine could not load (a real npm upgrade: "Cannot find module './sqlite-adapter'"), and so kept a daemon from the current install from starting. Such a session now falls back read-only and says to restart it. Also adds the CHANGELOG entries for the install check and grammar-load failures (#2335) and for the status report (#2336). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
762 lines
35 KiB
TypeScript
762 lines
35 KiB
TypeScript
/**
|
|
* A launcher replaces a daemon from an older install (#2335).
|
|
*
|
|
* A daemon keeps running the code it started with. The 1.6.1 daemon in #2335
|
|
* outlived an upgrade to 1.6.2, went on loading grammars from the install the
|
|
* upgrade removed, and wrote every file its watcher re-indexed as empty. A
|
|
* launcher that found it could only serve its own session in-process, read-only:
|
|
* the old daemon held the project's writer lock and file watcher, so no daemon
|
|
* from the new install could start. Daemons from before the fix cannot notice
|
|
* the upgrade themselves, so the launcher acts: a daemon of an OLDER release is
|
|
* stopped the way `codegraph daemon` stops one (identity-checked SIGTERM, a
|
|
* graceful shutdown on POSIX) and one from the launcher's own install takes its
|
|
* place. The launcher hears an older daemon's hello on its own socket, or —
|
|
* when it listens elsewhere (on Windows, daemons before #2278 named their pipe
|
|
* after the root as typed) — finds it by the project lock. A daemon of the
|
|
* same, a newer or an unknown version is never touched, so two installed
|
|
* versions cannot fight over the daemon.
|
|
*
|
|
* The end-to-end cases run real daemons of other versions: this build's dist/
|
|
* linked into a temp "install" whose package.json carries another version. A
|
|
* daemon resolves its version, and every grammar it loads, from its own files,
|
|
* so that install's daemon advertises that version in its lock and its hello.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ChildProcessWithoutNullStreams, execFileSync, spawn } from 'child_process';
|
|
import { once } from 'events';
|
|
import * as fs from 'fs';
|
|
import * as net from 'net';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { encodeLockInfo, getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
|
|
import { isProcessAlive, stopOlderDaemon } from '../src/mcp/daemon-registry';
|
|
import { connectWithHello } from '../src/mcp/proxy';
|
|
import { CodeGraphPackageVersion, isOlderRelease } from '../src/mcp/version';
|
|
import { getWriterPidPath, readWriterLock, swapWriterLock } from '../src/mcp/writer-lock';
|
|
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
|
|
import { recordSpawns, removeSpawnLog, settleLosingCandidates } from './daemon-candidates';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
const DIST = path.resolve(__dirname, '../dist');
|
|
const NODE_MODULES = path.resolve(__dirname, '../node_modules');
|
|
const OLDER_DAEMON = path.resolve(__dirname, 'fixtures/older-daemon.cjs');
|
|
|
|
/** Plain releases always below / above any version this repo will carry. */
|
|
const OLDER = '0.0.1';
|
|
const NEWER = '999.0.0';
|
|
|
|
describe('isOlderRelease', () => {
|
|
it('orders plain releases numerically', () => {
|
|
expect(isOlderRelease('1.6.1', '1.6.2')).toBe(true);
|
|
expect(isOlderRelease('1.6.9', '1.6.10')).toBe(true);
|
|
expect(isOlderRelease('1.9.0', '1.10.0')).toBe(true);
|
|
expect(isOlderRelease('0.99.99', '1.0.0')).toBe(true);
|
|
expect(isOlderRelease('1.6.2', '1.6.2')).toBe(false);
|
|
expect(isOlderRelease('1.6.3', '1.6.2')).toBe(false);
|
|
expect(isOlderRelease('2.0.0', '1.99.99')).toBe(false);
|
|
});
|
|
|
|
it('never calls a prerelease, a build or an unknown version older, in either position', () => {
|
|
for (const odd of ['0.0.0-unknown', '0.0.0-mismatch', '1.6.1-beta.1', '1.6.1+local', 'v1.6.1', 'unknown', 'test', '', '1.6']) {
|
|
expect(isOlderRelease(odd, '1.6.2'), odd).toBe(false);
|
|
expect(isOlderRelease('1.6.1', odd), odd).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('lets at most one of two versions replace the other', () => {
|
|
const versions = ['0.9.0', '1.6.1', '1.6.2', '1.6.10', '1.7.0', '2.0.0', '1.6.2-rc.1', '0.0.0-unknown'];
|
|
for (const a of versions) {
|
|
for (const b of versions) {
|
|
expect(isOlderRelease(a, b) && isOlderRelease(b, a), `${a} / ${b}`).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
/** A path a test server can listen on: a named pipe on Windows, a socket file elsewhere. */
|
|
function listenPath(dir: string, name: string): string {
|
|
return process.platform === 'win32'
|
|
? `\\\\.\\pipe\\${path.basename(dir)}-${name}`
|
|
: path.join(dir, `${name}.sock`);
|
|
}
|
|
|
|
async function listen(server: net.Server, socketPath: string): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(socketPath, resolve);
|
|
});
|
|
}
|
|
|
|
describe('connectWithHello', () => {
|
|
let dir: string;
|
|
let server: net.Server | null = null;
|
|
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hello-')); });
|
|
afterEach(async () => {
|
|
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
server = null;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
[OLDER, 'older-version', false],
|
|
[NEWER, 'version-mismatch', true],
|
|
['0.0.0-unknown', 'version-mismatch', true],
|
|
])('reports a %s daemon as %s', async (version, expected, logged) => {
|
|
const socketPath = listenPath(dir, 'daemon');
|
|
server = net.createServer((socket) => {
|
|
socket.write(JSON.stringify({ codegraph: version, pid: process.pid, socketPath, protocol: 1 }) + '\n');
|
|
});
|
|
await listen(server, socketPath);
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
try {
|
|
expect(await connectWithHello(socketPath, '1.6.2')).toBe(expected);
|
|
// An older daemon is the launcher's to report; it is not a mismatch to serve around.
|
|
expect(stderr.mock.calls.some(([line]) => String(line).includes('differs from ours'))).toBe(logged);
|
|
} finally {
|
|
stderr.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('swapWriterLock', () => {
|
|
let root: string;
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-writer-swap-'));
|
|
fs.mkdirSync(path.join(root, '.codegraph'));
|
|
});
|
|
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
|
|
|
const record = (pid: number, mode: string) => ({ pid, mode, startedAt: 1, ready: false });
|
|
|
|
it('replaces the record of the given pid, and only that record', () => {
|
|
const file = getWriterPidPath(root);
|
|
fs.writeFileSync(file, JSON.stringify(record(111, 'daemon')) + '\n');
|
|
expect(swapWriterLock(root, 222, record(333, 'handover'))).toBe(false);
|
|
expect(readWriterLock(root)).toMatchObject({ pid: 111, mode: 'daemon' });
|
|
expect(swapWriterLock(root, 111, record(333, 'handover'))).toBe(true);
|
|
expect(readWriterLock(root)).toMatchObject({ pid: 333, mode: 'handover' });
|
|
// A slot nobody holds is not handed over: it is acquired the usual way.
|
|
fs.rmSync(file);
|
|
expect(swapWriterLock(root, 333, record(444, 'daemon'))).toBe(false);
|
|
expect(fs.existsSync(file)).toBe(false);
|
|
expect(fs.readdirSync(path.join(root, '.codegraph'))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/** A live process to stand in for a daemon; signals reach it, never the test. */
|
|
function startDetachedProcess(): number {
|
|
const source = [
|
|
"const { spawn } = require('child_process');",
|
|
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
|
|
'child.unref();',
|
|
'console.log(child.pid);',
|
|
].join(' ');
|
|
const pid = Number(execFileSync(process.execPath, ['-e', source], { encoding: 'utf8' }).trim());
|
|
if (!Number.isInteger(pid) || pid <= 0) throw new Error('could not start daemon fixture');
|
|
return pid;
|
|
}
|
|
|
|
async function deadPid(): Promise<number> {
|
|
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
|
await once(child, 'exit');
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
return child.pid!;
|
|
}
|
|
|
|
describe('stopOlderDaemon', () => {
|
|
let root: string;
|
|
let pidPath: string;
|
|
let socketPath: string;
|
|
let server: net.Server | null;
|
|
let pids: number[];
|
|
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-older-daemon-'));
|
|
fs.mkdirSync(path.join(root, '.codegraph'));
|
|
pidPath = getDaemonPidPath(root);
|
|
socketPath = getDaemonSocketPath(root);
|
|
server = null;
|
|
pids = [];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
for (const pid of pids) if (isProcessAlive(pid)) process.kill(pid, 'SIGKILL');
|
|
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
/** A daemon of `version`: its lock, and a socket answering the hello as `helloPid`. */
|
|
async function fakeDaemon(version: string, helloPid?: number): Promise<{ pid: number; lock: string }> {
|
|
const pid = startDetachedProcess();
|
|
pids.push(pid);
|
|
server = net.createServer((socket) => {
|
|
socket.end(JSON.stringify({ protocol: 1, codegraph: version, pid: helloPid ?? pid, socketPath }) + '\n');
|
|
});
|
|
await listen(server, socketPath);
|
|
const lock = encodeLockInfo({ pid, version, socketPath, startedAt: Date.now() });
|
|
fs.writeFileSync(pidPath, lock);
|
|
return { pid, lock };
|
|
}
|
|
|
|
/** Signals sent to `pid`, other than the signal-0 liveness probe. */
|
|
function signalsTo(kill: ReturnType<typeof vi.spyOn>, pid: number): unknown[] {
|
|
return kill.mock.calls.filter(([target, signal]) => target === pid && signal !== 0).map(([, signal]) => signal);
|
|
}
|
|
|
|
/** The writer record, decoded. */
|
|
const writerRecord = (): any => {
|
|
try { return JSON.parse(fs.readFileSync(getWriterPidPath(root), 'utf8')); } catch { return null; }
|
|
};
|
|
|
|
it('stops a verified daemon of an older release and clears its lock', async () => {
|
|
const { pid } = await fakeDaemon('1.6.1');
|
|
const result = await stopOlderDaemon(root, '1.6.2');
|
|
expect(result).toMatchObject({ pid, outcome: 'term', version: '1.6.1' });
|
|
expect(isProcessAlive(pid)).toBe(false);
|
|
expect(fs.existsSync(pidPath)).toBe(false);
|
|
// Kept for the daemon the caller starts next.
|
|
expect(writerRecord()).toMatchObject({ pid: process.pid, mode: 'handover' });
|
|
}, 15000);
|
|
|
|
it('holds the writer slot from before the signal, so it is never free or stale', async () => {
|
|
const { pid } = await fakeDaemon('1.6.1');
|
|
fs.writeFileSync(getWriterPidPath(root), JSON.stringify({ pid, mode: 'daemon', startedAt: 1, ready: true }) + '\n');
|
|
let atSignal: unknown = null;
|
|
const originalKill = process.kill.bind(process);
|
|
vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
|
if (target === pid && signal === 'SIGTERM') atSignal = writerRecord();
|
|
return originalKill(target, signal);
|
|
});
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toMatchObject({ pid, outcome: 'term' });
|
|
// A session of the old daemon serving itself the moment it went found the
|
|
// slot held, and so served reads only.
|
|
expect(atSignal).toMatchObject({ pid: process.pid, mode: 'handover' });
|
|
expect(writerRecord()).toMatchObject({ pid: process.pid, mode: 'handover' });
|
|
expect(fs.existsSync(pidPath)).toBe(false);
|
|
}, 15000);
|
|
|
|
it('gives the writer slot back to an older daemon that does not exit', async () => {
|
|
const { pid, lock } = await fakeDaemon('1.6.1');
|
|
const record = { pid, mode: 'daemon', startedAt: 1, ready: true };
|
|
fs.writeFileSync(getWriterPidPath(root), JSON.stringify(record) + '\n');
|
|
// The signal reaches nothing: the daemon closes its socket, as its own
|
|
// shutdown does first, and then never exits.
|
|
const originalKill = process.kill.bind(process);
|
|
vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
|
if (target === pid && signal === 'SIGTERM') { server?.close(); return true; }
|
|
return originalKill(target, signal);
|
|
});
|
|
expect(await stopOlderDaemon(root, '1.6.2', { shutdownGraceMs: 300 })).toMatchObject({ pid, outcome: 'still-running' });
|
|
expect(writerRecord()).toEqual(record);
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
|
|
expect(isProcessAlive(pid)).toBe(true);
|
|
}, 15000);
|
|
|
|
it('leaves the daemon to another launcher already holding the slot to replace it', async () => {
|
|
const { pid, lock } = await fakeDaemon('1.6.1');
|
|
const other = startDetachedProcess();
|
|
pids.push(other);
|
|
const claim = { pid: other, mode: 'handover', startedAt: 1, ready: false };
|
|
fs.writeFileSync(getWriterPidPath(root), JSON.stringify(claim) + '\n');
|
|
const kill = vi.spyOn(process, 'kill');
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toBeNull();
|
|
expect(signalsTo(kill, pid)).toEqual([]);
|
|
expect(writerRecord()).toEqual(claim);
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
|
|
});
|
|
|
|
it.each(['1.6.2', '1.6.3', '2.0.0', '1.6.1-beta.1', '0.0.0-unknown', 'test'])(
|
|
'never signals a daemon of version %s',
|
|
async (version) => {
|
|
const { pid, lock } = await fakeDaemon(version);
|
|
const kill = vi.spyOn(process, 'kill');
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toBeNull();
|
|
expect(signalsTo(kill, pid)).toEqual([]);
|
|
expect(isProcessAlive(pid)).toBe(true);
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
|
|
},
|
|
);
|
|
|
|
it('leaves an older lock alone when its socket answers for another process (#1553)', async () => {
|
|
const { pid, lock } = await fakeDaemon('1.6.1', process.pid);
|
|
const kill = vi.spyOn(process, 'kill');
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toMatchObject({ pid, outcome: 'unverified' });
|
|
expect(signalsTo(kill, pid)).toEqual([]);
|
|
expect(signalsTo(kill, process.pid)).toEqual([]);
|
|
expect(isProcessAlive(pid)).toBe(true);
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
|
|
});
|
|
|
|
it('reports an older daemon that already exited, leaving its lock to its successor', async () => {
|
|
const lock = encodeLockInfo({ pid: await deadPid(), version: '1.6.1', socketPath, startedAt: Date.now() });
|
|
fs.writeFileSync(pidPath, lock);
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toMatchObject({ outcome: 'not-running', version: '1.6.1' });
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
|
|
});
|
|
|
|
it('has nothing to stop without a lock, and leaves a listening socket alone', async () => {
|
|
server = net.createServer((socket) => {
|
|
socket.end(JSON.stringify({ protocol: 1, codegraph: '1.6.1', pid: process.pid, socketPath }) + '\n');
|
|
});
|
|
await listen(server, socketPath);
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toBeNull();
|
|
expect(await connectWithHello(socketPath, '1.6.2')).toBe('older-version');
|
|
});
|
|
|
|
it('does not act on an unreadable lock', async () => {
|
|
fs.mkdirSync(pidPath);
|
|
expect(await stopOlderDaemon(root, '1.6.2')).toEqual({ root, pid: null, outcome: 'unverified' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A CodeGraph install of `version`: this build's dist/, hard-linked file by
|
|
* file (copied where a link is refused), next to a package.json of that version
|
|
* and a link to this checkout's node_modules.
|
|
*/
|
|
function makeInstall(version: string): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-install-${version}-`));
|
|
const linkTree = (from: string, to: string): void => {
|
|
fs.mkdirSync(to, { recursive: true });
|
|
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
const source = path.join(from, entry.name);
|
|
const target = path.join(to, entry.name);
|
|
if (entry.isDirectory()) linkTree(source, target);
|
|
else {
|
|
try { fs.linkSync(source, target); } catch { fs.copyFileSync(source, target); }
|
|
}
|
|
}
|
|
};
|
|
linkTree(DIST, path.join(dir, 'dist'));
|
|
fs.symlinkSync(NODE_MODULES, path.join(dir, 'node_modules'), 'junction');
|
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: '@colbymchenry/codegraph', version }) + '\n');
|
|
return dir;
|
|
}
|
|
|
|
interface Session {
|
|
child: ChildProcessWithoutNullStreams;
|
|
stdout: string[];
|
|
stderr: string[];
|
|
}
|
|
|
|
function lines(stream: NodeJS.ReadableStream, into: string[]): void {
|
|
let buffer = '';
|
|
stream.on('data', (chunk: Buffer) => {
|
|
buffer += chunk.toString('utf8');
|
|
let idx: number;
|
|
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
into.push(buffer.slice(0, idx));
|
|
buffer = buffer.slice(idx + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
function findResponse(stdout: string[], id: number): any | null {
|
|
for (const line of stdout) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) return parsed;
|
|
} catch { /* not JSON */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function waitFor<T>(predicate: () => T | undefined | null | false, timeoutMs: number, label: string): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const started = Date.now();
|
|
const tick = (): void => {
|
|
let value: T | undefined | null | false;
|
|
try { value = predicate(); } catch (e) { reject(e); return; }
|
|
if (value) { resolve(value as T); return; }
|
|
if (Date.now() - started > timeoutMs) { reject(new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`)); return; }
|
|
setTimeout(tick, 25);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
function isAlive(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
}
|
|
|
|
async function waitProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
return waitFor(() => !isAlive(pid), timeoutMs, `pid ${pid} to exit`).then(() => true, () => false);
|
|
}
|
|
|
|
function readJson(file: string): any | null {
|
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
}
|
|
|
|
describe('a launcher meeting a daemon of another version (#2335)', () => {
|
|
let olderInstall: string;
|
|
let newerInstall: string;
|
|
let tempDir: string;
|
|
let realRoot: string;
|
|
const sessions: Session[] = [];
|
|
const daemonPids = new Set<number>();
|
|
|
|
beforeAll(() => {
|
|
olderInstall = makeInstall(OLDER);
|
|
newerInstall = makeInstall(NEWER);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const dir of [olderInstall, newerInstall]) {
|
|
if (!dir) continue;
|
|
// The link first: removing an install must never reach this checkout's modules.
|
|
try { fs.unlinkSync(path.join(dir, 'node_modules')); } catch { /* not created */ }
|
|
await fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-older-version-'));
|
|
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function appMain() { return 1; }\n');
|
|
const cg = await CodeGraph.init(tempDir);
|
|
try { await cg.indexAll(); } finally { cg.close(); }
|
|
realRoot = fs.realpathSync(tempDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Every launcher first (with the runtime flags there is no relaunch child),
|
|
// then the losing daemon candidates, then the daemons themselves — and only
|
|
// then the fixture: on Windows a live process inside it blocks the removal.
|
|
await Promise.all(sessions.map(async ({ child }) => {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
const exited = once(child, 'exit');
|
|
child.kill('SIGKILL');
|
|
await exited;
|
|
}));
|
|
sessions.length = 0;
|
|
const lockPid = (): number | null => readJson(path.join(realRoot, '.codegraph', 'daemon.pid'))?.pid ?? null;
|
|
await settleLosingCandidates(tempDir, lockPid);
|
|
const holder = lockPid();
|
|
if (holder) daemonPids.add(holder);
|
|
for (const pid of daemonPids) {
|
|
if (pid === process.pid || !isAlive(pid)) continue;
|
|
try { process.kill(pid, 'SIGKILL'); } catch { /* raced to exit */ }
|
|
await waitProcessExit(pid, 5000);
|
|
}
|
|
daemonPids.clear();
|
|
removeSpawnLog(tempDir);
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}, 45_000);
|
|
|
|
/** An agent session: `install`'s launcher, `serve --mcp` in the project. */
|
|
function startSession(install: string | null, env: NodeJS.ProcessEnv = {}): Session {
|
|
const bin = install ? path.join(install, 'dist', 'bin', 'codegraph.js') : BIN;
|
|
const recorder = recordSpawns(tempDir);
|
|
const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, ...recorder.args, bin, 'serve', '--mcp'], {
|
|
cwd: tempDir,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
CODEGRAPH_MCP_LOG_ATTACH: '1',
|
|
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000',
|
|
...recorder.env,
|
|
...env,
|
|
},
|
|
}) as ChildProcessWithoutNullStreams;
|
|
child.on('error', () => { /* ignore */ });
|
|
child.stdin.on('error', () => { /* ignore */ });
|
|
const session: Session = { child, stdout: [], stderr: [] };
|
|
lines(child.stdout, session.stdout);
|
|
lines(child.stderr, session.stderr);
|
|
sessions.push(session);
|
|
send(session, {
|
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '0.0.0' }, rootUri: `file://${tempDir}` },
|
|
});
|
|
return session;
|
|
}
|
|
|
|
function send(session: Session, msg: unknown): void {
|
|
try { session.child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* gone */ }
|
|
}
|
|
|
|
async function status(session: Session, id: number, label: string): Promise<any> {
|
|
send(session, { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
|
|
const reply = await waitFor(() => findResponse(session.stdout, id), 20000, label).catch((err: Error) => {
|
|
throw new Error(`${err.message}\n--- stderr ---\n${session.stderr.join('\n')}\n--- daemon.log ---\n${daemonLog()}`);
|
|
});
|
|
expect(reply.error, label).toBeUndefined();
|
|
expect(reply.result?.isError, label).not.toBe(true);
|
|
expect(JSON.stringify(reply.result), label).toContain('CodeGraph Status');
|
|
return reply;
|
|
}
|
|
|
|
const attachedLine = (session: Session): string | undefined =>
|
|
session.stderr.find((l) => l.includes('Attached to shared daemon'));
|
|
const daemonLock = (): any => readJson(path.join(realRoot, '.codegraph', 'daemon.pid'));
|
|
const writerLock = (): any => readJson(path.join(realRoot, '.codegraph', 'writer.pid'));
|
|
const daemonLog = (): string => {
|
|
try { return fs.readFileSync(path.join(realRoot, '.codegraph', 'daemon.log'), 'utf8'); } catch { return ''; }
|
|
};
|
|
|
|
/** A session of `install` attached to a daemon of its own, which owns the project. */
|
|
async function sessionWithDaemon(install: string, version: string, env: NodeJS.ProcessEnv = {}): Promise<{ session: Session; pid: number }> {
|
|
const session = startSession(install, env);
|
|
await waitFor(() => attachedLine(session), 20000, `the ${version} session to attach`);
|
|
// Attached means listening, not initialized: let the engine open the
|
|
// database before anything stops this daemon.
|
|
await status(session, 2, `a ${version} tool call`);
|
|
const lock = daemonLock();
|
|
expect(lock).toMatchObject({ version });
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
daemonPids.add(lock.pid);
|
|
return { session, pid: lock.pid };
|
|
}
|
|
|
|
/** A daemon process for the project, started the way a launcher starts one. */
|
|
function startDaemon(env: NodeJS.ProcessEnv): { child: ChildProcessWithoutNullStreams; log: string[] } {
|
|
const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--path', realRoot], {
|
|
cwd: tempDir,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env, CODEGRAPH_DAEMON_INTERNAL: '1', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', ...env },
|
|
}) as ChildProcessWithoutNullStreams;
|
|
child.on('error', () => { /* ignore */ });
|
|
child.stdout.resume();
|
|
const log: string[] = [];
|
|
lines(child.stderr, log);
|
|
daemonPids.add(child.pid!);
|
|
return { child, log };
|
|
}
|
|
|
|
/** The slot as a launcher that just stopped an older daemon holds it: this test process stands in. */
|
|
function holdWriterSlotForSuccessor(): string {
|
|
const claim = JSON.stringify({ pid: process.pid, mode: 'handover', startedAt: Date.now(), ready: false }) + '\n';
|
|
fs.writeFileSync(path.join(realRoot, '.codegraph', 'writer.pid'), claim);
|
|
return claim;
|
|
}
|
|
|
|
it('a daemon takes over the writer slot held for it, and only that one', async () => {
|
|
const claim = holdWriterSlotForSuccessor();
|
|
|
|
// A daemon the slot is not held for finds it held, as by any live writer.
|
|
const stranger = startDaemon({});
|
|
const [strangerCode] = await once(stranger.child, 'exit');
|
|
expect(strangerCode).toBe(1);
|
|
expect(stranger.log.join('\n')).toContain(`writer lock held by PID ${process.pid} (handover mode)`);
|
|
expect(fs.readFileSync(path.join(realRoot, '.codegraph', 'writer.pid'), 'utf8')).toBe(claim);
|
|
|
|
const successor = startDaemon({ CODEGRAPH_DAEMON_HANDOVER: String(process.pid) });
|
|
await waitFor(
|
|
() => daemonLock()?.pid === successor.child.pid && successor.log.some((l) => l.includes('Listening on')),
|
|
20000, 'the successor to listen',
|
|
);
|
|
expect(writerLock()).toMatchObject({ pid: successor.child.pid, mode: 'daemon' });
|
|
expect(successor.log.join('\n')).toContain(`Took over the writer lock from launcher pid ${process.pid}`);
|
|
}, 60000);
|
|
|
|
it('a daemon the slot is held for outwaits a candidate still starting in its way', async () => {
|
|
holdWriterSlotForSuccessor();
|
|
// A racing launcher's candidate holds the daemon lock, still starting; it
|
|
// cannot get the writer slot, so it will give up.
|
|
const candidate = startDetachedProcess();
|
|
daemonPids.add(candidate);
|
|
const pidPath = path.join(realRoot, '.codegraph', 'daemon.pid');
|
|
const candidateLock = encodeLockInfo({
|
|
pid: candidate, version: CodeGraphPackageVersion, socketPath: getDaemonSocketPath(realRoot), startedAt: Date.now(),
|
|
});
|
|
fs.writeFileSync(pidPath, candidateLock);
|
|
|
|
// Any other daemon yields to it at once.
|
|
const yielding = startDaemon({});
|
|
const [yieldingCode] = await once(yielding.child, 'exit');
|
|
expect(yieldingCode).toBe(0);
|
|
expect(yielding.log.join('\n')).toContain(`Another daemon (pid ${candidate}) already holds the lock`);
|
|
|
|
const successor = startDaemon({ CODEGRAPH_DAEMON_HANDOVER: String(process.pid) });
|
|
await waitFor(
|
|
() => successor.log.some((l) => l.includes(`Waiting for daemon candidate pid ${candidate}`)),
|
|
20000, 'the successor to wait for the candidate',
|
|
);
|
|
expect(successor.child.exitCode).toBeNull();
|
|
expect(fs.readFileSync(pidPath, 'utf8')).toBe(candidateLock);
|
|
// The candidate gives up: its lock goes, then so does it.
|
|
fs.rmSync(pidPath);
|
|
process.kill(candidate, 'SIGKILL');
|
|
await waitFor(
|
|
() => daemonLock()?.pid === successor.child.pid && successor.log.some((l) => l.includes('Listening on')),
|
|
20000, 'the successor to listen',
|
|
);
|
|
expect(writerLock()).toMatchObject({ pid: successor.child.pid, mode: 'daemon' });
|
|
}, 60000);
|
|
|
|
it('keeps the older daemon\'s sessions read-only through the replacement, even mid-call', async () => {
|
|
const before = await sessionWithDaemon(olderInstall, OLDER);
|
|
// The older session calls every 20ms throughout, so it serves itself
|
|
// in-process the instant its daemon goes. Finding the writer slot free
|
|
// then, it would claim it — with the code of the install being replaced.
|
|
let next = 100;
|
|
const calling = setInterval(() => {
|
|
send(before.session, { jsonrpc: '2.0', id: next++, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
|
|
}, 20);
|
|
let after: Session;
|
|
try {
|
|
after = startSession(null);
|
|
await waitFor(() => attachedLine(after), 30000, 'the new session to attach');
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
} finally {
|
|
clearInterval(calling);
|
|
}
|
|
const lock = daemonLock();
|
|
daemonPids.add(lock.pid);
|
|
expect(lock.version).toBe(CodeGraphPackageVersion);
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
await status(after, 2, 'a tool call through the new daemon');
|
|
|
|
const last = next - 1;
|
|
await waitFor(() => findResponse(before.session.stdout, last), 20000, 'the older session to answer every call');
|
|
for (let id = 100; id <= last; id++) expect(findResponse(before.session.stdout, id), `call ${id}`).toBeTruthy();
|
|
const stderr = before.session.stderr.join('\n');
|
|
expect(stderr).toContain('Serving reads in-process without auto-sync');
|
|
expect(stderr).not.toContain('File watcher active');
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
}, 120000);
|
|
|
|
it('stops a daemon from an older install and starts one from its own', async () => {
|
|
const before = await sessionWithDaemon(olderInstall, OLDER);
|
|
|
|
const after = startSession(null);
|
|
const stopped = await waitFor(
|
|
() => after.stderr.find((l) => l.includes('[CodeGraph MCP] Stopped')),
|
|
30000, 'the older daemon to be stopped',
|
|
);
|
|
expect(stopped).toContain(`the CodeGraph ${OLDER} daemon (pid ${before.pid})`);
|
|
expect(stopped).toContain(`from this install (${CodeGraphPackageVersion})`);
|
|
expect(await waitProcessExit(before.pid, 10000)).toBe(true);
|
|
|
|
const attached = await waitFor(() => attachedLine(after), 20000, 'the new session to attach');
|
|
const lock = daemonLock();
|
|
daemonPids.add(lock.pid);
|
|
expect(lock.version).toBe(CodeGraphPackageVersion);
|
|
expect(lock.pid).not.toBe(before.pid);
|
|
expect(attached).toContain(`(pid ${lock.pid}, v${CodeGraphPackageVersion})`);
|
|
await status(after, 2, 'a tool call through the new daemon');
|
|
// The replacement owns updates for the project: writer lock and watcher,
|
|
// handed to it by the launcher that held the slot through the stop.
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
expect(daemonLog()).toContain(`Took over the writer lock from launcher pid ${after.child.pid}`);
|
|
if (process.platform !== 'win32') {
|
|
// SIGTERM ran the old daemon's own shutdown. (On Windows it is
|
|
// TerminateProcess, and the stop clears what is left.)
|
|
expect(daemonLog()).toContain(`Shutting down (SIGTERM`);
|
|
}
|
|
|
|
// The session from before the upgrade keeps answering, read-only, and
|
|
// leaves the new daemon its writer lock.
|
|
await waitFor(
|
|
() => before.session.stderr.some((l) => l.includes('Shared daemon connection lost')),
|
|
10000, 'the older session to lose its daemon',
|
|
);
|
|
await status(before.session, 3, 'a tool call in the older session');
|
|
expect(before.session.stderr.some((l) => l.includes('Serving reads in-process without auto-sync'))).toBe(true);
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
expect(daemonLock()).toEqual(lock);
|
|
expect(isAlive(lock.pid)).toBe(true);
|
|
}, 120000);
|
|
|
|
it('finds an older daemon its probe cannot reach by the project lock, and stops it', async () => {
|
|
// On Windows a 1.6.1 daemon named its pipe after the root as typed (#2278),
|
|
// so a newer launcher's probe never hears its hello; only its lock says
|
|
// where it listens. Stand one up on a socket no launcher probes.
|
|
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sock-'));
|
|
try {
|
|
const socketPath = listenPath(socketDir, 'older');
|
|
const older = spawn(process.execPath, [OLDER_DAEMON, realRoot, OLDER, socketPath], { detached: true, stdio: 'ignore' });
|
|
older.unref();
|
|
daemonPids.add(older.pid!);
|
|
await waitFor(() => daemonLock()?.pid === older.pid, 10000, 'the older daemon to hold the project');
|
|
expect(writerLock()).toMatchObject({ pid: older.pid, mode: 'daemon' });
|
|
|
|
const session = startSession(null);
|
|
const stopped = await waitFor(
|
|
() => session.stderr.find((l) => l.includes('[CodeGraph MCP] Stopped')),
|
|
30000, 'the older daemon to be stopped',
|
|
);
|
|
expect(stopped).toContain(`the CodeGraph ${OLDER} daemon (pid ${older.pid})`);
|
|
expect(await waitProcessExit(older.pid!, 10000)).toBe(true);
|
|
const attached = await waitFor(() => attachedLine(session), 20000, 'the session to attach');
|
|
const lock = daemonLock();
|
|
daemonPids.add(lock.pid);
|
|
expect(lock.version).toBe(CodeGraphPackageVersion);
|
|
expect(attached).toContain(`(pid ${lock.pid}, v${CodeGraphPackageVersion})`);
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
expect(daemonLog()).toContain(`Took over the writer lock from launcher pid ${session.child.pid}`);
|
|
await status(session, 2, 'a tool call through the new daemon');
|
|
} finally {
|
|
fs.rmSync(socketDir, { recursive: true, force: true });
|
|
}
|
|
}, 90000);
|
|
|
|
it('never stops a daemon from a newer install', async () => {
|
|
const retry = { CODEGRAPH_DAEMON_RETRY_MS: '300', CODEGRAPH_DAEMON_RETRY_MAX_MS: '600' };
|
|
const newer = await sessionWithDaemon(newerInstall, NEWER);
|
|
const lock = daemonLock();
|
|
|
|
const current = startSession(null, retry);
|
|
// The first probe and the retries that follow all find the newer daemon.
|
|
await waitFor(
|
|
() => current.stderr.filter((l) => l.includes(`version (${NEWER}) differs from ours`)).length >= 3,
|
|
20000, 'repeated probes of the newer daemon',
|
|
);
|
|
await status(current, 2, 'a tool call served in-process');
|
|
expect(current.stderr.some((l) => l.includes('Serving reads in-process without auto-sync'))).toBe(true);
|
|
expect(current.stderr.some((l) => l.includes('[CodeGraph MCP] Stopped'))).toBe(false);
|
|
|
|
expect(isAlive(newer.pid)).toBe(true);
|
|
expect(daemonLock()).toEqual(lock);
|
|
expect(writerLock()).toMatchObject({ pid: newer.pid, mode: 'daemon' });
|
|
await status(newer.session, 3, 'a tool call through the newer daemon');
|
|
}, 90000);
|
|
|
|
it('a session whose own install was upgraded serves reads only once its daemon exits', async () => {
|
|
// An upgrade in place: the daemon notices its package.json changed and
|
|
// exits (its install check), and its session falls back in-process. As
|
|
// the project's writer that session — running code the upgrade replaced —
|
|
// would only keep a daemon from the new install from starting.
|
|
const install = makeInstall('0.0.2');
|
|
try {
|
|
const before = await sessionWithDaemon(install, '0.0.2', {
|
|
CODEGRAPH_DAEMON_INSTALL_CHECK_MS: '200',
|
|
CODEGRAPH_DAEMON_RETRY_MS: '0',
|
|
});
|
|
fs.writeFileSync(path.join(install, 'package.json'), JSON.stringify({ name: '@colbymchenry/codegraph', version: '0.0.3' }) + '\n');
|
|
expect(await waitProcessExit(before.pid, 10000)).toBe(true);
|
|
expect(daemonLog()).toContain('Install replaced by v0.0.3');
|
|
await waitFor(
|
|
() => before.session.stderr.some((l) => l.includes('Shared daemon connection lost')),
|
|
10000, 'the session to lose its daemon',
|
|
);
|
|
|
|
await status(before.session, 3, 'a tool call after the daemon exited');
|
|
const stderr = before.session.stderr.join('\n');
|
|
expect(stderr).toContain("this session's CodeGraph install was upgraded or removed");
|
|
expect(stderr).not.toContain('File watcher active');
|
|
expect(writerLock()).toBeNull();
|
|
|
|
// So a session from the current install gets a daemon straight away.
|
|
const after = startSession(null);
|
|
await waitFor(() => attachedLine(after), 20000, 'the new session to attach');
|
|
const lock = daemonLock();
|
|
daemonPids.add(lock.pid);
|
|
expect(writerLock()).toMatchObject({ pid: lock.pid, mode: 'daemon' });
|
|
} finally {
|
|
// On Windows a process still running from the install blocks its removal.
|
|
for (const { child } of sessions) {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
const exited = once(child, 'exit');
|
|
child.kill('SIGKILL');
|
|
await exited;
|
|
}
|
|
}
|
|
try { fs.unlinkSync(path.join(install, 'node_modules')); } catch { /* not created */ }
|
|
await fs.promises.rm(install, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
}, 90000);
|
|
});
|