From dc93afffa04ed3bcee85af759d46ac6462710976 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 16 Aug 2026 14:58:25 +0300 Subject: [PATCH 1/2] fix: harden indexing recovery and daemon liveness --- CHANGELOG.md | 6 ++ __tests__/daemon-registry.test.ts | 55 +++++++++++++ __tests__/foundation.test.ts | 18 +++++ __tests__/large-corpus-regressions.test.ts | 79 ++++++++++++++++++ __tests__/parse-pool.test.ts | 13 ++- __tests__/sync.test.ts | 22 +++++ src/bin/codegraph.ts | 23 +++--- src/db/index.ts | 17 ++++ src/db/queries.ts | 5 +- src/extraction/index.ts | 93 ++++++++++++---------- src/extraction/parse-pool.ts | 15 +++- src/index.ts | 8 ++ src/mcp/daemon-manager.ts | 4 +- src/mcp/daemon-paths.ts | 50 ++++++++++++ src/mcp/daemon-registry.ts | 56 ++++++++++++- src/mcp/daemon.ts | 22 +++-- src/mcp/index.ts | 19 +++-- src/resolution/c-fnptr-synthesizer.ts | 4 +- src/resolution/callback-synthesizer.ts | 9 ++- 19 files changed, 436 insertions(+), 82 deletions(-) create mode 100644 __tests__/large-corpus-regressions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..b3d7eb89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/daemon-registry.test.ts b/__tests__/daemon-registry.test.ts index 55bafc45a..aa13ec100 100644 --- a/__tests__/daemon-registry.test.ts +++ b/__tests__/daemon-registry.test.ts @@ -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 { @@ -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((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((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); + }); }); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 12c136445..924aecbf6 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -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(); diff --git a/__tests__/large-corpus-regressions.test.ts b/__tests__/large-corpus-regressions.test.ts new file mode 100644 index 000000000..161544299 --- /dev/null +++ b/__tests__/large-corpus-regressions.test.ts @@ -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 = ""; }\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 = ""; }\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); + }); +}); diff --git a/__tests__/parse-pool.test.ts b/__tests__/parse-pool.test.ts index 641d24d12..6211481a4 100644 --- a/__tests__/parse-pool.test.ts +++ b/__tests__/parse-pool.test.ts @@ -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); diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts index f26c05e1f..c85877c80 100644 --- a/__tests__/sync.test.ts +++ b/__tests__/sync.test.ts @@ -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'); + }); }); }); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 067cdd3e0..3b349b7e6 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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; @@ -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, @@ -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); diff --git a/src/db/index.ts b/src/db/index.ts index 4d52b0c6c..d03d27c9a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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(); // Self-heal a killed session's leftover oversized WAL (#1431) — one // statSync when healthy, off-thread checkpoint+truncate when not. @@ -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([ + ...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. diff --git a/src/db/queries.ts b/src/db/queries.ts index 451c6a90f..b4072df54 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -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) => ({ diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 22108d1d1..fbc81141c 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1709,7 +1709,7 @@ export class ExtractionOrchestrator { const inFlight = new Set>(); const completed = new Map(); + | { ok: false; filePath: string; content: string; stats: fs.Stats; err: unknown }>(); let nextSeq = 0; // file-order sequence assigned at dispatch let nextToStore = 0; // cursor: next sequence to commit let aborted = false; @@ -1737,27 +1737,25 @@ export class ExtractionOrchestrator { // Store: on the writer thread when active (fresh DB — bundles applied // in the same file order this chain dispatches them), else on the main // thread (SQLite connections are per-thread). - if (nodeCount > 0 || result.errors.length === 0) { - const language = detectLanguage(filePath, content, overrides); - if (storeWriter) { - if (result.kernelBuffers) { - // Buffers go to the writer as-is; the worker decodes + finalizes. - // The main thread's only per-file work stays O(1) + the content hash. - storeWriter.send({ - kernel: true, - filePath, - language, - buffers: result.kernelBuffers, - file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors), - }); - } else { - storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result)); - } - await storeWriter.waitBelow(STORE_WRITER_WINDOW); + const language = detectLanguage(filePath, content, overrides); + if (storeWriter) { + if (result.kernelBuffers) { + // Buffers go to the writer as-is; the worker decodes + finalizes. + // The main thread's only per-file work stays O(1) + the content hash. + storeWriter.send({ + kernel: true, + filePath, + language, + buffers: result.kernelBuffers, + file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors), + }); } else { - const materialized = materializeKernelResult(result, filePath, language); - await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield); + storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result)); } + await storeWriter.waitBelow(STORE_WRITER_WINDOW); + } else { + const materialized = materializeKernelResult(result, filePath, language); + await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield); } if (result.errors.length > 0) { @@ -1788,16 +1786,19 @@ export class ExtractionOrchestrator { onProgress?.({ phase: 'parsing', current: processed, total, currentFile: filePath }); }; - const recordParseFailure = (filePath: string, err: unknown): void => { - processed++; - filesErrored++; - errors.push({ - message: err instanceof Error ? err.message : String(err), - filePath, - severity: 'error', - code: 'parse_error', + const recordParseFailure = async (filePath: string, content: string, stats: fs.Stats, err: unknown): Promise => { + await storeResult(filePath, content, stats, { + nodes: [], + edges: [], + unresolvedReferences: [], + errors: [{ + message: err instanceof Error ? err.message : String(err), + filePath, + severity: 'error', + code: 'parse_error', + }], + durationMs: 0, }); - onProgress?.({ phase: 'parsing', current: processed, total }); }; // Commit buffered parses to the DB in file order, advancing the cursor over @@ -1820,7 +1821,7 @@ export class ExtractionOrchestrator { completed.delete(nextToStore); nextToStore++; if (item.ok) await storeResult(item.filePath, item.content, item.stats, item.result); - else recordParseFailure(item.filePath, item.err); + else await recordParseFailure(item.filePath, item.content, item.stats, item.err); } } catch (err) { flushError = err; @@ -1839,7 +1840,7 @@ export class ExtractionOrchestrator { const result = await parseFile(filePath, content); completed.set(seq, { ok: true, filePath, content, stats, result }); } catch (parseErr) { - completed.set(seq, { ok: false, filePath, err: parseErr }); + completed.set(seq, { ok: false, filePath, content, stats, err: parseErr }); } flushOrdered(); })(); @@ -1910,15 +1911,18 @@ export class ExtractionOrchestrator { // useful symbols. The single-file extractFile path already enforces // this; the bulk path used to silently skip the check. if (stats.size > MAX_FILE_SIZE) { - processed++; - filesSkipped++; - errors.push({ - message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, - filePath, - severity: 'warning', - code: 'size_exceeded', + await storeResult(filePath, content, stats, { + nodes: [], + edges: [], + unresolvedReferences: [], + errors: [{ + message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, + filePath, + severity: 'warning', + code: 'size_exceeded', + }], + durationMs: 0, }); - onProgress?.({ phase: 'parsing', current: processed, total }); continue; } @@ -2220,9 +2224,11 @@ export class ExtractionOrchestrator { }; } + const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir)); + // Check file size if (stats.size > MAX_FILE_SIZE) { - return { + const result: ExtractionResult = { nodes: [], edges: [], unresolvedReferences: [], @@ -2236,10 +2242,11 @@ export class ExtractionOrchestrator { ], durationMs: 0, }; + await this.storeExtractionResult(relativePath, content, language, stats, result, createYielder()); + return result; } // Detect language (honoring the project's codegraph.json extension overrides) - const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir)); if (!isLanguageSupported(language)) { return { nodes: [], @@ -2257,9 +2264,7 @@ export class ExtractionOrchestrator { const result = extractFromSource(relativePath, content, language, frameworkNames); // Store in database - if (result.nodes.length > 0 || result.errors.length === 0) { - await this.storeExtractionResult(relativePath, content, language, stats, result, createYielder()); - } + await this.storeExtractionResult(relativePath, content, language, stats, result, createYielder()); return result; } diff --git a/src/extraction/parse-pool.ts b/src/extraction/parse-pool.ts index 26f8ca055..c0cacd216 100644 --- a/src/extraction/parse-pool.ts +++ b/src/extraction/parse-pool.ts @@ -61,6 +61,8 @@ const MAX_PARSE_POOL_SIZE = 16; const DEFAULT_RECYCLE_INTERVAL = 250; /** Base per-parse timeout; scaled up for large files by the caller's formula. */ const DEFAULT_PARSE_TIMEOUT_MS = 10_000; +/** Keep the default large-file budget bounded; the hard-kill window is 3× this. */ +const MAX_SCALED_PARSE_TIMEOUT_MS = 20_000; /** * A worker is only killed once a parse has gone this many × its budget with no * result. The base timer firing is NOT proof the parse is still running: after @@ -109,6 +111,17 @@ export function resolveParseTimeoutMs(envVal: string | undefined): number { return DEFAULT_PARSE_TIMEOUT_MS; } +/** + * Per-file soft timeout. Size scaling helps legitimate large sources, but an + * uncapped linear budget gave data-only headers near the 1 MiB file limit a + * 4.5–5 minute hard-kill window (#1555). Explicit larger base overrides remain + * respected for slow storage. + */ +export function resolveParseBudgetMs(baseMs: number, contentLength: number): number { + const scaled = baseMs + Math.floor(contentLength / 100_000) * 10_000; + return Math.min(scaled, Math.max(baseMs, MAX_SCALED_PARSE_TIMEOUT_MS)); +} + export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number { if (envVal !== undefined && envVal !== '') { const n = Number(envVal); @@ -344,7 +357,7 @@ export class ParseWorkerPool { this.parseCounts.set(w, (this.parseCounts.get(w) ?? 0) + 1); // Scale the timeout for large files: base + 10s per 100KB (matches the // original single-worker formula so pathological-file behaviour is unchanged). - const timeoutMs = this.parseTimeoutMs + Math.floor(job.task.content.length / 100_000) * 10_000; + const timeoutMs = resolveParseBudgetMs(this.parseTimeoutMs, job.task.content.length); job.budgetMs = timeoutMs; job.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs); job.timer.unref?.(); diff --git a/src/index.ts b/src/index.ts index a05661c82..5fb75a1db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -976,6 +976,14 @@ export class CodeGraph { } } catch { /* vocab is advisory — never fail a sync over it */ } + // A killed full index leaves this marker at `indexing`. Sync repairs + // missing files, pending refs, and (on open) dropped indexes, so a + // successful recovery must also close the metadata state (#1556). + const fullReconcile = !options.paths || options.paths.length === 0; + if (fullReconcile && this.getIndexState() === 'indexing') { + try { this.queries.setMetadata('index_state', 'complete'); } catch { /* advisory */ } + } + return result; } finally { // Mirror indexAll's teardown: stop the valve, then restore the diff --git a/src/mcp/daemon-manager.ts b/src/mcp/daemon-manager.ts index 47a61e077..0c1a991a1 100644 --- a/src/mcp/daemon-manager.ts +++ b/src/mcp/daemon-manager.ts @@ -61,7 +61,7 @@ export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, } export interface PickerDeps { - list: () => DaemonRecord[]; + list: () => DaemonRecord[] | Promise; stop: (root: string) => Promise; stopAll: () => Promise; /** Realpath'd root of the current project's daemon, or null. */ @@ -82,7 +82,7 @@ export interface PickerDeps { */ export async function runDaemonPicker(deps: PickerDeps): Promise { for (;;) { - const daemons = deps.list(); + const daemons = await deps.list(); if (daemons.length === 0) { deps.done('All daemons stopped.'); return; diff --git a/src/mcp/daemon-paths.ts b/src/mcp/daemon-paths.ts index 13f19045f..c860ee76c 100644 --- a/src/mcp/daemon-paths.ts +++ b/src/mcp/daemon-paths.ts @@ -29,6 +29,7 @@ */ import * as crypto from 'crypto'; +import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; import { getCodeGraphDir } from '../directory'; @@ -101,6 +102,55 @@ export interface DaemonLockInfo { startedAt: number; } +/** + * Verify that the process named by a lockfile is the CodeGraph daemon serving + * its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs + * after an OOM/SIGKILL (#1553). + */ +export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000): Promise { + if (!Number.isInteger(info.pid) || info.pid <= 0 || !info.socketPath) return Promise.resolve(false); + return new Promise((resolve) => { + let socket: net.Socket; + let buffer = ''; + let done = false; + const finish = (ok: boolean) => { + if (done) return; + done = true; + clearTimeout(timer); + socket.destroy(); + resolve(ok); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + try { + socket = net.createConnection(info.socketPath); + } catch { + clearTimeout(timer); + resolve(false); + return; + } + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += String(chunk); + if (buffer.length > 4096) return finish(false); + const newline = buffer.indexOf('\n'); + if (newline < 0) return; + try { + const hello = JSON.parse(buffer.slice(0, newline)) as Record; + finish( + hello.protocol === 1 && + hello.pid === info.pid && + (info.version === 'unknown' || hello.codegraph === info.version) + ); + } catch { + finish(false); + } + }); + socket.on('error', () => finish(false)); + socket.on('close', () => finish(false)); + }); +} + /** * Serialize a {@link DaemonLockInfo} for writing to the pidfile. JSON for * human readability — operators occasionally `cat` this when debugging. diff --git a/src/mcp/daemon-registry.ts b/src/mcp/daemon-registry.ts index e1885361c..f731563cf 100644 --- a/src/mcp/daemon-registry.ts +++ b/src/mcp/daemon-registry.ts @@ -22,7 +22,13 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as crypto from 'crypto'; -import { getDaemonPidPath, getDaemonSocketCandidates, decodeLockInfo } from './daemon-paths'; +import { + getDaemonPidPath, + getDaemonSocketCandidates, + decodeLockInfo, + probeDaemonIdentity, + type DaemonLockInfo, +} from './daemon-paths'; export interface DaemonRecord { /** Realpath'd project root the daemon serves. */ @@ -114,6 +120,26 @@ export function listDaemons(opts: { prune?: boolean } = {}): DaemonRecord[] { return live.sort((a, b) => b.startedAt - a.startedAt); } +/** + * Registry entries whose socket hello proves the recorded process is the + * daemon. Used by every user-facing list/stop-all path so a reused PID cannot + * appear as a phantom running daemon (#1553). + */ +export async function listVerifiedDaemons(opts: { prune?: boolean } = {}): Promise { + const prune = opts.prune ?? true; + const candidates = listDaemons({ prune }); + const checks = await Promise.all(candidates.map(async (rec) => ({ + rec, + verified: await probeDaemonIdentity(rec), + }))); + const verified: DaemonRecord[] = []; + for (const check of checks) { + if (check.verified) verified.push(check.rec); + else if (prune) deregisterDaemon(check.rec.root); + } + return verified; +} + /** Remove a stopped daemon's leftover lockfile + socket + registry record. */ function cleanupDaemonArtifacts(root: string): void { try { fs.unlinkSync(getDaemonPidPath(root)); } catch { /* gone */ } @@ -128,6 +154,20 @@ function cleanupDaemonArtifacts(root: string): void { deregisterDaemon(root); } +/** Remove daemon artifacts only when no matching daemon answers the socket hello. */ +export async function clearStaleDaemonArtifacts(root: string): Promise { + const pidPath = getDaemonPidPath(root); + const hadArtifacts = fs.existsSync(pidPath) || ( + process.platform !== 'win32' && getDaemonSocketCandidates(root).some((p) => fs.existsSync(p)) + ); + if (!hadArtifacts) return false; + let info: DaemonLockInfo | null = null; + try { info = decodeLockInfo(fs.readFileSync(pidPath, 'utf8')); } catch { /* missing/corrupt */ } + if (info && isProcessAlive(info.pid) && await probeDaemonIdentity(info)) return false; + cleanupDaemonArtifacts(root); + return true; +} + const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); async function waitForDeath(pid: number, timeoutMs: number): Promise { @@ -154,9 +194,10 @@ export interface StopResult { */ export async function stopDaemonAt(root: string): Promise { let pid: number | null = null; + let identity: DaemonLockInfo | null = null; try { - const info = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8')); - pid = info?.pid ?? null; + identity = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8')); + pid = identity?.pid ?? null; } catch { /* no lockfile */ } @@ -165,6 +206,7 @@ export async function stopDaemonAt(root: string): Promise { (r) => path.resolve(r.root) === path.resolve(root) ); pid = rec?.pid ?? null; + if (rec) identity = rec; } if (pid == null) { @@ -175,6 +217,12 @@ export async function stopDaemonAt(root: string): Promise { cleanupDaemonArtifacts(root); return { root, pid, outcome: 'not-running' }; } + // Never signal a process merely because it reused a stale daemon PID. The + // daemon's immediate hello is the process-identity proof (#1553). + if (!identity || !await probeDaemonIdentity(identity)) { + cleanupDaemonArtifacts(root); + return { root, pid, outcome: 'not-running' }; + } // POSIX: SIGTERM runs the daemon's graceful shutdown. Windows: TerminateProcess // (no graceful path), so we always sweep artifacts ourselves below. @@ -192,7 +240,7 @@ export async function stopDaemonAt(root: string): Promise { /** Stop every registered, live daemon. */ export async function stopAllDaemons(): Promise { const results: StopResult[] = []; - for (const rec of listDaemons()) { + for (const rec of await listVerifiedDaemons()) { results.push(await stopDaemonAt(rec.root)); } return results; diff --git a/src/mcp/daemon.ts b/src/mcp/daemon.ts index b1d45328b..500c48a8c 100644 --- a/src/mcp/daemon.ts +++ b/src/mcp/daemon.ts @@ -629,25 +629,31 @@ export function acquireLockViaExclusiveOpen(pidPath: string, info: DaemonLockInf } /** - * Remove a stale pidfile, but only if it still names a dead process. Re-reads - * the file immediately before unlinking so we never delete a lock that a live - * daemon (re)acquired in the meantime. + * Remove a stale pidfile. Re-reads the file immediately before unlinking so a + * different daemon that acquired the lock in the meantime is never disturbed. * * must-fix 1 (issue #411 review): the original unconditionally `unlink`'d, * which let a racing candidate delete a healthy daemon's lock. Passing * `expectedDeadPid` (the pid the caller believed was dead) makes the clear a - * compare-and-delete: bail if the file now holds a different pid, or any live - * pid. Returns true when the stale lock is gone (or was already gone). + * compare-and-delete: bail if the file now holds a different pid. By default a + * live pid is also preserved; `allowLivePid` is reserved for callers that have + * already disproved daemon identity with the socket hello (#1553). Returns true + * when the stale lock is gone (or was already gone). */ -export function clearStaleDaemonLock(pidPath: string, expectedDeadPid?: number): boolean { +export function clearStaleDaemonLock( + pidPath: string, + expectedDeadPid?: number, + opts: { allowLivePid?: boolean } = {} +): boolean { try { const raw = fs.readFileSync(pidPath, 'utf8'); const info = decodeLockInfo(raw); if (info) { // A different pid took over since we read it — not ours to clear. if (expectedDeadPid !== undefined && info.pid !== expectedDeadPid) return false; - // Holder is actually alive — never clear a live daemon's lock. - if (info.pid > 0 && isProcessAlive(info.pid)) return false; + // PID liveness is normally sufficient. The takeover caller may override + // it only after a failed identity handshake proves PID reuse. + if (!opts.allowLivePid && info.pid > 0 && isProcessAlive(info.pid)) return false; } fs.unlinkSync(pidPath); return true; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c7c59f622..971121054 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -48,7 +48,7 @@ import { tryAcquireDaemonLock, } from './daemon'; import { connectWithHello, runLocalHandshakeProxy } from './proxy'; -import { getDaemonSocketCandidates } from './daemon-paths'; +import { getDaemonSocketCandidates, probeDaemonIdentity } from './daemon-paths'; import { getTelemetry } from '../telemetry'; import { checkForUpdateInBackground } from '../upgrade/update-check'; import { EARLY_PPID } from './early-ppid'; @@ -423,15 +423,22 @@ export class MCPServer { // binding) — we're redundant; exit cleanly so the launcher proxies to it. const existing = lock.existing; if (existing && existing.pid > 0 && isProcessAlive(existing.pid)) { - process.stderr.write( - `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n` - ); - process.exit(0); + // Give a newly-elected daemon time to bind, then require its socket hello + // to match the lock PID/version. PID existence alone accepts an unrelated + // process after OS PID reuse and permanently wedges startup (#1553). + const age = Date.now() - existing.startedAt; + const stillStarting = existing.startedAt > 0 && age >= 0 && age < 10_000; + if (stillStarting || await probeDaemonIdentity(existing)) { + process.stderr.write( + `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n` + ); + process.exit(0); + } } // Holder is dead (or the record is unreadable) — clear it (pid-verified, // so we never delete a live daemon's lock) and retry the acquire. - clearStaleDaemonLock(lock.pidPath, existing?.pid); + clearStaleDaemonLock(lock.pidPath, existing?.pid, { allowLivePid: true }); await sleep(TAKEOVER_RETRY_DELAY_MS); } diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 1ab809918..568b782c9 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -1209,7 +1209,7 @@ export async function cFnPointerDispatchEdges( // ---- receiver-type resolution within a function's source ---- // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known // fn-pointer-bearing struct). - const recvReCache = new Map(); + const recvReCache = new LRUCache(4096); const recvTypeIn = (fnSrc: string, recv: string): string | null => { let re = recvReCache.get(recv); if (!re) { @@ -1228,7 +1228,7 @@ export async function cFnPointerDispatchEdges( // structs (the base of a chained receiver needn't carry a fn pointer itself). // Falls back to a file-scope table variable (`cmdnames` in `cmdnames[i].fn()`). const escapeRe = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const varReCache = new Map(); + const varReCache = new LRUCache(4096); const varTypeIn = (fnSrc: string, v: string): string | null => { let re = varReCache.get(v); if (!re) { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index dc8333149..60b389937 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1241,7 +1241,12 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs const content = ctx.readFile(file); if (!content || (!content.includes(''))) continue; // JSX-file gate - const parents = ctx.getNodesInFile(file).filter((n) => PARENT_KINDS.has(n.kind)); + // File-level language gate, not merely a project-level one: mixed C/JS + // monorepos must not interpret `""` inside C as JSX (#1560). + const parents = ctx.getNodesInFile(file).filter( + (n) => PARENT_KINDS.has(n.kind) && JS_FAMILY.includes(n.language) + ); + if (parents.length === 0) continue; for (const parent of parents) { const src = sliceLines(content, parent.startLine, parent.endLine); if (!src || (!src.includes(''))) continue; @@ -3533,7 +3538,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [ { name: 'closureCollEdges', gate: ALWAYS, run: (q, c, y) => closureCollectionEdges(q, c, y) }, { name: 'emitterEdges', gate: ALWAYS, run: (_q, c, y) => eventEmitterEdges(c, y) }, { name: 'renderEdges', gate: ALWAYS, run: (q, c, y) => reactRenderEdges(q, c, y) }, - { name: 'jsxEdges', gate: ALWAYS, run: (_q, c, y) => reactJsxChildEdges(c, y) }, + { name: 'jsxEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactJsxChildEdges(c, y) }, { name: 'vueEdges', gate: (has) => has('vue'), run: (_q, c, y) => vueTemplateEdges(c, y) }, { name: 'svelteKitEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitLoadEdges(c, y) }, { name: 'pascalEdges', gate: ALWAYS, run: (_q, c, y) => pascalFormEdges(c, y) }, From 5e1e2b1a01e515563b116146ab39015ab906bddf Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Mon, 17 Aug 2026 19:29:25 +0300 Subject: [PATCH 2/2] test: cover daemon and recovery review gaps --- __tests__/cli-unlock.test.ts | 102 +++++++++++++++++++++ __tests__/foundation.test.ts | 17 ++++ __tests__/large-corpus-regressions.test.ts | 39 ++++++-- __tests__/mcp-daemon.test.ts | 39 ++++++++ src/db/index.ts | 14 ++- 5 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 __tests__/cli-unlock.test.ts diff --git a/__tests__/cli-unlock.test.ts b/__tests__/cli-unlock.test.ts new file mode 100644 index 000000000..9db3b7a02 --- /dev/null +++ b/__tests__/cli-unlock.test.ts @@ -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 { + 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((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((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 924aecbf6..b7616272a 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -138,6 +138,23 @@ describe('CodeGraph Foundation', () => { 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(); diff --git a/__tests__/large-corpus-regressions.test.ts b/__tests__/large-corpus-regressions.test.ts index 161544299..6584f7e53 100644 --- a/__tests__/large-corpus-regressions.test.ts +++ b/__tests__/large-corpus-regressions.test.ts @@ -41,6 +41,23 @@ describe('large-corpus regression fixes', () => { 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)', () => { @@ -62,18 +79,26 @@ describe('JSX synthesis language boundary (#1560)', () => { expect(rows.c).toBe(0); }); - it('does not scan a C parent as JSX merely because the project also contains JavaScript', async () => { + it('runs for JavaScript while excluding C parents in the same project', async () => { fs.writeFileSync( path.join(dir, 'native.c'), - 'void Foo(void) {}\nvoid parent(void) { const char *s = ""; }\n' + 'void Widget(void) {}\nvoid native_parent(void) { const char *s = ""; }\n' + ); + fs.writeFileSync( + path.join(dir, 'ui.jsx'), + 'export function Widget() { return ; }\nexport function App() { return ; }\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 }; + 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.c).toBe(0); + expect(rows).toContainEqual({ source_file: 'ui.jsx', target_name: 'Widget' }); + expect(rows.some((row) => row.source_file === 'native.c')).toBe(false); }); }); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index ab7613664..c73ac564c 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -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); diff --git a/src/db/index.ts b/src/db/index.ts index d03d27c9a..f01d195d1 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -366,13 +366,19 @@ export class DatabaseConnection { /** 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([ + const names = [...new Set([ ...DatabaseConnection.BULK_PARSE_INDEX_NAMES, ...DatabaseConnection.BULK_REF_INDEX_NAMES, ...DatabaseConnection.BULK_EDGE_INDEX_NAMES, - ]); + ])]; + const placeholders = names.map(() => '?').join(','); + const row = this.db + .prepare(`SELECT count(*) AS c FROM sqlite_master WHERE type = 'index' AND name IN (${placeholders})`) + .get(...names) as { c: number } | undefined; + if ((row?.c ?? 0) >= names.length) return; + + const schemaPath = path.join(__dirname, 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); 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`);