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', {
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
436
|
+
const queryResult = await conn.query(cypher);
|
|
142
437
|
const result = unwrapQueryResult(queryResult);
|
|
143
438
|
return (await result.getAll());
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 {
|
|
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({
|
|
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,
|
|
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
|
-
|
|
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