From 05949456dea6bb98c169a00eed568017cf058d4a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:48:04 -0600 Subject: [PATCH 1/3] fix: resolve codegraph CLI hangs in git worktrees (WAL lock contention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed: 1. Hooks used CLAUDE_PROJECT_DIR for DB path, which always resolves to the main repo — all worktrees contended on the same graph.db. Now uses git rev-parse --show-toplevel (matching pre-commit.sh). 2. openReadonlyOrFail() had no busy_timeout pragma — readers hung indefinitely on WAL contention instead of retrying for 5 seconds. 3. openRepo() silently fell back from native (which has busy_timeout) to better-sqlite3 (which didn't) on SQLITE_BUSY errors, making contention worse. Now re-throws locking errors as DbError. Closes #839 --- .claude/hooks/enrich-context.sh | 4 +++- .claude/hooks/update-graph.sh | 8 +++++--- src/db/connection.ts | 9 ++++++++- tests/unit/db.test.ts | 12 ++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.claude/hooks/enrich-context.sh b/.claude/hooks/enrich-context.sh index 7bc6607c..592fcd4d 100644 --- a/.claude/hooks/enrich-context.sh +++ b/.claude/hooks/enrich-context.sh @@ -29,7 +29,9 @@ if [ -z "$REL_PATH" ]; then fi # Guard: codegraph DB must exist -DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db" +# Use git worktree root so each worktree reads its own DB (avoids WAL contention) +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +DB_PATH="$WORK_ROOT/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 fi diff --git a/.claude/hooks/update-graph.sh b/.claude/hooks/update-graph.sh index 9c78e4d6..d0be9cf1 100644 --- a/.claude/hooks/update-graph.sh +++ b/.claude/hooks/update-graph.sh @@ -38,16 +38,18 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then fi # Guard: codegraph DB must exist (project has been built at least once) -DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db" +# Use git worktree root so each worktree uses its own DB (avoids WAL contention) +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +DB_PATH="$WORK_ROOT/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 fi # Run incremental build (skips unchanged files via hash check) if command -v codegraph &>/dev/null; then - codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true + codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true else - node "${CLAUDE_PROJECT_DIR}/src/cli.js" build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true + node "${CLAUDE_PROJECT_DIR:-$WORK_ROOT}/src/cli.js" build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true fi exit 0 diff --git a/src/db/connection.ts b/src/db/connection.ts index e18b3c8b..4611de33 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -311,6 +311,7 @@ export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database { } const Database = getDatabase(); const db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database; + db.pragma('busy_timeout = 5000'); warnOnVersionMismatch(() => { const row = db @@ -378,7 +379,13 @@ export function openRepo( // Re-throw user-visible errors (e.g. DB not found) — only silently // fall back for native-engine failures (e.g. incompatible native binary). if (e instanceof DbError) throw e; - debug(`openRepo: native path failed, falling back to better-sqlite3: ${toErrorMessage(e)}`); + // Re-throw locking/busy errors — falling back to better-sqlite3 would + // hit the same contention (and potentially hang without busy_timeout). + const msg = toErrorMessage(e); + if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { + throw new DbError(`Database is busy (another process may be writing): ${msg}`, {}); + } + debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`); } } diff --git a/tests/unit/db.test.ts b/tests/unit/db.test.ts index 055afac9..1b635d5c 100644 --- a/tests/unit/db.test.ts +++ b/tests/unit/db.test.ts @@ -380,4 +380,16 @@ describe('openReadonlyOrFail', () => { expect(tables).toContain('nodes'); readDb.close(); }); + + it('sets busy_timeout pragma to 5000 on readonly connections', () => { + const dbPath = path.join(tmpDir, 'readonly-busy.db'); + const db = openDb(dbPath); + initSchema(db); + closeDb(db); + + const readDb = openReadonlyOrFail(dbPath); + const timeout = readDb.pragma('busy_timeout', { simple: true }); + expect(timeout).toBe(5000); + readDb.close(); + }); }); From c2b0ac36637704c05fbcac22900a6e54107342a0 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:51:14 -0600 Subject: [PATCH 2/3] fix: apply same worktree-aware DB path to example hooks Mirror the WORK_ROOT fix from the live hooks to the example hooks in docs/examples/claude-code-hooks/ so users copying the examples don't hit the same WAL contention in worktree workflows. --- docs/examples/claude-code-hooks/enrich-context.sh | 4 +++- docs/examples/claude-code-hooks/update-graph.sh | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/examples/claude-code-hooks/enrich-context.sh b/docs/examples/claude-code-hooks/enrich-context.sh index 52d80c3d..9a1ee94b 100644 --- a/docs/examples/claude-code-hooks/enrich-context.sh +++ b/docs/examples/claude-code-hooks/enrich-context.sh @@ -29,7 +29,9 @@ if [ -z "$REL_PATH" ]; then fi # Guard: codegraph DB must exist -DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db" +# Use git worktree root so each worktree reads its own DB (avoids WAL contention) +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +DB_PATH="$WORK_ROOT/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 fi diff --git a/docs/examples/claude-code-hooks/update-graph.sh b/docs/examples/claude-code-hooks/update-graph.sh index 1d9fde02..6b5ee577 100644 --- a/docs/examples/claude-code-hooks/update-graph.sh +++ b/docs/examples/claude-code-hooks/update-graph.sh @@ -38,16 +38,18 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then fi # Guard: codegraph DB must exist (project has been built at least once) -DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db" +# Use git worktree root so each worktree uses its own DB (avoids WAL contention) +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +DB_PATH="$WORK_ROOT/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 fi # Run incremental build (skips unchanged files via hash check) if command -v codegraph &>/dev/null; then - codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true + codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true else - npx --yes @optave/codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true + npx --yes @optave/codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true fi exit 0 From 61fe86671a5bc62ba5928d808ec6f647fdde03e2 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:53:59 -0600 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20better=20busy=20logging=20and=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openReadonlyWithNative: distinguish busy/locked errors in debug logs instead of generic "native path failed" message - Add dedicated test file for openRepo SQLITE_BUSY re-throw behavior --- src/db/connection.ts | 7 ++++- tests/unit/openRepo-busy.test.ts | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/unit/openRepo-busy.test.ts diff --git a/src/db/connection.ts b/src/db/connection.ts index 4611de33..333bc6ce 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -419,7 +419,12 @@ export function openReadonlyWithNative(customPath?: string): { const native = getNative(); nativeDb = native.NativeDatabase.openReadonly(dbPath); } catch (e) { - debug(`openReadonlyWithNative: native path failed: ${toErrorMessage(e)}`); + const msg = toErrorMessage(e); + if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { + debug(`openReadonlyWithNative: native path busy, skipping native DB: ${msg}`); + } else { + debug(`openReadonlyWithNative: native path failed: ${msg}`); + } } } diff --git a/tests/unit/openRepo-busy.test.ts b/tests/unit/openRepo-busy.test.ts new file mode 100644 index 00000000..41c3e730 --- /dev/null +++ b/tests/unit/openRepo-busy.test.ts @@ -0,0 +1,54 @@ +/** + * Tests that openRepo re-throws SQLITE_BUSY errors from the native engine + * instead of silently falling back to better-sqlite3 (which could hang). + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// Mock native module to simulate SQLITE_BUSY +vi.mock('../../src/infrastructure/native.js', () => ({ + isNativeAvailable: () => true, + getNative: () => ({ + NativeDatabase: { + openReadonly: () => { + throw new Error('Failed to open DB readonly: SQLITE_BUSY'); + }, + }, + }), +})); + +import { closeDb, initSchema, openDb, openRepo } from '../../src/db/index.js'; + +let tmpDir: string; +let dbPath: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-busy-')); + dbPath = path.join(tmpDir, 'graph.db'); + const db = openDb(dbPath); + initSchema(db); + closeDb(db); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('openRepo locking error handling', () => { + it('re-throws SQLITE_BUSY as DbError instead of falling back', () => { + expect(() => openRepo(dbPath)).toThrow(/Database is busy/); + }); + + it('thrown error is a DbError with code DB_ERROR', () => { + try { + openRepo(dbPath); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.name).toBe('DbError'); + expect(err.code).toBe('DB_ERROR'); + expect(err.message).toContain('SQLITE_BUSY'); + } + }); +});