fix(group): cache read-only bridge handle to fix Windows @group reopen (#2274) (#2313)

* fix(group): cache read-only bridge handle to fix Windows @group reopen (#2274)

A long-lived MCP server opened bridge.lbug read-only, queried, and closed
it on every @group trace/impact call. On Windows the in-process reopen of the
same file fails (the OS handle is not fully released before the next open races
in), so repeated @group calls broke. #2269 fixed Linux/macOS by skipping
CHECKPOINT on read-only handles; Windows stayed broken.

Instead of fighting LadybugDB's Windows close/reopen timing: cache one
read-only handle per groupDir and reuse it across calls (open-once-per-process
already works on Windows). getCachedBridgeReadOnly:
  - reuses a single handle keyed by resolved groupDir,
  - invalidates on mtime change (external writer / re-sync),
  - invalidates explicitly before same-process writes (writeBridge),
  - guards concurrent first-open with an in-flight promise (no handle leak),
  - closes all handles on process exit.

closeBridgeDb now no-ops for the cached handle (cache owns its lifetime);
uncached/writable handles are unaffected. ensureBridgeReady uses the cache.

The in-process write->read reopen of the same bridge.lbug file remains a known
LadybugDB Windows limitation, so the existing reopen tests stay win32-skipped.
A new cache-aware itCacheReopen gate applies to the 3 new tests whose setup
requires write-then-read in the same process (same class as itLbugReopen). The
cache itself exercises read->read reuse and is unaffected.

* fix(group): harden bridge RO-handle cache for concurrency, lifetime & Windows (#2313 review)

Addresses the tri-review + Copilot findings on the read-only bridge-handle cache:

- P1 (F2): serialize queryBridge per cached handle via a per-handle FIFO lock
  (the conn-lock.ts chain mechanic, keyed per cache entry, not the global lock).
  Two concurrent @group callers sharing one lbug.Connection can no longer
  dispatch two queries at once (the heap-corruption hazard). Uncached/writable
  handles skip the lock at zero cost.
- P1 (F3): refcount lease — getCachedBridgeReadOnly acquires, closeBridgeDb
  releases (no caller change). The native close is deferred until in-flight
  readers drain (refs===0) and runs exactly once (closeStarted guard).
  invalidateBridgeCache and the mtime-evict path share one evict/close path.
- Windows: bounded drain in evictBridgeEntry — a concurrent group_sync waits
  (<= WINDOWS_DRAIN_TIMEOUT_MS) for readers to release before the atomic rename
  on win32 so it stays clean; POSIX remains fully non-blocking; single-threaded
  sync still closes-before-rename on all platforms.
- P0 (F1/F6): gate the mtime cache test with itCacheReopen (win32-skipped) and
  drop the manual invalidate so writeBridge self-invalidation is under test;
  add an external-writer (fsp.utimes) reopen case.
- Windows coverage (F9): new cross-process integration test seeds bridge.lbug
  in a separate tsx process, so read->read handle reuse is proven on win32 CI
  (not skipped). Plus concurrent cold-open dedupe coverage.
- P2/P3: scope the Windows NOTE to read->read (F4); JSDoc the closeBridgeDb
  release/close contract (F5); drop the if-branch in the B2 probe (F7); revert
  incidental Prettier churn in cross-impact.ts (F14); fix the stale describe
  header (F15); document the beforeExit/signal and ENOENT-mtime behavior
  (F11/F13).

tsc clean; group unit + integration suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): run the B2 rename-clash probe on win32 via cross-process seed (#2313 review)

Moves the B2 "external rename while a cached RO handle is held" probe out of the
unit suite (where it was win32-skipped, because its in-process writeBridge->RO-open
is the unfixed Windows reopen) into the cross-process integration test, where a
separate-process seed makes the RO open clean. The probe now RUNS ON WIN32 CI and
empirically answers whether an open RO handle blocks an external atomic rename over
bridge.lbug — the assumption under writeBridge's invalidate-before-rename and the
win32 drain.

Hardened (per adversarial review) so a win32 RED is the real steady-state share-mode
signal, not an artifact:
- use production retryRename (not bare fsp.rename) so transient EBUSY/EPERM from the
  Windows AV/indexer scanning the fresh temp file is absorbed; a RED then means the
  rename is blocked even after retries (FILE_SHARE_DELETE absent -> invalidate-before-
  rename is load-bearing).
- stage the byte-identical replacement BEFORE opening the RO handle, so no second OS
  handle touches bridge.lbug while LadybugDB holds it (avoids a FILE_SHARE_READ red for
  the wrong question).
- drop the post-rename query (handle survival is covered by the reuse test); the probe's
  sole verdict is whether the rename is blocked.

Removes the old win32-skipped unit B2 (a strict subset of the new probe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sparsh 2026-06-30 12:19:47 +05:30 • committed by GitHub
parent 22bc07205d
commit b640e537af
5 changed files with 1004 additions and 71 deletions

View file

@ -13,7 +13,9 @@ import {
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
import { createLogger } from '../logger.js';
const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
const bridgeLogger = createLogger('bridge-db', {
debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE',
});
/**
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
@ -33,6 +35,358 @@ const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BR
*/
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
/* ------------------------------------------------------------------ */
/* Read-only bridge handle cache */
/* ------------------------------------------------------------------ */
/**
* Cache of read-only bridge handles keyed by groupDir. Keeps one RO handle
* per groupDir alive across @group tool calls so a long-lived MCP server
* never reopens the same bridge.lbug in-process — reopening fails on Windows
* because the OS file handle isn't fully released before the next open races
* in (see PR #2269, #2274).
*
* deliberation: mtime-based invalidation was chosen over a simpler
* time-to-live or explicit-close model because:
* 1. TTL would force a reopen on a timer even when nothing changed.
* 2. Explicit-close requires every caller to know about the cache.
* 3. A cheap `fsp.stat` (uncached, but typically a single inode lookup on
* modern kernels) before each `ensureBridgeReady` call detects external
* writers (e.g. another process ran group sync) with zero false
* positives and no timer complication.
* 4. Same-process writes invalidate explicitly via `invalidateBridgeCache`
* before the atomic rename so the cached RO handle does not block it.
*/
interface CachedBridgeEntry {
handle: BridgeHandle;
mtime: number;
/**
* Active leases: callers between `getCachedBridgeReadOnly` (acquire, `refs++`)
* and `closeBridgeDb` (release, `refs--`). The native handle is never closed
* while `refs > 0` — a concurrent `@group` reader may still be querying it,
* and closing under a live query is a native use-after-free.
*/
refs: number;
/** Set once the entry leaves the cache; the native close is deferred to the last release. */
evicted: boolean;
/** Guards `finalizeBridgeClose` so the native close runs exactly once. */
closeStarted: boolean;
/**
* Per-handle FIFO serialization tail. The cached RO handle is shared across
* concurrent `@group` callers, but a LadybugDB `Connection` is NOT safe for
* concurrent query execution (see `lbug/conn-lock.ts` — two queries on one
* connection corrupt the native heap). `queryBridge` runs each op on this
* chain so no two ever overlap on one handle. Per-handle (not a single global
* lock) so different groups — separate connections — stay parallel.
*/
lockTail: Promise<void>;
/**
* Resolves when the native handle has actually been closed. `writeBridge` on
* Windows awaits this (bounded — see `WINDOWS_DRAIN_TIMEOUT_MS`) before its
* atomic rename, because Windows cannot rename over an open handle. On POSIX
* the rename succeeds over an open RO handle (the old inode survives for the
* in-flight reader), so the close stays fully non-blocking there.
*/
drained: Promise<void>;
/** Resolver for {@link CachedBridgeEntry.drained}; called once by `finalizeBridgeClose`. */
resolveDrained: () => void;
}
/**
* Windows-only bound on how long `invalidateBridgeCache` waits for in-flight
* readers to release before letting `writeBridge` rename. Past this, it falls
* through and `retryRename` (EBUSY ×3) copes — so a pathologically long reader
* can never wedge `group_sync`. ponytail: fixed 5s ceiling; make it
* configurable if a real workload shows reads routinely outlasting it.
*/
const WINDOWS_DRAIN_TIMEOUT_MS = 5000;
const cachedBridgeHandles = new Map<string, CachedBridgeEntry>();
/**
* Reverse lookup: cache entry by its `BridgeHandle`. Lets `queryBridge` and
* `closeBridgeDb` find an entry from just the handle — including an *evicted*
* entry that is no longer in `cachedBridgeHandles` but whose native handle a
* lease still holds open. Uncached/writable handles (the `writeBridge` temp DB)
* are absent here, which is how those paths opt out of the lock and refcount.
*/
const bridgeEntryByHandle = new WeakMap<BridgeHandle, CachedBridgeEntry>();
/**
* In-flight opens keyed by groupDir. Prevents the TOCTOU race where two
* concurrent cache-miss calls both open a fresh handle and the second
* overwrites the first in `cachedBridgeHandles` — leaking the first
* handle. Mirrors the `local-backend.ts:1293` reinitPromises pattern.
*/
const inFlightOpens = new Map<string, Promise<BridgeHandle | null>>();
function bridgeCacheKey(groupDir: string): string {
return path.resolve(groupDir);
}
/**
* Serialize an operation on a cached handle's per-handle FIFO chain. Mirrors the
* promise-chain mechanic of `lbug/conn-lock.ts` (install a fresh unresolved
* tail, await the prior holder, release in `finally` so a throw never wedges the
* chain) — but keyed per handle, not a single global lock. No re-entry guard:
* `queryBridge` is a leaf (it never calls another locked bridge helper), and the
* native close runs outside the lock gated on `refs === 0`.
*/
export async function withHandleLock<T>(
lock: { lockTail: Promise<void> },
fn: () => Promise<T>,
): Promise<T> {
const prior = lock.lockTail;
let release!: () => void;
lock.lockTail = new Promise<void>((resolve) => {
release = resolve;
});
await prior;
try {
return await fn();
} finally {
release();
}
}
/**
* Close a cached entry's native handle exactly once. Guarded by `closeStarted`
* so the mtime-evict path, `invalidateBridgeCache`, the last lease release, and
* `closeAllCachedBridges` can all reach here and only one native close runs.
*/
async function finalizeBridgeClose(entry: CachedBridgeEntry): Promise<void> {
if (entry.closeStarted) return;
entry.closeStarted = true;
bridgeEntryByHandle.delete(entry.handle);
try {
await closeBridgeHandle(entry.handle);
} finally {
entry.resolveDrained();
}
}
/**
* Remove an entry from the cache and release its native handle. The native
* close is DEFERRED until in-flight leases drain (`refs === 0`): closing a
* handle a concurrent `@group` reader is still querying is a native
* use-after-free (the `conn-lock.ts` hazard). When `refs === 0` (the common
* single-threaded case — e.g. `group_sync` with no concurrent read) the close
* runs now and the returned promise resolves when it completes, so
* `writeBridge`'s atomic rename never races a live RO handle on Windows.
*
* When `refs > 0` (a concurrent reader holds a lease), the native close is
* deferred to the last `closeBridgeDb` release — closing now would be a
* use-after-free. Platform split for the rename that follows:
* - POSIX: return immediately. The rename succeeds over the still-open RO
* handle (old inode survives for the reader); no wait, no starvation.
* - Windows: a rename over an open handle fails (EBUSY), so wait — bounded by
* `WINDOWS_DRAIN_TIMEOUT_MS` — for the reader to release and the deferred
* close to complete, then the rename is clean. On timeout, fall through and
* let `retryRename` cope, so a slow reader can never wedge `group_sync`.
*
* This is the single eviction path for BOTH the mtime-change branch and
* `invalidateBridgeCache`.
*/
async function evictBridgeEntry(key: string, entry: CachedBridgeEntry): Promise<void> {
if (!entry.evicted) {
entry.evicted = true;
if (cachedBridgeHandles.get(key) === entry) cachedBridgeHandles.delete(key);
}
if (entry.refs <= 0) {
await finalizeBridgeClose(entry);
return;
}
// refs > 0: close deferred to the last closeBridgeDb release.
if (process.platform === 'win32') {
// Windows needs the handle closed before writeBridge renames. Wait (bounded)
// for readers to drain; on timeout, retryRename handles the residual EBUSY.
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, WINDOWS_DRAIN_TIMEOUT_MS);
});
await Promise.race([entry.drained, timeout]).finally(() => clearTimeout(timer));
}
}
/**
* Close a BridgeHandle's native resources without touching the cache.
* Shared by `closeBridgeDb` (uncached handles) and the cache invalidation
* / shutdown paths so neither duplicates the close logic.
*/
async function closeBridgeHandle(handle: BridgeHandle): Promise<void> {
if (!handle._readOnly) {
try {
await (handle._conn as lbug.Connection).query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
}
try {
await (handle._conn as lbug.Connection).close();
} catch {
/* ignore */
}
try {
await (handle._db as lbug.Database).close();
} catch {
/* ignore */
}
}
/**
* Get or create a cached read-only bridge handle for `groupDir`.
*
* - First call: delegates to `openBridgeDbReadOnly`, records the file's
* `mtimeMs`, and caches the handle.
* - Subsequent calls (mtime unchanged): returns the cached handle — no
* reopen, no OS file-handle churn.
* - After the file's mtime changes (external writer, e.g. another process
* ran `gitnexus group sync`): closes the stale handle, opens a fresh
* one, and updates the cache.
* - After the file disappears (ENOENT): invalidates cache, returns null.
*
* Returns `null` when the bridge file is missing, has an incompatible
* schema version, or cannot be opened even after the retry loop in
* `openBridgeDbReadOnly`.
*/
export async function getCachedBridgeReadOnly(groupDir: string): Promise<BridgeHandle | null> {
const key = bridgeCacheKey(groupDir);
const dbPath = path.join(groupDir, 'bridge.lbug');
// Fast path: cache hit, unchanged mtime → lease the cached handle.
const entry = cachedBridgeHandles.get(key);
if (entry) {
try {
const stat = await fsp.stat(dbPath);
// Re-check `evicted` AFTER the await: a concurrent writeBridge/invalidate
// may have evicted this entry while we awaited `stat`. Leasing an evicted
// (closing) handle would be a use-after-close. The `refs++` is the first
// synchronous statement after the check, so no evictor can slip between.
if (!entry.evicted && stat.mtimeMs === entry.mtime) {
entry.refs++;
return entry.handle;
}
} catch {
// File disappeared (ENOENT) — fall through to evict + reopen.
}
// mtime changed or file gone — evict (defers the native close if a
// concurrent reader still holds a lease; closes now otherwise).
if (!entry.evicted) await evictBridgeEntry(key, entry);
}
// TOCTOU guard: if another caller is already opening for this key, await
// their in-flight promise and take a lease on the result instead of opening
// a second handle.
const inFlight = inFlightOpens.get(key);
if (inFlight) {
const handle = await inFlight;
if (!handle) return null;
// Same post-await guard as the fast path: the opener's entry may have been
// evicted between caching and this awaiter resuming. Only lease a live,
// identity-matched entry; otherwise retry from the top for a fresh handle.
const opened = cachedBridgeHandles.get(key);
if (opened && !opened.evicted && opened.handle === handle) {
opened.refs++;
return handle;
}
return getCachedBridgeReadOnly(groupDir);
}
const openPromise: Promise<BridgeHandle | null> = (async () => {
try {
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) return null;
let mtime = 0;
try {
const stat = await fsp.stat(dbPath);
mtime = stat.mtimeMs;
} catch {
// bridge.lbug not stat-able right after open (rare race). Leaving
// mtime at 0 means the next call's fast-path comparison won't match
// (a real file's mtime is never 0), so it re-opens. Benign: the handle
// still works for this caller; we just don't cache-reuse it until a
// later open records a real mtime.
}
let resolveDrained!: () => void;
const drained = new Promise<void>((resolve) => {
resolveDrained = resolve;
});
const newEntry: CachedBridgeEntry = {
handle,
mtime,
refs: 0,
evicted: false,
closeStarted: false,
lockTail: Promise.resolve(),
drained,
resolveDrained,
};
cachedBridgeHandles.set(key, newEntry);
bridgeEntryByHandle.set(handle, newEntry);
return handle;
} finally {
inFlightOpens.delete(key);
}
})();
inFlightOpens.set(key, openPromise);
// Each caller (the opener and every awaiter) takes exactly one lease here, so
// refs counts callers correctly even under inFlightOpens coalescing.
const handle = await openPromise;
if (!handle) return null;
const opened = cachedBridgeHandles.get(key);
if (opened && !opened.evicted && opened.handle === handle) {
opened.refs++;
return handle;
}
return getCachedBridgeReadOnly(groupDir);
}
/**
* Invalidate the cached read-only handle for `groupDir`. Drops it from the
* cache immediately; the native close is deferred until any in-flight reader
* leases drain (see {@link evictBridgeEntry}). With no concurrent reader this
* resolves only after the handle is actually closed — which is why
* `writeBridge` awaits it before its atomic rename (Windows: a still-open RO
* handle would block the rename with EBUSY).
*/
export async function invalidateBridgeCache(groupDir: string): Promise<void> {
const key = bridgeCacheKey(groupDir);
const entry = cachedBridgeHandles.get(key);
if (entry) await evictBridgeEntry(key, entry);
}
/**
* Close ALL cached bridge handles. Call on process shutdown only — it force-
* closes regardless of refs (safe at `beforeExit`, which fires only at
* event-loop quiescence, so no query is in flight). Do NOT wire this to a
* SIGTERM/SIGINT handler that can fire mid-request: that would close a handle
* under a live query. Routes through `finalizeBridgeClose` for the close-once
* guarantee.
*/
export async function closeAllCachedBridges(): Promise<void> {
const entries = [...cachedBridgeHandles.values()];
cachedBridgeHandles.clear();
await Promise.all(entries.map((e) => finalizeBridgeClose(e)));
}
// Best-effort process-exit cleanup. 'beforeExit' fires before 'exit' and
// lets async work drain (unlike 'exit' which is synchronous-only). It does
// NOT fire on process.exit()/SIGTERM/SIGINT — but that is fine here: the OS
// reclaims all handles on any exit path, and for read-only handles there is
// no WAL to flush, so the only thing lost on signal death is a tidy close
// (cosmetic). We deliberately do NOT register a SIGTERM/SIGINT handler: a
// signal can fire mid-request, and closeAllCachedBridges force-closes
// regardless of refs, which would close a handle under a live query. Shutdown
// sequencing is the MCP server's responsibility — it should call
// closeAllCachedBridges() at a quiescent point (also how tests get a
// deterministic teardown).
process.once('beforeExit', () => {
void closeAllCachedBridges();
});
async function removeLbugFile(basePath: string): Promise<void> {
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
for (const f of candidates) {
@ -195,20 +549,29 @@ export async function queryBridge<T>(
cypher: string,
params?: Record<string, LbugValue>,
): Promise<T[]> {
const conn = handle._conn as lbug.Connection;
if (params && Object.keys(params).length > 0) {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Bridge query prepare failed: ${errMsg}`);
const run = async (): Promise<T[]> => {
const conn = handle._conn as lbug.Connection;
if (params && Object.keys(params).length > 0) {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Bridge query prepare failed: ${errMsg}`);
}
const queryResult = await conn.execute(stmt, params);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
const queryResult = await conn.execute(stmt, params);
const queryResult = await conn.query(cypher);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
const queryResult = await conn.query(cypher);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
};
// Cached RO handles are shared across concurrent @group callers, so serialize
// conn ops per handle (a LadybugDB Connection is not safe for concurrent
// queries — conn-lock.ts). Uncached/writable handles (the writeBridge temp DB)
// are single-threaded — they're absent from bridgeEntryByHandle and skip the
// lock at zero cost.
const entry = bridgeEntryByHandle.get(handle);
return entry ? withHandleLock(entry, run) : run();
}
/**
@ -230,48 +593,54 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
return queryResult;
}
/**
* Release a caller's reference to a bridge handle.
*
* - **Cache-owned handle** (returned by `getCachedBridgeReadOnly`): this is the
* matching *release* for that acquire — it decrements the lease refcount, it
* does NOT close the native handle. The cache owns the lifetime; the handle
* closes on explicit `invalidateBridgeCache`, mtime-eviction, or process
* shutdown. If the entry was already evicted and this is the last lease, the
* deferred native close fires here (exactly once).
* - **Uncached/writable handle** (e.g. the `writeBridge` temp DB): closes the
* native handle for real (CHECKPOINT-flush for writable handles).
*
* Contract: before renaming or deleting `bridge.lbug`, call
* `invalidateBridgeCache` (not this) — `closeBridgeDb` on a cache-owned handle
* is a lease release, so the file may stay open under other readers.
*/
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
// checkpoint thread can outlive the close call and leave sidecar pages
// pending on disk, which makes a subsequent read-side open either race
// with the WAL replay or trip the database-id check on the sidecars.
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
//
// ONLY on a writable handle. A read-only connection has nothing to flush,
// and issuing CHECKPOINT on it leaves a WAL/shadow lock artifact that makes
// the very next read-only open of the same path fail in-process — which broke
// repeated `@group` impact/trace calls in a long-lived MCP server (the read
// path opens read-only, queries, and closes per call).
if (!handle._readOnly) {
try {
await (handle._conn as lbug.Connection).query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
const entry = bridgeEntryByHandle.get(handle);
if (!entry) {
// Uncached or writable handle — close for real.
await closeBridgeHandle(handle);
return;
}
try {
await (handle._conn as lbug.Connection).close();
} catch {
/* ignore */
}
try {
await (handle._db as lbug.Database).close();
} catch {
/* ignore */
}
// NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
// known limitation (the writable close's OS file handle is not released before
// the read open races; the existing open-side LBUG_OPEN_RETRY only retries
// lock-pattern errors, not the post-rename sidecar database-id mismatch). The
// bridge's close-then-reopen tests stay Windows-skipped. A close-side
// waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
// safeClose) was tried and did NOT close that gap on Windows CI, so it was
// removed rather than carry latency/duplication for no Windows benefit. The
// read-only CHECKPOINT skip above is the load-bearing fix and works on
// Linux/macOS (the platforms where in-process reopen is supported).
// Cache-owned handle: release this lease. Close only the evicted handle whose
// last lease just dropped (deferred-close completion); the live cached handle
// stays open for reuse.
if (entry.refs > 0) entry.refs--;
if (entry.evicted && entry.refs <= 0) await finalizeBridgeClose(entry);
}
// NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
// known limitation (the writable close's OS file handle is not released before
// the read open races; the existing open-side LBUG_OPEN_RETRY only retries
// lock-pattern errors, not the post-rename sidecar database-id mismatch). The
// bridge's close-then-reopen tests stay Windows-skipped. A close-side
// waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
// safeClose) was tried and did NOT close that gap on Windows CI, so it was
// removed rather than carry latency/duplication for no Windows benefit.
//
// Scope of the RO bridge-handle cache (getCachedBridgeReadOnly): it removes the
// PRODUCTION symptom — a long-lived MCP serve process reopening bridge.lbug on
// every @group call — by keeping one RO handle alive for read→READ reuse.
// It does NOT fix the write→READ reopen: the first @group read right after an
// in-process group_sync is a cache miss → openBridgeDbReadOnly, i.e. the same
// unfixed reopen, so on Windows that first post-sync read still returns null.
// The read-only CHECKPOINT skip above remains the load-bearing fix on
// Linux/macOS.
/* ------------------------------------------------------------------ */
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
/* ------------------------------------------------------------------ */
@ -379,6 +748,13 @@ export async function writeBridge(
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
await fsp.mkdir(groupDir, { recursive: true });
// Invalidate the RO cache before writing. On Windows the cached handle
// would block the atomic rename (tmp → bridge.lbug) because the OS keeps
// a shared-mode lock on the open file. Closing it first guarantees the
// rename succeeds without EBUSY.
await invalidateBridgeCache(groupDir);
const contracts = dedupeContracts(input.contracts);
const crossLinks = dedupeCrossLinks(input.crossLinks);
@ -731,7 +1107,12 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
// (where we can retry) instead of on the first user query.
await handle.db.init();
await handle.conn.init();
return { _db: handle.db, _conn: handle.conn, groupDir, _readOnly: true } as BridgeHandle;
return {
_db: handle.db,
_conn: handle.conn,
groupDir,
_readOnly: true,
} as BridgeHandle;
} catch (err) {
lastErr = err;
if (handle) await closeLbugConnection(handle);
@ -748,7 +1129,11 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
const safeErrMsg =
lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
bridgeLogger.debug(
{ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
{
groupDir: safeGroupDir,
errMsg: safeErrMsg,
attempts: LBUG_OPEN_RETRY_ATTEMPTS,
},
'openBridgeDbReadOnly gave up',
);
return null;

View file

@ -22,7 +22,12 @@ import {
repoInSubgroup,
} from './group-path-utils.js';
import { getGroupDir } from './storage.js';
import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
import {
closeBridgeDb,
getCachedBridgeReadOnly,
queryBridge,
readBridgeMeta,
} from './bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
// High limit for the local phase of group impact so collectImpactSymbolUids
@ -369,7 +374,10 @@ export async function ensureBridgeReady(
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
};
}
const handle = await openBridgeDbReadOnly(groupDir);
// Use the cached read-only handle if available — avoids reopening the same
// bridge.lbug in a long-lived MCP server, which fails on Windows because
// the OS handle isn't fully released before the next open races in.
const handle = await getCachedBridgeReadOnly(groupDir);
if (!handle) {
return {
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,

View file

@ -0,0 +1,145 @@
/**
* Windows reopen fix — cross-process evidence (#2274 / PR #2313).
*
* The RO bridge-handle cache exists to fix a Windows-specific failure: in a
* long-lived MCP serve process, repeated `@group` calls used to reopen
* `bridge.lbug` per call, and the in-process reopen fails on Windows. The cache
* keeps ONE handle alive and reuses it, so there is no reopen.
*
* The unit-test cache cases all begin with an in-process `writeBridge` →
* read-only open (the unfixed write→read reopen), so they are win32-skipped and
* cannot prove the fix on the target platform. THIS test seeds `bridge.lbug` in
* a SEPARATE process (so the writable handle is fully released before we open
* read-only), making the first RO open a clean cross-process open. It therefore
* runs on win32 CI and proves the load-bearing property: a second/third
* `getCachedBridgeReadOnly` returns the SAME handle with no reopen.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import {
getCachedBridgeReadOnly,
queryBridge,
closeBridgeDb,
closeAllCachedBridges,
retryRename,
} from '../../../src/core/group/bridge-db.js';
import { cleanupTempDir } from '../../helpers/test-db.js';
// Absolute file:// URL to the tsx loader so the seed script runs under tsx in a
// child process (mirrors test/integration/cli-e2e.test.ts).
const _require = createRequire(import.meta.url);
const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json'));
const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href;
const seedScript = fileURLToPath(new URL('./fixtures/seed-bridge.ts', import.meta.url));
describe('bridge RO-handle cache — cross-process seed (Windows reopen fix, #2274)', () => {
let groupDir: string;
beforeEach(async () => {
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-xproc-'));
// Seed bridge.lbug in a SEPARATE process. Its writable handle is released by
// process death, so this process's first read-only open is NOT an in-process
// write→read reopen — the property that lets the reuse assertions run on win32.
const res = spawnSync(process.execPath, ['--import', tsxImportUrl, seedScript, groupDir], {
stdio: 'pipe',
timeout: 60_000,
});
expect(res.status, `seed process failed: ${res.stderr?.toString() ?? ''}`).toBe(0);
});
afterEach(async () => {
await closeAllCachedBridges();
await cleanupTempDir(groupDir);
});
it('reuses one cached handle across repeated calls without reopening', async () => {
// First open: a clean cross-process open (seed already exited) — succeeds on
// win32. This is the cold-cache open the cache does NOT need to fix.
const first = await getCachedBridgeReadOnly(groupDir);
expect(first).not.toBeNull();
// Repeated calls reuse the SAME handle — no reopen. THIS is the Windows fix:
// pre-cache, each of these reopened bridge.lbug and failed on Windows.
const second = await getCachedBridgeReadOnly(groupDir);
const third = await getCachedBridgeReadOnly(groupDir);
expect(second).toBe(first);
expect(third).toBe(first);
// The reused handle still answers queries.
const rows = await queryBridge<{ repo: string }>(
first!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toMatchObject([{ repo: 'backend' }]);
await closeBridgeDb(first!);
await closeBridgeDb(second!);
await closeBridgeDb(third!);
});
it('concurrent cold-cache opens dedupe to one handle (no double-open) on the target platform', async () => {
// N concurrent first-callers must coalesce to a single open via inFlightOpens
// and all receive the same handle — verified here on the cross-process seed so
// it exercises a real win32 open, not the skipped in-process reopen.
const N = 6;
const handles = await Promise.all(
Array.from({ length: N }, () => getCachedBridgeReadOnly(groupDir)),
);
expect(handles.every((h) => h !== null)).toBe(true);
const first = handles[0]!;
expect(handles).toMatchObject(Array.from({ length: N }, () => first));
for (const h of handles) await closeBridgeDb(h!);
});
// B2 probe — moved here from the unit suite so it RUNS ON WIN32. bridge.lbug is
// seeded cross-process (beforeEach), so opening RO is a clean cross-process
// open, not the in-process write→read reopen that forced the old probe to be
// win32-skipped. This settles, on Windows CI, the assumption under
// writeBridge's invalidate-before-rename and the win32 drain: does an open
// cached RO handle block an external atomic rename over bridge.lbug?
// LadybugDB opens RO with FILE_SHARE_DELETE, which should permit the rename;
// a failure on Windows CI means it does not, and invalidate-before-rename is
// load-bearing rather than belt-and-suspenders.
it('external rename over bridge.lbug succeeds while a cached RO handle is held', async () => {
const dbPath = path.join(groupDir, 'bridge.lbug');
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
// Stage the byte-identical replacement BEFORE opening the RO handle, so we
// never hold a SECOND OS handle on bridge.lbug while LadybugDB has it open
// (reading it concurrently would probe FILE_SHARE_READ — a different
// question — and could red for the wrong reason).
await fsp.copyFile(dbPath, tmpPath);
// Hold the cached RO handle open (a long-lived MCP serve process's state).
const handle = await getCachedBridgeReadOnly(groupDir);
expect(handle).not.toBeNull();
// Rename the staged copy over bridge.lbug WHILE the RO handle is held —
// exactly what a concurrent `gitnexus group sync` does. Use production's
// retryRename policy (writeBridge uses it), so transient EBUSY/EPERM from
// the Windows AV/indexer scanning the fresh temp file is absorbed. A RED is
// then the real steady-state answer: an open RO handle blocks the atomic
// rename on Windows (FILE_SHARE_DELETE not set) → writeBridge's
// invalidate-before-rename and the win32 drain are load-bearing. (The
// handle-survives-rename property is covered by the reuse test above; this
// probe's sole verdict is whether the rename itself is blocked.)
let renameError: string | null = null;
try {
await retryRename(tmpPath, dbPath);
} catch (err: unknown) {
renameError = err instanceof Error ? err.message : String(err);
}
expect(
renameError,
`[B2] external rename blocked while cached RO handle held: ${renameError}`,
).toBeNull();
await closeBridgeDb(handle!);
});
});

View file

@ -0,0 +1,38 @@
/**
* Cross-process bridge seeder for `bridge-cache-reopen.test.ts`.
*
* Writes a valid `bridge.lbug` into `argv[2]` and exits. Running this as a
* SEPARATE process is the whole point: the writable handle is fully released by
* process death before the parent test opens read-only, so the test's first RO
* open is a clean cross-process open — NOT the in-process write→read reopen that
* still fails on Windows. That is what lets the cache's read→read REUSE
* assertion actually run on win32 instead of being skipped.
*
* Invoked as: node --import <tsx-loader> seed-bridge.ts <groupDir>
*/
import { writeBridge } from '../../../../src/core/group/bridge-db.js';
import { makeContract } from '../../../unit/group/fixtures.js';
async function main(): Promise<void> {
const groupDir = process.argv[2];
if (!groupDir) {
process.stderr.write('usage: seed-bridge.ts <groupDir>\n');
process.exit(2);
}
await writeBridge(groupDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
}
main().then(
() => process.exit(0),
(err: unknown) => {
process.stderr.write(
`seed-bridge failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
);
process.exit(1);
},
);

View file

@ -18,7 +18,7 @@ import {
indexContract,
findContractNode,
} from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
@ -30,13 +30,12 @@ import { makeContract } from './fixtures.js';
* `closeBridgeDb` fix that skips CHECKPOINT on read-only handles (a CHECKPOINT
* on a read-only connection left a lock artifact that failed the next open).
*
* On WINDOWS the writable-close → read-open handoff still does not release the
* OS file handle before the read open races (the existing open-side
* `LBUG_OPEN_RETRY_*` only retries lock-pattern errors, not the post-rename
* sidecar database-id mismatch), so these tests stay Windows-skipped — the
* pre-existing limitation. A close-side `waitForWindowsHandleRelease` +
* `finalizeLbugSidecarsAfterClose` probe was tried and did not close the gap on
* Windows CI, so it was not kept.
* On WINDOWS the direct openBridgeDbReadOnly reopen still fails (see NOTE in
* closeBridgeDb). The read-only bridge-handle cache (getCachedBridgeReadOnly)
* solves this for production by keeping one handle alive across calls instead
* of reopening — see the `bridge handle cache` describe block. These tests
* exercise the DIRECT reopen path (bypassed by the cache) and stay skipped on
* Windows.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
@ -145,7 +144,9 @@ describe('writeBridge + read', () => {
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
repoSnapshots: {
backend: { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: ['missing-repo'],
});
const exists = await bridgeExists(tmpDir);
@ -156,7 +157,9 @@ describe('writeBridge + read', () => {
const report = await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
crossLinks: [],
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
repoSnapshots: {
backend: { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: [],
});
expect(report.contractsInserted).toBe(2);
@ -268,7 +271,9 @@ describe('writeBridge + read', () => {
await writeBridge(tmpDir, {
contracts: [],
crossLinks: [],
repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } },
repoSnapshots: {
'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' },
},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
@ -313,7 +318,11 @@ describe('writeBridge + read', () => {
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>(
const rows = await queryBridge<{
fromRepo: string;
toRepo: string;
matchType: string;
}>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType',
);
@ -377,7 +386,11 @@ describe('writeBridge + read', () => {
});
const handle = await openBridgeDbReadOnly(tmpDir);
const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>(
const contracts = await queryBridge<{
repo: string;
symbolUid: string;
symbolName: string;
}>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo',
);
@ -436,6 +449,332 @@ describe('writeBridge + read', () => {
});
});
/* ------------------------------------------------------------------ */
/* getCachedBridgeReadOnly cache tests */
/* ------------------------------------------------------------------ */
/**
* The RO bridge-handle cache avoids reopening bridge.lbug per @group
* tool call, which fails on Windows (the OS handle isn't fully released
* before the next open races in). These tests verify read→read reuse and
* mtime/size-based invalidation on macOS/Linux. Each begins with the
* beforeEach `writeBridge` (writable) followed by a read-only open, i.e. the
* in-process write→read reopen that is the unfixed LadybugDB Windows
* limitation — so every test here is `itCacheReopen` (win32-skipped). The
* cache layer (one handle held alive) is what these exercise, not the native
* close-then-reopen the itLbugReopen tests cover.
*/
describe('bridge handle cache', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-cache-test-'));
// Create a valid bridge.lbug to open
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
});
afterEach(async () => {
// Close any cached handles so cleanupTempDir doesn't hit EBUSY
const { closeAllCachedBridges } = await import('../../../src/core/group/bridge-db.js');
await closeAllCachedBridges();
await cleanupTempDir(tmpDir);
});
// The beforeEach calls writeBridge (writable) then the test body opens
// read-only via getCachedBridgeReadOnly. On Windows this in-process
// write→read reopen is the known LadybugDB limitation (same class as
// itLbugReopen) — the OS handle isn't fully released after the writer
// closes. The cache exercises read→read reuse, not write→read, so the
// skip only affects the test setup, not the cache logic.
const itCacheReopen = process.platform === 'win32' ? it.skip : it;
itCacheReopen('same groupDir returns the same handle instance', async () => {
const { getCachedBridgeReadOnly } = await import('../../../src/core/group/bridge-db.js');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// Must be the SAME object — not a new open
expect(second).toBe(first);
});
itCacheReopen('writeBridge self-invalidates the cache (no manual invalidate)', async () => {
const { getCachedBridgeReadOnly, queryBridge, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
// Rewrite the bridge WITHOUT calling invalidateBridgeCache here — writeBridge
// must self-invalidate (impl: invalidateBridgeCache before its atomic rename).
// A manual invalidate would mask that, leaving the load-bearing invariant
// untested.
await writeBridge(tmpDir, {
contracts: [makeContract({ repo: 'updated-repo' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// Different handle — writeBridge's own invalidate dropped the old entry.
expect(second).not.toBe(first);
// New handle sees the updated data.
const rows = await queryBridge<{ repo: string }>(
second!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toMatchObject([{ repo: 'updated-repo' }]);
// Release leases as real consumers do (finally{closeBridgeDb}). `first` was
// evicted by writeBridge (close deferred behind this lease) — releasing it
// fires the deferred native close.
await closeBridgeDb(first!);
await closeBridgeDb(second!);
});
itCacheReopen('cache reopens when an external writer bumps mtime', async () => {
// Exercises the stat-based mtime/size invalidation branch directly, WITHOUT
// going through writeBridge's own invalidate. bridge.lbug is a native binary,
// so we bump mtime with fsp.utimes (rewriting bytes would corrupt it and the
// reopen would return null). Simulates another process having written the
// bridge out-of-band.
const { getCachedBridgeReadOnly, queryBridge, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const dbPath = path.join(tmpDir, 'bridge.lbug');
const first = await getCachedBridgeReadOnly(tmpDir);
expect(first).not.toBeNull();
const future = new Date(Date.now() + 5000);
await fsp.utimes(dbPath, future, future);
const second = await getCachedBridgeReadOnly(tmpDir);
expect(second).not.toBeNull();
// mtime moved → the fast path missed → a fresh handle was opened.
expect(second).not.toBe(first);
const rows = await queryBridge<{ repo: string }>(
second!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toMatchObject([{ repo: 'backend' }]);
await closeBridgeDb(first!);
await closeBridgeDb(second!);
});
itCacheReopen('concurrent calls return the same handle instance', async () => {
const { getCachedBridgeReadOnly } = await import('../../../src/core/group/bridge-db.js');
// Fire N concurrent cache-miss calls — the TOCTOU guard should make
// only ONE actual openBridgeDbReadOnly call; all the rest await it.
const N = 10;
const results = await Promise.all(
Array.from({ length: N }, () => getCachedBridgeReadOnly(tmpDir)),
);
// All returned the same handle instance (proves no double-open)
const first = results[0]!;
for (const h of results) {
expect(h).toBe(first);
}
// Verify the handle works — query returns expected data
const { queryBridge } = await import('../../../src/core/group/bridge-db.js');
const rows = await queryBridge<{ repo: string }>(
first,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
});
});
/* ------------------------------------------------------------------ */
/* Concurrency: per-handle query serialization + refcount lease */
/* ------------------------------------------------------------------ */
/**
* The cached RO handle is shared across concurrent @group callers in a
* long-lived MCP serve process. Two correctness properties must hold:
* 1. No two queries run on one connection at once (LadybugDB Connection is
* not concurrency-safe — conn-lock.ts). queryBridge serializes per handle.
* 2. The native handle is never closed while a reader holds a lease, and is
* closed exactly once on the last release (refcount).
*/
describe('bridge handle lock (withHandleLock)', () => {
it('serializes — never two operations overlap on one lock', async () => {
const { withHandleLock } = await import('../../../src/core/group/bridge-db.js');
const lock = { lockTail: Promise.resolve() };
let active = 0;
const observedMax: number[] = [];
const section = async () => {
active++;
observedMax.push(active);
await new Promise((r) => setTimeout(r, 1));
active--;
};
await Promise.all(Array.from({ length: 8 }, () => withHandleLock(lock, section)));
// If two sections ever overlapped, active would reach 2.
expect(Math.max(...observedMax)).toBe(1);
});
it('releases the lock when an operation throws (chain not wedged)', async () => {
const { withHandleLock } = await import('../../../src/core/group/bridge-db.js');
const lock = { lockTail: Promise.resolve() };
await expect(
withHandleLock(lock, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
// A subsequent op still runs — the failed op released its tail.
const result = await withHandleLock(lock, async () => 'ok');
expect(result).toBe('ok');
});
});
describe('bridge handle cache — refcount lease', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-refcount-test-'));
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
});
afterEach(async () => {
const { closeAllCachedBridges } = await import('../../../src/core/group/bridge-db.js');
await closeAllCachedBridges();
await cleanupTempDir(tmpDir);
});
// Each test below opens RO right after the beforeEach writeBridge (write→read
// reopen) — the unfixed Windows limitation — so all are win32-skipped.
const itCacheReopen = process.platform === 'win32' ? it.skip : it;
// Spy on the native close of a handle without `any` (strict-typing rule):
// _conn is typed `unknown`, so cast to the minimal structural shape we use.
const spyConnClose = (handle: BridgeHandle) =>
vi.spyOn(handle._conn as { close: () => Promise<unknown> }, 'close');
itCacheReopen('invalidate defers the native close until the last lease releases', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
// Two leases on the same cached handle (refs === 2).
const a = await getCachedBridgeReadOnly(tmpDir);
const b = await getCachedBridgeReadOnly(tmpDir);
expect(a).not.toBeNull();
expect(b).toBe(a);
const closeSpy = spyConnClose(a!);
// group_sync-style invalidate while readers hold leases → close deferred.
await invalidateBridgeCache(tmpDir);
expect(closeSpy).not.toHaveBeenCalled();
// First release: refs 2 → 1, still not closed.
await closeBridgeDb(a!);
expect(closeSpy).not.toHaveBeenCalled();
// Last release: refs 1 → 0, native close fires exactly once.
await closeBridgeDb(b!);
expect(closeSpy).toHaveBeenCalledTimes(1);
});
itCacheReopen('refs count every awaiter under inFlightOpens (not just one)', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
// N concurrent cache-miss calls coalesce to one open but each takes a lease.
const N = 5;
const handles = await Promise.all(
Array.from({ length: N }, () => getCachedBridgeReadOnly(tmpDir)),
);
const first = handles[0]!;
expect(handles).toMatchObject(Array.from({ length: N }, () => first));
const closeSpy = spyConnClose(first);
await invalidateBridgeCache(tmpDir);
// Release N-1 leases — if refs had been miscounted as 1, the close would
// have fired on the first release. It must not.
for (let i = 0; i < N - 1; i++) await closeBridgeDb(first);
expect(closeSpy).not.toHaveBeenCalled();
// The Nth release drops refs to 0 → close once.
await closeBridgeDb(first);
expect(closeSpy).toHaveBeenCalledTimes(1);
});
itCacheReopen('mtime-evict also defers close while a lease is held', async () => {
const { getCachedBridgeReadOnly, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const dbPath = path.join(tmpDir, 'bridge.lbug');
const stale = await getCachedBridgeReadOnly(tmpDir);
expect(stale).not.toBeNull();
const closeSpy = spyConnClose(stale!);
// External writer bumps mtime; the next get evicts the stale entry. The
// lease on `stale` is still held, so its close must defer (this is the
// OTHER live close-under-lease path, alongside invalidate).
const future = new Date(Date.now() + 5000);
await fsp.utimes(dbPath, future, future);
const fresh = await getCachedBridgeReadOnly(tmpDir);
expect(fresh).not.toBe(stale);
expect(closeSpy).not.toHaveBeenCalled();
// Releasing the stale lease fires its deferred close exactly once.
await closeBridgeDb(stale!);
expect(closeSpy).toHaveBeenCalledTimes(1);
await closeBridgeDb(fresh!);
});
// Exercises the win32-only bounded-drain branch by mocking process.platform on
// a non-Windows runner (the real win32 path is proven by the cross-process
// integration test; this proves the branch LOGIC — that invalidate blocks
// until the reader releases on Windows rather than racing the rename).
// Skipped on real win32 (its in-process setup is the unsupported reopen).
itCacheReopen('on win32, invalidate waits for the in-flight reader to drain', async () => {
const { getCachedBridgeReadOnly, invalidateBridgeCache, closeBridgeDb } =
await import('../../../src/core/group/bridge-db.js');
const handle = await getCachedBridgeReadOnly(tmpDir);
expect(handle).not.toBeNull();
const realPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
let invalidateResolved = false;
const invalidate = invalidateBridgeCache(tmpDir).then(() => {
invalidateResolved = true;
});
// With a lease held, the win32 drain must NOT resolve yet (POSIX would
// return immediately here — that's the platform difference under test).
await new Promise((r) => setTimeout(r, 20));
expect(invalidateResolved).toBe(false);
// Releasing the lease drains refs→0, closes the handle, and unblocks the
// waiting invalidate well within the bounded timeout.
await closeBridgeDb(handle!);
await invalidate;
expect(invalidateResolved).toBe(true);
} finally {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
}
});
});
describe('retryRename', () => {
afterEach(() => {
vi.restoreAllMocks();
@ -476,7 +815,9 @@ describe('retryRename', () => {
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({
code: 'ENOENT',
});
expect(calls).toBe(1);
});
@ -489,7 +830,9 @@ describe('retryRename', () => {
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM' });
await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({
code: 'EPERM',
});
expect(calls).toBe(3);
});
@ -523,7 +866,11 @@ describe('findContractNode', () => {
it('tier 1: returns contract matched by symbolUid', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend', role: 'provider' });
const c = makeContract({
symbolUid: 'uid-42',
repo: 'backend',
role: 'provider',
});
indexContract(index, c, 'node-A');
expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe(
'node-A',
@ -541,7 +888,11 @@ describe('findContractNode', () => {
it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', role: 'provider', repo: 'backend' });
const c = makeContract({
symbolUid: 'uid-42',
role: 'provider',
repo: 'backend',
});
indexContract(index, c, 'node-A');
expect(
findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'),
@ -625,3 +976,9 @@ describe('findContractNode', () => {
);
});
});
// The B2 cross-process rename-clash probe moved to
// test/integration/group/bridge-cache-reopen.test.ts, where a cross-process
// seed lets it run on win32 (the in-process write→read reopen no longer gates
// it). It empirically answers whether an open cached RO handle blocks an
// external atomic rename of bridge.lbug on Windows.