Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
126 changes: 126 additions & 0 deletions __tests__/status-during-rebuild.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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');
});
});
19 changes: 17 additions & 2 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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".');
Expand Down
9 changes: 6 additions & 3 deletions src/mcp/writer-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}