import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fsp from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { cleanupTempDir } from '../../helpers/test-db.js'; import { openBridgeDb, ensureBridgeSchema, queryBridge, closeBridgeDb, contractNodeId, writeBridge, writeBridgeUnlocked, bridgeMetaMatchesFile, openBridgeDbReadOnly, readBridgeMeta, bridgeExists, createContractLookupIndex, indexContract, findContractNode, type WriteBridgeInput, } from '../../../src/core/group/bridge-db.js'; import { getGroupSyncLockDir, withGroupSyncLock } from '../../../src/core/group/group-lock.js'; import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; import { retryRename } from '../../../src/storage/fs-atomic.js'; import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js'; import { makeContract } from './fixtures.js'; /** * In-process close-then-reopen of `bridge.lbug` (`writeBridge → * openBridgeDbReadOnly`, and the read path's open→query→close→reopen) — exactly * what a long-lived MCP server does on repeated `@group` impact/trace calls. * * On Linux/macOS this is now a supported, exercised pattern thanks to the * `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 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; describe('bridge-db core', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-')); }); afterEach(async () => { await cleanupTempDir(tmpDir); }); it('test_openBridgeDb_returns_handle_and_closes', async () => { const dbPath = path.join(tmpDir, 'test.lbug'); const handle = await openBridgeDb(dbPath); expect(handle).toBeDefined(); expect(handle._db).toBeDefined(); expect(handle._conn).toBeDefined(); expect(handle.groupDir).toBe(tmpDir); // Close should not throw await closeBridgeDb(handle); }); it('test_ensureBridgeSchema_creates_tables_idempotent', async () => { const dbPath = path.join(tmpDir, 'test.lbug'); const handle = await openBridgeDb(dbPath); await ensureBridgeSchema(handle); // Run again — should not throw await ensureBridgeSchema(handle); const rows = await queryBridge<{ cnt: number }>( handle, 'MATCH (c:Contract) RETURN count(c) AS cnt', ); expect(rows[0].cnt).toBe(0); await closeBridgeDb(handle); }); it('test_queryBridge_returns_inserted_data', async () => { const dbPath = path.join(tmpDir, 'test.lbug'); const handle = await openBridgeDb(dbPath); await ensureBridgeSchema(handle); await queryBridge( handle, `CREATE (c:Contract { id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider', repo: 'backend', confidence: 0.9 })`, ); const rows = await queryBridge<{ repo: string; confidence: number }>( handle, 'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence', ); expect(rows).toHaveLength(1); expect(rows[0].repo).toBe('backend'); expect(rows[0].confidence).toBe(0.9); await closeBridgeDb(handle); }); it('test_queryBridge_parameterized', async () => { const dbPath = path.join(tmpDir, 'test.lbug'); const handle = await openBridgeDb(dbPath); await ensureBridgeSchema(handle); await queryBridge( handle, `CREATE (c:Contract { id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider', repo: 'backend', confidence: 0.9 })`, ); const rows = await queryBridge<{ repo: string }>( handle, 'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo', { r: 'backend' }, ); expect(rows).toHaveLength(1); expect(rows[0].repo).toBe('backend'); await closeBridgeDb(handle); }); it('test_contractNodeId_full_sha256', () => { const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); expect(id).toHaveLength(64); // full SHA-256 hex // Same inputs → same hash const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts'); expect(id).toBe(id2); // Different filePath → different hash const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts'); expect(id).not.toBe(id3); }); }); describe('writeBridge + read', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-')); }); afterEach(async () => { await cleanupTempDir(tmpDir); }); it('test_writeBridge_creates_bridge_lbug_file', async () => { await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' }, }, missingRepos: ['missing-repo'], }); const exists = await bridgeExists(tmpDir); expect(exists).toBe(true); }); it("replaces the previous sync's metadata on a successful rebuild", async () => { // meta.json describes the bridge's completeness, and since #3011 that is // load-bearing: runGroupImpact folds `unreadableRepos ∪ missingRepos` into // its truncation fields, so a value from an earlier sync is a wrong answer // about this one. // // Scope, stated because the obvious stronger reading is wrong: this covers // the SUCCESSFUL path only. It cannot pin the removal-before-swap ordering, // because writeBridge overwrites meta.json at the end either way — the // assertions below hold with the removal in either position. The ordering // is pinned in `bridge-meta-swap-window.test.ts`, which fails the swap // itself and checks the previous sync's metadata cannot survive it. await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], }); const before = await readBridgeMeta(tmpDir); await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], unreadableRepos: ['svc/users'], }); const after = await readBridgeMeta(tmpDir); expect(before.unreadableRepos).toBeUndefined(); expect(after.unreadableRepos).toEqual(['svc/users']); }); it('reports version 0 for a bridge whose meta.json is gone', async () => { // `version: 0` is the "no provenance" signal `runGroupImpact` fails closed // on, so it is worth asserting directly rather than only through the // callers that consume it. No fault is injected into writeBridge here — // the file is removed afterwards — so this pins readBridgeMeta's contract, // not the write ordering (see `bridge-meta-swap-window.test.ts` for that). await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], unreadableRepos: ['svc/users'], }); await fsp.rm(path.join(tmpDir, 'meta.json'), { force: true }); const meta = await readBridgeMeta(tmpDir); expect(meta.version).toBe(0); expect(meta.unreadableRepos).toBeUndefined(); }); it('persists an explicitly empty unreadableRepos measurement', async () => { // Same distinction as the registry: `[]` means the sync accounted for every // repo, and dropping it collapses that into "never recorded". await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], unreadableRepos: [], }); const meta = await readBridgeMeta(tmpDir); expect(meta.unreadableRepos).toEqual([]); }); it('test_writeBridge_returns_report_with_insert_counts', async () => { const report = await writeBridge(tmpDir, { contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], crossLinks: [], repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' }, }, missingRepos: [], }); expect(report.contractsInserted).toBe(2); expect(report.contractsFailed).toBe(0); expect(report.snapshotsInserted).toBe(1); expect(report.snapshotsFailed).toBe(0); expect(report.linksInserted).toBe(0); expect(report.linksFailed).toBe(0); expect(report.linksDroppedMissingNode).toBe(0); expect(report.sampleErrors).toHaveLength(0); }); it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => { // Provider + cross-link that references a non-existent consumer node → // findContractNode returns null for `from`, link gets dropped. const provider = makeContract({ role: 'provider' }); const report = await writeBridge(tmpDir, { contracts: [provider], crossLinks: [ { from: { repo: 'ghost', symbolUid: '', symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' }, }, to: { repo: provider.repo, symbolUid: provider.symbolUid, symbolRef: provider.symbolRef, }, type: 'http', contractId: provider.contractId, matchType: 'exact', confidence: 1.0, }, ], repoSnapshots: {}, missingRepos: [], }); expect(report.linksInserted).toBe(0); expect(report.linksDroppedMissingNode).toBe(1); expect(report.linksFailed).toBe(0); expect(report.contractsInserted).toBe(1); }); itLbugReopen('test_writeBridge_contracts_queryable', async () => { await writeBridge(tmpDir, { contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], crossLinks: [], repoSnapshots: {}, missingRepos: [], }); const handle = await openBridgeDbReadOnly(tmpDir); expect(handle).not.toBeNull(); const rows = await queryBridge<{ repo: string }>( handle!, 'MATCH (c:Contract) RETURN c.repo AS repo', ); expect(rows).toHaveLength(2); await closeBridgeDb(handle!); }); itLbugReopen('test_openBridgeDbReadOnly_can_reopen_in_same_process', async () => { // Regression: closeBridgeDb used to issue CHECKPOINT on read-only handles // too, which left a WAL/shadow lock artifact that made the next read-only // open of the same file fail in-process — breaking repeated @group // impact/trace calls in a long-lived MCP server. closeBridgeDb now skips // the checkpoint for read-only handles, so open→query→close→open works. await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], }); const first = await openBridgeDbReadOnly(tmpDir); expect(first).not.toBeNull(); const r1 = await queryBridge<{ n: number }>(first!, 'MATCH (c:Contract) RETURN count(c) AS n'); expect(r1[0].n).toBe(1); await closeBridgeDb(first!); // Second open in the SAME process must succeed (previously returned null). const second = await openBridgeDbReadOnly(tmpDir); expect(second).not.toBeNull(); const r2 = await queryBridge<{ n: number }>(second!, 'MATCH (c:Contract) RETURN count(c) AS n'); expect(r2[0].n).toBe(1); await closeBridgeDb(second!); // And a third, to confirm it is not a one-shot. const third = await openBridgeDbReadOnly(tmpDir); expect(third).not.toBeNull(); await closeBridgeDb(third!); }); it('test_writeBridge_meta_json_persists_missingRepos', async () => { await writeBridge(tmpDir, { contracts: [], crossLinks: [], repoSnapshots: {}, missingRepos: ['repo-a', 'repo-b'], }); const meta = await readBridgeMeta(tmpDir); expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']); expect(meta.version).toBeGreaterThan(0); expect(meta.generatedAt).toBeTruthy(); }); itLbugReopen('test_writeBridge_repoSnapshots_queryable', async () => { await writeBridge(tmpDir, { contracts: [], crossLinks: [], repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' }, }, missingRepos: [], }); const handle = await openBridgeDbReadOnly(tmpDir); const rows = await queryBridge<{ id: string; indexedAt: string }>( handle!, 'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt', ); expect(rows).toHaveLength(1); expect(rows[0].id).toBe('hr/backend'); expect(rows[0].indexedAt).toBe('2026-01-01'); await closeBridgeDb(handle!); }); itLbugReopen('test_writeBridge_crossLinks_queryable', async () => { const provider = makeContract({ repo: 'backend', role: 'provider' }); const consumer = makeContract({ repo: 'frontend', role: 'consumer', symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, symbolName: 'fetchUsers', }); const link: CrossLink = { from: { repo: 'frontend', symbolUid: '', symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, }, to: { repo: 'backend', symbolUid: 'uid-1', symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, }, type: 'http', contractId: 'http::GET::/api/users', matchType: 'exact', confidence: 1.0, }; await writeBridge(tmpDir, { contracts: [provider, consumer], crossLinks: [link], repoSnapshots: {}, missingRepos: [], }); const handle = await openBridgeDbReadOnly(tmpDir); 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', ); expect(rows).toHaveLength(1); expect(rows[0].fromRepo).toBe('frontend'); expect(rows[0].toRepo).toBe('backend'); expect(rows[0].matchType).toBe('exact'); await closeBridgeDb(handle!); }); itLbugReopen('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => { const provider = makeContract({ repo: 'backend', role: 'provider', symbolUid: '', symbolName: 'auth.AuthService/Login', symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, contractId: 'grpc::auth.AuthService/Login', type: 'grpc', meta: { source: 'manifest' }, }); const concreteProvider = makeContract({ ...provider, symbolUid: 'uid-auth-login', symbolName: 'Login', confidence: 0.85, meta: { source: 'analyze' }, }); const consumer = makeContract({ repo: 'frontend', role: 'consumer', symbolUid: '', symbolName: 'auth.AuthService/Login', symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, contractId: 'grpc::auth.AuthService/Login', type: 'grpc', meta: { source: 'manifest' }, }); const link: CrossLink = { from: { repo: 'frontend', symbolUid: '', symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, }, to: { repo: 'backend', symbolUid: '', symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, }, type: 'grpc', contractId: 'grpc::auth.AuthService/Login', matchType: 'manifest', confidence: 1, }; await writeBridge(tmpDir, { contracts: [provider, concreteProvider, consumer], crossLinks: [link, { ...link }], repoSnapshots: {}, missingRepos: [], }); const handle = await openBridgeDbReadOnly(tmpDir); 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', ); const links = await queryBridge<{ fromRepo: string; toRepo: string }>( handle!, 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo', ); expect(contracts).toHaveLength(2); expect(contracts[0]).toEqual({ repo: 'backend', symbolUid: 'uid-auth-login', symbolName: 'Login', }); expect(links).toHaveLength(1); await closeBridgeDb(handle!); }); it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => { const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent')); expect(handle).toBeNull(); }); it('test_bridgeExists_false_for_missing', async () => { expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false); }); itLbugReopen('test_writeBridge_overwrites_previous', async () => { await writeBridge(tmpDir, { contracts: [makeContract()], crossLinks: [], repoSnapshots: {}, missingRepos: [], }); await writeBridge(tmpDir, { contracts: [makeContract({ repo: 'new-repo' })], crossLinks: [], repoSnapshots: {}, missingRepos: [], }); const handle = await openBridgeDbReadOnly(tmpDir); const rows = await queryBridge<{ repo: string }>( handle!, 'MATCH (c:Contract) RETURN c.repo AS repo', ); expect(rows).toHaveLength(1); expect(rows[0].repo).toBe('new-repo'); await closeBridgeDb(handle!); }); it('test_readBridgeMeta_returns_defaults_for_missing', async () => { const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent')); expect(meta.version).toBe(0); expect(meta.generatedAt).toBe(''); expect(meta.missingRepos).toEqual([]); }); }); /* ------------------------------------------------------------------ */ /* The bridge swap runs inside the caller's critical section (R9) */ /* ------------------------------------------------------------------ */ /** * R9, writer-writer half. The swap and the metadata write are two operations: * `bridge.lbug` is renamed into place first, and only then is `meta.json` * written with the size and mtime of the file it describes. Two writers that * overlap can therefore leave one writer's metadata beside the other's * database. What prevents it is the group sync lock, held across the whole * swap. * * That is why the swap comes in two halves. `writeBridgeUnlocked` assumes the * lock is already held and is what `syncGroup` calls from inside its * `withGroupSyncLock` region; `writeBridge` is the thin acquiring wrapper for * callers that are not already in that region — every caller in this file, and * every other direct caller in the suite. Routing the held-lock caller through * the wrapper instead would be a SECOND acquisition of a non-reentrant * primitive, which does not fail fast: it waits out the ten-minute ceiling * against a lock its own call stack holds. The first case below is the * regression gate for exactly that, and it goes red by timeout. * * SCOPE, so the block is not read as more than it is: this is writer-writer * exclusion only. The reader-side promotion of a leftover backup file runs on * ordinary reads, outside anyone's critical section, and `bridgeMetaMatchesFile` * remains the reader's defense there. * * Nothing here opens `bridge.lbug`. The in-process write-then-read reopen is * the documented LadybugDB Windows limitation this file skips elsewhere * (`itLbugReopen`), so every assertion below is made on file state and on * `bridgeMetaMatchesFile`, which reads `meta.json` and the database's `stat` * and never opens it. No case in this block is platform-skipped, and the * surrounding `writeBridge + read` describe is the unchanged control for the * single-direct-call path. */ describe("writeBridge — the swap runs inside the caller's critical section (R9)", () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-lock-')); }); afterEach(async () => { await cleanupTempDir(tmpDir); }); /** One contract, plus a `missingRepos` marker naming the writer that built it. */ const payload = (writer: string): WriteBridgeInput => ({ contracts: [makeContract({ repo: writer })], crossLinks: [], repoSnapshots: {}, missingRepos: [writer], }); /** * How long a contended writer is given to finish while the lock is held * elsewhere. An uncontended write of this payload takes ~50-110ms in this * suite, so the window is more than fifteen times the work — "still not * finished" is a statement about the lock rather than about how fast the host * is, and a wrapper that does not acquire finishes inside it on any host. */ const CONTENDED_WINDOW_MS = 2000; /** * A plain existence check. Deliberately NOT `bridgeExists`, which is a READER * and promotes a leftover `bridge.lbug.bak` back into place on its way to an * answer — the reader-side path this block makes no claim about, and one that * would repair the crashed state the last case is trying to hand to the next * writer. */ const onDisk = (name: string): Promise => fsp.access(path.join(tmpDir, name)).then( () => true, () => false, ); it('a caller that already holds the group lock completes the swap without acquiring a second one', async () => { // The production shape: `syncGroup` holds the lock across its whole persist // section and calls the LOCK-FREE half from inside it. `acquireIndexLock` is // not reentrant, so a swap that acquired for itself would not fail fast — it // would wait out GROUP_SYNC_LOCK_TIMEOUT_MS (ten minutes) against a lock this // very call stack is holding. The short per-case timeout is the assertion: // this case goes red by TIMEOUT the moment the inner half starts acquiring. const report = await withGroupSyncLock(tmpDir, () => writeBridgeUnlocked(tmpDir, payload('held-lock-caller')), ); expect(report.contractsInserted).toBe(1); expect(await bridgeExists(tmpDir)).toBe(true); const meta = await readBridgeMeta(tmpDir); expect(meta.missingRepos).toEqual(['held-lock-caller']); expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); }, 15_000); it('a direct write cannot enter the swap while another holder has the group lock', async () => { // The exclusion itself, observed as ordering rather than as a race: a holder // takes the group lock, a direct `writeBridge` starts underneath it, and the // write may not complete until the holder lets go. Nothing is mocked — the // holder takes the same real lock the wrapper does. const order: string[] = []; let markHeld!: () => void; const lockIsHeld = new Promise((resolve) => { markHeld = resolve; }); let releaseHolder!: () => void; const holderMayRelease = new Promise((resolve) => { releaseHolder = resolve; }); const holder = withGroupSyncLock(tmpDir, async () => { markHeld(); await holderMayRelease; order.push('holder-released'); }); await lockIsHeld; let settled = false; const contender = writeBridge(tmpDir, payload('contender')).then(() => { order.push('write-finished'); settled = true; }); await new Promise((resolve) => setTimeout(resolve, CONTENDED_WINDOW_MS)); // The write is still outside the swap. Without the wrapper's acquisition it // has long since finished, and the ordering assertion below inverts. expect(settled).toBe(false); // And it has not written anything either: the swap is what produces both files. expect(await onDisk('bridge.lbug')).toBe(false); expect(await onDisk('meta.json')).toBe(false); releaseHolder(); await holder; await contender; expect(order).toEqual(['holder-released', 'write-finished']); expect((await readBridgeMeta(tmpDir)).missingRepos).toEqual(['contender']); }, 30_000); it('after two contended direct writes the metadata on disk vouches for the database on disk', async () => { // Two writers into one group at once. Serialized, the loser's swap completes // in full before the winner's begins, so what is left is one writer's // database under one writer's metadata — never a mixture, and never a stamp // taken from the other writer's file. const [first, second] = await Promise.all([ writeBridge(tmpDir, payload('writer-a')), writeBridge(tmpDir, payload('writer-b')), ]); expect(first.contractsInserted).toBe(1); expect(second.contractsInserted).toBe(1); const meta = await readBridgeMeta(tmpDir); // Exactly one writer's measurement — not both, not neither. expect(meta.missingRepos).toHaveLength(1); expect(['writer-a', 'writer-b']).toContain(meta.missingRepos[0]); // ...and the stamp it carries describes the database that is actually there. expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); expect(meta.provenanceUnknown).toBeUndefined(); expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION); // Nothing is left half-swapped: the backup was consumed and neither staging // directory survives its writer. const left = await fsp.readdir(tmpDir); expect(left.filter((f) => f.startsWith('bridge.lbug.bak'))).toEqual([]); expect(left.filter((f) => f.startsWith('bridge-tmp-'))).toEqual([]); }, 60_000); it('the wrapper releases the group lock, so the next writer is not blocked by the last one', async () => { await writeBridge(tmpDir, payload('first')); // Sequential, not concurrent. A wrapper that acquired and never released // would not fail here — it would hang until the ten-minute ceiling, which is // what the short per-case timeout turns into a red. await expect(withGroupSyncLock(tmpDir, async () => 'free')).resolves.toBe('free'); const again = await writeBridge(tmpDir, payload('second')); expect(again.contractsInserted).toBe(1); const meta = await readBridgeMeta(tmpDir); expect(meta.missingRepos).toEqual(['second']); expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); }, 30_000); it('a writer that died mid-swap leaves state the next write recovers from', async () => { await writeBridge(tmpDir, payload('before-the-crash')); // Reproduce what a process killed between the two renames leaves behind: the // live database moved aside to `bridge.lbug.bak`, no `bridge.lbug` at all, // the staging directory it never cleaned up, and its lock directory still on // disk. That last one matters on the file backend, where the directory // outlives the holder; on the socket backend the kernel drops the binding // when the holder dies and there is nothing on disk to leave. Pre-creating it // is harmless there and load-bearing here, which is why it is not skipped. await fsp.rename(path.join(tmpDir, 'bridge.lbug'), path.join(tmpDir, 'bridge.lbug.bak')); for (const suffix of ['.wal', '.shadow']) { await fsp .rename( path.join(tmpDir, `bridge.lbug${suffix}`), path.join(tmpDir, `bridge.lbug.bak${suffix}`), ) .catch(() => { /* sidecar absent — nothing to move */ }); } const orphanStaging = path.join(tmpDir, 'bridge-tmp-deadwriter'); await fsp.mkdir(orphanStaging, { recursive: true }); await fsp.writeFile(path.join(orphanStaging, 'bridge.lbug'), 'half-written'); await fsp.mkdir(getGroupSyncLockDir(tmpDir), { recursive: true }); expect(await onDisk('bridge.lbug')).toBe(false); expect(await onDisk('bridge.lbug.bak')).toBe(true); const report = await writeBridge(tmpDir, payload('after-the-crash')); expect(report.contractsInserted).toBe(1); expect(await onDisk('bridge.lbug')).toBe(true); const meta = await readBridgeMeta(tmpDir); expect(meta.missingRepos).toEqual(['after-the-crash']); expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); // The dead writer's backup is consumed by the recovery, not inherited by it. expect(await onDisk('bridge.lbug.bak')).toBe(false); }, 60_000); }); /* ------------------------------------------------------------------ */ /* 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 }, '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(); }); it('retries on EBUSY and eventually succeeds', async () => { // Spy on fs.promises.rename and make the first two attempts fail with // EBUSY, then succeed on the third. Verifies that Windows-style // transient rename failures don't immediately bubble up. const attempts: Array<[string, string]> = []; let calls = 0; const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => { attempts.push([String(src), String(dst)]); calls++; if (calls < 3) { const err = new Error('resource busy or locked') as NodeJS.ErrnoException; err.code = 'EBUSY'; throw err; } // Third attempt: pretend the rename worked. return undefined; }); await retryRename('/src/a', '/dst/b', 3); expect(spy).toHaveBeenCalledTimes(3); expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true); }); it('rethrows non-retryable errors immediately', async () => { // A non-retryable code (e.g. ENOENT) should NOT be swallowed into a // retry loop — that would mask real bugs and waste time. let calls = 0; vi.spyOn(fsp, 'rename').mockImplementation(async () => { calls++; const err = new Error('no such file') as NodeJS.ErrnoException; err.code = 'ENOENT'; throw err; }); await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT', }); expect(calls).toBe(1); }); it('gives up after the configured number of attempts', async () => { let calls = 0; vi.spyOn(fsp, 'rename').mockImplementation(async () => { calls++; const err = new Error('locked') as NodeJS.ErrnoException; err.code = 'EPERM'; throw err; }); await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM', }); expect(calls).toBe(3); }); it('retries on EACCES as well', async () => { let calls = 0; vi.spyOn(fsp, 'rename').mockImplementation(async () => { calls++; if (calls < 2) { const err = new Error('permission denied') as NodeJS.ErrnoException; err.code = 'EACCES'; throw err; } return undefined; }); await retryRename('/src/a', '/dst/b', 3); expect(calls).toBe(2); }); }); describe('findContractNode', () => { // Pure-function tests for the lookup index + three-tier resolver that // were previously an inner closure of `writeBridge` and therefore // untestable in isolation. Every test here builds its own index and // never touches the DB. it('returns null on empty index', () => { const index = createContractLookupIndex(); expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull(); }); it('tier 1: returns contract matched by symbolUid', () => { const index = createContractLookupIndex(); 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', ); }); it('tier 1 is repo-scoped: same uid in a different repo does not match', () => { const index = createContractLookupIndex(); const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' }); indexContract(index, c, 'node-A'); expect( findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'), ).toBeNull(); }); 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', }); indexContract(index, c, 'node-A'); expect( findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'), ).toBeNull(); }); it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => { const index = createContractLookupIndex(); const c = makeContract({ symbolUid: '', symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, symbolName: 'handler', }); indexContract(index, c, 'node-B'); expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe( 'node-B', ); }); it('tier 2: falls through when the given symbolUid does not match anything', () => { const index = createContractLookupIndex(); const c = makeContract({ symbolUid: 'uid-real', symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' }, }); indexContract(index, c, 'node-B'); // Wrong uid; but filePath+name still resolves. expect( findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'), ).toBe('node-B'); }); it('tier 3: resolves by filePath alone when exactly one contract lives there', () => { const index = createContractLookupIndex(); const c = makeContract({ symbolUid: '', symbolRef: { filePath: 'src/solo.ts', name: 'actualName' }, }); indexContract(index, c, 'node-C'); // filePath+name miss (name is wrong), but tier 3 picks the sole entry. expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe( 'node-C', ); }); it('tier 3: does NOT resolve when multiple contracts live in the same file', () => { const index = createContractLookupIndex(); const a = makeContract({ symbolUid: '', symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' }, }); const b = makeContract({ symbolUid: '', symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' }, contractId: 'http::GET::/api/b', }); indexContract(index, a, 'node-MA'); indexContract(index, b, 'node-MB'); // Wrong symbolName → no tier 2 match. Two contracts in the same file // → tier 3 must refuse to guess. expect( findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'), ).toBeNull(); }); it('prefers tier 1 over tier 2 when both could resolve', () => { const index = createContractLookupIndex(); const tier1Contract = makeContract({ symbolUid: 'uid-1', symbolRef: { filePath: 'src/a.ts', name: 'first' }, }); const tier2Contract = makeContract({ symbolUid: '', symbolRef: { filePath: 'src/a.ts', name: 'first' }, contractId: 'http::POST::/api/x', }); indexContract(index, tier1Contract, 'tier1-id'); indexContract(index, tier2Contract, 'tier2-id'); expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe( 'tier1-id', ); }); }); // 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.