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/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 diff --git a/src/db/connection.ts b/src/db/connection.ts index e18b3c8b..333bc6ce 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}`); } } @@ -412,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/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(); + }); }); 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'); + } + }); +});