diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index b27330d8fc..b144a95e56 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -43,6 +43,7 @@ const PLATFORM_LOGIC = [ 'test/unit/cursor-hook.test.ts', 'test/unit/sidecar-recovery.test.ts', 'test/unit/pool-wal-recovery.test.ts', + 'test/unit/lbug-adapter-wal-schema.test.ts', 'test/unit/detect-changes-worktree.test.ts', 'test/unit/eval-server-bind-restriction.test.ts', 'test/unit/ignore-service.test.ts', diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 7c571b7feb..46b7269169 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -38,7 +38,7 @@ import { } from './lbug-config.js'; import { finalizeLbugSidecarsAfterClose, - inspectLbugSidecars, + guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, @@ -507,29 +507,18 @@ const queryAndDrain = async (targetConn: lbug.Connection, cypher: string): Promi const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1'; /** - * Reject the quarantine path when the orphan WAL is too large to safely - * discard (>TINY_ORPHAN_WAL_BYTES). Mirrors the preflight policy at - * sidecar-recovery.ts:153-160 ("warn, do not quarantine"). Symmetric across - * read-only and writable recovery paths (PR #1747 review D2). - * - * Throws shadowSidecarRecoveryMessage immediately when the WAL is large, - * preserving the uncheckpointed pages for explicit operator recovery. - * Returns silently when the WAL is absent, tiny, or in any other state - * where the existing recovery path is safe to proceed. + * Serve-side entry to the shared WAL-quarantine safety gate. Refuses (throws) + * when the `.shadow` is present on disk or the orphan WAL is too large to + * safely discard; returns silently otherwise. The policy itself lives in + * `guardWalQuarantine` (sidecar-recovery.ts) so serve and the MCP pool share + * one source of truth (PR #1747 review D2; issue #2382 review, Finding B). */ const refuseLargeWalQuarantine = async ( dbPath: string, mode: 'read-only' | 'writable', triggeringErr: unknown, ): Promise => { - const state = await inspectLbugSidecars(dbPath); - if (state.kind === 'orphan-wal') { - logger.warn( - `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` + - 'manual recovery required — run `gitnexus analyze --force --index-only`.', - ); - throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr)); - } + await guardWalQuarantine(dbPath, mode, triggeringErr, logger); }; const reopenReadOnlyAfterMissingShadow = async ( diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 11b1f76902..3073288603 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -26,6 +26,7 @@ import { WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; import { + guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, @@ -437,8 +438,14 @@ type TryQuarantineResult = { kind: 'quarantined'; path: string } | { kind: 'peer */ async function tryQuarantineForMissingShadow( dbPath: string, - opts: { reason: string }, + opts: { reason: string; err: unknown }, ): Promise { + // Refuse (throw) before renaming a live WAL when the shadow is present on + // disk or the orphan WAL is too large — parity with the serve path's + // refuseLargeWalQuarantine (issue #2382 review, Finding B). Kept OUTSIDE the + // try so the actionable recovery message propagates to the MCP caller rather + // than being re-wrapped as a rename failure. + await guardWalQuarantine(dbPath, opts.reason, opts.err, poolSidecarLogger); try { const quarantinePath = await quarantineWalForMissingShadow(dbPath, { logger: poolSidecarLogger, @@ -485,6 +492,7 @@ async function replayShadowPagesWithWritableOpen(dbPath: string): Promise if (isMissingShadowSidecarError(err)) { await tryQuarantineForMissingShadow(dbPath, { reason: 'pool writable replay recovery', + err, }); return; } @@ -516,6 +524,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { db = undefined; await tryQuarantineForMissingShadow(dbPath, { reason: 'pool read-only recovery', + err, }); await preflightLbugSidecars(dbPath, { mode: 'read-only', @@ -654,6 +663,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { if ( lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') || + lastError.message.startsWith('LadybugDB checkpoint sidecar is present but unreachable') || lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') || isMissingShadowSidecarError(lastError) ) { diff --git a/gitnexus/src/core/lbug/sidecar-recovery.ts b/gitnexus/src/core/lbug/sidecar-recovery.ts index 35467bd670..9264fa0323 100644 --- a/gitnexus/src/core/lbug/sidecar-recovery.ts +++ b/gitnexus/src/core/lbug/sidecar-recovery.ts @@ -109,16 +109,45 @@ const warnOnce = (logger: SidecarRecoveryLogger, key: string, message: string): }; // LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text. -// When bumping LadybugDB, re-validate this regex against the new error format +// When bumping LadybugDB, re-validate this against the new error format // — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot. -// Verified by upstream source/changelog diff only — forcing a genuine -// `.shadow`-missing state via a live crash to trigger this error is not -// reliably reproducible (a SIGKILL at the exact moment `.shadow` exists on -// disk still recovers via `.wal.checkpoint` alone), so this matcher does not -// have live-trigger test coverage. +// +// Two native formats reach here for a genuinely-missing shadow sidecar: +// POSIX: `Cannot open file .shadow: No such file or directory` +// Windows: `Cannot open file. path: .shadow - Error 2: ` +// Windows OS text is localized on non-English installs (issue #2382 was filed +// from a non-English Windows), so we key on the locale-invariant Win32 code +// (2 = ERROR_FILE_NOT_FOUND), NOT the English phrase. The code is matched only +// in the reason AFTER the LAST `.shadow` token (the real failing sidecar; the +// reason text never contains `.shadow`), so a repo *path* containing e.g. +// `\error 2\` — even under a `.shadow`-suffixed parent directory — cannot trip +// it. Deliberate exclusions: +// - `Error 3` (ERROR_PATH_NOT_FOUND): the #1811 non-ASCII path-garble +// artifact (see lbug-config.ts) where the shadow is PRESENT on disk; +// treating it as missing would quarantine a live WAL — data loss. +// - `Error 5` / `Error 32` / POSIX `Permission denied`: present-but-locked; +// handled as permission/lock classes, must not quarantine. +// The quarantine path adds a present-shadow disk check as a belt (see +// refuseLargeWalQuarantine in lbug-adapter.ts). +// +// The Windows branch is derived from the issue #2382 reported string, not a +// self-produced live crash; unit/consumer tests inject that same string, so +// GREEN TESTS DO NOT PROVE the byte-exact 0.18.0 Windows format — confirm +// against a real Windows run before closing #2382. export const isMissingShadowSidecarError = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); - return /Cannot open file .*\.shadow: No such file or directory/i.test(msg); + if (!/cannot open file/i.test(msg)) return false; + // Anchor on the LAST `.shadow`, not the first: LadybugDB names the failing + // sidecar as the final `.shadow` token and its reason text (POSIX + // `: No such file or directory` / Windows ` - Error N: ...`) never contains + // `.shadow`. Slicing from the last match isolates the true reason, so an + // earlier `.shadow`-suffixed path segment (e.g. a `branch=subdir` directory + // like `snap.shadow\`) can't shift the anchor and let a path-embedded + // `error 2` be read as the Win32 code (issue #2382 review, Finding A). + const lastShadow = [...msg.matchAll(/\.shadow\b/gi)].at(-1); + if (lastShadow?.index === undefined) return false; + const reason = msg.slice(lastShadow.index); + return /no such file or directory/i.test(reason) || /\berror\s+2\b/i.test(reason); }; // LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text. @@ -141,6 +170,27 @@ export const shadowSidecarRecoveryMessage = (dbPath: string, err: unknown): stri ); }; +/** + * Actionable message for the case where LadybugDB reports a "missing shadow" + * but `inspectLbugSidecars` finds the `.shadow` PRESENT on disk — the open + * failed on path reachability or a lock, not a genuinely-missing sidecar (issue + * #2382 review, S2). Unlike `shadowSidecarRecoveryMessage` it does NOT tell the + * operator to rebuild the index (the remedy is fixing the lock/path). Keeps the + * `Original error:` tail so downstream `isMissingShadowSidecarError` recognition + * still matches the wrapped error. + */ +export const presentShadowUnreachableMessage = (dbPath: string, err: unknown): string => { + const msg = err instanceof Error ? err.message : String(err); + return ( + `LadybugDB checkpoint sidecar is present but unreachable for ${dbPath}. ` + + 'The .shadow file is on disk, so the open likely failed on path reachability or a file lock ' + + '(antivirus, another process holding a handle, or a non-ASCII path) rather than a missing sidecar. ' + + 'Check filesystem access and locks; only run `gitnexus analyze --force --index-only` ' + + 'if the index is genuinely broken.' + + `\n Original error: ${msg.slice(0, 200)}` + ); +}; + const PERMISSION_RENAME_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']); export const isPermissionRenameError = (err: unknown): boolean => { @@ -199,6 +249,55 @@ export async function inspectLbugSidecars(dbPath: string): PromiseTINY_ORPHAN_WAL_BYTES); preserve the uncheckpointed pages for explicit + * operator recovery. + * + * Throws `shadowSidecarRecoveryMessage` in either case. Returns silently only + * when the shadow is absent AND the WAL is absent or tiny — the states where + * the existing recovery path is safe to proceed. `mode` is a label used only in + * the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery'). + */ +export const guardWalQuarantine = async ( + dbPath: string, + mode: string, + triggeringErr: unknown, + logger: SidecarRecoveryLogger, +): Promise => { + const state = await inspectLbugSidecars(dbPath); + if (state.kind === 'wal-with-shadow') { + warnOnce( + logger, + `${dbPath}:present-shadow-refuse:${mode}`, + `GitNexus: refusing to quarantine WAL at ${dbPath}.wal during ${mode} recovery — ` + + 'the .shadow sidecar is present on disk, so the open likely failed on path reachability or a lock ' + + 'rather than a missing shadow. Run `gitnexus analyze --force --index-only` if the index is genuinely broken.', + ); + throw new Error(presentShadowUnreachableMessage(dbPath, triggeringErr)); + } + if (state.kind === 'orphan-wal') { + warnOnce( + logger, + `${dbPath}:large-wal-refuse:${mode}`, + `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` + + 'manual recovery required — run `gitnexus analyze --force --index-only`.', + ); + throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr)); + } +}; + export async function quarantineWalForMissingShadow( dbPath: string, options: { diff --git a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts index d7cdb40907..25db417c58 100644 --- a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts +++ b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts @@ -143,6 +143,7 @@ vi.mock('../../../src/core/lbug/lbug-config.js', () => ({ vi.mock('../../../src/core/lbug/sidecar-recovery.js', () => ({ preflightLbugSidecars: vi.fn().mockResolvedValue(undefined), + guardWalQuarantine: vi.fn().mockResolvedValue(undefined), isMissingFsError: vi.fn(() => false), isMissingShadowSidecarError: vi.fn(() => false), isReadOnlyShadowReplayError: vi.fn(() => false), diff --git a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts index 2cd1b1ed98..ebd9562284 100644 --- a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts +++ b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts @@ -553,7 +553,11 @@ const TINY_ORPHAN_WAL_BYTES_TEST = 4 * 1024; * `orphan-wal` vs `tiny-orphan-wal` branches of refuseLargeWalQuarantine * without spinning up real files. */ -function makeFsMockWithWalSize(dbPath: string, walBytes: number | 'missing') { +function makeFsMockWithWalSize( + dbPath: string, + walBytes: number | 'missing', + shadowBytes: number | 'missing' = 'missing', +) { const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); const isWal = (p: string): boolean => p === `${dbPath}.wal`; const isShadow = (p: string): boolean => p === `${dbPath}.shadow`; @@ -564,6 +568,7 @@ function makeFsMockWithWalSize(dbPath: string, walBytes: number | 'missing') { }), access: vi.fn(async (p: string) => { if (isWal(p) && walBytes !== 'missing') return; + if (isShadow(p) && shadowBytes !== 'missing') return; throw ENOENT; }), stat: vi.fn(async (p: string) => { @@ -571,7 +576,10 @@ function makeFsMockWithWalSize(dbPath: string, walBytes: number | 'missing') { if (walBytes === 'missing') throw ENOENT; return { size: walBytes }; } - if (isShadow(p)) throw ENOENT; + if (isShadow(p)) { + if (shadowBytes === 'missing') throw ENOENT; + return { size: shadowBytes }; + } return { size: 0 }; }), unlink: vi.fn(async () => {}), @@ -588,9 +596,14 @@ describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', vi.unstubAllEnvs(); }); - const setupShadowMissingRecovery = (dbPath: string, walBytes: number | 'missing') => { + const setupShadowMissingRecovery = ( + dbPath: string, + walBytes: number | 'missing', + opts: { errorMessage?: string; shadowBytes?: number | 'missing' } = {}, + ) => { const missingShadowError = new Error( - `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, + opts.errorMessage ?? + `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const firstConn = { @@ -607,7 +620,7 @@ describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', .fn() .mockResolvedValueOnce({ db: firstDb, conn: firstConn }) .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn }); - const fsMock = makeFsMockWithWalSize(dbPath, walBytes); + const fsMock = makeFsMockWithWalSize(dbPath, walBytes, opts.shadowBytes ?? 'missing'); const warnMock = vi.fn(); vi.doMock('fs/promises', () => fsMock); @@ -713,4 +726,89 @@ describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', ); await adapter.closeLbug(); }); + + // ─── Windows-format missing-shadow recovery (issue #2382) ───────────────── + // + // On Windows the native engine reports a missing shadow as + // `Cannot open file. path:

.shadow - Error 2: `, not the + // POSIX `: No such file or directory`. Before the fix isMissingShadowSidecarError + // missed that form, so the read-only open on serve repo-switch rethrew the raw + // error as an HTTP 500 and never quarantined the orphan WAL — the repo stayed + // broken. These drive the SAME recovery path with the Windows string through + // both consumers (read-only + writable) and pin the present-shadow guard (KTD7). + + const windowsError2 = (dbPath: string) => + `IO exception: Cannot open file. path: ${dbPath}.shadow - Error 2: The system cannot find the file specified.`; + + it('read-only: recognizes the Windows Error 2 form and self-heals a tiny orphan WAL', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-win-selfheal/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, 1024, { + errorMessage: windowsError2(dbPath), + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( + 'ok', + ); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + await adapter.closeLbug(); + }); + + it('read-only: Windows Error 2 with a large WAL yields the actionable message (not the raw 500)', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-win-largewal/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1, { + errorMessage: windowsError2(dbPath), + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect( + adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), + ).rejects.toThrow(/LadybugDB checkpoint sidecar is missing/); + expect(fsMock.default.rename).not.toHaveBeenCalled(); + }); + + it('writable: Windows Error 2 flows through the same guarded recovery (blast-radius R4)', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-win-writable/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, 1024, { + errorMessage: windowsError2(dbPath), + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + await adapter.closeLbug(); + }); + + it('KTD7 guard: refuses to quarantine when the shadow is present on disk (data-loss guard)', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-win-shadow-present/lbug'; + const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, 1024, { + errorMessage: windowsError2(dbPath), + shadowBytes: 64, + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect( + adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), + // Present-shadow refusal throws the present-but-unreachable message (S2), + // NOT the "sidecar is missing / rebuild" message — the shadow is present. + ).rejects.toThrow(/LadybugDB checkpoint sidecar is present but unreachable/); + expect(fsMock.default.rename).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining('the .shadow sidecar is present on disk'), + ); + }); }); diff --git a/gitnexus/test/unit/lbug-pool-pinning.test.ts b/gitnexus/test/unit/lbug-pool-pinning.test.ts index 6022395253..f5480ea18e 100644 --- a/gitnexus/test/unit/lbug-pool-pinning.test.ts +++ b/gitnexus/test/unit/lbug-pool-pinning.test.ts @@ -52,6 +52,7 @@ vi.mock('../../src/core/lbug/lbug-config.js', () => ({ vi.mock('../../src/core/lbug/sidecar-recovery.js', () => ({ preflightLbugSidecars: vi.fn().mockResolvedValue(undefined), + guardWalQuarantine: vi.fn().mockResolvedValue(undefined), isMissingFsError: vi.fn(() => false), isMissingShadowSidecarError: vi.fn(() => false), isReadOnlyShadowReplayError: vi.fn(() => false), diff --git a/gitnexus/test/unit/pool-wal-recovery.test.ts b/gitnexus/test/unit/pool-wal-recovery.test.ts index 03f41232fe..77ccea84dc 100644 --- a/gitnexus/test/unit/pool-wal-recovery.test.ts +++ b/gitnexus/test/unit/pool-wal-recovery.test.ts @@ -64,8 +64,26 @@ function makeMockDb() { return { init: mockInit, close: mockClose, _isClosed: false } as any; } +// Path-aware default sidecar state for the pool tests: `.shadow` absent, +// `.wal` tiny-present, bare dbPath present → `tiny-orphan-wal`, the state the +// missing-shadow recovery/permission tests model. This keeps `guardWalQuarantine` +// (which now runs before the pool rename — issue #2382 review, Finding B) in its +// "proceed" branch so the existing rename behavior is preserved. Refusal tests +// override this per-case to drive `wal-with-shadow` / large `orphan-wal`. +const ENOENT_STAT = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); +function statTinyOrphanWal(p: string): { size: number } { + if (p.endsWith('.shadow')) throw ENOENT_STAT; + if (p.endsWith('.wal')) return { size: 128 }; + return { size: 0 }; +} + describe('WAL corruption recovery in doInitLbug (#1402)', () => { beforeEach(() => { + // Preflight (which also classifies via inspectLbugSidecars) would quarantine + // a tiny orphan WAL before the probe even runs, dissolving the + // probe-fails-then-recover premise these tests are built on. Disable it so + // only the reactive path (which the guard gates) exercises the sidecars. + vi.stubEnv('GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT', '1'); (createLbugDatabase as any).mockReset(); (fs.stat as any).mockReset(); (fs.rename as any).mockReset(); @@ -78,7 +96,7 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { }); mockInit.mockResolvedValue(undefined); mockClose.mockResolvedValue(undefined); - (fs.stat as any).mockResolvedValue({}); + (fs.stat as any).mockImplementation(async (p: string) => statTinyOrphanWal(p)); (fs.rename as any).mockResolvedValue(undefined); }); @@ -86,6 +104,7 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { vi.useRealTimers(); await closeLbug().catch(() => {}); vi.clearAllMocks(); + vi.unstubAllEnvs(); }); it('retries with WAL quarantine on corrupted WAL init error', async () => { @@ -191,6 +210,38 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { ); }); + it('recognizes the Windows Error 2 shadow form and recovers (issue #2382, MCP pool)', async () => { + // Same missing-shadow recovery as above, but with the Windows native error + // format. Before the fix isMissingShadowSidecarError matched only the POSIX + // phrasing, so this string rethrew raw through the MCP/wiki/augmentation + // pool the same way it did on serve (R4 — one central matcher, all consumers). + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-shadow-missing-win/lbug'; + + const readOnlyDb1 = makeMockDb(); + const readOnlyDb2 = makeMockDb(); + connectionQueryMock + .mockRejectedValueOnce( + new Error( + `IO exception: Cannot open file. path: ${dbPath}.shadow - Error 2: The system cannot find the file specified.`, + ), + ) + .mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1).mockReturnValueOnce(readOnlyDb2); + + await initLbug('test-repo-shadow-missing-win', dbPath); + + expect(createLbugDatabase).toHaveBeenCalledTimes(2); + expect(readOnlyDb1.close).toHaveBeenCalled(); + expect(fs.rename).toHaveBeenCalledWith( + dbPath + '.wal', + expect.stringContaining('.wal.missing-shadow.'), + ); + }); + it('does not quarantine on lock error (preserves existing lock retry)', async () => { const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => { @@ -261,6 +312,11 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { describe('Pool-adapter missing-shadow quarantine: TOCTOU + permission classification (PR #1747 review)', () => { beforeEach(() => { + // See the sibling describe: disable preflight and default to a + // `tiny-orphan-wal` state so guardWalQuarantine (now gating the pool rename) + // proceeds, preserving the pre-guard rename/permission behavior these tests + // assert. Refusal cases override `fs.stat` per-case. + vi.stubEnv('GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT', '1'); (createLbugDatabase as any).mockReset(); (fs.stat as any).mockReset(); (fs.rename as any).mockReset(); @@ -273,7 +329,7 @@ describe('Pool-adapter missing-shadow quarantine: TOCTOU + permission classifica }); mockInit.mockResolvedValue(undefined); mockClose.mockResolvedValue(undefined); - (fs.stat as any).mockResolvedValue({ size: 128 }); + (fs.stat as any).mockImplementation(async (p: string) => statTinyOrphanWal(p)); (fs.rename as any).mockResolvedValue(undefined); }); @@ -281,6 +337,7 @@ describe('Pool-adapter missing-shadow quarantine: TOCTOU + permission classifica vi.useRealTimers(); await closeLbug().catch(() => {}); vi.clearAllMocks(); + vi.unstubAllEnvs(); }); const enoent = (): NodeJS.ErrnoException => { @@ -403,4 +460,51 @@ describe('Pool-adapter missing-shadow quarantine: TOCTOU + permission classifica // shadowSidecarRecoveryMessage. await expect(initLbug('test-repo-pool-defensive', dbPath)).rejects.toThrow(/Rebuild the index/); }); + + // ─── Present-shadow / large-WAL refusal on the pool path (issue #2382 Finding B) ─── + // The broadened matcher now routes Windows Error 2 into the pool quarantine + // path; guardWalQuarantine must refuse (throw, no rename) when the shadow is + // present or the orphan WAL is large — parity with serve's refuseLargeWalQuarantine. + const windowsError2 = (dbPath: string): Error => + new Error( + `IO exception: Cannot open file. path: ${dbPath}.shadow - Error 2: The system cannot find the file specified.`, + ); + + it('refuses to quarantine when the .shadow is present on disk (pool data-loss guard — Finding B)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-present-shadow/lbug'; + + // Both sidecars present → wal-with-shadow → guard refuses before any rename. + (fs.stat as any).mockImplementation(async () => ({ size: 128 })); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(windowsError2(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + // Present shadow → the guard throws the present-but-unreachable message + // (S2), which propagates cleanly to the MCP caller — not a silent rename. + await expect(initLbug('test-repo-pool-present-shadow', dbPath)).rejects.toThrow( + /present but unreachable/, + ); + expect(fs.rename).not.toHaveBeenCalled(); + }); + + it('refuses to quarantine a large orphan WAL on the pool path (Finding B)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-large-wal/lbug'; + + // Large orphan WAL (> TINY_ORPHAN_WAL_BYTES), shadow absent → orphan-wal → refuse. + (fs.stat as any).mockImplementation(async (p: string) => { + if (p.endsWith('.shadow')) throw ENOENT_STAT; + if (p.endsWith('.wal')) return { size: 8192 }; + return { size: 0 }; + }); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(windowsError2(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + await expect(initLbug('test-repo-pool-large-wal', dbPath)).rejects.toThrow(/Rebuild the index/); + expect(fs.rename).not.toHaveBeenCalled(); + }); }); diff --git a/gitnexus/test/unit/sidecar-recovery.test.ts b/gitnexus/test/unit/sidecar-recovery.test.ts index c0a2c47529..ad95573c22 100644 --- a/gitnexus/test/unit/sidecar-recovery.test.ts +++ b/gitnexus/test/unit/sidecar-recovery.test.ts @@ -6,11 +6,14 @@ import { readFileSync } from 'node:fs'; import { _resetSidecarRecoveryWarningsForTest, finalizeLbugSidecarsAfterClose, + guardWalQuarantine, inspectLbugSidecars, + isMissingShadowSidecarError, isPermissionRenameError, isReadOnlyShadowReplayError, listQuarantinedMissingShadowWals, preflightLbugSidecars, + presentShadowUnreachableMessage, renameFailureMessage, shadowSidecarRecoveryMessage, TINY_ORPHAN_WAL_BYTES, @@ -224,6 +227,213 @@ describe('LadybugDB sidecar recovery', () => { }); }); + describe('isMissingShadowSidecarError (Windows-locale-robust, issue #2382)', () => { + // Non-ASCII-safe Windows shadow path used across the Windows-format cases. + const winShadow = String.raw`F:\McMod\repo\.gitnexus\lbug.shadow`; + + it('matches the exact #2382 Windows (English) string', () => { + expect( + isMissingShadowSidecarError( + new Error( + `IO exception: Cannot open file. path: ${winShadow} - Error 2: The system cannot find the file specified.`, + ), + ), + ).toBe(true); + }); + + it('matches Windows Error 2 with LOCALIZED trailing text (keys on the code, not the phrase)', () => { + // Simulated non-English Windows: the OS reason is localized but the Win32 + // code stays 2. R2 requires recognition here — the reporter's platform. + expect( + isMissingShadowSidecarError( + new Error( + `IO exception: Cannot open file. path: ${winShadow} - Error 2: 系统找不到指定的文件。`, + ), + ), + ).toBe(true); + }); + + it('matches the POSIX form (unchanged — R5)', () => { + expect( + isMissingShadowSidecarError( + new Error( + 'Cannot open file /home/u/repo/.gitnexus/lbug.shadow: No such file or directory', + ), + ), + ).toBe(true); + }); + + it('rejects Error 3 path-not-found (non-ASCII garble artifact, shadow present — data-loss guard)', () => { + expect( + isMissingShadowSidecarError( + new Error( + `Cannot open file. path: ${winShadow} - Error 3: The system cannot find the path.`, + ), + ), + ).toBe(false); + }); + + it('rejects Error 5 access-denied (present-but-locked)', () => { + expect( + isMissingShadowSidecarError( + new Error(`Cannot open file. path: ${winShadow} - Error 5: Access is denied.`), + ), + ).toBe(false); + }); + + it('rejects Error 32 sharing-violation and does not confuse it with Error 2', () => { + expect( + isMissingShadowSidecarError( + new Error( + `Cannot open file. path: ${winShadow} - Error 32: The process cannot access the file because it is being used by another process.`, + ), + ), + ).toBe(false); + }); + + it('rejects a path-embedded "error 2" when the real reason is a locked code (suffix-anchored — KTD2)', () => { + expect( + isMissingShadowSidecarError( + new Error( + String.raw`Cannot open file. path: F:\error 2\repo\.gitnexus\lbug.shadow - Error 32: The process cannot access the file.`, + ), + ), + ).toBe(false); + }); + + it('rejects an EARLIER .shadow-suffixed dir + later "error 2" segment with a real Error 32 (last-anchor — Finding A)', () => { + // Regression for the first-`.shadow` false-positive: a `.shadow`-suffixed + // parent dir (e.g. a branch=subdir dir) before the real `lbug.shadow`, + // plus a path-embedded `error 2`, must not read the path number as the + // Win32 code when the true trailing code is an excluded one (32 = locked). + expect( + isMissingShadowSidecarError( + new Error( + String.raw`IO exception: Cannot open file. path: F:\snap.shadow\error 2\repo\.gitnexus\lbug.shadow - Error 32: The process cannot access the file.`, + ), + ), + ).toBe(false); + }); + + it('rejects an earlier .shadow-suffixed dir + "error 2" segment with a real Error 5 (last-anchor — Finding A)', () => { + expect( + isMissingShadowSidecarError( + new Error( + String.raw`Cannot open file. path: F:\repos\.shadow\error 2\project\.gitnexus\lbug.shadow - Error 5: Access is denied.`, + ), + ), + ).toBe(false); + }); + + it('rejects a .shadow-backup dir (hyphen boundary) + "error 2" segment with a real Error 3 (last-anchor — Finding A)', () => { + // `.shadow-backup` matches `/\.shadow\b/` (hyphen is a word boundary), so + // first-match anchoring would slice from it; last-match must still land on + // the real `lbug.shadow` and read the true Error 3 (present-shadow garble). + expect( + isMissingShadowSidecarError( + new Error( + String.raw`Cannot open file. path: F:\repos\.shadow-backup\error 2\p\.gitnexus\lbug.shadow - Error 3: The system cannot find the path.`, + ), + ), + ).toBe(false); + }); + + it('rejects POSIX permission-denied on the shadow', () => { + expect( + isMissingShadowSidecarError( + new Error('Cannot open file /home/u/repo/.gitnexus/lbug.shadow: Permission denied'), + ), + ).toBe(false); + }); + + it('rejects a missing non-shadow file (WAL / main DB)', () => { + expect( + isMissingShadowSidecarError( + new Error('Cannot open file /home/u/repo/.gitnexus/lbug.wal: No such file or directory'), + ), + ).toBe(false); + }); + + it('rejects unrelated errors', () => { + expect(isMissingShadowSidecarError(new Error('something else entirely'))).toBe(false); + }); + + it('stays distinct from isReadOnlyShadowReplayError (predicates did not merge — KTD5)', () => { + const winMissing = new Error( + `Cannot open file. path: ${winShadow} - Error 2: The system cannot find the file specified.`, + ); + expect(isReadOnlyShadowReplayError(winMissing)).toBe(false); + const replay = new Error( + "Runtime exception: Couldn't replay shadow pages under read-only mode.", + ); + expect(isMissingShadowSidecarError(replay)).toBe(false); + }); + }); + + describe('guardWalQuarantine warn anti-spam (warnOnce milestones — S2/S3)', () => { + it('warns once, not per-call, on a repeated present-shadow refusal', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(128)); + await fs.writeFile(`${dbPath}.shadow`, Buffer.alloc(64)); + const log = logger(); + const trigger = new Error('trigger'); + + await expect(guardWalQuarantine(dbPath, 'read-only', trigger, log)).rejects.toThrow( + /present but unreachable/, + ); + await expect(guardWalQuarantine(dbPath, 'read-only', trigger, log)).rejects.toThrow( + /present but unreachable/, + ); + + // First refusal warns (milestone 1); the second same-key occurrence is + // downgraded to debug by warnOnce rather than warning every request. + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('the .shadow sidecar is present on disk'), + ); + expect(log.debug).toHaveBeenCalled(); + }); + + it('warns once, not per-call, on a repeated large-orphan-WAL refusal', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(TINY_ORPHAN_WAL_BYTES + 1)); + const log = logger(); + const trigger = new Error('trigger'); + + await expect(guardWalQuarantine(dbPath, 'writable', trigger, log)).rejects.toThrow( + /Rebuild the index/, + ); + await expect(guardWalQuarantine(dbPath, 'writable', trigger, log)).rejects.toThrow( + /Rebuild the index/, + ); + + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalled(); + }); + }); + + describe('presentShadowUnreachableMessage (present-but-locked, not missing — S2)', () => { + const dbPath = '/repo/.gitnexus/lbug'; + const original = new Error( + String.raw`IO exception: Cannot open file. path: F:\repo\.gitnexus\lbug.shadow - Error 5: Access is denied.`, + ); + + it('describes a present-but-unreachable sidecar and does NOT instruct a rebuild', () => { + const message = presentShadowUnreachableMessage(dbPath, original); + expect(message).toMatch(/present but unreachable/); + expect(message).toMatch(/path reachability or a file lock/); + // The distinguishing property vs shadowSidecarRecoveryMessage: the shadow + // is present, so it must not tell the operator to rebuild the index. + expect(message).not.toMatch(/Rebuild the index/); + }); + + it('preserves the Original error tail so downstream recognition still matches', () => { + const message = presentShadowUnreachableMessage(dbPath, original); + expect(message).toContain('Original error:'); + expect(isMissingShadowSidecarError(new Error(message))).toBe(false); // Error 5, still excluded + // Contrast: shadowSidecarRecoveryMessage tells the operator to rebuild. + expect(shadowSidecarRecoveryMessage(dbPath, original)).toMatch(/Rebuild the index/); + }); + }); + it('lists only missing-shadow WAL quarantine files for cleanup', async () => { await fs.writeFile(`${dbPath}.wal.missing-shadow.1-a`, ''); await fs.writeFile(`${dbPath}.wal.missing-shadow.2-b`, '');