From 83976b0b7797941e1426ed047530719a5e562c88 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 26 Sep 2026 10:44:28 +0300 Subject: [PATCH 1/3] fix(mcp): close pending sockets and allow read-only fallback (#1963) --- __tests__/mcp-daemon.test.ts | 34 +++++++++++++++++++++++++++------- src/mcp/daemon.ts | 9 +++++++++ src/mcp/index.ts | 9 ++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index add971cb32..ee4335caf3 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -35,6 +35,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; import * as fs from 'fs'; +import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; @@ -199,6 +200,25 @@ describe('Shared MCP daemon (issue #411)', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it.runIf(process.platform !== 'win32')('stops despite a socket still waiting for its client hello (#1963)', async () => { + const server = spawnServer(tempDir); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + const pid = await waitFor(() => readLockPid(realRoot), 10000); + const raw = net.connect(getDaemonSocketPath(realRoot)); + try { + await new Promise((resolve, reject) => { + raw.once('data', () => resolve()); + raw.once('error', reject); + }); + process.kill(pid, 'SIGTERM'); + expect(await waitProcessExit(pid, 1500)).toBe(true); + } finally { + raw.destroy(); + } + }, 20000); + it('two invocations share ONE detached daemon; both attach as proxies', async () => { const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; @@ -490,9 +510,9 @@ describe('Shared MCP daemon (issue #411)', () => { params: { name: 'codegraph_status', arguments: {} }, }); const toolResponse = await waitFor(() => findResponse(second.stdout, 3), 5000); - expect(toolResponse).toMatchObject({ - error: { message: expect.stringContaining('writer lock held') }, - }); + expect(toolResponse.error).toBeUndefined(); + expect(toolResponse.result?.isError).not.toBe(true); + expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); }, 50000); it('does not replace a live legacy lock with a second daemon', async () => { @@ -557,7 +577,7 @@ describe('Shared MCP daemon (issue #411)', () => { }); }, 30000); - it('proxy falls back to direct mode on a daemon version mismatch', async () => { + it('proxy falls back to read-only mode on a daemon version mismatch', async () => { const net = await import('net'); const sockPath = getDaemonSocketPath(realRoot); // Plant a live-pid lockfile so the launcher treats the lock as held, and a @@ -597,9 +617,9 @@ describe('Shared MCP daemon (issue #411)', () => { params: { name: 'codegraph_status', arguments: {} }, }); const toolResponse = await waitFor(() => findResponse(server.stdout, 2), 5000); - expect(toolResponse).toMatchObject({ - error: { message: expect.stringContaining('live daemon') }, - }); + expect(toolResponse.error).toBeUndefined(); + expect(toolResponse.result?.isError).not.toBe(true); + expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); } finally { await new Promise((resolve) => miniServer.close(() => resolve())); diff --git a/src/mcp/daemon.ts b/src/mcp/daemon.ts index 18a7f036d8..72ed8a2d77 100644 --- a/src/mcp/daemon.ts +++ b/src/mcp/daemon.ts @@ -170,6 +170,9 @@ export interface DaemonStartResult { */ export class Daemon { private server: net.Server | null = null; + // Includes sockets waiting for the optional client hello, before a session + // exists. server.close() waits for these too, so stop must destroy them. + private acceptedSockets = new Set(); private clients = new Set(); /** Per-client peer pids from the optional client-hello, for the liveness sweep. */ private clientPeers = new Map(); @@ -362,6 +365,8 @@ export class Daemon { try { session.stop(); } catch { /* best-effort */ } } this.clients.clear(); + for (const socket of this.acceptedSockets) socket.destroy(); + this.acceptedSockets.clear(); if (this.server) { await new Promise((resolve) => this.server!.close(() => resolve())); this.server = null; @@ -379,6 +384,9 @@ export class Daemon { } private handleConnection(socket: net.Socket): void { + if (this.stopping) { socket.destroy(); return; } + this.acceptedSockets.add(socket); + socket.once('close', () => this.acceptedSockets.delete(socket)); // Hello first so the proxy can verify versions before piping any // application bytes. The proxy reads exactly one line, then forwards. const hello: DaemonHello = { @@ -394,6 +402,7 @@ export class Daemon { // timeout, a non-hello first line, an early close — yields null pids and we // fall back to the socket-close lifecycle exactly as before (#692). void readClientHello(socket).then((peers) => { + if (this.stopping || socket.destroyed) { socket.destroy(); return; } const transport = new SocketTransport(socket); const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectRoot, diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e14b6219ab..3a82eb10b7 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -50,7 +50,6 @@ import { import { clearStaleDaemonArtifacts } from './daemon-registry'; import { connectWithHello, runLocalHandshakeProxy } from './proxy'; import { - getWriterPidPath, readWriterLock, releaseWriterLock, tryAcquireWriterLock, @@ -117,12 +116,12 @@ function makeFallbackEngine(root: string): MCPEngine { } const writer = readWriterLock(root); if (writer && writer.pid > 0 && isProcessAlive(writer.pid)) { - throw new Error(writerLockHeldMessage(writer, getWriterPidPath(root))); + // Another process owns updates. A fallback may still serve read-only WAL + // queries without claiming a second writer or starting a watcher (#1963). + return new MCPEngine({ watch: false }); } if (existing && isProcessAlive(existing.pid)) { - throw new Error( - `Cannot start an in-process fallback while live daemon pid ${existing.pid} holds the project lock.` - ); + return new MCPEngine({ watch: false }); } return new MCPEngine({ writerLockRoot: root, queryPool: true, queryPoolDefaultMax: DIRECT_QUERY_POOL_MAX }); } From 07fe51e1c0be586bd968aae6b90ab2400f1726e1 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 27 Sep 2026 09:49:45 +0300 Subject: [PATCH 2/3] fix(mcp): log when a fallback serves reads without auto-sync (#1963) A read-only fallback starts no watcher and holds no writer lock, so a session that quietly stopped syncing looked the same as a healthy one in the logs. Name the holder on stderr when the fallback goes read-only. Suggested by @bompus in #1979. Co-Authored-By: Claude Opus 5.5 --- __tests__/mcp-daemon.test.ts | 2 ++ src/mcp/index.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index ee4335caf3..ea26abef76 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -513,6 +513,7 @@ describe('Shared MCP daemon (issue #411)', () => { expect(toolResponse.error).toBeUndefined(); expect(toolResponse.result?.isError).not.toBe(true); expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); + expect(second.stderr.some((line) => line.includes('Serving reads in-process without auto-sync'))).toBe(true); }, 50000); it('does not replace a live legacy lock with a second daemon', async () => { @@ -621,6 +622,7 @@ describe('Shared MCP daemon (issue #411)', () => { expect(toolResponse.result?.isError).not.toBe(true); expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); + expect(server.stderr.some((l) => l.includes('Serving reads in-process without auto-sync: live daemon PID'))).toBe(true); } finally { await new Promise((resolve) => miniServer.close(() => resolve())); } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 3a82eb10b7..2bfb5b215e 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -95,6 +95,16 @@ const TAKEOVER_RETRY_DELAY_MS = 100; * legacy daemon. Plain-PID locks cannot prove daemon identity, but they still * prove that a process owns the legacy writer slot. */ +/** + * A fallback that serves reads without a watcher or a writer lock (#1963). + * Say so on stderr: otherwise a session that quietly stopped syncing looks + * the same as a healthy one in the logs. + */ +function readOnlyFallback(holder: string): MCPEngine { + process.stderr.write(`[CodeGraph MCP] Serving reads in-process without auto-sync: ${holder}.\n`); + return new MCPEngine({ watch: false }); +} + function makeFallbackEngine(root: string): MCPEngine { let existing: ReturnType = null; try { @@ -118,10 +128,10 @@ function makeFallbackEngine(root: string): MCPEngine { if (writer && writer.pid > 0 && isProcessAlive(writer.pid)) { // Another process owns updates. A fallback may still serve read-only WAL // queries without claiming a second writer or starting a watcher (#1963). - return new MCPEngine({ watch: false }); + return readOnlyFallback(`writer lock held by PID ${writer.pid} (${writer.mode} mode)`); } if (existing && isProcessAlive(existing.pid)) { - return new MCPEngine({ watch: false }); + return readOnlyFallback(`live daemon PID ${existing.pid} holds the project lock`); } return new MCPEngine({ writerLockRoot: root, queryPool: true, queryPoolDefaultMax: DIRECT_QUERY_POOL_MAX }); } From 5419e21658911b6435351b410e9f9fc712a48177 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 27 Sep 2026 09:13:33 -0500 Subject: [PATCH 3/3] fix(mcp): stop pending connections and serve read-only fallbacks (#1963) Pending handshakes could block daemon shutdown or register phantom sessions after disconnect. Track accepted sockets and reject late hello continuations. Use explicit read-only engines and SQLite connections for blocked fallbacks, suppressing synchronization, watching, migrations, and repairs. Preserve writer ownership and the existing project lifecycle, with regression coverage for #1963 and #1356. Co-authored-by: danusha2345 Co-authored-by: danusha2345 Co-authored-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 --- __tests__/db-reopen-on-replace.test.ts | 17 +++++++ __tests__/mcp-daemon.test.ts | 55 ++++++++++++++++++++-- __tests__/mcp-writer-lock.test.ts | 64 ++++++++++++++++++++++++++ __tests__/sqlite-backend.test.ts | 17 +++++++ src/db/index.ts | 17 ++++--- src/index.ts | 10 ++-- src/mcp/engine.ts | 17 ++++--- src/mcp/index.ts | 12 ++--- 8 files changed, 181 insertions(+), 28 deletions(-) diff --git a/__tests__/db-reopen-on-replace.test.ts b/__tests__/db-reopen-on-replace.test.ts index c00b3d803e..b156a6a137 100644 --- a/__tests__/db-reopen-on-replace.test.ts +++ b/__tests__/db-reopen-on-replace.test.ts @@ -77,6 +77,23 @@ describe('CodeGraph.reopenIfReplaced (issue #925)', () => { fs.rmSync(root, { recursive: true, force: true }); }); + posixOnly('preserves read-only access after replacing the database (#1963)', async () => { + const initial = CodeGraph.initSync(root); + await initial.indexAll(); + initial.close(); + const reader = CodeGraph.openSync(root, { readOnly: true }); + try { + fs.rmSync(getCodeGraphDir(root), { recursive: true, force: true }); + const replacement = CodeGraph.initSync(root); + await replacement.indexAll(); + replacement.close(); + expect(reader.reopenIfReplaced()).toBe(true); + expect(reader.searchNodes('fooOld').length).toBeGreaterThan(0); + // The reopened handle must not silently become a writer. + expect(() => reader.clear()).toThrow(/readonly|read-only/i); + } finally { reader.close(); } + }); + posixOnly('heals a held connection after the index is removed and recreated at the same path', async () => { // The "server" opens and holds the DB for its lifetime. const server = CodeGraph.initSync(root); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index ea26abef76..94400fbe17 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -173,6 +173,15 @@ async function waitProcessExit(pid: number, timeoutMs: number): Promise return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false); } +async function staleIndex(root: string): Promise { + fs.writeFileSync(path.join(root, 'app.ts'), 'export function originalSymbol() {}\n'); + const cg = CodeGraph.openSync(root); + try { await cg.indexAll(); } finally { cg.close(); } + const before = fs.readFileSync(path.join(root, '.codegraph', 'codegraph.db')); + fs.writeFileSync(path.join(root, 'app.ts'), 'export function changedSymbol() {}\n'); + return before; +} + describe('Shared MCP daemon (issue #411)', () => { let tempDir: string; // the (possibly symlinked) path processes are spawned with let realRoot: string; // its canonical form — what the daemon keys paths on @@ -214,11 +223,37 @@ describe('Shared MCP daemon (issue #411)', () => { }); process.kill(pid, 'SIGTERM'); expect(await waitProcessExit(pid, 1500)).toBe(true); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); } finally { raw.destroy(); } }, 20000); + it('a disconnect before client hello leaves no phantom session and the idle reaper exits (#1356)', async () => { + const server = spawnServer(tempDir, { + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', + CODEGRAPH_DAEMON_MAX_IDLE_MS: '0', + CODEGRAPH_DAEMON_CLIENT_SWEEP_MS: '0', + }); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 10000); + const pid = await waitFor(() => readLockPid(realRoot), 10000); + const raw = net.connect(getDaemonSocketPath(realRoot)); + try { + await new Promise((resolve, reject) => { + raw.once('data', () => resolve()); + raw.once('error', reject); + }); + raw.destroy(); + server.child.stdin.end(); + expect(await waitProcessExit(pid, 8000)).toBe(true); + expect(readDaemonLog(realRoot)).toContain('Shutting down (idle timeout; clients=0)'); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); + } finally { raw.destroy(); } + }, 20000); + it('two invocations share ONE detached daemon; both attach as proxies', async () => { const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; @@ -483,6 +518,7 @@ describe('Shared MCP daemon (issue #411)', () => { fs.writeFileSync(daemonPath, staleDaemonLock); fs.writeFileSync(writerPath, staleWriterLock); + const before = await staleIndex(realRoot); const second = spawnServer(tempDir, env); servers.push(second); sendInitialize(second.child, `file://${tempDir}`, 2); @@ -514,6 +550,10 @@ describe('Shared MCP daemon (issue #411)', () => { expect(toolResponse.result?.isError).not.toBe(true); expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); expect(second.stderr.some((line) => line.includes('Serving reads in-process without auto-sync'))).toBe(true); + second.child.stdin.end(); + await waitFor(() => second.child.exitCode !== null, 5000); + expect(fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'))).toEqual(before); + expect(fs.readFileSync(writerPath, 'utf8')).toBe(staleWriterLock); }, 50000); it('does not replace a live legacy lock with a second daemon', async () => { @@ -578,7 +618,11 @@ describe('Shared MCP daemon (issue #411)', () => { }); }, 30000); - it('proxy falls back to read-only mode on a daemon version mismatch', async () => { + it.each([null, 'daemon', 'fallback'])('proxy falls back to read-only mode on a daemon version mismatch (writer: %s)', async (mode) => { + const before = await staleIndex(realRoot); + const writerPath = path.join(realRoot, '.codegraph', 'writer.pid'); + const writer = JSON.stringify({ pid: process.pid, mode, startedAt: Date.now() }); + if (mode) fs.writeFileSync(writerPath, writer); const net = await import('net'); const sockPath = getDaemonSocketPath(realRoot); // Plant a live-pid lockfile so the launcher treats the lock as held, and a @@ -621,8 +665,13 @@ describe('Shared MCP daemon (issue #411)', () => { expect(toolResponse.error).toBeUndefined(); expect(toolResponse.result?.isError).not.toBe(true); expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status'); - expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); - expect(server.stderr.some((l) => l.includes('Serving reads in-process without auto-sync: live daemon PID'))).toBe(true); + expect(fs.existsSync(writerPath)).toBe(mode !== null); + expect(server.stderr.some((l) => l.includes('Serving reads in-process without auto-sync:'))).toBe(true); + server.child.stdin.end(); + await waitFor(() => server.child.exitCode !== null, 5000); + expect(fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'))).toEqual(before); + expect(readLockPid(realRoot)).toBe(process.pid); + if (mode) expect(fs.readFileSync(writerPath, 'utf8')).toBe(writer); } finally { await new Promise((resolve) => miniServer.close(() => resolve())); } diff --git a/__tests__/mcp-writer-lock.test.ts b/__tests__/mcp-writer-lock.test.ts index fdb92cb05e..b8a1b18b3c 100644 --- a/__tests__/mcp-writer-lock.test.ts +++ b/__tests__/mcp-writer-lock.test.ts @@ -9,9 +9,15 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +import type { MCPEngine } from '../src/mcp/engine'; + +import { createDatabase } from '../src/db/sqlite-adapter'; import { getWriterPidPath } from '../src/mcp/writer-lock'; import { isProcessAlive, stopDaemonAt } from '../src/mcp/daemon-registry'; +// Exercise the shipped lazy CommonJS loader as well as the engine lifecycle. +const { MCPEngine: BuiltMCPEngine } = require('../dist/mcp/engine') as typeof import('../src/mcp/engine'); + const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function sleep(ms: number): Promise { @@ -175,3 +181,61 @@ describe('issue #1740 — direct-mode writer lock', () => { expect(fs.existsSync(tempDir)).toBe(false); }, 25000); }); + + +describe('reader-only MCP engine (#1963)', () => { + let root: string; + let engine: MCPEngine; + + beforeEach(async () => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg1963-reader-'))); + fs.writeFileSync(path.join(root, 'app.ts'), 'export function originalSymbol() { return 1; }\n'); + const cg = await CodeGraph.init(root); + try { await cg.indexAll(); } finally { cg.close(); } + }); + + afterEach(async () => { + await engine?.stop(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['async', 'retry', 'explicit'] as const)('%s initialization never updates the index or owner lock', async (mode) => { + const lockPath = getWriterPidPath(root); + const owner = JSON.stringify({ pid: process.pid, mode: 'daemon', startedAt: Date.now() }); + fs.writeFileSync(lockPath, owner); + fs.writeFileSync(path.join(root, 'app.ts'), 'export function changedSymbol() { return 2; }\n'); + const { db } = createDatabase(path.join(root, '.codegraph', 'codegraph.db'), { readOnly: true }); + const version = db.pragma('data_version', { simple: true }); + // readOnly overrides watching, pool workers, and writer acquisition. + engine = new BuiltMCPEngine({ readOnly: true, watch: true, queryPool: true, writerLockRoot: root }); + try { + if (mode === 'async') await engine.ensureInitialized(root); + if (mode === 'retry') { + const empty = path.join(root, 'empty'); + fs.mkdirSync(empty); + engine.retryInitializeSync(empty); + } + const args = mode === 'explicit' ? { projectPath: root } : {}; + const result = await engine.getToolHandler().execute('codegraph_search', { ...args, query: 'originalSymbol' }); + expect(result.isError).not.toBe(true); + expect(result.content[0].text).toContain('originalSymbol'); + fs.writeFileSync(path.join(root, 'added.ts'), 'export function addedSymbol() {}\n'); + // Includes the lifecycle's periodic ownership retry and watcher debounce. + await sleep(2200); + expect(db.pragma('data_version', { simple: true })).toBe(version); + expect(fs.readFileSync(lockPath, 'utf8')).toBe(owner); + await engine.stop(); + expect(db.pragma('data_version', { simple: true })).toBe(version); + expect(fs.readFileSync(lockPath, 'utf8')).toBe(owner); + } finally { db.close(); } + }); + + it('watch:false still performs the normal startup catch-up', async () => { + fs.writeFileSync(path.join(root, 'app.ts'), 'export function changedSymbol() {}\n'); + engine = new BuiltMCPEngine({ watch: false }); + await engine.ensureInitialized(root); + const result = await engine.getToolHandler().execute('codegraph_search', { query: 'changedSymbol' }); + expect(result.isError).not.toBe(true); + expect(result.content[0].text).toContain('changedSymbol'); + }); +}); diff --git a/__tests__/sqlite-backend.test.ts b/__tests__/sqlite-backend.test.ts index 0815551dca..1d593032b5 100644 --- a/__tests__/sqlite-backend.test.ts +++ b/__tests__/sqlite-backend.test.ts @@ -32,6 +32,23 @@ describe('DatabaseConnection — backend reporting', () => { conn.close(); }); + it('read-only opens never migrate, repair bulk-load state, or permit writes (#1963)', () => { + const dbPath = path.join(dir, 'test.db'); + const owner = DatabaseConnection.initialize(dbPath); + const db = owner.getDb(); + db.exec('DELETE FROM schema_versions WHERE version = (SELECT MAX(version) FROM schema_versions)'); + db.exec('DROP TRIGGER nodes_ai'); + const before = db.pragma('data_version', { simple: true }); + const reader = DatabaseConnection.open(dbPath, { readOnly: true }); + try { + expect(reader.getDb().prepare('SELECT COUNT(*) AS n FROM nodes').get()).toEqual({ n: 0 }); + expect(() => reader.getDb().exec("CREATE TABLE reader_write (value TEXT)")) + .toThrow(/readonly|read-only/i); + expect(db.pragma('data_version', { simple: true })).toBe(before); + expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'nodes_ai'").get()).toBeUndefined(); + } finally { reader.close(); owner.close(); } + }); + it('CodeGraph.getBackend() delegates to the underlying DatabaseConnection', async () => { fs.writeFileSync(path.join(dir, 'x.ts'), `export function x(): void {}\n`); const cg = await CodeGraph.init(dir, { index: true }); diff --git a/src/db/index.ts b/src/db/index.ts index 3b53ba3a19..29ae0887f5 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -27,10 +27,10 @@ export { SqliteDatabase, SqliteBackend } from './sqlite-adapter'; * on a writer, so this timeout only governs cross-process write contention * (e.g. the git-hook `codegraph sync` running while the MCP server writes). */ -function configureConnection(db: SqliteDatabase): void { +function configureConnection(db: SqliteDatabase, readOnly = false): void { db.pragma('busy_timeout = 5000'); // MUST be first — see above db.pragma('foreign_keys = ON'); - db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform + if (!readOnly) db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform db.pragma('synchronous = NORMAL'); // safe with WAL mode db.pragma('cache_size = -64000'); // 64 MB page cache db.pragma('temp_store = MEMORY'); // temp tables in memory @@ -89,7 +89,7 @@ export class DatabaseConnection { */ readonly fts5Available: boolean; - private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend, fts5Available: boolean) { + private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend, fts5Available: boolean, readonly readOnly = false) { this.db = db; this.dbPath = dbPath; this.backend = backend; @@ -162,14 +162,14 @@ export class DatabaseConnection { /** * Open an existing database */ - static open(dbPath: string): DatabaseConnection { + static open(dbPath: string, options: { readOnly?: boolean } = {}): DatabaseConnection { if (!fs.existsSync(dbPath)) { throw new Error(`Database not found: ${dbPath}`); } - const { db, backend } = createDatabase(dbPath); + const { db, backend } = createDatabase(dbPath, options); - configureConnection(db); + configureConnection(db, options.readOnly); // Detect FTS5 availability for search fallback (#1532) let fts5Available = true; @@ -180,7 +180,10 @@ export class DatabaseConnection { } // Check and run migrations if needed - const conn = new DatabaseConnection(db, dbPath, backend, fts5Available); + const conn = new DatabaseConnection(db, dbPath, backend, fts5Available, options.readOnly); + // A concurrent reader must leave migrations, bulk-load repair, and WAL + // maintenance to the writer, including when versions differ (#1963). + if (options.readOnly) return conn; const currentVersion = getCurrentVersion(db); if (currentVersion < CURRENT_SCHEMA_VERSION) { diff --git a/src/index.ts b/src/index.ts index 1a7c5814c0..443e249737 100644 --- a/src/index.ts +++ b/src/index.ts @@ -254,7 +254,7 @@ export class CodeGraph { // Open the live file FIRST — if that throws (e.g. mid-recreate), the old // handle stays in place and the caller retries on the next query, rather // than leaving this instance with no connection at all. - const fresh = DatabaseConnection.open(dbPath); + const fresh = DatabaseConnection.open(dbPath, { readOnly: this.db.readOnly }); const stale = this.db; this.db = fresh; this.queries = new QueryBuilder(fresh.getDb()); @@ -351,13 +351,13 @@ export class CodeGraph { // Open database const dbPath = getDatabasePath(resolvedRoot); - const db = DatabaseConnection.open(dbPath); + const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly }); const queries = new QueryBuilder(db.getDb()); const instance = new CodeGraph(db, queries, resolvedRoot); // Sync if requested - if (options.sync) { + if (options.sync && !options.readOnly) { try { await instance.sync(); } catch (err) { @@ -420,7 +420,7 @@ export class CodeGraph { /** * Open synchronously (without sync) */ - static openSync(projectRoot: string): CodeGraph { + static openSync(projectRoot: string, options: Pick = {}): CodeGraph { const resolvedRoot = path.resolve(projectRoot); // Check if initialized @@ -436,7 +436,7 @@ export class CodeGraph { // Open database const dbPath = getDatabasePath(resolvedRoot); - const db = DatabaseConnection.open(dbPath); + const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly }); const queries = new QueryBuilder(db.getDb()); return new CodeGraph(db, queries, resolvedRoot); diff --git a/src/mcp/engine.ts b/src/mcp/engine.ts index f7b98d3b1b..bc5e922964 100644 --- a/src/mcp/engine.ts +++ b/src/mcp/engine.ts @@ -32,6 +32,8 @@ const loadCodeGraph = (): typeof import('../index').default => const RETRY_SUBSCAN_TTL_MS = 5_000; export interface MCPEngineOptions { + /** Serve existing index contents without syncing, watching, or claiming a writer slot. */ + readOnly?: boolean; /** * Whether to start the file watcher when initializing. Daemon and direct * modes both want this true; tests may set it false to keep the engine @@ -90,10 +92,11 @@ export class MCPEngine { private queryPool: QueryPool | null = null; constructor(opts: MCPEngineOptions = {}) { - this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false, queryPoolDefaultMax: opts.queryPoolDefaultMax }; + this.opts = { readOnly: opts.readOnly ?? false, watch: opts.watch ?? true, queryPool: opts.queryPool ?? false, queryPoolDefaultMax: opts.queryPoolDefaultMax }; this.toolHandler = new ToolHandler(null); this.toolHandler.setProjectLifecycle({ open: (root, open) => { + if (this.opts.readOnly) return loadCodeGraph().openSync(root, { readOnly: true }); if (!this.opts.watch) return open(); const lease = acquireProject(root, open, this.watchOptions()); this.explicitProjects.set(lease.cg, lease); @@ -102,7 +105,7 @@ export class MCPEngine { activate: (cg) => this.explicitProjects.get(cg)?.ready() ?? Promise.resolve(), release: (cg) => this.releaseExplicitProject(cg), }); - if (opts.writerLockRoot) { + if (opts.writerLockRoot && !this.opts.readOnly) { const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback'); if (writer.kind === 'taken') { throw new Error(writerLockHeldMessage(writer.existing, writer.pidPath)); @@ -118,7 +121,7 @@ export class MCPEngine { * in-process, so the pool can only help, never break, tool calls. */ private maybeStartPool(root: string | null): void { - if (!this.opts.queryPool || this.queryPool || this.closed) return; + if (this.opts.readOnly || !this.opts.queryPool || this.queryPool || this.closed) return; const envSize = process.env.CODEGRAPH_QUERY_POOL_SIZE; let size = resolvePoolSize(envSize, os.cpus().length); if ((envSize === undefined || envSize === '') && this.opts.queryPoolDefaultMax !== undefined) { @@ -222,7 +225,7 @@ export class MCPEngine { try { this.cg.close(); } catch { /* ignore */ } this.cg = null; } - this.cg = loadCodeGraph().openSync(resolvedRoot); + this.cg = loadCodeGraph().openSync(resolvedRoot, { readOnly: this.opts.readOnly }); this.projectPath = resolvedRoot; this.toolHandler.setDefaultCodeGraph(this.cg); this.startWatching(); @@ -345,7 +348,7 @@ export class MCPEngine { this.projectPath = resolvedRoot; try { - const opened = await loadCodeGraph().open(resolvedRoot); + const opened = await loadCodeGraph().open(resolvedRoot, { readOnly: this.opts.readOnly }); if (this.closed) { opened.close(); return; } this.cg = opened; this.toolHandler.setDefaultCodeGraph(this.cg); @@ -374,7 +377,7 @@ export class MCPEngine { * keep working. */ private startWatching(): void { - if (!this.cg || this.watcherStarted || !this.opts.watch) return; + if (this.opts.readOnly || !this.cg || this.watcherStarted || !this.opts.watch) return; const opened = this.cg; this.defaultLease = acquireProject(opened.getProjectRoot(), () => opened, this.watchOptions()); @@ -396,7 +399,7 @@ export class MCPEngine { */ private catchUpSync(): void { const cg = this.cg; - if (!cg) return; + if (!cg || this.opts.readOnly) return; if (this.defaultLease) { this.toolHandler.setCatchUpGate(this.defaultLease.ready()); return; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 2bfb5b215e..0a15a31055 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -90,11 +90,6 @@ const DAEMON_INTERNAL_ENV = 'CODEGRAPH_DAEMON_INTERNAL'; const TAKEOVER_MAX_RETRIES = 5; const TAKEOVER_RETRY_DELAY_MS = 100; -/** - * Create an in-process fallback only when it cannot conflict with a live - * legacy daemon. Plain-PID locks cannot prove daemon identity, but they still - * prove that a process owns the legacy writer slot. - */ /** * A fallback that serves reads without a watcher or a writer lock (#1963). * Say so on stderr: otherwise a session that quietly stopped syncing looks @@ -102,9 +97,14 @@ const TAKEOVER_RETRY_DELAY_MS = 100; */ function readOnlyFallback(holder: string): MCPEngine { process.stderr.write(`[CodeGraph MCP] Serving reads in-process without auto-sync: ${holder}.\n`); - return new MCPEngine({ watch: false }); + return new MCPEngine({ readOnly: true }); } +/** + * Create an in-process fallback only when it cannot conflict with a live + * legacy daemon. Plain-PID locks cannot prove daemon identity, but they still + * prove that a process owns the legacy writer slot. + */ function makeFallbackEngine(root: string): MCPEngine { let existing: ReturnType = null; try {