gitnexus 1.6.9-rc.29 → 1.6.9-rc.30

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.
@@ -1,5 +1,51 @@
1
1
  import type { LbugValue } from '@ladybugdb/core';
2
2
  import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
3
+ /**
4
+ * Serialize an operation on a cached handle's per-handle FIFO chain. Mirrors the
5
+ * promise-chain mechanic of `lbug/conn-lock.ts` (install a fresh unresolved
6
+ * tail, await the prior holder, release in `finally` so a throw never wedges the
7
+ * chain) — but keyed per handle, not a single global lock. No re-entry guard:
8
+ * `queryBridge` is a leaf (it never calls another locked bridge helper), and the
9
+ * native close runs outside the lock gated on `refs === 0`.
10
+ */
11
+ export declare function withHandleLock<T>(lock: {
12
+ lockTail: Promise<void>;
13
+ }, fn: () => Promise<T>): Promise<T>;
14
+ /**
15
+ * Get or create a cached read-only bridge handle for `groupDir`.
16
+ *
17
+ * - First call: delegates to `openBridgeDbReadOnly`, records the file's
18
+ * `mtimeMs`, and caches the handle.
19
+ * - Subsequent calls (mtime unchanged): returns the cached handle — no
20
+ * reopen, no OS file-handle churn.
21
+ * - After the file's mtime changes (external writer, e.g. another process
22
+ * ran `gitnexus group sync`): closes the stale handle, opens a fresh
23
+ * one, and updates the cache.
24
+ * - After the file disappears (ENOENT): invalidates cache, returns null.
25
+ *
26
+ * Returns `null` when the bridge file is missing, has an incompatible
27
+ * schema version, or cannot be opened even after the retry loop in
28
+ * `openBridgeDbReadOnly`.
29
+ */
30
+ export declare function getCachedBridgeReadOnly(groupDir: string): Promise<BridgeHandle | null>;
31
+ /**
32
+ * Invalidate the cached read-only handle for `groupDir`. Drops it from the
33
+ * cache immediately; the native close is deferred until any in-flight reader
34
+ * leases drain (see {@link evictBridgeEntry}). With no concurrent reader this
35
+ * resolves only after the handle is actually closed — which is why
36
+ * `writeBridge` awaits it before its atomic rename (Windows: a still-open RO
37
+ * handle would block the rename with EBUSY).
38
+ */
39
+ export declare function invalidateBridgeCache(groupDir: string): Promise<void>;
40
+ /**
41
+ * Close ALL cached bridge handles. Call on process shutdown only — it force-
42
+ * closes regardless of refs (safe at `beforeExit`, which fires only at
43
+ * event-loop quiescence, so no query is in flight). Do NOT wire this to a
44
+ * SIGTERM/SIGINT handler that can fire mid-request: that would close a handle
45
+ * under a live query. Routes through `finalizeBridgeClose` for the close-once
46
+ * guarantee.
47
+ */
48
+ export declare function closeAllCachedBridges(): Promise<void>;
3
49
  export declare function contractNodeId(repo: string, contractId: string, role: string, filePath: string): string;
4
50
  /**
5
51
  * In-memory index of contract node IDs keyed three ways, mirroring the
@@ -47,6 +93,22 @@ export declare function findContractNode(index: ContractLookupIndex, repo: strin
47
93
  export declare function openBridgeDb(dbPath: string): Promise<BridgeHandle>;
48
94
  export declare function ensureBridgeSchema(handle: BridgeHandle): Promise<void>;
49
95
  export declare function queryBridge<T>(handle: BridgeHandle, cypher: string, params?: Record<string, LbugValue>): Promise<T[]>;
96
+ /**
97
+ * Release a caller's reference to a bridge handle.
98
+ *
99
+ * - **Cache-owned handle** (returned by `getCachedBridgeReadOnly`): this is the
100
+ * matching *release* for that acquire — it decrements the lease refcount, it
101
+ * does NOT close the native handle. The cache owns the lifetime; the handle
102
+ * closes on explicit `invalidateBridgeCache`, mtime-eviction, or process
103
+ * shutdown. If the entry was already evicted and this is the last lease, the
104
+ * deferred native close fires here (exactly once).
105
+ * - **Uncached/writable handle** (e.g. the `writeBridge` temp DB): closes the
106
+ * native handle for real (CHECKPOINT-flush for writable handles).
107
+ *
108
+ * Contract: before renaming or deleting `bridge.lbug`, call
109
+ * `invalidateBridgeCache` (not this) — `closeBridgeDb` on a cache-owned handle
110
+ * is a lease release, so the file may stay open under other readers.
111
+ */
50
112
  export declare function closeBridgeDb(handle: BridgeHandle): Promise<void>;
