mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix(db): recover secondary indexes off-thread (#1887)
This commit is contained in:
committed by
Forge
parent
a36a9e1013
commit
5c41bdf606
@@ -181,6 +181,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". (#1895)
|
||||
- An MPEG transport stream video that happens to be named `.ts` (golden fixtures under `testdata/`, e2e clips) is now recognised from its first bytes and skipped as non-source instead of being fed to the TypeScript parser — a 900 KB clip used to cost about 28 seconds of CPU per file for no symbols, and a folder of them minutes. Real TypeScript is never affected. (#1910)
|
||||
|
||||
- Restarting after an interrupted index no longer gets trapped in repeated watchdog restarts while repairing the database. (#1887)
|
||||
|
||||
- Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861)
|
||||
|
||||
- Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869)
|
||||
|
||||
@@ -112,13 +112,13 @@ describe('issue #238 — ToolHandler reuses the default instance (#2)', () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => {
|
||||
const openSpy = vi.spyOn(CodeGraph, 'openSync');
|
||||
it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', async () => {
|
||||
const openSpy = vi.spyOn(CodeGraph, 'open');
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolved = (handler as any).getCodeGraph(root);
|
||||
const resolved = await (handler as any).getCodeGraph(root);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist'));
|
||||
const nested = await (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist'));
|
||||
expect(resolved).toBe(cg);
|
||||
expect(nested).toBe(cg); // a sub-path resolves up to the same default project
|
||||
expect(openSpy).not.toHaveBeenCalled(); // no second connection opened
|
||||
@@ -128,7 +128,7 @@ describe('issue #238 — ToolHandler reuses the default instance (#2)', () => {
|
||||
});
|
||||
|
||||
it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => {
|
||||
const openSpy = vi.spyOn(CodeGraph, 'openSync');
|
||||
const openSpy = vi.spyOn(CodeGraph, 'open');
|
||||
try {
|
||||
const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [
|
||||
handler.execute('codegraph_search', { query: 'helper' }),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* repros are gated to non-Windows; `isReplacedOnDisk` is verified to stay false
|
||||
* on Windows.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -114,4 +114,29 @@ describe('CodeGraph.reopenIfReplaced (issue #925)', () => {
|
||||
expect(server.reopenIfReplaced()).toBe(false);
|
||||
server.destroy();
|
||||
});
|
||||
|
||||
it('does not publish a replacement connection after close wins the race', async () => {
|
||||
const server = CodeGraph.initSync(root);
|
||||
const stale = (server as unknown as { db: DatabaseConnection }).db;
|
||||
const fresh = DatabaseConnection.initialize(path.join(root, 'replacement.db'));
|
||||
let finishOpen!: (connection: DatabaseConnection) => void;
|
||||
const openGate = new Promise<DatabaseConnection>((resolve) => { finishOpen = resolve; });
|
||||
const replacedSpy = vi.spyOn(stale, 'isReplacedOnDisk').mockReturnValue(true);
|
||||
const openSpy = vi.spyOn(DatabaseConnection, 'openAsync').mockReturnValue(openGate);
|
||||
|
||||
try {
|
||||
const reopening = server.reopenIfReplacedAsync();
|
||||
server.close();
|
||||
finishOpen(fresh);
|
||||
|
||||
expect(await reopening).toBe(false);
|
||||
expect(fresh.isOpen()).toBe(false);
|
||||
expect(openSpy).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
replacedSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
try { server.close(); } catch { /* may already be closed */ }
|
||||
try { fresh.close(); } catch { /* recovery closes it on the guarded path */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +138,24 @@ describe('CodeGraph Foundation', () => {
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('restores every secondary index through the asynchronous open path (#1887)', async () => {
|
||||
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 = await DatabaseConnection.openAsync(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);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { DatabaseConnection, getDatabasePath } from '../src/db';
|
||||
import {
|
||||
parseWatchdogTimeoutMs,
|
||||
deriveCheckIntervalMs,
|
||||
@@ -58,25 +61,35 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
|
||||
env: Record<string, string>,
|
||||
body: string,
|
||||
hardTimeoutMs: number,
|
||||
progressPaths?: string[]
|
||||
): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> {
|
||||
progressPaths?: string[],
|
||||
prelude = ''
|
||||
): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null; stderr: string }> {
|
||||
const src = `
|
||||
const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)});
|
||||
installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''});
|
||||
${body}
|
||||
(async () => {
|
||||
${prelude}
|
||||
const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)});
|
||||
installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''});
|
||||
${body}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(23);
|
||||
});
|
||||
`;
|
||||
const child = spawn(process.execPath, ['-e', src], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk; });
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolve({ code: null, signal: 'TIMEOUT' });
|
||||
resolve({ code: null, signal: 'TIMEOUT', stderr });
|
||||
}, hardTimeoutMs);
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, signal });
|
||||
resolve({ code, signal, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -183,6 +196,69 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
|
||||
expectKilled(r);
|
||||
}, 20000);
|
||||
|
||||
it('keeps interrupted secondary-index recovery off the watched event loop (#1887)', async () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-recovery-'));
|
||||
try {
|
||||
const project = CodeGraph.initSync(projectRoot);
|
||||
project.close();
|
||||
|
||||
const dbPath = getDatabasePath(projectRoot);
|
||||
const seed = DatabaseConnection.open(dbPath);
|
||||
const expectedIndexes = (seed.getDb()
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
|
||||
.all() as Array<{ name: string }>).map((row) => row.name);
|
||||
|
||||
seed.beginBulkNodeLoad();
|
||||
seed.beginBulkParseLoad();
|
||||
const insert = seed.getDb().prepare(
|
||||
`INSERT INTO nodes (
|
||||
id, kind, name, qualified_name, file_path, language,
|
||||
start_line, end_line, start_column, end_column, updated_at
|
||||
) VALUES (?, 'function', ?, ?, ?, 'typescript', 1, 1, 0, 1, ?)`
|
||||
);
|
||||
seed.getDb().exec('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < 600_000; i++) {
|
||||
const name = `symbol_${i}`;
|
||||
insert.run(`node_${i}`, name, name, `src/file_${i % 1000}.ts`, Date.now());
|
||||
}
|
||||
seed.getDb().exec('COMMIT');
|
||||
} catch (error) {
|
||||
seed.getDb().exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
seed.endBulkNodeLoad();
|
||||
seed.close();
|
||||
|
||||
const DIST_INDEX = path.resolve(__dirname, '../dist/index.js');
|
||||
const prelude = `
|
||||
const { CodeGraph, initGrammars } = require(${JSON.stringify(DIST_INDEX)});
|
||||
await initGrammars();
|
||||
`;
|
||||
const body = `
|
||||
CodeGraph.open(${JSON.stringify(projectRoot)}).then((cg) => {
|
||||
const names = cg.db.getDb()
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
|
||||
.all().map((row) => row.name);
|
||||
cg.close();
|
||||
process.exit(JSON.stringify(names) === ${JSON.stringify(JSON.stringify(expectedIndexes))} ? 0 : 21);
|
||||
}).catch(() => process.exit(22));
|
||||
`;
|
||||
const r = await runChild(
|
||||
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '100' },
|
||||
body,
|
||||
20_000,
|
||||
[dbPath, `${dbPath}-wal`],
|
||||
prelude
|
||||
);
|
||||
|
||||
expect(r.signal).toBeNull();
|
||||
expect(r.code, r.stderr).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => {
|
||||
const { code, signal } = await runChild(
|
||||
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' },
|
||||
|
||||
+106
-10
@@ -163,6 +163,42 @@ export class DatabaseConnection {
|
||||
* Open an existing database
|
||||
*/
|
||||
static open(dbPath: string): DatabaseConnection {
|
||||
const conn = DatabaseConnection.openBase(dbPath);
|
||||
try {
|
||||
conn.healBulkSecondaryIndexes();
|
||||
|
||||
// Self-heal a killed session's leftover oversized WAL (#1431) — one
|
||||
// statSync when healthy, off-thread checkpoint+truncate when not.
|
||||
void conn.healOversizedWal();
|
||||
return conn;
|
||||
} catch (error) {
|
||||
try { conn.close(); } catch { /* preserve the recovery error */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an existing database without rebuilding dropped secondary indexes on
|
||||
* the caller's event loop. A killed bulk-load window can leave those indexes
|
||||
* absent, and rebuilding them on a large graph is long-running SQLite work.
|
||||
*/
|
||||
static async openAsync(dbPath: string): Promise<DatabaseConnection> {
|
||||
const conn = DatabaseConnection.openBase(dbPath);
|
||||
try {
|
||||
await conn.healBulkSecondaryIndexesOffThread();
|
||||
|
||||
// Self-heal a killed session's leftover oversized WAL (#1431) — one
|
||||
// statSync when healthy, off-thread checkpoint+truncate when not.
|
||||
void conn.healOversizedWal();
|
||||
return conn;
|
||||
} catch (error) {
|
||||
try { conn.close(); } catch { /* preserve the recovery error */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Open and perform the short, synchronous recovery shared by both paths. */
|
||||
private static openBase(dbPath: string): DatabaseConnection {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`Database not found: ${dbPath}`);
|
||||
}
|
||||
@@ -191,12 +227,6 @@ 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.
|
||||
void conn.healOversizedWal();
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
@@ -429,6 +459,20 @@ export class DatabaseConnection {
|
||||
|
||||
/** Recreate every secondary index a killed bulk parse/ref/edge window may leave dropped. */
|
||||
private healBulkSecondaryIndexes(): void {
|
||||
for (const ddl of this.bulkSecondaryIndexDdls()) {
|
||||
this.db.exec(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recreate dropped bulk-load indexes on a dedicated SQLite connection. */
|
||||
private async healBulkSecondaryIndexesOffThread(): Promise<void> {
|
||||
const ddls = this.bulkSecondaryIndexDdls();
|
||||
if (ddls.length === 0) return;
|
||||
await this.runSqlOffThread(ddls);
|
||||
}
|
||||
|
||||
/** Return the canonical DDL needed to heal a killed bulk-load window. */
|
||||
private bulkSecondaryIndexDdls(): string[] {
|
||||
const names = [...new Set<string>([
|
||||
...DatabaseConnection.BULK_PARSE_INDEX_NAMES,
|
||||
...DatabaseConnection.BULK_REF_INDEX_NAMES,
|
||||
@@ -438,15 +482,15 @@ export class DatabaseConnection {
|
||||
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;
|
||||
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) {
|
||||
return names.map((idx) => {
|
||||
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]);
|
||||
}
|
||||
return m[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,6 +862,58 @@ export class DatabaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run required SQL on a worker-owned connection. Unlike maintenance pragmas,
|
||||
* crash recovery is not best-effort: the database must not be handed to a
|
||||
* caller until every statement succeeds.
|
||||
*/
|
||||
private async runSqlOffThread(statements: string[]): Promise<void> {
|
||||
const { Worker } = await import('node:worker_threads');
|
||||
const workerSource = `
|
||||
const { workerData, parentPort } = require('node:worker_threads');
|
||||
let db;
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
db = new DatabaseSync(workerData.dbPath);
|
||||
db.exec('PRAGMA busy_timeout=30000');
|
||||
for (const sql of workerData.statements) db.exec(sql);
|
||||
db.close();
|
||||
db = undefined;
|
||||
parentPort.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
try { if (db) db.close(); } catch {}
|
||||
parentPort.postMessage({
|
||||
ok: false,
|
||||
error: error && error.message ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const worker = new Worker(workerSource, {
|
||||
eval: true,
|
||||
workerData: { dbPath: this.dbPath, statements },
|
||||
});
|
||||
worker.once('message', (message: { ok: boolean; error?: string }) => {
|
||||
if (message.ok) finish();
|
||||
else finish(new Error(`Secondary-index recovery failed: ${message.error ?? 'unknown worker error'}`));
|
||||
});
|
||||
worker.once('error', (error) => finish(error));
|
||||
worker.once('exit', (code) => {
|
||||
if (code !== 0) finish(new Error(`Secondary-index recovery worker exited with code ${code}`));
|
||||
else finish(new Error('Secondary-index recovery worker exited before reporting completion'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
+38
-1
@@ -181,6 +181,8 @@ export class CodeGraph {
|
||||
|
||||
// File watcher for auto-sync on file changes
|
||||
private watcher: FileWatcher | null = null;
|
||||
private closed = false;
|
||||
private reopenPromise: Promise<boolean> | null = null;
|
||||
|
||||
private constructor(
|
||||
db: DatabaseConnection,
|
||||
@@ -278,6 +280,7 @@ export class CodeGraph {
|
||||
* file can't be unlinked there, and st_ino is unreliable).
|
||||
*/
|
||||
reopenIfReplaced(): boolean {
|
||||
if (this.closed) return false;
|
||||
recoverExtensions(this.projectRoot);
|
||||
if (!this.db.isReplacedOnDisk()) return false;
|
||||
const dbPath = this.db.getPath();
|
||||
@@ -295,6 +298,38 @@ export class CodeGraph {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Async counterpart used by long-lived servers so recovery stays off-loop. */
|
||||
async reopenIfReplacedAsync(): Promise<boolean> {
|
||||
if (this.closed) return false;
|
||||
if (this.reopenPromise) return this.reopenPromise;
|
||||
const reopening = this.doReopenIfReplacedAsync();
|
||||
this.reopenPromise = reopening;
|
||||
try {
|
||||
return await reopening;
|
||||
} finally {
|
||||
if (this.reopenPromise === reopening) this.reopenPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized implementation for {@link reopenIfReplacedAsync}. */
|
||||
private async doReopenIfReplacedAsync(): Promise<boolean> {
|
||||
if (!this.db.isReplacedOnDisk()) return false;
|
||||
const dbPath = this.db.getPath();
|
||||
// As above, complete the new open before disturbing the still-usable stale
|
||||
// handle. This path also keeps secondary-index recovery off the event loop.
|
||||
const fresh = await DatabaseConnection.openAsync(dbPath);
|
||||
if (this.closed) {
|
||||
try { fresh.close(); } catch { /* close() won the lifecycle race */ }
|
||||
return false;
|
||||
}
|
||||
const stale = this.db;
|
||||
this.db = fresh;
|
||||
this.queries = new QueryBuilder(fresh.getDb());
|
||||
this.wireLayers();
|
||||
try { stale.close(); } catch { /* the old inode is gone; closing just frees fds */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lifecycle Methods
|
||||
// ===========================================================================
|
||||
@@ -394,7 +429,7 @@ export class CodeGraph {
|
||||
|
||||
// Open database
|
||||
const dbPath = getDatabasePath(resolvedRoot);
|
||||
const db = DatabaseConnection.open(dbPath);
|
||||
const db = await DatabaseConnection.openAsync(dbPath);
|
||||
const queries = new QueryBuilder(db.getDb());
|
||||
|
||||
const instance = new CodeGraph(db, queries, resolvedRoot);
|
||||
@@ -497,6 +532,8 @@ export class CodeGraph {
|
||||
* Close the CodeGraph instance and release resources
|
||||
*/
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.unwatch();
|
||||
this.semanticParseReuse.clear();
|
||||
// Release file lock if held
|
||||
|
||||
+30
-7
@@ -173,13 +173,26 @@ export class MCPEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous last-resort init used by the per-session retry loop when the
|
||||
* background `ensureInitialized` already finished (or failed) and we need
|
||||
* to pick up a project that appeared *after* the engine started.
|
||||
* Last-resort init used by the per-session retry loop when the background
|
||||
* `ensureInitialized` already finished (or failed) and we need to pick up a
|
||||
* project that appeared *after* the engine started.
|
||||
*/
|
||||
retryInitializeSync(searchFrom: string): void {
|
||||
async retryInitializeSync(searchFrom: string): Promise<void> {
|
||||
if (this.closed) return;
|
||||
if (this.toolHandler.hasDefaultCodeGraph()) return;
|
||||
if (this.initPromise) {
|
||||
try { await this.initPromise; } catch { /* let caller retry */ }
|
||||
return;
|
||||
}
|
||||
|
||||
this.initPromise = this.doRetryInitialize(searchFrom).finally(() => {
|
||||
this.initPromise = null;
|
||||
});
|
||||
try { await this.initPromise; } catch { /* let the next tool call retry */ }
|
||||
}
|
||||
|
||||
/** Serialized implementation for {@link retryInitializeSync}. */
|
||||
private async doRetryInitialize(searchFrom: string): Promise<void> {
|
||||
this.toolHandler.setDefaultProjectHint(searchFrom);
|
||||
// Same resolution `doInitialize` used: up-walk, then the bounded workspace
|
||||
// down-scan (#1606) — this retry is exactly the path that picks up a
|
||||
@@ -201,7 +214,12 @@ export class MCPEngine {
|
||||
try { this.cg.close(); } catch { /* ignore */ }
|
||||
this.cg = null;
|
||||
}
|
||||
this.cg = loadCodeGraph().openSync(resolvedRoot);
|
||||
const opened = await loadCodeGraph().open(resolvedRoot);
|
||||
if (this.closed) {
|
||||
try { opened.close(); } catch { /* shutdown already owns cleanup */ }
|
||||
return;
|
||||
}
|
||||
this.cg = opened;
|
||||
this.projectPath = resolvedRoot;
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
@@ -269,7 +287,12 @@ export class MCPEngine {
|
||||
|
||||
this.projectPath = resolvedRoot;
|
||||
try {
|
||||
this.cg = await loadCodeGraph().open(resolvedRoot);
|
||||
const opened = await loadCodeGraph().open(resolvedRoot);
|
||||
if (this.closed) {
|
||||
try { opened.close(); } catch { /* shutdown already owns cleanup */ }
|
||||
return;
|
||||
}
|
||||
this.cg = opened;
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
this.catchUpSync();
|
||||
@@ -296,7 +319,7 @@ export class MCPEngine {
|
||||
* keep working.
|
||||
*/
|
||||
private startWatching(): void {
|
||||
if (!this.cg || this.watcherStarted || !this.opts.watch) return;
|
||||
if (this.closed || !this.cg || this.watcherStarted || !this.opts.watch) return;
|
||||
|
||||
// #1740: only one live watcher/writer per project. Daemon and startDirect
|
||||
// usually already hold writer.pid (re-entrant for this pid). Proxy
|
||||
|
||||
+3
-3
@@ -345,10 +345,10 @@ export class MCPSession {
|
||||
if (this.engine.hasDefaultCodeGraph()) return;
|
||||
}
|
||||
|
||||
// Last resort: walk from the best candidate (sync open). Picks up
|
||||
// projects that appeared after the server started.
|
||||
// Last resort: walk from the best candidate. Picks up projects that
|
||||
// appeared after the server started.
|
||||
const candidate = hint ?? process.cwd();
|
||||
this.engine.retryInitializeSync(candidate);
|
||||
await this.engine.retryInitializeSync(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+45
-22
@@ -1466,6 +1466,11 @@ const DEFAULT_MCP_TOOLS = new Set(['explore']);
|
||||
export class ToolHandler {
|
||||
// Cache of opened CodeGraph instances for cross-project queries
|
||||
private projectCache: Map<string, CodeGraph> = new Map();
|
||||
// Coalesce concurrent first queries for the same project while its async
|
||||
// recovery/open is still in flight, so only one connection is created.
|
||||
private projectOpenPromises: Map<string, Promise<CodeGraph>> = new Map();
|
||||
// Invalidates an in-flight open when closeAll() begins during shutdown.
|
||||
private projectCacheGeneration = 0;
|
||||
// The directory the server last searched for a default project. Surfaced in
|
||||
// the "not initialized" error so users can see why detection missed.
|
||||
private defaultProjectHint: string | null = null;
|
||||
@@ -1709,7 +1714,7 @@ export class ToolHandler {
|
||||
* Walks up parent directories to find the nearest .codegraph/ folder,
|
||||
* similar to how git finds .git/ directories.
|
||||
*/
|
||||
private getCodeGraph(projectPath?: string): CodeGraph {
|
||||
private async getCodeGraph(projectPath?: string): Promise<CodeGraph> {
|
||||
if (!projectPath) {
|
||||
if (!this.cg) {
|
||||
const searched = this.defaultProjectHint ?? process.cwd();
|
||||
@@ -1782,9 +1787,25 @@ export class ToolHandler {
|
||||
const cached = this.projectCache.get(resolvedRoot);
|
||||
if (cached) return this.freshen(cached);
|
||||
|
||||
const cg = loadCodeGraph().openSync(resolvedRoot);
|
||||
this.projectCache.set(resolvedRoot, cg);
|
||||
return cg;
|
||||
const generation = this.projectCacheGeneration;
|
||||
let opening = this.projectOpenPromises.get(resolvedRoot);
|
||||
if (!opening) {
|
||||
opening = loadCodeGraph().open(resolvedRoot);
|
||||
this.projectOpenPromises.set(resolvedRoot, opening);
|
||||
}
|
||||
try {
|
||||
const cg = await opening;
|
||||
if (generation !== this.projectCacheGeneration) {
|
||||
try { cg.close(); } catch { /* another waiter may already have closed it */ }
|
||||
throw new Error('Project cache closed while the database was opening');
|
||||
}
|
||||
this.projectCache.set(resolvedRoot, cg);
|
||||
return cg;
|
||||
} finally {
|
||||
if (this.projectOpenPromises.get(resolvedRoot) === opening) {
|
||||
this.projectOpenPromises.delete(resolvedRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1797,9 +1818,9 @@ export class ToolHandler {
|
||||
* stat() and a no-op unless the inode actually changed; it never throws into a
|
||||
* tool call.
|
||||
*/
|
||||
private freshen(cg: CodeGraph): CodeGraph {
|
||||
private async freshen(cg: CodeGraph): Promise<CodeGraph> {
|
||||
try {
|
||||
if (cg.reopenIfReplaced()) {
|
||||
if (await cg.reopenIfReplacedAsync()) {
|
||||
process.stderr.write(
|
||||
'[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
|
||||
'recreated at the same path); reopened the live database in place.\n'
|
||||
@@ -1816,10 +1837,12 @@ export class ToolHandler {
|
||||
* Close all cached project connections
|
||||
*/
|
||||
closeAll(): void {
|
||||
this.projectCacheGeneration++;
|
||||
for (const cg of this.projectCache.values()) {
|
||||
cg.close();
|
||||
}
|
||||
this.projectCache.clear();
|
||||
this.projectOpenPromises.clear();
|
||||
this.worktreeMismatchCache.clear();
|
||||
}
|
||||
|
||||
@@ -1877,7 +1900,7 @@ export class ToolHandler {
|
||||
* (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
|
||||
* broken by this check.
|
||||
*/
|
||||
private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
|
||||
private async worktreeMismatchFor(projectPath?: string): Promise<WorktreeIndexMismatch | null> {
|
||||
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
|
||||
|
||||
// The verdict depends on BOTH the start path AND the index root it resolves
|
||||
@@ -1891,7 +1914,7 @@ export class ToolHandler {
|
||||
// that first verdict until restart (#926).
|
||||
let indexRoot: string;
|
||||
try {
|
||||
indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
|
||||
indexRoot = (await this.getCodeGraph(projectPath)).getProjectRoot();
|
||||
} catch {
|
||||
// No resolvable project (or any other resolution error) → nothing to warn.
|
||||
return null;
|
||||
@@ -1914,9 +1937,9 @@ export class ToolHandler {
|
||||
* is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
|
||||
* warning — so it stays out of this path.
|
||||
*/
|
||||
private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult {
|
||||
private async withWorktreeNotice(result: ToolResult, projectPath?: string): Promise<ToolResult> {
|
||||
if (result.isError) return result;
|
||||
const mismatch = this.worktreeMismatchFor(projectPath);
|
||||
const mismatch = await this.worktreeMismatchFor(projectPath);
|
||||
if (!mismatch) return result;
|
||||
|
||||
const notice = worktreeMismatchNotice(mismatch);
|
||||
@@ -2001,12 +2024,12 @@ export class ToolHandler {
|
||||
return stale;
|
||||
}
|
||||
|
||||
private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult {
|
||||
private async withStalenessNotice(result: ToolResult, projectPath?: string): Promise<ToolResult> {
|
||||
if (result.isError) return result;
|
||||
|
||||
let cg: CodeGraph;
|
||||
try {
|
||||
cg = this.getCodeGraph(projectPath);
|
||||
cg = await this.getCodeGraph(projectPath);
|
||||
} catch {
|
||||
return result; // no default project — leave as is
|
||||
}
|
||||
@@ -2181,7 +2204,7 @@ export class ToolHandler {
|
||||
// internal bookkeeping and must never reach the client, whether or not a
|
||||
// caller passed session state.
|
||||
const result = this.takeExploreEmission(raw, sessionState);
|
||||
const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
|
||||
const withWorktree = await this.withWorktreeNotice(result, args.projectPath as string | undefined);
|
||||
return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
|
||||
} catch (err) {
|
||||
// Expected condition, not a malfunction: answer as a SUCCESS so the
|
||||
@@ -2310,7 +2333,7 @@ export class ToolHandler {
|
||||
const query = this.validateString(args.query, 'query');
|
||||
if (typeof query !== 'string') return query;
|
||||
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const rawKind = args.kind as string | undefined;
|
||||
// The schema enum says 'type' (what agents naturally reach for); the
|
||||
// NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
|
||||
@@ -2370,7 +2393,7 @@ export class ToolHandler {
|
||||
const symbol = this.validateString(args.symbol, 'symbol');
|
||||
if (typeof symbol !== 'string') return symbol;
|
||||
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const limit = clamp((args.limit as number) || 20, 1, 100);
|
||||
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
||||
|
||||
@@ -2451,7 +2474,7 @@ export class ToolHandler {
|
||||
const symbol = this.validateString(args.symbol, 'symbol');
|
||||
if (typeof symbol !== 'string') return symbol;
|
||||
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const limit = clamp((args.limit as number) || 20, 1, 100);
|
||||
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
||||
|
||||
@@ -2529,7 +2552,7 @@ export class ToolHandler {
|
||||
const symbol = this.validateString(args.symbol, 'symbol');
|
||||
if (typeof symbol !== 'string') return symbol;
|
||||
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const depth = clamp((args.depth as number) || 2, 1, 10);
|
||||
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
||||
|
||||
@@ -3291,7 +3314,7 @@ export class ToolHandler {
|
||||
// ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
|
||||
const query = normalizeQuerySpelling(rawQuery);
|
||||
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const projectRoot = cg.getProjectRoot();
|
||||
|
||||
// Resolve adaptive output budget from project size. Falls back to the
|
||||
@@ -6134,7 +6157,7 @@ export class ToolHandler {
|
||||
* Handle codegraph_node
|
||||
*/
|
||||
private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
// Default to false to minimize context usage
|
||||
const includeCode = args.includeCode === true;
|
||||
const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
|
||||
@@ -6522,7 +6545,7 @@ export class ToolHandler {
|
||||
* Handle codegraph_status
|
||||
*/
|
||||
private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
let cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
let cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
// Same trick as withStalenessNotice — when an explicit projectPath
|
||||
// resolves to the same project as the default session cg, prefer the
|
||||
// default so getPendingFiles() (only populated by the default's watcher)
|
||||
@@ -6541,7 +6564,7 @@ export class ToolHandler {
|
||||
// Queries then reflect that tree's branch, not the worktree being edited.
|
||||
// status shows the verbose, multi-line form; the read tools get the compact
|
||||
// one-liner via withWorktreeNotice. Both share the cached detection.
|
||||
const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
|
||||
const mismatch = await this.worktreeMismatchFor(args.projectPath as string | undefined);
|
||||
|
||||
const lines: string[] = [
|
||||
'**CodeGraph Status**',
|
||||
@@ -6646,7 +6669,7 @@ export class ToolHandler {
|
||||
* Handle codegraph_files - get project file structure from the index
|
||||
*/
|
||||
private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const cg = await this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const pathFilter = args.path as string | undefined;
|
||||
const pattern = args.pattern as string | undefined;
|
||||
const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
|
||||
|
||||
Reference in New Issue
Block a user