gitnexus 1.6.12-rc.40 → 1.6.12-rc.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ import { extensionManager, getFtsCapability, resolveAnalyzeInstallPolicy, } from
26
26
  // Remedy classification for LOAD failures (#2374/#2383). Pure + node:fs only, so
27
27
  // this adds no cycle: `extension-loader.ts` already depends on it.
28
28
  import { diagnoseExtensionLoad } from './extension-load-error.js';
29
- import { classifyDeleteAllError, closeLbugConnection, HANDLE_RELEASE_PROBE_ATTEMPTS, HANDLE_RELEASE_PROBE_DELAY_MS, isDbBusyError, isOpenRetryExhausted, isWalCorruptionError, bufferPoolExhaustionRemedy, openLbugConnection, sleep, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
29
+ import { classifyDeleteAllError, closeLbugConnection, HANDLE_RELEASE_PROBE_ATTEMPTS, HANDLE_RELEASE_PROBE_DELAY_MS, isDbBusyError, isOpenRetryExhausted, isStorageVersionMismatchError, isWalCorruptionError, throwIfStorageVersionMismatch, bufferPoolExhaustionRemedy, openLbugConnection, sleep, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
30
30
  import { cleanQuarantinedMissingShadowWals, finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, lbugLockRemediation, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, sidecarPreflightDisabled, } from './sidecar-recovery.js';
31
31
  import { isProcessAlive } from '../../utils/process-identity.js';
32
32
  import { logger } from '../logger.js';
@@ -569,6 +569,11 @@ const runSchemaCreationQueries = async (dbPath) => {
569
569
  throw new Error(`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
570
570
  ` Original error: ${msg.slice(0, 200)}`);
571
571
  }
572
+ if (isStorageVersionMismatchError(err)) {
573
+ await safeClose();
574
+ resetOpenConnectionState();
575
+ throwIfStorageVersionMismatch(err);
576
+ }
572
577
  if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
573
578
  logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
574
579
  }
@@ -662,8 +667,28 @@ const doInitLbug = async (dbPath, options = {}) => {
662
667
  logger,
663
668
  allowQuarantine: false,
664
669
  });
665
- const opened = await openLbugConnection(lbug, dbPath, { readOnly: true });
666
- const usable = await ensureReadOnlyConnectionUsable(dbPath, opened);
670
+ let usable;
671
+ try {
672
+ const opened = await openLbugConnection(lbug, dbPath, { readOnly: true });
673
+ // The storage-version check isn't necessarily enforced by the native
674
+ // engine until the first real query runs (ensureReadOnlyConnectionUsable's
675
+ // own probe query) — openLbugConnection alone can succeed on a
676
+ // mismatched file. Wrap both.
677
+ usable = await ensureReadOnlyConnectionUsable(dbPath, opened);
678
+ }
679
+ catch (err) {
680
+ // Not retryable: the on-disk file's storage version doesn't change on
681
+ // its own, so withLbugDb's retry loop (which only handles
682
+ // isDbBusyError) would just repeat the same native exception. Fail
683
+ // immediately with an actionable message instead (review finding on
684
+ // PR #3189 — this became reachable once the pinned engine version can
685
+ // trail behind whatever version last wrote an index, e.g. after
686
+ // downgrading the dependency). Mirrors the pool-adapter.ts check for
687
+ // the same error, on the separate open path /api/graph and /api/query
688
+ // actually use (withLbugDb, not the pool).
689
+ throwIfStorageVersionMismatch(err);
690
+ throw err;
691
+ }
667
692
  db = usable.db;
668
693
  conn = usable.conn;
669
694
  currentDbReadOnly = true;
@@ -763,10 +788,19 @@ const doInitLbug = async (dbPath, options = {}) => {
763
788
  logger,
764
789
  allowQuarantine: true,
765
790
  });
766
- const opened = await openLbugConnection(lbug, dbPath);
767
- db = opened.db;
768
- conn = opened.conn;
769
- currentDbReadOnly = false;
791
+ try {
792
+ const opened = await openLbugConnection(lbug, dbPath);
793
+ db = opened.db;
794
+ conn = opened.conn;
795
+ currentDbReadOnly = false;
796
+ }
797
+ catch (err) {
798
+ // Incremental analyze can hit a storage-version mismatch on construct
799
+ // or (more often) on the first schema query below. Fail immediately
800
+ // with the rebuild hint instead of warn-and-continue.
801
+ throwIfStorageVersionMismatch(err);
802
+ throw err;
803
+ }
770
804
  }
771
805
  finally {
772
806
  await releaseInitLock();
@@ -125,6 +125,10 @@ export declare const getEffectiveBufferPoolSize: () => number;
125
125
  export declare const bufferPoolExhaustionRemedy: (message: string, pageSize?: number | undefined) => string | undefined;
126
126
  export declare const WAL_RECOVERY_SUGGESTION = "WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.";
127
127
  export declare function isWalCorruptionError(err: unknown): boolean;
128
+ export declare const STORAGE_VERSION_MISMATCH_SUGGESTION = "This index was written by a different @ladybugdb/core build (a different storage version) than the one currently installed. Run `gitnexus analyze --force` on this repo to rebuild it with the current engine.";
129
+ export declare function isStorageVersionMismatchError(err: unknown): boolean;
130
+ /** Throws the rebuild-hint Error when `err` is a storage-version mismatch. */
131
+ export declare function throwIfStorageVersionMismatch(err: unknown): void;
128
132
  /**
129
133
  * True when `err` looks like a Ladybug WAL-checkpoint rotation/remove IO
130
134
  * failure. Tries strict matchers first (renames + removes), then falls
@@ -513,6 +513,27 @@ export function isWalCorruptionError(err) {
513
513
  const msg = err instanceof Error ? err.message : String(err);
514
514
  return WAL_CORRUPTION_RE.test(msg);
515
515
  }
516
+ /** Matches a LadybugDB storage-version mismatch: the on-disk file was
517
+ * written by a different @ladybugdb/core build (a different storage
518
+ * version) than the one currently installed — e.g. an index built by a
519
+ * newer engine, opened after downgrading the pinned dependency. Example:
520
+ * "Runtime exception: Trying to read a database file with a different
521
+ * version. Database file version: 43, Current build storage version: 42" */
522
+ const STORAGE_VERSION_MISMATCH_RE = /database file with a different version/i;
523
+ export const STORAGE_VERSION_MISMATCH_SUGGESTION = 'This index was written by a different @ladybugdb/core build (a different storage version) than the one currently installed. Run `gitnexus analyze --force` on this repo to rebuild it with the current engine.';
524
+ export function isStorageVersionMismatchError(err) {
525
+ if (!err)
526
+ return false;
527
+ const msg = err instanceof Error ? err.message : String(err);
528
+ return STORAGE_VERSION_MISMATCH_RE.test(msg);
529
+ }
530
+ /** Throws the rebuild-hint Error when `err` is a storage-version mismatch. */
531
+ export function throwIfStorageVersionMismatch(err) {
532
+ if (!isStorageVersionMismatchError(err))
533
+ return;
534
+ const msg = err instanceof Error ? err.message : String(err);
535
+ throw new Error(`${STORAGE_VERSION_MISMATCH_SUGGESTION} (${msg})`);
536
+ }
516
537
  // ─── Ladybug WAL checkpoint IO error matchers ───────────────────────────────
517
538
  //
518
539
  // Matched against LadybugDB v0.18.0 (see `gitnexus/package.json`
@@ -99,10 +99,13 @@ export declare function restoreStdout(): void;
99
99
  * Initialize (or reuse) a Database + connection pool for a specific repo.
100
100
  * Retries on lock errors (e.g., when `gitnexus analyze` is running).
101
101
  *
102
- * Concurrent calls for the same repoId are deduplicated — the second caller
103
- * awaits the first's in-progress init rather than starting a redundant one.
104
- */
105
- /**
102
+ * Concurrent calls (for the same repoId or different ones) serialize on
103
+ * poolLock below for evict / close / native open / register, so a second
104
+ * caller for a repo already being initialized waits its turn and then hits
105
+ * the "existing" fast path — no separate per-repoId dedup needed. Lock-retry
106
+ * *sleeps* run outside the mutex so one analyze-locked repo does not block
107
+ * every other pool init for LOCK_RETRY_DELAY_MS * attempt.
108
+ *
106
109
  * Returns `true` when this call (re)opened a fresh handle onto the current
107
110
  * on-disk file, `false` when it reused/served the existing handle (unchanged,
108
111
  * or changed-but-a-query-is-in-flight). Callers that gate their own freshness
@@ -19,7 +19,7 @@ import lbug from '@ladybugdb/core';
19
19
  import { isReadOnlyDbError, loadFTSExtension, loadVectorExtension } from './lbug-adapter.js';
20
20
  import { closeQueryResults } from './query-result-utils.js';
21
21
  import { warnIfQueryTextUnbounded } from './query-batch.js';
22
- import { createLbugDatabase, isWalCorruptionError, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
22
+ import { createLbugDatabase, isWalCorruptionError, sleep, throwIfStorageVersionMismatch, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
23
23
  import { guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
24
24
  export async function statDbIdentity(dbPath) {
25
25
  try {
@@ -124,7 +124,37 @@ function ensureIdleTimer() {
124
124
  if (pinnedRepos.has(repoId))
125
125
  continue;
126
126
  if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
127
- closeOne(repoId);
127
+ // Routed through the same mutex as initLbug (not awaited here — this
128
+ // sweep is periodic best-effort cleanup with nothing waiting on it).
129
+ // closeOne now removes the pool entry before its awaited db.close(),
130
+ // so an unsynchronized idle close racing a concurrent initLbug for
131
+ // the same repoId would let that init treat the repo as absent and
132
+ // open a fresh native handle on the same file while the idle close's
133
+ // checkpoint is still in flight — reopening the exact race this pool
134
+ // rework exists to close, just via the idle path instead of LRU
135
+ // eviction (review finding on PR #3187). withPoolLock serializes it
136
+ // against every initLbug call the same way evictLRU already is.
137
+ //
138
+ // This callback can now sit queued behind an in-progress initLbug
139
+ // before its turn comes, and that init's existing-entry path (or a
140
+ // concurrent touchRepo()) can refresh lastUsed in the meantime — so
141
+ // the repo may no longer be idle by the time this actually runs.
142
+ // Re-check inside the lock, right before closing, instead of trusting
143
+ // the snapshot taken above (second review finding on PR #3187).
144
+ // Also re-check pinnedRepos: the outer loop's check above is the
145
+ // same kind of stale snapshot — pinRepo() can run while this
146
+ // callback is queued behind an in-progress initLbug, and closing a
147
+ // repo the caller just pinned would drop that lease entirely
148
+ // (review finding on PR #3189).
149
+ withPoolLock(async () => {
150
+ const current = pool.get(repoId);
151
+ if (current &&
152
+ !pinnedRepos.has(repoId) &&
153
+ Date.now() - current.lastUsed > IDLE_TIMEOUT_MS &&
154
+ current.checkedOut === 0) {
155
+ await closeOne(repoId);
156
+ }
157
+ });
128
158
  }
129
159
  }
130
160
  }, 60_000);
@@ -202,7 +232,7 @@ export const getMaxResidentRepos = () => MAX_POOL_SIZE;
202
232
  * entry is pinned, no eviction occurs and the pool transiently exceeds
203
233
  * MAX_POOL_SIZE (see the pinnedRepos docstring).
204
234
  */
205
- function evictLRU() {
235
+ async function evictLRU() {
206
236
  if (pool.size < MAX_POOL_SIZE)
207
237
  return;
208
238
  let oldestId = null;
@@ -216,7 +246,12 @@ function evictLRU() {
216
246
  }
217
247
  }
218
248
  if (oldestId) {
219
- closeOne(oldestId);
249
+ // Awaited: the caller opens a new connection right after evicting one, and
250
+ // closeOne's db.close() below triggers a checkpoint. A fire-and-forget close
251
+ // here let that new open race the still-in-flight checkpoint of the evicted
252
+ // repo, surfacing as "Cannot open database in read-only mode while checkpoint
253
+ // is in progress" on the read path.
254
+ await closeOne(oldestId);
220
255
  }
221
256
  }
222
257
  /**
@@ -224,7 +259,7 @@ function evictLRU() {
224
259
  * shared Database ref. Only closes the Database when no other repoIds
225
260
  * reference it (refCount === 0).
226
261
  */
227
- function closeOne(repoId) {
262
+ async function closeOne(repoId) {
228
263
  const entry = pool.get(repoId);
229
264
  if (!entry)
230
265
  return;
@@ -251,6 +286,27 @@ function closeOne(repoId) {
251
286
  entry.available.length = 0;
252
287
  // Checked-out connections can't be closed here — they're in-flight.
253
288
  // The checkin() function detects entry.closed and closes them on return.
289
+ // Remove the entry — and clear its pin, and notify listeners — BEFORE the
290
+ // possible await below. `available` is already empty and `closed` is
291
+ // already set, so nothing further to lose; but `shared.db.close()` can
292
+ // suspend, and until this repoId is actually gone from `pool`,
293
+ // `isLbugReady(repoId)` (a bare `pool.has`) still reports true. A
294
+ // concurrent same-repo `initLbug`/query during that window would see a
295
+ // "ready" pool entry with no available connections and no in-flight
296
+ // open — a zombie that `checkout` can only fail on with a misleading
297
+ // "pool integrity error" instead of just reopening. Deleting first makes
298
+ // that window disappear: any concurrent caller instead sees "not
299
+ // initialized" and takes the normal fresh-open path.
300
+ pool.delete(repoId);
301
+ pinnedRepos.delete(repoId);
302
+ for (const listener of poolCloseListeners) {
303
+ try {
304
+ listener(repoId);
305
+ }
306
+ catch {
307
+ // Isolate listener failures — teardown must complete.
308
+ }
309
+ }
254
310
  // Only close the Database when no other repoIds reference it.
255
311
  // External databases (injected via initLbugWithDb) are never closed here —
256
312
  // the core adapter owns them and handles their lifecycle.
@@ -268,26 +324,19 @@ function closeOne(repoId) {
268
324
  shared.vectorLoadPromise = undefined;
269
325
  }
270
326
  else {
271
- shared.db.close().catch(() => { });
327
+ // Awaited (unlike the per-connection closes above): this is the shared
328
+ // Database handle whose close() drives the checkpoint that the caller's
329
+ // subsequent reopen (evictLRU / the "idle & changed" path below) must not
330
+ // race. See the awaited call site in evictLRU for the full rationale.
331
+ await shared.db.close().catch(() => { });
272
332
  dbCache.delete(entry.dbPath);
273
333
  }
274
334
  }
275
335
  }
276
- pool.delete(repoId);
277
- // Clear any eviction pin — the entry is gone, so the pin is meaningless and
278
- // would otherwise leak across operations in a long-lived process. Teardown
279
- // is authoritative: an explicit close always wins over a pin.
336
+ // Close yields on native db.close() above. A pinRepo during that await
337
+ // would otherwise survive teardown and apply to the next init, contradicting
338
+ // the documented lease contract (pins do not outlive closeOne).
280
339
  pinnedRepos.delete(repoId);
281
- // Notify listeners AFTER the pool entry is gone so any cache-invalidation
282
- // they perform is consistent with `isLbugReady(repoId) === false`.
283
- for (const listener of poolCloseListeners) {
284
- try {
285
- listener(repoId);
286
- }
287
- catch {
288
- // Isolate listener failures — teardown must complete.
289
- }
290
- }
291
340
  traceRss('close', repoId);
292
341
  }
293
342
  /**
@@ -529,16 +578,31 @@ async function tryQuarantineAndReopen(dbPath, repoId) {
529
578
  `Run \`gitnexus analyze\` to rebuild the index.\n`);
530
579
  return await openReadOnlyDatabase(dbPath);
531
580
  }
532
- /** Deduplicates concurrent initLbug calls for the same repoId */
533
- const initPromises = new Map();
581
+ // Serializes pool mutations (evict / close / native open / register) across
582
+ // concurrent callers. Awaiting closeOne/evictLRU closes the race within a
583
+ // single call; this mutex makes those mutations mutually exclusive across
584
+ // repos so two inits cannot race each other's checkpoint.
585
+ let poolLock = Promise.resolve();
586
+ function withPoolLock(fn) {
587
+ const run = poolLock.then(fn, fn);
588
+ poolLock = run.then(() => undefined, () => undefined);
589
+ return run;
590
+ }
591
+ function ladybugUnavailableError(repoId, err) {
592
+ return new Error(`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
593
+ `Retry later. (${err?.message || 'unknown error'})`);
594
+ }
534
595
  /**
535
596
  * Initialize (or reuse) a Database + connection pool for a specific repo.
536
597
  * Retries on lock errors (e.g., when `gitnexus analyze` is running).
537
598
  *
538
- * Concurrent calls for the same repoId are deduplicated — the second caller
539
- * awaits the first's in-progress init rather than starting a redundant one.
540
- */
541
- /**
599
+ * Concurrent calls (for the same repoId or different ones) serialize on
600
+ * poolLock below for evict / close / native open / register, so a second
601
+ * caller for a repo already being initialized waits its turn and then hits
602
+ * the "existing" fast path — no separate per-repoId dedup needed. Lock-retry
603
+ * *sleeps* run outside the mutex so one analyze-locked repo does not block
604
+ * every other pool init for LOCK_RETRY_DELAY_MS * attempt.
605
+ *
542
606
  * Returns `true` when this call (re)opened a fresh handle onto the current
543
607
  * on-disk file, `false` when it reused/served the existing handle (unchanged,
544
608
  * or changed-but-a-query-is-in-flight). Callers that gate their own freshness
@@ -546,6 +610,19 @@ const initPromises = new Map();
546
610
  * return value; callers that only need the pool ready can ignore it.
547
611
  */
548
612
  export const initLbug = async (repoId, dbPath) => {
613
+ let lastError;
614
+ for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
615
+ const result = await withPoolLock(() => initLbugInner(repoId, dbPath));
616
+ if (result.status === 'done')
617
+ return result.reopened;
618
+ lastError = result.error;
619
+ if (attempt === LOCK_RETRY_ATTEMPTS)
620
+ break;
621
+ await sleep(LOCK_RETRY_DELAY_MS * attempt);
622
+ }
623
+ throw ladybugUnavailableError(repoId, lastError);
624
+ };
625
+ const initLbugInner = async (repoId, dbPath) => {
549
626
  const existing = pool.get(repoId);
550
627
  if (existing) {
551
628
  existing.lastUsed = Date.now();
@@ -554,8 +631,9 @@ export const initLbug = async (repoId, dbPath) => {
554
631
  // unlinked-but-open) inode until LRU/idle eviction — a stale-read window
555
632
  // of up to IDLE_TIMEOUT_MS after analyze finishes.
556
633
  const current = await statDbIdentity(dbPath);
557
- if (!dbIdentityChanged(existing.dbIdentity, current))
558
- return false; // unchanged → reuse
634
+ if (!dbIdentityChanged(existing.dbIdentity, current)) {
635
+ return { status: 'done', reopened: false }; // unchanged → reuse
636
+ }
559
637
  // A query is in flight on this entry; closing its connection (and the
560
638
  // shared Database at refCount 0) mid-use is a native use-after-free. Serve
561
639
  // the current handle for this dispatch — the next initLbug that finds the
@@ -567,26 +645,11 @@ export const initLbug = async (repoId, dbPath) => {
567
645
  // freshness THROUGH initLbug (rather than calling closeLbug directly) get
568
646
  // this guard for free; that is why LocalBackend delegates here (#2614).
569
647
  if (existing.checkedOut > 0)
570
- return false;
571
- closeOne(repoId); // idle & changed → evict, then fall through to reopen the new file
572
- }
573
- // Deduplicate concurrent init calls for the same repoId —
574
- // prevents double-init race when multiple parallel tool calls
575
- // trigger initialization for the same repo simultaneously.
576
- const pending = initPromises.get(repoId);
577
- if (pending) {
578
- await pending;
579
- return true;
648
+ return { status: 'done', reopened: false };
649
+ // Awaited: see the rationale on the evictLRU call site in doInitLbug below.
650
+ await closeOne(repoId); // idle & changed → evict, then fall through to reopen the new file
580
651
  }
581
- const promise = doInitLbug(repoId, dbPath);
582
- initPromises.set(repoId, promise);
583
- try {
584
- await promise;
585
- }
586
- finally {
587
- initPromises.delete(repoId);
588
- }
589
- return true;
652
+ return doInitLbug(repoId, dbPath);
590
653
  };
591
654
  /**
592
655
  * Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
@@ -601,7 +664,15 @@ async function doInitLbug(repoId, dbPath) {
601
664
  catch {
602
665
  throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`);
603
666
  }
604
- evictLRU();
667
+ // Awaited: without this, the connection opened just below could race the
668
+ // checkpoint from the LRU victim's still-in-flight close (see evictLRU /
669
+ // closeOne). The caller holds withPoolLock for this attempt, so this await
670
+ // only covers this call's own evict-then-reopen — not other callers.
671
+ // Lock-retry re-enters this function after sleeping *outside* the mutex.
672
+ // evictLRU is a no-op unless the pool is full again (another repo may have
673
+ // taken the slot we freed on a prior attempt). Skipping it on retry would
674
+ // let a 6th native open race a still-resident victim's checkpoint.
675
+ await evictLRU();
605
676
  // Reuse an existing native Database if another repoId already opened this path.
606
677
  // This prevents buffer manager exhaustion from multiple mmap regions on the same file.
607
678
  let shared = dbCache.get(dbPath);
@@ -623,51 +694,48 @@ async function doInitLbug(repoId, dbPath) {
623
694
  if (!shared) {
624
695
  // Open in read-only mode — MCP server never writes to the database.
625
696
  // This allows multiple MCP server instances to read concurrently, and
626
- // avoids lock conflicts when `gitnexus analyze` is writing.
627
- let lastError = null;
628
- for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
629
- try {
630
- const db = await openReadOnlyDatabase(dbPath);
631
- shared = { db, refCount: 0, ftsLoaded: false, dbIdentity: await statDbIdentity(dbPath) };
632
- dbCache.set(dbPath, shared);
633
- break;
634
- }
635
- catch (err) {
636
- lastError = err instanceof Error ? err : new Error(String(err));
637
- if (isWalCorruptionError(lastError)) {
638
- try {
639
- const db = await tryQuarantineAndReopen(dbPath, repoId);
640
- shared = {
641
- db,
642
- refCount: 0,
643
- ftsLoaded: false,
644
- dbIdentity: await statDbIdentity(dbPath),
645
- };
646
- dbCache.set(dbPath, shared);
647
- break;
648
- }
649
- catch (retryErr) {
650
- throw new Error(`LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` +
651
- `(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`);
652
- }
697
+ // avoids lock conflicts when `gitnexus analyze` is writing. This attempt
698
+ // is one native open; lock-retry backoff lives in initLbug.
699
+ try {
700
+ const db = await openReadOnlyDatabase(dbPath);
701
+ shared = { db, refCount: 0, ftsLoaded: false, dbIdentity: await statDbIdentity(dbPath) };
702
+ dbCache.set(dbPath, shared);
703
+ }
704
+ catch (err) {
705
+ const lastError = err instanceof Error ? err : new Error(String(err));
706
+ // Not retryable: the on-disk file's storage version doesn't change
707
+ // on its own. Fail immediately with an actionable message.
708
+ throwIfStorageVersionMismatch(lastError);
709
+ if (isWalCorruptionError(lastError)) {
710
+ try {
711
+ const db = await tryQuarantineAndReopen(dbPath, repoId);
712
+ shared = {
713
+ db,
714
+ refCount: 0,
715
+ ftsLoaded: false,
716
+ dbIdentity: await statDbIdentity(dbPath),
717
+ };
718
+ dbCache.set(dbPath, shared);
719
+ }
720
+ catch (retryErr) {
721
+ throw new Error(`LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` +
722
+ `(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`);
653
723
  }
724
+ }
725
+ if (!shared) {
654
726
  if (lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') ||
655
727
  lastError.message.startsWith('LadybugDB checkpoint sidecar is present but unreachable') ||
656
728
  lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') ||
657
729
  isMissingShadowSidecarError(lastError)) {
658
730
  throw lastError;
659
731
  }
660
- const isLockError = lastError.message.includes('Could not set lock') ||
661
- /\block(\b|ed|ing)/i.test(lastError.message);
662
- if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS)
663
- break;
664
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
732
+ if (lastError.message.includes('Could not set lock') ||
733
+ /\block(\b|ed|ing)/i.test(lastError.message)) {
734
+ return { status: 'retry', error: lastError };
735
+ }
736
+ throw ladybugUnavailableError(repoId, lastError);
665
737
  }
666
738
  }
667
- if (!shared) {
668
- throw new Error(`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
669
- `Retry later. (${lastError?.message || 'unknown error'})`);
670
- }
671
739
  }
672
740
  shared.refCount++;
673
741
  const db = shared.db;
@@ -711,6 +779,7 @@ async function doInitLbug(repoId, dbPath) {
711
779
  });
712
780
  ensureIdleTimer();
713
781
  traceRss('init', repoId);
782
+ return { status: 'done', reopened: true };
714
783
  }
715
784
  /**
716
785
  * Initialize a pool entry from a pre-existing Database object.
@@ -724,6 +793,9 @@ async function doInitLbug(repoId, dbPath) {
724
793
  * repoId already injected it), the existing entry is reused.
725
794
  */
726
795
  export async function initLbugWithDb(repoId, existingDb, dbPath) {
796
+ return withPoolLock(() => initLbugWithDbInner(repoId, existingDb, dbPath));
797
+ }
798
+ async function initLbugWithDbInner(repoId, existingDb, dbPath) {
727
799
  const existing = pool.get(repoId);
728
800
  if (existing) {
729
801
  existing.lastUsed = Date.now();
@@ -1060,12 +1132,29 @@ export const executeParameterized = async (repoId, cypher, params) => {
1060
1132
  */
1061
1133
  export const closeLbug = async (repoId) => {
1062
1134
  if (repoId) {
1063
- closeOne(repoId);
1135
+ // Locked: closeOne now deletes the pool entry before its awaited
1136
+ // db.close() finishes, so an unlocked call here could race a concurrent
1137
+ // initLbug(repoId, ...) — that init could acquire the lock right after
1138
+ // the delete, see no cached entry, and start opening a fresh connection
1139
+ // while this close's checkpoint is still in flight, reopening the exact
1140
+ // race withPoolLock exists to close (review finding on PR #3189).
1141
+ // Awaited: closeOne is now async (see evictLRU's rationale); callers of
1142
+ // closeLbug rely on pool.delete() having already run — e.g. isLbugReady()
1143
+ // returning false — by the time this promise resolves.
1144
+ await withPoolLock(() => closeOne(repoId));
1064
1145
  return;
1065
1146
  }
1066
- for (const id of [...pool.keys()]) {
1067
- closeOne(id);
1068
- }
1147
+ // Locked for the same reason as the per-repoId branch above, plus: without
1148
+ // this, an initLbug that runs while this loop is mid-await (closeOne
1149
+ // yields during the native close) can register a fresh pool entry after
1150
+ // `pool.keys()` was already snapshotted, so a caller expecting closeLbug()
1151
+ // to mean "pool is now empty" would find that new entry still resident
1152
+ // (review finding on PR #3187).
1153
+ await withPoolLock(async () => {
1154
+ for (const id of [...pool.keys()]) {
1155
+ await closeOne(id);
1156
+ }
1157
+ });
1069
1158
  if (idleTimer) {
1070
1159
  clearInterval(idleTimer);
1071
1160
  idleTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.40",
3
+ "version": "1.6.12-rc.41",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -57,7 +57,7 @@
57
57
  "version": "node scripts/sync-plugin-manifests.mjs"
58
58
  },
59
59
  "dependencies": {
60
- "@ladybugdb/core": "^0.19.0",
60
+ "@ladybugdb/core": "0.18.3",
61
61
  "@modelcontextprotocol/sdk": "^1.0.0",
62
62
  "@scarf/scarf": "^1.4.0",
63
63
  "busboy": "^1.6.0",