51
113
  export declare function retryRename(src: string, dst: string, attempts?: number): Promise<void>;
52
114
  export declare function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void>;
@@ -6,7 +6,9 @@ import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js
6
6
  import { closeLbugConnection, openLbugConnection, } from '../lbug/lbug-config.js';
7
7
  import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
8
8
  import { createLogger } from '../logger.js';
9
- const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
9
+ const bridgeLogger = createLogger('bridge-db', {
10
+ debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE',
11
+ });
10
12
  /**
11
13
  * Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
12
14
  *
@@ -24,6 +26,294 @@ const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BR
24
26
  * cleaned up explicitly or the next writer trips the database-id check.
25
27
  */
26
28
  const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'];
29
+ /**
30
+ * Windows-only bound on how long `invalidateBridgeCache` waits for in-flight
31
+ * readers to release before letting `writeBridge` rename. Past this, it falls
32
+ * through and `retryRename` (EBUSY ×3) copes — so a pathologically long reader
33
+ * can never wedge `group_sync`. ponytail: fixed 5s ceiling; make it
34
+ * configurable if a real workload shows reads routinely outlasting it.
35
+ */
36
+ const WINDOWS_DRAIN_TIMEOUT_MS = 5000;
37
+ const cachedBridgeHandles = new Map();
38
+ /**
39
+ * Reverse lookup: cache entry by its `BridgeHandle`. Lets `queryBridge` and
40
+ * `closeBridgeDb` find an entry from just the handle — including an *evicted*
41
+ * entry that is no longer in `cachedBridgeHandles` but whose native handle a
42
+ * lease still holds open. Uncached/writable handles (the `writeBridge` temp DB)
43
+ * are absent here, which is how those paths opt out of the lock and refcount.
44
+ */
45
+ const bridgeEntryByHandle = new WeakMap();
46
+ /**
47
+ * In-flight opens keyed by groupDir. Prevents the TOCTOU race where two
48
+ * concurrent cache-miss calls both open a fresh handle and the second
49
+ * overwrites the first in `cachedBridgeHandles` — leaking the first
50
+ * handle. Mirrors the `local-backend.ts:1293` reinitPromises pattern.
51
+ */
52
+ const inFlightOpens = new Map();
53
+ function bridgeCacheKey(groupDir) {
54
+ return path.resolve(groupDir);
55
+ }
56
+ /**
57
+ * Serialize an operation on a cached handle's per-handle FIFO chain. Mirrors the
58
+ * promise-chain mechanic of `lbug/conn-lock.ts` (install a fresh unresolved
59
+ * tail, await the prior holder, release in `finally` so a throw never wedges the
60
+ * chain) — but keyed per handle, not a single global lock. No re-entry guard:
61
+ * `queryBridge` is a leaf (it never calls another locked bridge helper), and the
62
+ * native close runs outside the lock gated on `refs === 0`.
63
+ */
64
+ export async function withHandleLock(lock, fn) {
65
+ const prior = lock.lockTail;
66
+ let release;
67
+ lock.lockTail = new Promise((resolve) => {
68
+ release = resolve;
69
+ });
70
+ await prior;
71
+ try {
72
+ return await fn();
73
+ }
74
+ finally {
75
+ release();
76
+ }
77
+ }
78
+ /**
79
+ * Close a cached entry's native handle exactly once. Guarded by `closeStarted`
80
+ * so the mtime-evict path, `invalidateBridgeCache`, the last lease release, and
81
+ * `closeAllCachedBridges` can all reach here and only one native close runs.
82
+ */
83
+ async function finalizeBridgeClose(entry) {
84
+ if (entry.closeStarted)
85
+ return;
86
+ entry.closeStarted = true;
87
+ bridgeEntryByHandle.delete(entry.handle);
88
+ try {
89
+ await closeBridgeHandle(entry.handle);
90
+ }
91
+ finally {
92
+ entry.resolveDrained();
93
+ }
94
+ }
95
+ /**
96
+ * Remove an entry from the cache and release its native handle. The native
97
+ * close is DEFERRED until in-flight leases drain (`refs === 0`): closing a
98
+ * handle a concurrent `@group` reader is still querying is a native
99
+ * use-after-free (the `conn-lock.ts` hazard). When `refs === 0` (the common
100
+ * single-threaded case — e.g. `group_sync` with no concurrent read) the close
101
+ * runs now and the returned promise resolves when it completes, so
102
+ * `writeBridge`'s atomic rename never races a live RO handle on Windows.
103
+ *
104
+ * When `refs > 0` (a concurrent reader holds a lease), the native close is
105
+ * deferred to the last `closeBridgeDb` release — closing now would be a
106
+ * use-after-free. Platform split for the rename that follows:
107
+ * - POSIX: return immediately. The rename succeeds over the still-open RO
108
+ * handle (old inode survives for the reader); no wait, no starvation.
109
+ * - Windows: a rename over an open handle fails (EBUSY), so wait — bounded by
110
+ * `WINDOWS_DRAIN_TIMEOUT_MS` — for the reader to release and the deferred
111
+ * close to complete, then the rename is clean. On timeout, fall through and
112
+ * let `retryRename` cope, so a slow reader can never wedge `group_sync`.
113
+ *
114
+ * This is the single eviction path for BOTH the mtime-change branch and
115
+ * `invalidateBridgeCache`.
116
+ */
117
+ async function evictBridgeEntry(key, entry) {
118
+ if (!entry.evicted) {
119
+ entry.evicted = true;
120
+ if (cachedBridgeHandles.get(key) === entry)
121
+ cachedBridgeHandles.delete(key);
122
+ }
123
+ if (entry.refs <= 0) {
124
+ await finalizeBridgeClose(entry);
125
+ return;
126
+ }
127
+ // refs > 0: close deferred to the last closeBridgeDb release.
128
+ if (process.platform === 'win32') {
129
+ // Windows needs the handle closed before writeBridge renames. Wait (bounded)
130
+ // for readers to drain; on timeout, retryRename handles the residual EBUSY.
131
+ let timer;
132
+ const timeout = new Promise((resolve) => {
133
+ timer = setTimeout(resolve, WINDOWS_DRAIN_TIMEOUT_MS);
134
+ });
135
+ await Promise.race([entry.drained, timeout]).finally(() => clearTimeout(timer));
136
+ }
137
+ }
138
+ /**
139
+ * Close a BridgeHandle's native resources without touching the cache.
140
+ * Shared by `closeBridgeDb` (uncached handles) and the cache invalidation
141
+ * / shutdown paths so neither duplicates the close logic.
142
+ */
143
+ async function closeBridgeHandle(handle) {
144
+ if (!handle._readOnly) {
145
+ try {
146
+ await handle._conn.query('CHECKPOINT');
147
+ }
148
+ catch {
149
+ /* ignore — older LadybugDB or schemaless DB may not accept it */
150
+ }
151
+ }
152
+ try {
153
+ await handle._conn.close();
154
+ }
155
+ catch {
156
+ /* ignore */
157
+ }
158
+ try {
159
+ await handle._db.close();
160
+ }
161
+ catch {
162
+ /* ignore */
163
+ }
164
+ }
165
+ /**
166
+ * Get or create a cached read-only bridge handle for `groupDir`.
167
+ *
168
+ * - First call: delegates to `openBridgeDbReadOnly`, records the file's
169
+ * `mtimeMs`, and caches the handle.
170
+ * - Subsequent calls (mtime unchanged): returns the cached handle — no
171
+ * reopen, no OS file-handle churn.
172
+ * - After the file's mtime changes (external writer, e.g. another process
173
+ * ran `gitnexus group sync`): closes the stale handle, opens a fresh
174
+ * one, and updates the cache.
175
+ * - After the file disappears (ENOENT): invalidates cache, returns null.
176
+ *
177
+ * Returns `null` when the bridge file is missing, has an incompatible
178
+ * schema version, or cannot be opened even after the retry loop in
179
+ * `openBridgeDbReadOnly`.
180
+ */
181
+ export async function getCachedBridgeReadOnly(groupDir) {
182
+ const key = bridgeCacheKey(groupDir);
183
+ const dbPath = path.join(groupDir, 'bridge.lbug');
184
+ // Fast path: cache hit, unchanged mtime → lease the cached handle.
185
+ const entry = cachedBridgeHandles.get(key);
186
+ if (entry) {
187
+ try {
188
+ const stat = await fsp.stat(dbPath);
189
+ // Re-check `evicted` AFTER the await: a concurrent writeBridge/invalidate
190
+ // may have evicted this entry while we awaited `stat`. Leasing an evicted
191
+ // (closing) handle would be a use-after-close. The `refs++` is the first
192
+ // synchronous statement after the check, so no evictor can slip between.
193
+ if (!entry.evicted && stat.mtimeMs === entry.mtime) {
194
+ entry.refs++;
195
+ return entry.handle;
196
+ }
197
+ }
198
+ catch {
199
+ // File disappeared (ENOENT) — fall through to evict + reopen.
200
+ }
201
+ // mtime changed or file gone — evict (defers the native close if a
202
+ // concurrent reader still holds a lease; closes now otherwise).
203
+ if (!entry.evicted)
204
+ await evictBridgeEntry(key, entry);
205
+ }
206
+ // TOCTOU guard: if another caller is already opening for this key, await
207
+ // their in-flight promise and take a lease on the result instead of opening
208
+ // a second handle.
209
+ const inFlight = inFlightOpens.get(key);
210
+ if (inFlight) {
211
+ const handle = await inFlight;
212
+ if (!handle)
213
+ return null;
214
+ // Same post-await guard as the fast path: the opener's entry may have been
215
+ // evicted between caching and this awaiter resuming. Only lease a live,
216
+ // identity-matched entry; otherwise retry from the top for a fresh handle.
217
+ const opened = cachedBridgeHandles.get(key);
218
+ if (opened && !opened.evicted && opened.handle === handle) {
219
+ opened.refs++;
220
+ return handle;
221
+ }
222
+ return getCachedBridgeReadOnly(groupDir);
223
+ }
224
+ const openPromise = (async () => {
225
+ try {
226
+ const handle = await openBridgeDbReadOnly(groupDir);
227
+ if (!handle)
228
+ return null;
229
+ let mtime = 0;
230
+ try {
231
+ const stat = await fsp.stat(dbPath);
232
+ mtime = stat.mtimeMs;
233
+ }
234
+ catch {
235
+ // bridge.lbug not stat-able right after open (rare race). Leaving
236
+ // mtime at 0 means the next call's fast-path comparison won't match
237
+ // (a real file's mtime is never 0), so it re-opens. Benign: the handle
238
+ // still works for this caller; we just don't cache-reuse it until a
239
+ // later open records a real mtime.
240
+ }
241
+ let resolveDrained;
242
+ const drained = new Promise((resolve) => {
243
+ resolveDrained = resolve;
244
+ });
245
+ const newEntry = {
246
+ handle,
247
+ mtime,
248
+ refs: 0,
249
+ evicted: false,
250
+ closeStarted: false,
251
+ lockTail: Promise.resolve(),
252
+ drained,
253
+ resolveDrained,
254
+ };
255
+ cachedBridgeHandles.set(key, newEntry);
256
+ bridgeEntryByHandle.set(handle, newEntry);
257
+ return handle;
258
+ }
259
+ finally {
260
+ inFlightOpens.delete(key);
261
+ }
262
+ })();
263
+ inFlightOpens.set(key, openPromise);
264
+ // Each caller (the opener and every awaiter) takes exactly one lease here, so
265
+ // refs counts callers correctly even under inFlightOpens coalescing.
266
+ const handle = await openPromise;
267
+ if (!handle)
268
+ return null;
269
+ const opened = cachedBridgeHandles.get(key);
270
+ if (opened && !opened.evicted && opened.handle === handle) {
271
+ opened.refs++;
272
+ return handle;
273
+ }
274
+ return getCachedBridgeReadOnly(groupDir);
275
+ }
276
+ /**
277
+ * Invalidate the cached read-only handle for `groupDir`. Drops it from the
278
+ * cache immediately; the native close is deferred until any in-flight reader
279
+ * leases drain (see {@link evictBridgeEntry}). With no concurrent reader this
280
+ * resolves only after the handle is actually closed — which is why
281
+ * `writeBridge` awaits it before its atomic rename (Windows: a still-open RO
282
+ * handle would block the rename with EBUSY).
283
+ */
284
+ export async function invalidateBridgeCache(groupDir) {
285
+ const key = bridgeCacheKey(groupDir);
286
+ const entry = cachedBridgeHandles.get(key);
287
+ if (entry)
288
+ await evictBridgeEntry(key, entry);
289
+ }
290
+ /**
291
+ * Close ALL cached bridge handles. Call on process shutdown only — it force-
292
+ * closes regardless of refs (safe at `beforeExit`, which fires only at
293
+ * event-loop quiescence, so no query is in flight). Do NOT wire this to a
294
+ * SIGTERM/SIGINT handler that can fire mid-request: that would close a handle
295
+ * under a live query. Routes through `finalizeBridgeClose` for the close-once
296
+ * guarantee.
297
+ */
298
+ export async function closeAllCachedBridges() {
299
+ const entries = [...cachedBridgeHandles.values()];
300
+ cachedBridgeHandles.clear();
301
+ await Promise.all(entries.map((e) => finalizeBridgeClose(e)));
302
+ }
303
+ // Best-effort process-exit cleanup. 'beforeExit' fires before 'exit' and
304
+ // lets async work drain (unlike 'exit' which is synchronous-only). It does
305
+ // NOT fire on process.exit()/SIGTERM/SIGINT — but that is fine here: the OS
306
+ // reclaims all handles on any exit path, and for read-only handles there is
307
+ // no WAL to flush, so the only thing lost on signal death is a tidy close
308
+ // (cosmetic). We deliberately do NOT register a SIGTERM/SIGINT handler: a
309
+ // signal can fire mid-request, and closeAllCachedBridges force-closes
310
+ // regardless of refs, which would close a handle under a live query. Shutdown
311
+ // sequencing is the MCP server's responsibility — it should call
312
+ // closeAllCachedBridges() at a quiescent point (also how tests get a
313
+ // deterministic teardown).
314
+ process.once('beforeExit', () => {
315
+ void closeAllCachedBridges();
316
+ });
27
317
  async function removeLbugFile(basePath) {
28
318
  const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
29
319
  for (const f of candidates) {
@@ -131,20 +421,29 @@ export async function ensureBridgeSchema(handle) {
131
421
  }
132
422
  }
133
423
  export async function queryBridge(handle, cypher, params) {
134
- const conn = handle._conn;
135
- if (params && Object.keys(params).length > 0) {
136
- const stmt = await conn.prepare(cypher);
137
- if (!stmt.isSuccess()) {
138
- const errMsg = await stmt.getErrorMessage();
139
- throw new Error(`Bridge query prepare failed: ${errMsg}`);
424
+ const run = async () => {
425
+ const conn = handle._conn;
426
+ if (params && Object.keys(params).length > 0) {
427
+ const stmt = await conn.prepare(cypher);
428
+ if (!stmt.isSuccess()) {
429
+ const errMsg = await stmt.getErrorMessage();
430
+ throw new Error(`Bridge query prepare failed: ${errMsg}`);
431
+ }
432
+ const queryResult = await conn.execute(stmt, params);
433
+ const result = unwrapQueryResult(queryResult);
434
+ return (await result.getAll());
140
435
  }
141
- const queryResult = await conn.execute(stmt, params);
436
+ const queryResult = await conn.query(cypher);
142
437
  const result = unwrapQueryResult(queryResult);
143
438
  return (await result.getAll());
144
- }
145
- const queryResult = await conn.query(cypher);
146
- const result = unwrapQueryResult(queryResult);
147
- return (await result.getAll());
439
+ };
440
+ // Cached RO handles are shared across concurrent @group callers, so serialize
441
+ // conn ops per handle (a LadybugDB Connection is not safe for concurrent
442
+ // queries — conn-lock.ts). Uncached/writable handles (the writeBridge temp DB)
443
+ // are single-threaded — they're absent from bridgeEntryByHandle and skip the
444
+ // lock at zero cost.
445
+ const entry = bridgeEntryByHandle.get(handle);
446
+ return entry ? withHandleLock(entry, run) : run();
148
447
  }
149
448
  /**
150
449
  * LadybugDB's `conn.query` / `conn.execute` can return either a single
@@ -164,50 +463,54 @@ function unwrapQueryResult(queryResult) {
164
463
  }
165
464
  return queryResult;
166
465
  }
466
+ /**
467
+ * Release a caller's reference to a bridge handle.
468
+ *
469
+ * - **Cache-owned handle** (returned by `getCachedBridgeReadOnly`): this is the
470
+ * matching *release* for that acquire — it decrements the lease refcount, it
471
+ * does NOT close the native handle. The cache owns the lifetime; the handle
472
+ * closes on explicit `invalidateBridgeCache`, mtime-eviction, or process
473
+ * shutdown. If the entry was already evicted and this is the last lease, the
474
+ * deferred native close fires here (exactly once).
475
+ * - **Uncached/writable handle** (e.g. the `writeBridge` temp DB): closes the
476
+ * native handle for real (CHECKPOINT-flush for writable handles).
477
+ *
478
+ * Contract: before renaming or deleting `bridge.lbug`, call
479
+ * `invalidateBridgeCache` (not this) — `closeBridgeDb` on a cache-owned handle
480
+ * is a lease release, so the file may stay open under other readers.
481
+ */
167
482
  export async function closeBridgeDb(handle) {
168
- // CHECKPOINT before close so the WAL/.shadow contents are flushed into
169
- // the main database file. Without this, LadybugDB 0.16.0's non-blocking
170
- // checkpoint thread can outlive the close call and leave sidecar pages
171
- // pending on disk, which makes a subsequent read-side open either race
172
- // with the WAL replay or trip the database-id check on the sidecars.
173
- // CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
174
- //
175
- // ONLY on a writable handle. A read-only connection has nothing to flush,
176
- // and issuing CHECKPOINT on it leaves a WAL/shadow lock artifact that makes
177
- // the very next read-only open of the same path fail in-process — which broke
178
- // repeated `@group` impact/trace calls in a long-lived MCP server (the read
179
- // path opens read-only, queries, and closes per call).
180
- if (!handle._readOnly) {
181
- try {
182
- await handle._conn.query('CHECKPOINT');
183
- }
184
- catch {
185
- /* ignore — older LadybugDB or schemaless DB may not accept it */
186
- }
187
- }
188
- try {
189
- await handle._conn.close();
190
- }
191
- catch {
192
- /* ignore */
193
- }
194
- try {
195
- await handle._db.close();
196
- }
197
- catch {
198
- /* ignore */
483
+ const entry = bridgeEntryByHandle.get(handle);
484
+ if (!entry) {
485
+ // Uncached or writable handle close for real.
486
+ await closeBridgeHandle(handle);
487
+ return;
199
488
  }
200
- // NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
201
- // known limitation (the writable close's OS file handle is not released before
202
- // the read open races; the existing open-side LBUG_OPEN_RETRY only retries
203
- // lock-pattern errors, not the post-rename sidecar database-id mismatch). The
204
- // bridge's close-then-reopen tests stay Windows-skipped. A close-side
205
- // waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
206
- // safeClose) was tried and did NOT close that gap on Windows CI, so it was
207
- // removed rather than carry latency/duplication for no Windows benefit. The
208
- // read-only CHECKPOINT skip above is the load-bearing fix and works on
209
- // Linux/macOS (the platforms where in-process reopen is supported).
489
+ // Cache-owned handle: release this lease. Close only the evicted handle whose
490
+ // last lease just dropped (deferred-close completion); the live cached handle
491
+ // stays open for reuse.
492
+ if (entry.refs > 0)
493
+ entry.refs--;
494
+ if (entry.evicted && entry.refs <= 0)
495
+ await finalizeBridgeClose(entry);
210
496
  }
497
+ // NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
498
+ // known limitation (the writable close's OS file handle is not released before
499
+ // the read open races; the existing open-side LBUG_OPEN_RETRY only retries
500
+ // lock-pattern errors, not the post-rename sidecar database-id mismatch). The
501
+ // bridge's close-then-reopen tests stay Windows-skipped. A close-side
502
+ // waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
503
+ // safeClose) was tried and did NOT close that gap on Windows CI, so it was
504
+ // removed rather than carry latency/duplication for no Windows benefit.
505
+ //
506
+ // Scope of the RO bridge-handle cache (getCachedBridgeReadOnly): it removes the
507
+ // PRODUCTION symptom — a long-lived MCP serve process reopening bridge.lbug on
508
+ // every @group call — by keeping one RO handle alive for read→READ reuse.
509
+ // It does NOT fix the write→READ reopen: the first @group read right after an
510
+ // in-process group_sync is a cache miss → openBridgeDbReadOnly, i.e. the same
511
+ // unfixed reopen, so on Windows that first post-sync read still returns null.
512
+ // The read-only CHECKPOINT skip above remains the load-bearing fix on
513
+ // Linux/macOS.
211
514
  /* ------------------------------------------------------------------ */
212
515
  /* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
213
516
  /* ------------------------------------------------------------------ */
@@ -278,6 +581,11 @@ function errMessage(err) {
278
581
  }
279
582
  export async function writeBridge(groupDir, input) {
280
583
  await fsp.mkdir(groupDir, { recursive: true });
584
+ // Invalidate the RO cache before writing. On Windows the cached handle
585
+ // would block the atomic rename (tmp → bridge.lbug) because the OS keeps
586
+ // a shared-mode lock on the open file. Closing it first guarantees the
587
+ // rename succeeds without EBUSY.
588
+ await invalidateBridgeCache(groupDir);
281
589
  const contracts = dedupeContracts(input.contracts);
282
590
  const crossLinks = dedupeCrossLinks(input.crossLinks);
283
591
  const finalPath = path.join(groupDir, 'bridge.lbug');
@@ -595,7 +903,12 @@ export async function openBridgeDbReadOnly(groupDir) {
595
903
  // (where we can retry) instead of on the first user query.
596
904
  await handle.db.init();
597
905
  await handle.conn.init();
598
- return { _db: handle.db, _conn: handle.conn, groupDir, _readOnly: true };
906
+ return {
907
+ _db: handle.db,
908
+ _conn: handle.conn,
909
+ groupDir,
910
+ _readOnly: true,
911
+ };
599
912
  }
600
913
  catch (err) {
601
914
  lastErr = err;
@@ -613,7 +926,11 @@ export async function openBridgeDbReadOnly(groupDir) {
613
926
  // measure so CodeQL can see the taint flow is broken.
614
927
  const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' ');
615
928
  const safeErrMsg = lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
616
- bridgeLogger.debug({ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS }, 'openBridgeDbReadOnly gave up');
929
+ bridgeLogger.debug({
930
+ groupDir: safeGroupDir,
931
+ errMsg: safeErrMsg,
932
+ attempts: LBUG_OPEN_RETRY_ATTEMPTS,
933
+ }, 'openBridgeDbReadOnly gave up');
617
934
  return null;
618
935
  }
619
936
  /* ------------------------------------------------------------------ */
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
8
8
  import { fileMatchesServicePrefix, normalizeServicePrefix, repoInSubgroup, } from './group-path-utils.js';
9
9
  import { getGroupDir } from './storage.js';
10
- import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
10
+ import { closeBridgeDb, getCachedBridgeReadOnly, queryBridge, readBridgeMeta, } from './bridge-db.js';
11
11
  import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
12
12
  // High limit for the local phase of group impact so collectImpactSymbolUids
13
13
  // sees (nearly) all symbols. Bypasses the MCP-facing default of 100.
@@ -283,7 +283,10 @@ export async function ensureBridgeReady(groupDir) {
283
283
  error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
284
284
  };
285
285
  }
286
- const handle = await openBridgeDbReadOnly(groupDir);
286
+ // Use the cached read-only handle if available — avoids reopening the same
287
+ // bridge.lbug in a long-lived MCP server, which fails on Windows because
288
+ // the OS handle isn't fully released before the next open races in.
289
+ const handle = await getCachedBridgeReadOnly(groupDir);
287
290
  if (!handle) {
288
291
  return {
289
292
  error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.29",
3
+ "version": "1.6.9-rc.30",
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",