mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
145 lines
6.8 KiB
TypeScript
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!);
|
|
});
|
|
});
|