Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
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);
});
});
18 changes: 18 additions & 0 deletions __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,24 @@ 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('should return correct database size', () => {
const cg = CodeGraph.initSync(tempDir);
const stats = cg.getStats();
Expand Down
79 changes: 79 additions & 0 deletions __tests__/large-corpus-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 });
}
});
});

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('does not scan a C parent as JSX merely because the project also contains JavaScript', async () => {
fs.writeFileSync(
path.join(dir, 'native.c'),
'void Foo(void) {}\nvoid parent(void) { const char *s = "<Foo/>"; }\n'
);
fs.writeFileSync(path.join(dir, 'marker.js'), 'export const marker = true;\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);
});
});
13 changes: 12 additions & 1 deletion __tests__/parse-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions __tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

Expand Down
23 changes: 12 additions & 11 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,10 +1691,10 @@ program
.aliases(['daemons'])
.description('Manage running CodeGraph background daemons — pick one and press enter to stop it')
.action(async () => {
const { listDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
const { listVerifiedDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
const { runDaemonPicker } = await import('../mcp/daemon-manager');

const daemons = listDaemons();
const daemons = await listVerifiedDaemons();
if (daemons.length === 0) {
info('No CodeGraph daemons running.');
return;
Expand All @@ -1717,7 +1717,7 @@ program
const clack = await importESM('@clack/prompts');
clack.intro('CodeGraph daemons');
await runDaemonPicker({
list: listDaemons,
list: listVerifiedDaemons,
stop: stopDaemonAt,
stopAll: stopAllDaemons,
cwdRoot,
Expand Down Expand Up @@ -1823,14 +1823,15 @@ program
}

const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');

if (!fs.existsSync(lockPath)) {
info(`No lock file found ${getGlyphs().dash} nothing to do`);
return;
}

fs.unlinkSync(lockPath);
success('Removed lock file. You can now run indexing again.');
let removed = false;
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
removed = true;
}
const { clearStaleDaemonArtifacts } = await import('../mcp/daemon-registry');
removed = await clearStaleDaemonArtifacts(projectPath) || removed;
if (removed) success('Removed stale lock artifacts. You can now run indexing again.');
else info(`No stale lock files found ${getGlyphs().dash} nothing to do`);
} catch (err) {
error(`Failed to remove lock: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
Expand Down
17 changes: 17 additions & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export class DatabaseConnection {
// beginBulkNodeLoad and endBulkNodeLoad): the FTS triggers are missing and
// nodes_fts is stale. Rebuild + recreate so search stays in sync.
conn.healBulkNodeLoad();
conn.healBulkSecondaryIndexes();
Comment thread
danusha2345 marked this conversation as resolved.

// Self-heal a killed session's leftover oversized WAL (#1431) — one
// statSync when healthy, off-thread checkpoint+truncate when not.
Expand Down Expand Up @@ -363,6 +364,22 @@ export class DatabaseConnection {
this.endBulkNodeLoad();
}

/** Recreate every secondary index a killed bulk parse/ref/edge window may leave dropped. */
private healBulkSecondaryIndexes(): void {
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
const names = new Set<string>([
...DatabaseConnection.BULK_PARSE_INDEX_NAMES,
...DatabaseConnection.BULK_REF_INDEX_NAMES,
...DatabaseConnection.BULK_EDGE_INDEX_NAMES,
]);
for (const idx of names) {
const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
if (!m) throw new Error(`schema.sql: index ${idx} not found for crash recovery`);
this.db.exec(m[0]);
}
}

/**
* Recreate the FTS sync triggers from schema.sql — extracted from the file
* rather than duplicated here so the DDL cannot drift from the schema.
Expand Down
5 changes: 4 additions & 1 deletion src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2359,7 +2359,10 @@ export class QueryBuilder {
const chunkRows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE status = 'pending' AND file_path IN (${placeholders})`)
.all(...chunk) as UnresolvedRefRow[];
rows.push(...chunkRows);
// A dense 500-file chunk can return hundreds of thousands of rows. Spread
// passes every row as a function argument and exceeds V8's argument/stack
// limit even though the SQL parameter count itself is bounded (#1558).
for (const row of chunkRows) rows.push(row);
}

return rows.map((row) => ({
Expand Down
Loading