mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
This commit is contained in:
parent
eb865233ea
commit
5272e38d69
9 changed files with 546 additions and 33 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`, '');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue