fix(lbug): recognize Windows missing-shadow error so serve repo-switch recovers (#2382) (#2387)

This commit is contained in:
Gergő Magyar 2026-07-07 06:03:38 +01:00 • committed by GitHub
parent eb865233ea
commit 5272e38d69
9 changed files with 546 additions and 33 deletions

View file

@ -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',

View file

@ -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<void> => {
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 <repo-path> --index-only`.',
);
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
}
await guardWalQuarantine(dbPath, mode, triggeringErr, logger);
};
const reopenReadOnlyAfterMissingShadow = async (

View file

@ -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<TryQuarantineResult> {
// 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<void>
if (isMissingShadowSidecarError(err)) {
await tryQuarantineForMissingShadow(dbPath, {
reason: 'pool writable replay recovery',
err,
});
return;
}
@ -516,6 +524,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
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<void> {
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)
) {

View file

@ -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 <path>.shadow: No such file or directory`
// Windows: `Cannot open file. path: <path>.shadow - Error 2: <system text>`
// 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 <repo-path> --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): Promise<LbugSidecarSt
return { kind: 'clean', dbPath };
}
/**
* Reject the WAL-quarantine path when discarding the WAL would be unsafe or
* wrong. Shared by every reactive missing-shadow recovery consumer — serve (via
* lbug-adapter's `refuseLargeWalQuarantine`) and the MCP/wiki/augmentation pool
* (via pool-adapter's `tryQuarantineForMissingShadow`) — so the quarantine
* safety policy has a single source of truth (issue #2382 review, Finding B).
*
* 1. `wal-with-shadow` — the `.shadow` sidecar is PRESENT on disk. A
* "missing shadow" error alongside a present shadow means the open failed
* on path reachability or a lock (the #1811 non-ASCII path-garble on
* Windows), not a genuinely-missing shadow; quarantining would move a live
* WAL sitting next to its shadow — data loss.
* 2. `orphan-wal` — the orphan WAL is too large to safely discard
* (>TINY_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<void> => {
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 <repo-path> --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 <repo-path> --index-only`.',
);
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
}
};
export async function quarantineWalForMissingShadow(
dbPath: string,
options: {

View file

@ -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),

View file

@ -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: <p>.shadow - Error 2: <localized text>`, 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'),
);
});
});

View file

@ -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),

View file

@ -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();
});
});

View file

@ -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`, '');