Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions __tests__/db-reopen-on-replace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
87 changes: 79 additions & 8 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -172,6 +173,15 @@ async function waitProcessExit(pid: number, timeoutMs: number): Promise<boolean>
return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false);
}

async function staleIndex(root: string): Promise<Buffer> {
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
Expand Down Expand Up @@ -199,6 +209,51 @@ 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<void>((resolve, reject) => {
raw.once('data', () => resolve());
raw.once('error', reject);
});
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<void>((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' };

Expand Down Expand Up @@ -463,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);
Expand Down Expand Up @@ -490,9 +546,14 @@ 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');
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 () => {
Expand Down Expand Up @@ -557,7 +618,11 @@ describe('Shared MCP daemon (issue #411)', () => {
});
}, 30000);

it('proxy falls back to direct 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
Expand Down Expand Up @@ -597,10 +662,16 @@ 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(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false);
expect(toolResponse.error).toBeUndefined();
expect(toolResponse.result?.isError).not.toBe(true);
expect(JSON.stringify(toolResponse.result)).toContain('CodeGraph Status');
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<void>((resolve) => miniServer.close(() => resolve()));
}
Expand Down
64 changes: 64 additions & 0 deletions __tests__/mcp-writer-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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');
});
});
17 changes: 17 additions & 0 deletions __tests__/sqlite-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
17 changes: 10 additions & 7 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -420,7 +420,7 @@ export class CodeGraph {
/**
* Open synchronously (without sync)
*/
static openSync(projectRoot: string): CodeGraph {
static openSync(projectRoot: string, options: Pick<OpenOptions, 'readOnly'> = {}): CodeGraph {
const resolvedRoot = path.resolve(projectRoot);

// Check if initialized
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/mcp/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<net.Socket>();
private clients = new Set<MCPSession>();
/** Per-client peer pids from the optional client-hello, for the liveness sweep. */
private clientPeers = new Map<MCPSession, { pid: number | null; hostPid: number | null }>();
Expand Down Expand Up @@ -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<void>((resolve) => this.server!.close(() => resolve()));
this.server = null;
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down
Loading