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); }