fix: harden daemon and large-index recovery paths (#1562)

* fix: harden indexing recovery and daemon liveness

* test: cover daemon and recovery review gaps

* test: pin that a failure marker never blocks a later successful parse (#1557 retry-discard guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-08-20 12:53:49 -05:00
committed by GitHub
co-authored by Claude Fable 5 danusha2345 Colby McHenry
parent d8f2eeaddf
commit 81e1f4a92f
20 changed files with 680 additions and 83 deletions
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { execFile, execFileSync } from 'child_process';
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 { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function runCodegraph(args: string[], cwd: string): string {
return execFileSync(process.execPath, [BIN, ...args], {
cwd,
encoding: 'utf8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function runCodegraphAsync(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[BIN, ...args],
{ cwd, encoding: 'utf8', env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' } },
(error, stdout, stderr) => {
if (error) reject(new Error(`${error.message}\n${stderr}`));
else resolve(stdout);
},
);
});
}
describe('codegraph unlock — daemon artifact recovery (#1553)', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unlock-'));
const cg = CodeGraph.initSync(tempDir);
cg.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('removes indexing and phantom-daemon artifacts, then permits indexing', () => {
const graphDir = path.join(tempDir, '.codegraph');
const pidPath = getDaemonPidPath(tempDir);
const socketPath = getDaemonSocketPath(tempDir);
fs.writeFileSync(path.join(graphDir, 'codegraph.lock'), 'stale\n');
fs.writeFileSync(pidPath, JSON.stringify({
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath,
startedAt: Date.now() - 60_000,
}));
if (process.platform !== 'win32') fs.writeFileSync(socketPath, 'stale\n');
const output = runCodegraph(['unlock', tempDir], tempDir);
expect(output).toContain('Removed stale lock artifacts');
expect(fs.existsSync(path.join(graphDir, 'codegraph.lock'))).toBe(false);
expect(fs.existsSync(pidPath)).toBe(false);
if (process.platform !== 'win32') expect(fs.existsSync(socketPath)).toBe(false);
expect(() => process.kill(process.pid, 0)).not.toThrow();
expect(() => runCodegraph(['index', '--quiet', tempDir], tempDir)).not.toThrow();
});
it('preserves artifacts when the recorded live daemon answers the socket hello', async () => {
const pidPath = getDaemonPidPath(tempDir);
const socketPath = getDaemonSocketPath(tempDir);
const server = net.createServer((socket) => {
socket.end(JSON.stringify({
codegraph: CodeGraphPackageVersion,
pid: process.pid,
socketPath,
protocol: 1,
}) + '\n');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
fs.writeFileSync(pidPath, JSON.stringify({
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath,
startedAt: Date.now(),
}));
try {
const output = await runCodegraphAsync(['unlock', tempDir], tempDir);
expect(output).toContain('No stale lock files found');
expect(fs.existsSync(pidPath)).toBe(true);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+55
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import {
@@ -9,8 +10,11 @@ import {
registerDaemon,
deregisterDaemon,
listDaemons,
listVerifiedDaemons,
stopDaemonAt,
type DaemonRecord,
} from '../src/mcp/daemon-registry';
import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';
/** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
async function deadPid(): Promise<number> {
@@ -100,4 +104,55 @@ describe('daemon-registry', () => {
const live = listDaemons();
expect(live.map((d) => d.root)).toEqual(['/proj/new', '/proj/old']);
});
it('keeps a registry entry whose socket hello matches its PID and version', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'verified-'));
const socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\cg-reg-${process.pid}-${Date.now()}`
: path.join(tmpHome, 'verified.sock');
const server = net.createServer((socket) => {
socket.end(JSON.stringify({
protocol: 1,
pid: process.pid,
codegraph: '1.5.0',
socketPath,
}) + '\n');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
try {
registerDaemon({ root, pid: process.pid, version: '1.5.0', socketPath, startedAt: 1 });
expect((await listVerifiedDaemons()).map((d) => d.root)).toEqual([root]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('never signals a reused live PID when no matching daemon answers (#1553)', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'project-'));
const pidPath = getDaemonPidPath(root);
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, encodeLockInfo({
pid: process.pid,
version: '1.5.0',
socketPath: path.join(root, '.codegraph', 'missing.sock'),
startedAt: Date.now() - 60_000,
}));
registerDaemon({
root,
pid: process.pid,
version: '1.5.0',
socketPath: path.join(root, '.codegraph', 'missing.sock'),
startedAt: Date.now() - 60_000,
});
expect(await listVerifiedDaemons()).toEqual([]);
const result = await stopDaemonAt(root);
expect(result).toMatchObject({ pid: process.pid, outcome: 'not-running' });
expect(isProcessAlive(process.pid)).toBe(true);
expect(fs.existsSync(pidPath)).toBe(false);
});
});
+35
View File
@@ -120,6 +120,41 @@ describe('CodeGraph Foundation', () => {
cg.close();
});
it('restores every secondary index after a crash inside bulk parse load (#1556)', () => {
const dbPath = getDatabasePath(tempDir);
const first = DatabaseConnection.initialize(dbPath);
const before = (first.getDb()
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
.all() as Array<{ name: string }>).map((r) => r.name);
first.beginBulkParseLoad();
first.close();
const reopened = DatabaseConnection.open(dbPath);
const after = (reopened.getDb()
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
.all() as Array<{ name: string }>).map((r) => r.name);
reopened.close();
expect(after).toEqual(before);
});
it('skips secondary-index DDL when the schema is already healthy', () => {
const dbPath = getDatabasePath(tempDir);
const connection = DatabaseConnection.initialize(dbPath);
const db = connection.getDb();
const originalExec = db.exec.bind(db);
let execCalls = 0;
db.exec = (sql: string) => {
execCalls++;
originalExec(sql);
};
(connection as any).healBulkSecondaryIndexes();
connection.close();
expect(execCalls).toBe(0);
});
it('should return correct database size', () => {
const cg = CodeGraph.initSync(tempDir);
const stats = cg.getStats();
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import CodeGraph from '../src/index';
import { QueryBuilder } from '../src/db/queries';
describe('large-corpus regression fixes', () => {
it('collects a dense unresolved-reference chunk without spreading it onto the V8 stack (#1558)', () => {
const row = {
id: 1,
from_node_id: 'source',
reference_name: 'target',
reference_kind: 'calls',
line: 1,
col: 1,
candidates: null,
file_path: 'dense.c',
language: 'c',
status: 'pending',
name_tail: 'target',
};
const denseRows = new Array(200_000).fill(row);
const db = { prepare: () => ({ all: () => denseRows }) };
const queries = new QueryBuilder(db as any);
expect(queries.getUnresolvedReferencesByFiles(['dense.c'])).toHaveLength(200_000);
});
it('records an oversized file during a fresh index so sync does not retry it (#1557)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-skipped-file-'));
try {
fs.writeFileSync(path.join(dir, 'oversized.py'), 'value = 1\n'.repeat(120_000));
const cg = await CodeGraph.init(dir, { silent: true });
const indexed = await cg.indexAll();
expect(indexed.filesSkipped).toBe(1);
expect(cg.getFiles().find((f) => f.path === 'oversized.py')?.errors?.[0]?.code).toBe('size_exceeded');
const synced = await cg.sync();
expect(synced.filesAdded).toBe(0);
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('records an oversized file through the single-file indexing path (#1557)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-single-skipped-file-'));
try {
fs.writeFileSync(path.join(dir, 'oversized.py'), 'value = 1\n'.repeat(120_000));
const cg = await CodeGraph.init(dir, { silent: true });
const indexed = await cg.indexFiles(['oversized.py']);
expect(indexed.filesSkipped).toBe(1);
expect(cg.getFiles().find((f) => f.path === 'oversized.py')?.errors?.[0]?.code).toBe('size_exceeded');
const synced = await cg.sync();
expect(synced.filesAdded).toBe(0);
expect(synced.filesModified).toBe(0);
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('JSX synthesis language boundary (#1560)', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-gate-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('does not create jsx-render edges from JSX-looking text in a C-only project', async () => {
fs.writeFileSync(
path.join(dir, 'only.c'),
'void Foo(void) {}\nvoid parent(void) { const char *s = "<Foo/>"; }\n'
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const rows = (cg as any).db.db.prepare(
"SELECT count(*) AS c FROM edges WHERE json_extract(metadata, '$.synthesizedBy') = 'jsx-render'"
).get() as { c: number };
cg.close();
expect(rows.c).toBe(0);
});
it('runs for JavaScript while excluding C parents in the same project', async () => {
fs.writeFileSync(
path.join(dir, 'native.c'),
'void Widget(void) {}\nvoid native_parent(void) { const char *s = "<Widget/>"; }\n'
);
fs.writeFileSync(
path.join(dir, 'ui.jsx'),
'export function Widget() { return <span/>; }\nexport function App() { return <Widget/>; }\n'
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const rows = (cg as any).db.db.prepare(`
SELECT source.file_path AS source_file, target.name AS target_name
FROM edges e
JOIN nodes source ON source.id = e.source
JOIN nodes target ON target.id = e.target
WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
`).all() as Array<{ source_file: string; target_name: string }>;
cg.close();
expect(rows).toContainEqual({ source_file: 'ui.jsx', target_name: 'Widget' });
expect(rows.some((row) => row.source_file === 'native.c')).toBe(false);
});
});
describe('failure markers vs later real results (#1557 × #1541)', () => {
it('a failure marker never blocks storing a later successful parse of the same bytes', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-override-'));
try {
const rel = 'flaky.py';
const content = 'def real_fn():\n return 1\n\nclass RealClass:\n def m(self):\n return 2\n';
fs.writeFileSync(path.join(dir, rel), content);
const cg = await CodeGraph.init(dir, { silent: true });
const { initGrammars, loadGrammarsForLanguages } = await import('../src/extraction/grammars');
await initGrammars();
await loadGrammarsForLanguages(['python']);
const orch = (cg as any).orchestrator;
const stats = fs.statSync(path.join(dir, rel));
// What recordParseFailure persists when a parse worker dies: a marker
// row under the SAME content hash the retry will store with.
await orch.storeExtractionResult(rel, content, 'python', stats, {
nodes: [], edges: [], unresolvedReferences: [],
errors: [{ message: 'Worker exited with code 1', filePath: rel, severity: 'error', code: 'parse_error' }],
durationMs: 0,
});
expect(cg.getFile(rel)?.nodeCount).toBe(0);
// The retry pass succeeds with identical bytes — the marker must be
// replaced, not treated as "no changes".
const { extractFromSource } = await import('../src/extraction/tree-sitter');
const real = extractFromSource(rel, content, 'python');
expect(real.nodes.length).toBeGreaterThan(0);
await orch.storeExtractionResult(rel, content, 'python', stats, real);
expect(cg.getFile(rel)?.nodeCount).toBe(real.nodes.length);
expect(cg.getNodesInFile(rel).map((n: { name: string }) => n.name)).toContain('real_fn');
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+39
View File
@@ -39,6 +39,7 @@ import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
@@ -336,6 +337,44 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(isAlive(livePid!)).toBe(true);
}, 40000);
it('takes over after SIGKILL even when the stale PID has been reused (#1553)', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000' };
const first = spawnServer(tempDir, env);
servers.push(first);
sendInitialize(first.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
await waitFor(() => countListeningLines(realRoot) >= 1, 10000);
const killedPid = readLockPid(realRoot)!;
process.kill(killedPid, 'SIGKILL');
expect(await waitProcessExit(killedPid, 8000)).toBe(true);
// Model OS PID reuse without risking another process: the stale lock now
// names this live vitest worker, but no daemon answers the leftover socket.
fs.writeFileSync(
path.join(realRoot, '.codegraph', 'daemon.pid'),
JSON.stringify({
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath: getDaemonSocketPath(realRoot),
startedAt: Date.now() - 60_000,
}),
);
const second = spawnServer(tempDir, env);
servers.push(second);
sendInitialize(second.child, `file://${tempDir}`, 2);
const response = await waitFor(() => findResponse(second.stdout, 2), 12000);
expect(response.result.serverInfo.name).toBe('codegraph');
await waitFor(() => countListeningLines(realRoot) >= 2, 10000);
const replacementPid = readLockPid(realRoot)!;
expect(replacementPid).not.toBe(killedPid);
expect(replacementPid).not.toBe(process.pid);
expect(isAlive(replacementPid)).toBe(true);
expect(isAlive(process.pid)).toBe(true);
}, 50000);
it('proxy falls back to direct mode on a daemon version mismatch', async () => {
const net = await import('net');
const sockPath = getDaemonSocketPath(realRoot);
+12 -1
View File
@@ -11,7 +11,7 @@
* parallelism safe.
*/
import { describe, it, expect } from 'vitest';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs, type ParsePoolWorker, type ParseTask } from '../src/extraction/parse-pool';
import { ParseWorkerPool, resolveParseBudgetMs, resolveParsePoolSize, resolveParseTimeoutMs, type ParsePoolWorker, type ParseTask } from '../src/extraction/parse-pool';
import type { Language, ExtractionResult } from '../src/types';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -94,6 +94,17 @@ describe('resolveParseTimeoutMs', () => {
});
});
describe('resolveParseBudgetMs', () => {
it('caps near-limit blob headers at a 20s soft / 60s hard window (#1555)', () => {
expect(resolveParseBudgetMs(10_000, 940_800)).toBe(20_000);
expect(resolveParseBudgetMs(10_000, 857_376)).toBe(20_000);
});
it('does not clamp an explicit larger base timeout', () => {
expect(resolveParseBudgetMs(45_000, 940_800)).toBe(45_000);
});
});
describe('resolveParsePoolSize', () => {
it('treats explicit 0 and 1 as a single worker (the rollback path)', () => {
expect(resolveParsePoolSize('0', 8)).toBe(1);
+22
View File
@@ -149,6 +149,28 @@ describe('Sync Module', () => {
expect(result.filesRemoved).toBe(0);
expect(result.filesChecked).toBeGreaterThan(0);
});
it('persists an oversized skipped file so later syncs do not retry it (#1557)', async () => {
const filePath = path.join(testDir, 'src', 'oversized.ts');
fs.writeFileSync(filePath, 'const value = 1;\n'.repeat(70_000));
const first = await cg.sync();
expect(first.filesAdded).toBe(1);
expect(cg.getFiles().find((f) => f.path === 'src/oversized.ts')?.errors?.[0]?.code).toBe('size_exceeded');
const second = await cg.sync();
expect(second.filesAdded).toBe(0);
expect(second.filesModified).toBe(0);
});
it('marks a successfully recovered indexing state complete (#1556)', async () => {
(cg as any).queries.setMetadata('index_state', 'indexing');
await cg.sync({ paths: ['src/index.ts'] });
expect(cg.getIndexState()).toBe('indexing');
await cg.sync();
expect(cg.getIndexState()).toBe('complete');
});
});
});