From b14b898f501ffbaa7d4556bc6ef5e219f45d1db9 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Tue, 27 Oct 2026 14:34:36 +0300 Subject: [PATCH] fix(cli): `codegraph status` opens the index read-only, and says when a rebuild is running Status opened the database read-write, so the open ran the writer's repairs: migrations, the FTS trigger and secondary-index recovery an interrupted bulk load leaves behind, WAL maintenance. During a `codegraph index` that recovery recreated the five edge indexes the indexer had dropped for its bulk load, and the indexer's next edge write, whose transaction had already read its endpoints, failed with SQLITE_BUSY_SNAPSHOT: "Failed to index: database is locked" (#2913). Status reads: it opens with `readOnly: true`, which leaves every repair to the writer, as the MCP reader connections already do. When another live process holds `rebuild.pid`, the report says so (`rebuildInProgress` in `--json`, a warning in the text), since its numbers are a snapshot of a graph being rebuilt. `isRebuildInProgress` is the check `assertNoRebuild` already made, exported for a caller that reports rather than refuses. The test runs the built CLI against an index whose writer holds a bulk edge load with both locks: status answers, the dropped indexes stay dropped, and the writer's edge insert succeeds; a read-write open still repairs them. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + __tests__/status-during-rebuild.test.ts | 126 ++++++++++++++++++++++++ src/bin/codegraph.ts | 19 +++- src/mcp/writer-lock.ts | 9 +- 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 __tests__/status-during-rebuild.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17022e6343..31272c6b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- `codegraph status` no longer interferes with a `codegraph index` running at the same time. Status opened the database read-write and ran the writer's open-time repairs, which during a rebuild recreated the indexes the indexer had dropped and made it fail with "database is locked". Status now opens the index read-only and says when a rebuild is running. (#2913) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/status-during-rebuild.test.ts b/__tests__/status-during-rebuild.test.ts new file mode 100644 index 0000000000..48a8c02ab1 --- /dev/null +++ b/__tests__/status-during-rebuild.test.ts @@ -0,0 +1,126 @@ +/** + * `codegraph status` while another process rebuilds the index (#2913). + * + * Status opened the database read-write, and the open ran the writer's + * repairs: during a rebuild's bulk load it recreated the secondary edge + * indexes the indexer had dropped, so the indexer's next edge write failed + * with SQLITE_BUSY_SNAPSHOT ("database is locked"). Status now opens read-only + * and leaves the dropped indexes alone, and it says that a rebuild is running. + * + * The CLI runs against the built binary, as the issue's reproduction did. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { DatabaseConnection } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function runStatus(cwd: string, json: boolean): string { + return execFileSync(process.execPath, [BIN, 'status', ...(json ? ['--json'] : [])], { + cwd, + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function statusJson(cwd: string): Record { + const line = runStatus(cwd, true).trim().split('\n').filter(Boolean).pop()!; + return JSON.parse(line); +} + +/** The secondary indexes on `edges`, the ones a bulk edge load drops. */ +function edgeIndexes(conn: DatabaseConnection): string[] { + return (conn.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'edges' AND sql IS NOT NULL" + ).all() as Array<{ name: string }>).map((r) => r.name).sort(); +} + +describe('codegraph status during an index rebuild (#2913)', () => { + let dir: string; + let dbPath: string; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-status-rebuild-')); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function a() { return b(); }\nexport function b() { return 1; }\n'); + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + cg.close(); + dbPath = path.join(dir, '.codegraph', 'codegraph.db'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Both locks name this process, as `codegraph index` writes them. */ + function holdRebuildLocks(pid: number): void { + const lock = JSON.stringify({ pid, mode: 'rebuild', startedAt: Date.now(), ready: false }); + fs.writeFileSync(path.join(dir, '.codegraph', 'rebuild.pid'), lock); + fs.writeFileSync(path.join(dir, '.codegraph', 'writer.pid'), lock); + } + + it('leaves the indexes a bulk load dropped alone, so the writer goes on writing', async () => { + const writer = DatabaseConnection.open(dbPath); + try { + const complete = edgeIndexes(writer); + writer.beginBulkEdgeLoad(); + const dropped = edgeIndexes(writer); + expect(dropped.length).toBeLessThan(complete.length); + holdRebuildLocks(process.pid); + + const status = statusJson(dir); + expect(status.initialized).toBe(true); + expect(status.nodeCount).toBeGreaterThan(0); + expect(status.rebuildInProgress).toBe(true); + // Status repaired nothing: the bulk load's indexes are still down. + expect(edgeIndexes(writer)).toEqual(dropped); + + // The indexer's next edge write, as it happens after status returned. + const ids = (writer.getDb().prepare('SELECT id FROM nodes WHERE kind = ? ORDER BY start_line').all('function') as Array<{ id: string }>).map((r) => r.id); + expect(ids.length).toBe(2); + const queries = new QueryBuilder(writer.getDb()); + expect(() => queries.insertEdges([{ source: ids[0]!, target: ids[1]!, kind: 'references' }])).not.toThrow(); + await writer.endBulkEdgeLoad(); + expect(edgeIndexes(writer)).toEqual(complete); + } finally { + writer.close(); + } + }); + + it('a read-write open still recreates them: the repair stays with the writer', () => { + const writer = DatabaseConnection.open(dbPath); + try { + const complete = edgeIndexes(writer); + writer.beginBulkEdgeLoad(); + expect(edgeIndexes(writer).length).toBeLessThan(complete.length); + const other = DatabaseConnection.open(dbPath); + other.close(); + expect(edgeIndexes(writer)).toEqual(complete); + } finally { + writer.close(); + } + }); + + it('says a rebuild is running, and not when its lock belongs to a dead process', () => { + holdRebuildLocks(process.pid); + expect(runStatus(dir, false)).toContain('An index rebuild ("codegraph index") is running in another process'); + + // A pid no process has: the lock is stale, not a rebuild. + holdRebuildLocks(2_147_483_000); + expect(statusJson(dir).rebuildInProgress).toBe(false); + expect(runStatus(dir, false)).not.toContain('is running in another process'); + }); + + it('reports a project with no rebuild as before', () => { + const status = statusJson(dir); + expect(status.rebuildInProgress).toBe(false); + expect(status.nodeCount).toBeGreaterThan(0); + expect(runStatus(dir, false)).toContain('Index Statistics'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 95a291a565..8a4ed4c093 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1123,7 +1123,17 @@ program } const { default: CodeGraph } = await loadCodeGraph(); - const cg = await CodeGraph.open(projectPath); + // Status only reads. Opened read-write, it ran the open-time repairs + // meant for the writer: during a rebuild's bulk load it recreated the + // secondary indexes the indexer had dropped, and the indexer's next + // edge write failed with SQLITE_BUSY_SNAPSHOT, "database is locked" + // (#2913). Read-only, it leaves migrations, index repair and WAL + // maintenance to the writer. + const cg = await CodeGraph.open(projectPath, { readOnly: true }); + const { isRebuildInProgress } = await import('../mcp/writer-lock'); + // A `codegraph index` in another process owns the database: the + // numbers below are a snapshot of a graph being rebuilt. + const rebuildInProgress = isRebuildInProgress(projectPath); const stats = cg.getStats(); const changes = cg.getChangedFiles(); const backend = cg.getBackend(); @@ -1165,6 +1175,9 @@ program worktreeMismatch: worktreeMismatch ? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot } : null, + // True while another process runs `codegraph index` on this + // project: the statistics are a snapshot of the rebuild (#2913). + rebuildInProgress, index: { builtWithVersion: buildInfo.version, builtWithExtractionVersion: buildInfo.extractionVersion, @@ -1197,7 +1210,9 @@ program if (worktreeMismatch) { warn(worktreeMismatchWarning(worktreeMismatch)); } - if (indexState === 'indexing') { + if (rebuildInProgress) { + warn('An index rebuild ("codegraph index") is running in another process — the statistics below are a snapshot of it.'); + } else if (indexState === 'indexing') { warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".'); } else if (indexState === 'partial') { warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".'); diff --git a/src/mcp/writer-lock.ts b/src/mcp/writer-lock.ts index 20f3e94e2c..d7b78f2351 100644 --- a/src/mcp/writer-lock.ts +++ b/src/mcp/writer-lock.ts @@ -258,8 +258,11 @@ export class RebuildInProgressError extends Error { } export function assertNoRebuild(root: string): void { + if (isRebuildInProgress(root)) throw new RebuildInProgressError(); +} + +/** Whether another live process holds the project's rebuild lock (`codegraph index`). */ +export function isRebuildInProgress(root: string): boolean { const lock = readWriterLock(root, 'rebuild.pid'); - if (lock && lock.pid !== process.pid && isProcessAlive(lock.pid)) { - throw new RebuildInProgressError(); - } + return !!lock && lock.pid !== process.pid && isProcessAlive(lock.pid); }