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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
|
|
533
|
-
|
|
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
|
|
539
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
661
|
-
/\block(\b|ed|ing)/i.test(lastError.message)
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
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
|
|
1067
|
-
|
|
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.
|
|
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": "
|
|
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",
|