Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- A daemon left behind by an OOM or force-kill can no longer block every future session when the operating system reuses its PID, and daemon management never signals an unrelated process that happens to own that PID; `codegraph unlock` now clears stale daemon artifacts as well as the indexing lock. (#1553)
- Data-only C/C++ headers near the file-size limit no longer hold a parser worker for 4.5–5 minutes before timing out; the default large-file timeout is now bounded while explicit operator overrides remain honored. (#1555)
- Reopening an index after a crash during bulk loading now restores every dropped database index, and a successful recovery sync moves the index state back to complete instead of leaving it permanently marked as interrupted. (#1556)
- Files skipped because they are too large or fail parsing are now recorded with their reason, so unchanged rejected files are not rediscovered and retried on every status check and sync. (#1557)
- Dense recovery syncs no longer hit V8's argument limit when one changed-file batch contains hundreds of thousands of unresolved references, and C/C++ function-pointer analysis now bounds its compiled-pattern caches so very large repositories cannot exhaust RegExp code space. (#1558, #1559)
- JSX rendering analysis now runs only on JavaScript-family files, preventing JSX-looking strings in C/C++ and other languages from creating impossible call edges in pure-language or mixed monorepos. (#1560)
- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)

- A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out.
Expand Down
102 changes: 102 additions & 0 deletions __tests__/cli-unlock.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 55 additions & 0 deletions __tests__/daemon-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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> {
Expand Down Expand Up @@ -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 changes: 35 additions & 0 deletions __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
104 changes: 104 additions & 0 deletions __tests__/large-corpus-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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);
});
});
39 changes: 39 additions & 0 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand Down
Loading