mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
1315 lines
50 KiB
TypeScript
1315 lines
50 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import { spawnSync } from 'node:child_process';
|
||
import fsp from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import os from 'node:os';
|
||
import { cleanupTempDir } from '../../helpers/test-db.js';
|
||
import { tsxLoaderUrl } from '../../helpers/cli-entry.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<boolean> =>
|
||
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<void>((resolve) => {
|
||
markHeld = resolve;
|
||
});
|
||
let releaseHolder!: () => void;
|
||
const holderMayRelease = new Promise<void>((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('releases the native database before returning, while the writer process is still alive', async () => {
|
||
await writeBridge(tmpDir, payload('writer'));
|
||
const meta = await readBridgeMeta(tmpDir);
|
||
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
|
||
|
||
// Block this process while a new writer opens the published bridge. This
|
||
// prevents parent-side GC from hiding QueryResults that still own the DB.
|
||
const reader = spawnSync(
|
||
process.execPath,
|
||
[
|
||
'--import',
|
||
tsxLoaderUrl(),
|
||
'--input-type=module',
|
||
'-e',
|
||
`
|
||
const { openBridgeDb, queryBridge, closeBridgeDb } = await import(process.argv[1]);
|
||
const handle = await openBridgeDb(process.argv[2]);
|
||
try {
|
||
const rows = await queryBridge(handle, 'MATCH (c:Contract) RETURN c.repo AS repo');
|
||
console.log(JSON.stringify(rows));
|
||
} finally {
|
||
await closeBridgeDb(handle);
|
||
}
|
||
`,
|
||
new URL('../../../src/core/group/bridge-db.ts', import.meta.url).href,
|
||
path.join(tmpDir, 'bridge.lbug'),
|
||
],
|
||
{ encoding: 'utf8', timeout: 15_000 },
|
||
);
|
||
|
||
expect(reader.error).toBeUndefined();
|
||
expect(reader.status, reader.stderr).toBe(0);
|
||
expect(JSON.parse(reader.stdout)).toEqual([{ repo: 'writer' }]);
|
||
}, 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<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();
|
||
});
|
||
|
||
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.
|