mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 01:29:02 +08:00
fix(mcp,extraction): exit a daemon whose install changed; never store a grammar-load failure (#2335, #2336) (#2346)
* fix(mcp,extraction): a daemon whose install changed exits; grammar-load failures are never stored (#2335, #2336) A daemon that outlived an upgrade loaded grammars lazily from an install that no longer existed, and stored every file its watcher re-indexed as a zero-node row under the file's new content hash: no hash-based sync ever revisited those rows, and `codegraph status` called the index up to date. - The daemon checks its own package.json every 30 s (CODEGRAPH_DAEMON_INSTALL_CHECK_MS, 0 turns it off) and exits once the file is gone or carries another version, so the next session starts a daemon from the current install. - A result whose grammar failed to load (`parser_error`) is never stored: the file keeps its previous data and the next sync or index retries it, and a row an older engine stored that way is re-indexed even though its hash matches. The tag-based CFML path reports a missing grammar with the same code. - A launcher whose own install was deleted no longer crashes on the spawn error of the daemon it tried to start. - `codegraph status` and `status --json` (filesNeedingReindex, filesWithParseErrors) report indexed files whose symbols are missing although their content is current (#2336), and `files --json` carries each file's recorded errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): a session whose own install changed never claims the writer lock (#2335) A daemon now exits once its install is upgraded or removed, and nobody holds the writer slot for a successor at that point. Its sessions run the same replaced code and serve themselves in-process the moment it goes; one that found the slot free claimed it as its writer even though its engine could not load (a real npm upgrade: "Cannot find module './sqlite-adapter'"), and so kept a daemon from the current install from starting. Such a session now falls back read-only and says to restart it. Also adds the CHANGELOG entries for the install check and grammar-load failures (#2335) and for the status report (#2336). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
511d86e94d
commit
dea076fd1e
+71
-1
@@ -54,7 +54,7 @@ import {
|
||||
getDaemonSocketCandidates,
|
||||
getDaemonSocketPath,
|
||||
} from './daemon-paths';
|
||||
import { CodeGraphPackageVersion } from './version';
|
||||
import { CodeGraphPackageJsonPath, CodeGraphPackageVersion } from './version';
|
||||
import {
|
||||
releaseWriterLock,
|
||||
swapWriterLock,
|
||||
@@ -120,6 +120,38 @@ export function finalizeDaemonExit(
|
||||
/** How often the daemon sweeps connected clients for a dead peer process (#692). */
|
||||
const DEFAULT_CLIENT_SWEEP_MS = 30_000;
|
||||
|
||||
/** How often the daemon checks that the install it runs from is still in place (#2335). */
|
||||
const DEFAULT_INSTALL_CHECK_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Why the install this daemon was started from no longer matches it, or null
|
||||
* while it still does. An upgrade replaces or deletes the package directory
|
||||
* under a running daemon (npm reinstalling the package, the launcher pruning
|
||||
* an old bundle), and everything the daemon loads lazily from there afterwards
|
||||
* fails — grammars among them, so every file its watcher re-indexed came out
|
||||
* empty (#2335). Such a daemon should make way for one started from the
|
||||
* current install. Any other read failure, or a package.json caught mid-write,
|
||||
* answers null: the next check looks again. Exported for testing.
|
||||
*/
|
||||
export function installChangedReason(packageJsonPath: string, runningVersion: string): string | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return code === 'ENOENT' || code === 'ENOTDIR' ? `Install removed (${packageJsonPath} is gone)` : null;
|
||||
}
|
||||
let version: unknown;
|
||||
try {
|
||||
version = (JSON.parse(raw) as { version?: unknown } | null)?.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return typeof version === 'string' && version !== runningVersion
|
||||
? `Install replaced by v${version} (this daemon is v${runningVersion})`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** How long the daemon waits for the optional client-hello before proceeding without it. */
|
||||
const CLIENT_HELLO_TIMEOUT_MS = 3_000;
|
||||
|
||||
@@ -185,6 +217,7 @@ export class Daemon {
|
||||
private lastActivityAt = Date.now();
|
||||
private maxIdleTimer: NodeJS.Timeout | null = null;
|
||||
private clientSweepTimer: NodeJS.Timeout | null = null;
|
||||
private installCheckTimer: NodeJS.Timeout | null = null;
|
||||
private engine: MCPEngine;
|
||||
private stopping = false;
|
||||
private socketPath: string;
|
||||
@@ -372,6 +405,10 @@ export class Daemon {
|
||||
clearInterval(this.clientSweepTimer);
|
||||
this.clientSweepTimer = null;
|
||||
}
|
||||
if (this.installCheckTimer) {
|
||||
clearInterval(this.installCheckTimer);
|
||||
this.installCheckTimer = null;
|
||||
}
|
||||
process.stderr.write(`[CodeGraph daemon] Shutting down (${reason}; clients=${this.clients.size}).\n`);
|
||||
for (const session of [...this.clients]) {
|
||||
try { session.stop(); } catch { /* best-effort */ }
|
||||
@@ -491,6 +528,31 @@ export class Daemon {
|
||||
this.clientSweepTimer = setInterval(() => this.reapDeadClients(isProcessAlive), sweepMs);
|
||||
this.clientSweepTimer.unref?.();
|
||||
}
|
||||
// An install whose version could not be read at startup has nothing to
|
||||
// compare against; leave that daemon alone.
|
||||
const installMs = resolveInstallCheckMs();
|
||||
if (installMs > 0 && CodeGraphPackageVersion !== '0.0.0-unknown') {
|
||||
this.installCheckTimer = setInterval(() => { this.checkInstall(); }, installMs);
|
||||
this.installCheckTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit once the install this daemon runs from has been upgraded or removed
|
||||
* (#2335; see {@link installChangedReason}). Its clients' proxies fall back
|
||||
* and reconnect, and the next launch starts a daemon from the current
|
||||
* install. Returns whether the daemon is exiting. The check is injected for
|
||||
* tests; the timer passes the real one.
|
||||
*/
|
||||
checkInstall(
|
||||
changed: () => string | null = () => installChangedReason(CodeGraphPackageJsonPath, CodeGraphPackageVersion),
|
||||
): boolean {
|
||||
if (this.stopping) return false;
|
||||
const reason = changed();
|
||||
if (!reason) return false;
|
||||
process.stderr.write(`[CodeGraph daemon] ${reason}; exiting so the next session starts a daemon from the current install.\n`);
|
||||
void this.stop('install changed');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -865,6 +927,14 @@ function resolveClientSweepMs(): number {
|
||||
return Math.floor(parsed); // 0 disables the sweep
|
||||
}
|
||||
|
||||
function resolveInstallCheckMs(): number {
|
||||
const raw = process.env.CODEGRAPH_DAEMON_INSTALL_CHECK_MS;
|
||||
if (raw === undefined || raw === '') return DEFAULT_INSTALL_CHECK_MS;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_INSTALL_CHECK_MS;
|
||||
return Math.floor(parsed); // 0 disables the check
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one client-hello line. Returns the peer pids if `line` is a well-formed
|
||||
* client-hello (carries the `codegraph_client` marker), or null otherwise — in
|
||||
|
||||
+16
-1
@@ -44,12 +44,13 @@ import { MCPSession } from './session';
|
||||
import {
|
||||
Daemon,
|
||||
clearStaleDaemonLock,
|
||||
installChangedReason,
|
||||
isProcessAlive,
|
||||
tryAcquireDaemonLock,
|
||||
} from './daemon';
|
||||
import { clearStaleDaemonArtifacts, stopOlderDaemon } from './daemon-registry';
|
||||
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
|
||||
import { CodeGraphPackageVersion } from './version';
|
||||
import { CodeGraphPackageJsonPath, CodeGraphPackageVersion } from './version';
|
||||
import {
|
||||
readWriterLock,
|
||||
assertNoRebuild,
|
||||
@@ -150,6 +151,14 @@ function makeFallbackEngine(root: string): MCPEngine {
|
||||
if (existing && isProcessAlive(existing.pid)) {
|
||||
return readOnlyFallback(`live daemon PID ${existing.pid} holds the project lock`);
|
||||
}
|
||||
// This session's own install was upgraded or removed underneath it (#2335):
|
||||
// its code no longer loads whole, so as the project's writer it would only
|
||||
// keep a daemon from the current install from starting. Its daemon exits
|
||||
// for the same reason, which is how it got here.
|
||||
if (CodeGraphPackageVersion !== '0.0.0-unknown' &&
|
||||
installChangedReason(CodeGraphPackageJsonPath, CodeGraphPackageVersion) !== null) {
|
||||
return readOnlyFallback('this session\'s CodeGraph install was upgraded or removed; restart the session to use the current one');
|
||||
}
|
||||
return new MCPEngine({ writerLockRoot: root, queryPool: true, queryPoolDefaultMax: DIRECT_QUERY_POOL_MAX });
|
||||
}
|
||||
|
||||
@@ -295,6 +304,12 @@ function spawnDetachedDaemon(root: string, handover = false): void {
|
||||
env,
|
||||
},
|
||||
);
|
||||
// An upgrade can delete the install this launcher runs from — its daemon
|
||||
// then exits (#2335) and a respawn finds no executable. spawn reports that
|
||||
// as an asynchronous 'error' event, which would crash this process if
|
||||
// nobody listened. No daemon binds either way; the caller's poll gives up
|
||||
// and the session is served in-process.
|
||||
child.on('error', () => { /* no daemon — see above */ });
|
||||
child.unref();
|
||||
} finally {
|
||||
// The child holds its own dup of the log fd now; the launcher doesn't need it.
|
||||
|
||||
+4
-2
@@ -20,10 +20,12 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/** The `package.json` of the install this process runs from. */
|
||||
export const CodeGraphPackageJsonPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
|
||||
function readPackageVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
const raw = fs.readFileSync(pkgPath, 'utf8');
|
||||
const raw = fs.readFileSync(CodeGraphPackageJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.version === 'string' && parsed.version.length > 0) {
|
||||
return parsed.version;
|
||||
|
||||
Reference in New Issue
Block a user