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

* fix: harden indexing recovery and daemon liveness

* test: cover daemon and recovery review gaps

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

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

---------

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-08-20 12:53:49 -05:00
committed by GitHub
co-authored by Claude Fable 5 danusha2345 Colby McHenry
parent d8f2eeaddf
commit 81e1f4a92f
20 changed files with 680 additions and 83 deletions
+11 -10
View File
@@ -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;
let removed = false;
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
removed = true;
}
fs.unlinkSync(lockPath);
success('Removed lock file. You can now run indexing again.');
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);
+23
View File
@@ -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<string>([
...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.
+70 -47
View File
@@ -1714,7 +1714,7 @@ export class ExtractionOrchestrator {
const inFlight = new Set<Promise<void>>();
const completed = new Map<number,
| { ok: true; filePath: string; content: string; stats: fs.Stats; result: ExtractionResult }
| { ok: false; filePath: string; err: unknown }>();
| { 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<void> => {
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
+14 -1
View File
@@ -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.55 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?.();
+8
View File
@@ -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
+2 -2
View File
@@ -61,7 +61,7 @@ export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null,
}
export interface PickerDeps {
list: () => DaemonRecord[];
list: () => DaemonRecord[] | Promise<DaemonRecord[]>;
stop: (root: string) => Promise<StopResult>;
stopAll: () => Promise<StopResult[]>;
/** 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<void> {
for (;;) {
const daemons = deps.list();
const daemons = await deps.list();
if (daemons.length === 0) {
deps.done('All daemons stopped.');
return;
+50
View File
@@ -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<boolean> {
if (!Number.isInteger(info.pid) || info.pid <= 0 || !info.socketPath) return Promise.resolve(false);
return new Promise<boolean>((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<string, unknown>;
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.
+52 -4
View File
@@ -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<DaemonRecord[]> {
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<boolean> {
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<void> => new Promise((r) => setTimeout(r, ms));
async function waitForDeath(pid: number, timeoutMs: number): Promise<boolean> {
@@ -154,9 +194,10 @@ export interface StopResult {
*/
export async function stopDaemonAt(root: string): Promise<StopResult> {
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<StopResult> {
(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<StopResult> {
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<StopResult> {
/** Stop every registered, live daemon. */
export async function stopAllDaemons(): Promise<StopResult[]> {
const results: StopResult[] = [];
for (const rec of listDaemons()) {
for (const rec of await listVerifiedDaemons()) {
results.push(await stopDaemonAt(rec.root));
}
return results;
+14 -8
View File
@@ -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;
+13 -6
View File
@@ -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);
}
+2 -2
View File
@@ -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<string, RegExp>();
const recvReCache = new LRUCache<string, RegExp>(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<string, RegExp>();
const varReCache = new LRUCache<string, RegExp>(4096);
const varTypeIn = (fnSrc: string, v: string): string | null => {
let re = varReCache.get(v);
if (!re) {
+7 -2
View File
@@ -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('</') && !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 `"<Foo/>"` 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('</') && !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) },