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:
co-authored by
Claude Fable 5
danusha2345
Colby McHenry
parent
d8f2eeaddf
commit
81e1f4a92f
+11
-10
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user