diff --git a/CHANGELOG.md b/CHANGELOG.md index 32724a53a..f44be8edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fixed a v1.5.0 regression where a perfectly valid file could be permanently recorded as having 0 symbols, with no error reported. When a file's first parse attempt was interrupted — a parsing worker crash or timeout, most likely on slow or heavily loaded machines — the automatic retry stored an empty result for any language on the native extraction path, so the file's functions and classes silently vanished from search, callers, and impact until the file was next edited. Retries now store the file's real symbols, and a file already recorded as symbol-free is detected and repaired automatically by the next sync or re-index after upgrading. Thanks @Baiae for the report. (#1541) - When indexing has to fall back to parsing a file with its comment lines stripped — a last-resort recovery after repeated parser crashes — the file is now flagged with a visible warning instead of being reported as cleanly indexed. The recovered result can be incomplete, and reporting success made a fresh index quietly disagree with a later re-parse of the same unchanged file. Thanks @jeremypetz for the precise init-versus-sync symbol accounting that exposed this. (#1565) - Syncing a large batch of changed files no longer crashes with "Maximum call stack size exceeded" partway through. The crash aborted reference resolution after the files' symbols were already stored, leaving the graph with far fewer connections than a fresh index would have — and it hit exactly the scenarios that re-parse many files at once, including the automatic repair above. Thanks @netbrah for pinpointing the failure. (#1558) +- A background daemon left behind by an out-of-memory kill or force-kill can no longer block every future session when the operating system reuses its process ID: daemon management now verifies a recorded process is really a CodeGraph daemon before trusting or signaling it, and `codegraph unlock` clears stale daemon artifacts as well as the indexing lock. Thanks @hcg1023 for the report and @danusha2345 for the fix. (#1553) +- Data-only C/C++ headers near the file-size limit no longer hold a parser worker for several minutes before timing out; the default large-file parse budget is now bounded, while an explicitly configured larger timeout is still honored. (#1555) +- Reopening an index after a crash during bulk loading now restores every dropped database index, and a successful recovery sync marks the index complete instead of leaving it permanently flagged as interrupted. (#1556) +- Files skipped because they are too large or repeatedly fail to parse are now recorded with the reason, so unchanged rejected files are no longer rediscovered and retried on every status check and sync — and a later successful parse of such a file replaces the record with its real symbols. Thanks @netbrah for the exceptional failure analysis behind this batch, and @danusha2345 for the fixes. (#1557) +- C/C++ function-pointer analysis now bounds its compiled-pattern caches, so very large repositories can no longer exhaust the JavaScript engine's regular-expression code space during indexing. (#1559) +- JSX rendering analysis now runs only on JavaScript-family files, so JSX-looking strings in C/C++ (or any other language) no longer create impossible call edges — in pure-C projects and in mixed-language monorepos alike. (#1560) ## [1.5.0] - 2026-07-21 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__/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..b7616272a 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -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(); diff --git a/__tests__/large-corpus-regressions.test.ts b/__tests__/large-corpus-regressions.test.ts new file mode 100644 index 000000000..109032cb8 --- /dev/null +++ b/__tests__/large-corpus-regressions.test.ts @@ -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 = ""; }\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 = ""; }\n' + ); + fs.writeFileSync( + path.join(dir, 'ui.jsx'), + 'export function Widget() { return ; }\nexport function App() { return ; }\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 }); + } + }); +}); 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/__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 acec9c2cb..e4038200d 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1708,10 +1708,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; @@ -1734,7 +1734,7 @@ program const clack = await importESM('@clack/prompts'); clack.intro('CodeGraph daemons'); await runDaemonPicker({ - list: listDaemons, + list: listVerifiedDaemons, stop: stopDaemonAt, stopAll: stopAllDaemons, cwdRoot, @@ -1840,14 +1840,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..f01d195d1 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,28 @@ export class DatabaseConnection { this.endBulkNodeLoad(); } + /** Recreate every secondary index a killed bulk parse/ref/edge window may leave dropped. */ + private healBulkSecondaryIndexes(): void { + 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`); + 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/extraction/index.ts b/src/extraction/index.ts index 3606d2591..2b61636b6 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1714,7 +1714,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; @@ -1742,27 +1742,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) { @@ -1793,16 +1791,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 @@ -1825,7 +1826,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; @@ -1844,7 +1845,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(); })(); @@ -1915,15 +1916,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; } @@ -2242,9 +2246,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: [], @@ -2258,10 +2264,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: [], @@ -2279,9 +2286,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; } @@ -2303,7 +2308,15 @@ export class ExtractionOrchestrator { */ private healZeroNodeRows(): void { for (const f of this.queries.getAllFiles()) { - if (f.nodeCount === 0 && !isFileLevelOnlyLanguage(f.language)) { + // A zero-node row WITH recorded errors is a deliberate skip marker + // (#1557: oversized / repeatedly-unparseable files are persisted with + // their reason so syncs stop retrying them) — leave those alone. The + // #1541 wipe rows are the error-FREE zero-node rows. + if ( + f.nodeCount === 0 && + !isFileLevelOnlyLanguage(f.language) && + (f.errors === undefined || f.errors.length === 0) + ) { this.queries.deleteFile(f.path); } } @@ -2332,10 +2345,20 @@ export class ExtractionOrchestrator { const STORE_CHUNK = 2000; const contentHash = hashContent(content); - // Check if file already exists and hasn't changed + // Check if file already exists and hasn't changed. A skip/failure MARKER + // row (zero nodes + recorded errors, #1557) never blocks a store carrying + // real content: markers are written BEFORE the retry pass under the same + // content hash, so treating them as "no changes" would silently discard a + // successful retry's symbols — a permanent empty file presented as + // recovered (the #1541 wipe, reintroduced through the marker path). const existingFile = this.queries.getFileByPath(filePath); if (existingFile && existingFile.contentHash === contentHash) { - return; // No changes + const existingIsMarker = + existingFile.nodeCount === 0 && (existingFile.errors?.length ?? 0) > 0; + const incomingHasContent = result.nodes.length > 0; + if (!existingIsMarker || !incomingHasContent) { + return; // No changes + } } // Re-decided on every re-index of a changed file, so a banner added (or 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 15eaf7a7a..2942575b5 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) },