Merge branch 'main' into feat/unified-deployment-enhancement

This commit is contained in:
Gergő Magyar 2026-06-30 16:05:42 +01:00 • committed by GitHub
commit 6d6b359639
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1387 additions and 115 deletions

View file

@ -16,7 +16,7 @@
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"commander": "^15.0.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
@ -2527,12 +2527,12 @@
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=22.12.0"
}
},
"node_modules/content-disposition": {
@ -4200,15 +4200,15 @@
}
},
"node_modules/onnxruntime-common": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz",
"integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==",
"hasInstallScript": true,
"license": "MIT",
"os": [
@ -4219,9 +4219,15 @@
"dependencies": {
"adm-zip": "^0.5.16",
"global-agent": "^4.1.3",
"onnxruntime-common": "1.26.0"
"onnxruntime-common": "1.27.0"
}
},
"node_modules/onnxruntime-node/node_modules/onnxruntime-common": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
"license": "MIT"
},
"node_modules/onnxruntime-web": {
"version": "1.26.0-dev.20260416-b7804b056c",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",

View file

@ -61,7 +61,7 @@
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"commander": "^15.0.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",

View file

@ -112,11 +112,14 @@ flowchart TD
EMIT --> BRIDGE[(bridge.lbug<br/>#795)]
```
Label-scoped queries in `resolveSymbol` keep accidental cross-matches
out:
- `topic` → `(n:Function|Method|Class|Interface)`
- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)`
- `lib` → `(n:Package|Module)`
Label-scoped queries in `resolveSymbol` keep accidental cross-matches out.
They use the `MATCH (n) WHERE labels(n) IN [...]` allowlist form, NOT the
`MATCH (n:A|B)` disjunction — LadybugDB's parser rejects a disjunction that
names a reserved keyword (e.g. `Macro`, `Union`), which is what broke the
`custom` branch in #2325:
- `topic` → `labels(n) IN ['Function','Method','Class','Interface']`
- `grpc`/`thrift` method → `labels(n) IN ['Function','Method']`, service → `labels(n) IN ['Class','Interface']`
- `lib` → `labels(n) IN ['Module']`
## Cross-impact query (PR #606)

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

@ -80,10 +80,21 @@ WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels`;
// Repo-wide lookup of a symbol by exact name (label-union, as in
// manifest-extractor.ts). Used to resolve a provider's named handler when it is
// defined in a file OTHER than its route registration — and only honored when
// the result is unique (see resolveSymbolByNameUnique).
// Repo-wide lookup of a symbol by exact name. Used to resolve a provider's
// named handler when it is defined in a file OTHER than its route registration —
// and only honored when the result is unique (see resolveSymbolByNameUnique).
//
// Label filtering uses `labels(n) IN [...]` rather than the openCypher
// disjunction `MATCH (n:A|B|C)`. NOTE: this 3-label set (Function/Method/
// CodeElement) actually PARSES — LadybugDB only rejects a disjunction that
// names a reserved keyword (e.g. `Macro`, `Union`) or a missing node table,
// neither of which applies here. So this query was NOT broken by #2325; it
// uses the `labels(n) IN` form for consistency with the manifest custom-branch
// fix (which WAS broken) and to stay immune if a reserved-keyword label is
// added later. `labels(n)` returns the node's single label as a string here, so
// `IN [...]` is an exact allowlist. (Exported so integration tests can run the
// exact production query against a real LadybugDB — the bug shipped because no
// test ran these strings against the real parser.)
//
// `n.filePath <> ''` excludes synthetic non-source `CodeElement` nodes that
// carry no real file — ORM model/table nodes (orm.ts emits `filePath: ''`) and
@ -91,9 +102,9 @@ RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
// degenerate edge-less node NOR inflates the uniqueness count and masks the real
// handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
// ambiguous (>=2) never needs more than two rows (the count guard stays exact).
const RESOLVE_BY_NAME_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE n.name = $name AND n.filePath <> ''
export const RESOLVE_BY_NAME_QUERY = `
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
AND n.name = $name AND n.filePath <> ''
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
@ -103,9 +114,9 @@ LIMIT 2`;
// the precise rung — it survives aliases and local same-name collisions that a
// repo-wide name lookup cannot, and only resolves on a unique match within that
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
const RESOLVE_IN_MODULE_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
export const RESOLVE_IN_MODULE_QUERY = `
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
AND n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;

View file

@ -7,6 +7,21 @@ export interface ManifestExtractResult {
crossLinks: CrossLink[];
}
// Repo-wide symbol lookup for `custom` workspace contracts. Exported so the
// #2325 integration test can run the EXACT production query against a real
// LadybugDB — a hand-copied query string in the test would silently drift
// from this allowlist. Uses the `labels(n) IN [...]` allowlist form rather
// than a `MATCH (n:A|B)` disjunction: this 21-label list contains the
// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
// a disjunction that names a reserved keyword (#2325) — which the resolver's
// try/catch then swallowed. `labels(n) IN` has no such collision.
export const CUSTOM_CONTRACT_RESOLVE_QUERY = `MATCH (n)
WHERE labels(n) IN ['Function','Method','Class','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
AND n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`;
/**
* Canonicalize an HTTP path for matching against Route.name in the graph.
* Mirrors core/ingestion/pipeline.ts ensureSlash semantics:
@ -189,6 +204,18 @@ export class ManifestExtractor {
// Cross-impact still works: the bridge query joins on the synthetic
// uid, and the local impact engine derives the same uid for the
// unresolved symbol — name-based hints are the additional safety net.
//
// Label filtering uses `MATCH (n) WHERE labels(n) IN [...]`, NOT the
// openCypher disjunction `MATCH (n:A|B|C)`. LadybugDB's parser rejects a
// disjunction that names a reserved keyword (`Macro` and `Union` both are)
// OR a label with no node table (e.g. the old `lib` branch's `Package`).
// The `custom` branch (reserved keywords in its list) and `lib` branch
// (missing `Package` table) genuinely threw (#2325) and the whole try/catch
// below swallowed it; the other branches parsed but use the same form for
// consistency and future-proofing. `labels(n)` returns the node's single
// label as a string here, so `IN [...]` is an exact allowlist that includes
// listed labels and excludes everything else — and is immune to both
// failure modes (no keyword collision; an unknown label is just a non-match).
try {
let rows: Record<string, unknown>[];
if (link.type === 'http') {
@ -222,7 +249,7 @@ export class ManifestExtractor {
// avoid cross-matching Files/Variables/Imports that happen to
// share the topic name.
rows = await executor(
`MATCH (n:Function|Method|Class|Interface) WHERE n.name = $contract
`MATCH (n) WHERE labels(n) IN ['Function','Method','Class','Interface'] AND n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@ -246,7 +273,7 @@ export class ManifestExtractor {
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
`MATCH (n:Function|Method) WHERE n.name = $methodName
`MATCH (n) WHERE labels(n) IN ['Function','Method'] AND n.name = $methodName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@ -254,7 +281,7 @@ export class ManifestExtractor {
);
} else if (serviceName) {
rows = await executor(
`MATCH (n:Class|Interface) WHERE n.name = $serviceName
`MATCH (n) WHERE labels(n) IN ['Class','Interface'] AND n.name = $serviceName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@ -266,11 +293,12 @@ export class ManifestExtractor {
} else if (link.type === 'lib') {
// Only exact match on the symbol's name. Previous fallback to
// CONTAINS on n.filePath would promote "react" to "react-native"
// or "@types/react" — silent wrong attribution. Restrict to
// package-level labels so we don't return arbitrary symbols
// named after a library.
// or "@types/react" — silent wrong attribution. Restrict to the
// package-level `Module` label so we don't return arbitrary symbols
// named after a library. (There is no `Package` node table — see
// NODE_TABLES — so a `Package` entry only ever matched nothing.)
rows = await executor(
`MATCH (n:Package|Module) WHERE n.name = $contract
`MATCH (n) WHERE labels(n) IN ['Module'] AND n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
@ -291,14 +319,7 @@ export class ManifestExtractor {
const symbolName = link.contract.includes('::')
? link.contract.split('::').pop()!
: link.contract;
rows = await executor(
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
WHERE n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ symbolName },
);
rows = await executor(CUSTOM_CONTRACT_RESOLVE_QUERY, { symbolName });
} else {
return null;
}

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

@ -0,0 +1,111 @@
/**
* Issue #2325 (http-route extractor half): `RESOLVE_BY_NAME_QUERY` and
* `RESOLVE_IN_MODULE_QUERY` were converted from the `MATCH (n:A|B)` multi-label
* disjunction to the `labels(n) IN [...]` allowlist form, for consistency with
* the manifest custom-branch fix.
*
* IMPORTANT nuance (verified against the real parser): the actual #2325 failure
* was triggered by *reserved-keyword* labels. LadybugDB's parser rejects a
* disjunction that names a reserved keyword — `Macro` and `Union` both are — so
* the manifest custom branch (whose 21-label list contains `Macro`/`Union`)
* genuinely threw, and the resolver's try/catch swallowed it. The http-route
* 3-label disjunction `(n:Function|Method|CodeElement)` contains no reserved
* keyword and actually PARSES — so this half was a consistency conversion, not a
* parser fix. The value of these cases is verifying the EXPORTED production
* queries resolve and filter correctly against a real LadybugDB (the unit tests
* cover the resolution *logic* with a fake executor; these cover *parsing +
* filtering*). The last case pins the real reserved-keyword trigger so a future
* query that reintroduces a `MATCH (n:…|Macro|…)` disjunction is caught.
*/
import { it, expect, afterEach } from 'vitest';
import {
RESOLVE_BY_NAME_QUERY,
RESOLVE_IN_MODULE_QUERY,
} from '../../../src/core/group/extractors/http-route-extractor.js';
import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
const SEED = [
// BY_NAME: one real-file Function + a same-named File (label excluded) + a
// same-named CodeElement with empty filePath (excluded by `n.filePath <> ''`).
`CREATE (:Function {id:'fn:getOrders', name:'getOrders', filePath:'src/handlers/orders.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:File {id:'file:getOrders', name:'getOrders', filePath:'src/getOrders.ts'})`,
`CREATE (:CodeElement {id:'ce:getOrders', name:'getOrders', filePath:''})`,
// IN_MODULE: same name in two modules — only the prefixed one resolves.
`CREATE (:Function {id:'fn:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:listUsers:admin', name:'listUsers', filePath:'src/admin/users.ts', startLine:1, endLine:9, content:'', description:''})`,
// IN_MODULE label-allowlist decoy: same name, SAME module prefix, wrong label.
// The STARTS-WITH prefix would match it, so only the `labels(n) IN [...]` filter
// excludes it — drop the filter and this surfaces, flipping the row count.
`CREATE (:File {id:'file:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts'})`,
// LIMIT 2 cap: three same-named Functions — the uniqueness count must stay exact.
`CREATE (:Function {id:'fn:dup:1', name:'dup', filePath:'src/a.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:dup:2', name:'dup', filePath:'src/b.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:dup:3', name:'dup', filePath:'src/c.ts', startLine:1, endLine:9, content:'', description:''})`,
];
withTestLbugDB(
'issue-2325-http-route-resolveSymbol',
(handle) => {
afterEach(async () => {
try {
await closeLbug(handle.repoId);
} catch {
/* best-effort */
}
});
it('RESOLVE_BY_NAME_QUERY resolves a handler by name, excluding a File and an empty-filePath node', async () => {
await initLbug(handle.repoId, handle.dbPath);
const rows = await executeParameterized(handle.repoId, RESOLVE_BY_NAME_QUERY, {
name: 'getOrders',
});
// File (wrong label) and the empty-filePath CodeElement are both excluded.
expect(rows).toHaveLength(1);
expect(rows[0].uid).toBe('fn:getOrders');
expect(rows[0].filePath).toBe('src/handlers/orders.ts');
});
it('RESOLVE_IN_MODULE_QUERY resolves only the handler in the target module prefix', async () => {
await initLbug(handle.repoId, handle.dbPath);
const rows = await executeParameterized(handle.repoId, RESOLVE_IN_MODULE_QUERY, {
name: 'listUsers',
fileDot: 'src/handlers/users.',
fileSlash: 'src/handlers/users/',
});
expect(rows).toHaveLength(1);
expect(rows[0].uid).toBe('fn:listUsers:handlers');
expect(rows[0].filePath).toBe('src/handlers/users.ts');
});
it('RESOLVE_BY_NAME_QUERY caps materialization at 2 rows (uniqueness count stays exact)', async () => {
await initLbug(handle.repoId, handle.dbPath);
const rows = await executeParameterized(handle.repoId, RESOLVE_BY_NAME_QUERY, {
name: 'dup',
});
// Three matches exist; LIMIT 2 returns exactly two so the caller treats it
// as ambiguous (>=2) without over-materializing.
expect(rows).toHaveLength(2);
});
it('LadybugDB rejects a disjunction naming a reserved-keyword label (the real #2325 trigger)', async () => {
await initLbug(handle.repoId, handle.dbPath);
// The genuine #2325 failure: a `MATCH (n:A|B)` disjunction whose label set
// includes a reserved keyword (`Macro`/`Union`) is a parser error the
// resolver's try/catch silently swallowed. The exported queries avoid this
// by using the `labels(n) IN [...]` allowlist form. (The http-route
// `Function|Method|CodeElement` form parses — this guards the real cause.)
await expect(
executeParameterized(
handle.repoId,
`MATCH (n:Function|Macro|Union) WHERE n.name = $name RETURN n.id AS uid LIMIT 2`,
{ name: 'getOrders' },
),
).rejects.toThrow(/Parser exception|Invalid input|Prepare failed/i);
});
},
{
seed: SEED,
poolAdapter: true,
},
);

View file

@ -0,0 +1,173 @@
/**
* Regression test for issue #2325:
* manifest-extractor `resolveSymbol` used a multi-label Cypher disjunction
* (`MATCH (n:Function|Method|Class|...)`). LadybugDB's parser rejects such a
* disjunction only when a label is a reserved keyword (`Macro` and `Union` both
* are) or names a missing node table — so the `custom` branch (reserved
* keywords in its 21-label list) and the `lib` branch (no `Package` table)
* genuinely threw, silently falling back to a synthetic UID with empty
* `filePath`; the other branches happened to parse. The fix uses
* `MATCH (n) WHERE labels(n) IN [...]` uniformly — immune to both failure modes
* (LadybugDB returns a node's single label as a string, so `IN [...]` is an
* exact allowlist).
*
* These cases run the REAL production query (via `extractFromManifest`) against a
* real LadybugDB — the only layer that can catch a parser rejection, since the
* unit tests mock the executor. Each per-branch case seeds a wrong-label decoy
* whose `filePath` sorts BEFORE the target so a widened/broken allowlist would
* surface the decoy under `ORDER BY n.filePath ASC LIMIT 1` and flip the
* assertion — making the exclusion check non-vacuous.
*/
import { it, expect, afterEach } from 'vitest';
import {
ManifestExtractor,
CUSTOM_CONTRACT_RESOLVE_QUERY,
} from '../../../src/core/group/extractors/manifest-extractor.js';
import type { GroupManifestLink } from '../../../src/core/group/types.js';
import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js';
import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
// Targets + sort-first wrong-label decoys for each link-type branch.
const SEED = [
// custom → Class; decoy File (not in allowlist) sorts first.
`CREATE (:Class {id:'cls:MyServiceFacade', name:'MyServiceFacade', filePath:'src/main/java/com/example/MyServiceFacade.java', startLine:1, endLine:42, content:'', description:''})`,
`CREATE (:File {id:'file:MyServiceFacade', name:'MyServiceFacade', filePath:'aaa/MyServiceFacade.java'})`,
// grpc/thrift method → Method; decoy Class (not in [Function,Method]) sorts first.
`CREATE (:Method {id:'mth:Login', name:'Login', filePath:'src/auth_grpc.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Class {id:'cls:Login', name:'Login', filePath:'aaa/Login.java', startLine:1, endLine:9, content:'', description:''})`,
// grpc/thrift service → Class; decoy Function (not in [Class,Interface]) sorts first.
`CREATE (:Class {id:'cls:AuthService', name:'AuthService', filePath:'src/auth_service.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:AuthService', name:'AuthService', filePath:'aaa/AuthService.go', startLine:1, endLine:9, content:'', description:''})`,
// lib → Module (there is NO Package node table); decoy Function sorts first.
`CREATE (:Module {id:'mod:mylib', name:'mylib', filePath:'src/index.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:mylib', name:'mylib', filePath:'aaa/mylib.ts', startLine:1, endLine:9, content:'', description:''})`,
// topic → Function; decoy File sorts first.
`CREATE (:Function {id:'fn:orders.created', name:'orders.created', filePath:'src/consumer.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:File {id:'file:orders.created', name:'orders.created', filePath:'aaa/orders.created.ts'})`,
];
/** Custom-branch `custom` query as emitted BEFORE the fix — kept to document
* the real failure: its label list contains the reserved keywords `Macro` and
* `Union`, which make LadybugDB's parser reject the whole disjunction. */
const MULTI_LABEL_CUSTOM_QUERY = `MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
WHERE n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`;
// Direct-query canary for the `labels(n)`-is-a-string assumption the whole fix
// relies on. Uses the EXACT production query (imported, not hand-copied) so the
// canary can never silently drift from the real allowlist.
withTestLbugDB(
'issue-2325-manifest-resolveSymbol',
(handle) => {
afterEach(async () => {
try {
await closeLbug(handle.repoId);
} catch {
/* best-effort */
}
});
// role:'consumer' → providerRepo = link.to = 'repo-b' (the seeded DB); the
// resolved symbol lands in crossLinks[0].to. consumerRepo ('repo-a') has no
// executor, so to.symbolRef carries the provider-side resolution.
const resolveVia = async (
type: GroupManifestLink['type'],
contract: string,
): Promise<{ symbolUid: string; filePath: string; name: string }> => {
await initLbug(handle.repoId, handle.dbPath);
const executor: CypherExecutor = (query, params) =>
executeParameterized(handle.repoId, query, params ?? {});
const extractor = new ManifestExtractor();
const result = await extractor.extractFromManifest(
[{ from: 'repo-a', to: 'repo-b', type, contract, role: 'consumer' }],
new Map([['repo-b', executor]]),
);
expect(result.crossLinks).toHaveLength(1);
const to = result.crossLinks[0].to;
return {
symbolUid: to.symbolUid,
filePath: to.symbolRef.filePath,
name: to.symbolRef.name,
};
};
it('LadybugDB rejects the custom-branch disjunction (its list names reserved keywords Macro/Union)', async () => {
await initLbug(handle.repoId, handle.dbPath);
// The genuine #2325 trigger for the custom branch: `Macro` and `Union` are
// reserved keywords, so the parser rejects the whole `(n:…|Macro|…|Union|…)`
// disjunction — which the resolver's try/catch then swallowed.
await expect(
executeParameterized(handle.repoId, MULTI_LABEL_CUSTOM_QUERY, {
symbolName: 'MyServiceFacade',
}),
).rejects.toThrow(/Parser exception|Invalid input|Prepare failed/i);
});
it('direct labels(n) IN query resolves the symbol (canary for labels()-is-a-string)', async () => {
await initLbug(handle.repoId, handle.dbPath);
const rows = await executeParameterized(handle.repoId, CUSTOM_CONTRACT_RESOLVE_QUERY, {
symbolName: 'MyServiceFacade',
});
expect(rows).toHaveLength(1);
expect(rows[0].uid).toBe('cls:MyServiceFacade');
expect(rows[0].filePath).toBe('src/main/java/com/example/MyServiceFacade.java');
});
it('custom contract resolves the real Class symbol, excluding a same-named File (#2325)', async () => {
const r = await resolveVia('custom', 'custom::MyServiceFacade');
expect(r.symbolUid).toBe('cls:MyServiceFacade');
expect(r.filePath).toBe('src/main/java/com/example/MyServiceFacade.java');
expect(r.name).toBe('MyServiceFacade');
// real resolution, not the synthetic fallback the bug produced
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
it('grpc method contract resolves a Method, excluding a same-named Class', async () => {
const r = await resolveVia('grpc', 'AuthService/Login');
expect(r.symbolUid).toBe('mth:Login');
expect(r.filePath).toBe('src/auth_grpc.ts');
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
it('grpc service contract resolves a Class, excluding a same-named Function', async () => {
const r = await resolveVia('grpc', 'AuthService');
expect(r.symbolUid).toBe('cls:AuthService');
expect(r.filePath).toBe('src/auth_service.ts');
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
it('thrift service contract strips the package prefix before resolving the Class', async () => {
// The thrift-only branch strips `package.` from the service name
// (`com.example.AuthService` -> `AuthService`). Without the strip the
// lookup would query for `com.example.AuthService`, match nothing, and
// fall back to a synthetic `manifest::` uid — so this resolving to the
// real Class is the load-bearing assertion for the package-strip path.
const r = await resolveVia('thrift', 'com.example.AuthService');
expect(r.symbolUid).toBe('cls:AuthService');
expect(r.filePath).toBe('src/auth_service.ts');
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
it('lib contract resolves a Module, excluding a same-named Function', async () => {
const r = await resolveVia('lib', 'mylib');
expect(r.symbolUid).toBe('mod:mylib');
expect(r.filePath).toBe('src/index.ts');
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
it('topic contract resolves a Function, excluding a same-named File', async () => {
const r = await resolveVia('topic', 'orders.created');
expect(r.symbolUid).toBe('fn:orders.created');
expect(r.filePath).toBe('src/consumer.ts');
expect(r.symbolUid.startsWith('manifest::')).toBe(false);
});
},
{
seed: SEED,
poolAdapter: true,
},
);

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.

View file

@ -839,7 +839,17 @@ describe('ManifestExtractor', () => {
await extractor.extractFromManifest(links, dbExecutors);
expect(capturedCypher).toContain('Function|Method|Class|Interface|Struct|Enum|Trait');
// This `custom`-branch list contains the reserved keywords `Macro`/`Union`,
// which make LadybugDB's parser reject the `MATCH (n:A|B|C)` disjunction
// (#2325), so the allowlist is carried as `labels(n) IN [...]`, not `n:A|B`.
expect(capturedCypher).toContain('labels(n) IN [');
// Membership checks that tolerate label order/spacing changes in the
// production allowlist (the negative guards below are the real regression
// check — a re-introduced `:A|B` disjunction has no quoted labels at all).
expect(capturedCypher).toContain("'Function'");
expect(capturedCypher).toContain("'Method'");
expect(capturedCypher).toContain("'CodeElement'");
expect(capturedCypher).not.toContain('Function|Method');
expect(capturedCypher).not.toContain('NOT n:File');
});

View file

@ -72,6 +72,8 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',
],
fileParallelism: false,
sequence: { groupOrder: 1 },
@ -107,6 +109,8 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',
'test/integration/skills-e2e.test.ts',
],
},