GitNexus/gitnexus/test/integration/group/bridge-cache-reopen.test.ts

145 lines
6.8 KiB
TypeScript

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