mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(lbug): pin Ladybug core so Dependabot cannot ship a skewed FTS artifact The extension version is a separate upstream constant. Ignore daily core bumps and fail the pairing gate when the committed manifest does not name the installed core. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): make doctor and CI FTS gates resolve the packaged artifact Doctor and the REQUIRE_FTS file gates still treated an empty ~/.lbdb as unavailable, which would turn three CI jobs red once analyze stops installing into that tree. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): name native-abort and tuple-missing so analyze cannot mis-advise The CLI summary's trailing else treated every unknown skip reason as a missing extension. New crash and platform causes must get their own remedies, not a network-install hint. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): delete the dead read-path FTS index create ensureFTSIndex had no production callers and swallowed read-only CREATE_FTS_INDEX failures, which hid the only signal that a reader tried to write. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): vendor per-platform FTS artifacts so analyze needs no host install Keyword search depended on a CDN fetch into ~/.lbdb. Shipping the five published tuples inside the package makes air-gapped and ignore-scripts installs load the same artifact the publish gate checksums. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): load the packaged FTS artifact before any network install Analyze still required a CDN fetch into ~/.lbdb even when the package already shipped the file. FTS now path-loads the vendored tuple first and records source labels so a later truncated home copy cannot steal the diagnosis. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): diagnose a core/extension version skew instead of a missing runtime A structurally valid FTS artifact whose path version disagrees with the packaged pin must name both versions, not prescribe VC++ or OpenSSL. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): stamp an FTS phase so repair stays usable after an in-place abort A native CREATE_FTS_INDEX abort leaves no skip reason; the next run infers it from the dirty flag, and --repair-fts must not treat that phase as a half-written graph. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): park an in-place FTS crash WAL without wiping the graph An FTS abort after a successful checkpoint must reopen the live index on macOS, Windows, and Linux. Staging never parks the live WAL; readers keep today's large-WAL refusal. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): refuse read-only opens of an FTS-poisoned WAL MCP and serve cannot repair a leftover in-place abort. Fail before the native open and name --repair-fts, on macOS, Windows, and Linux. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): name a vendor-neutral Windows OpenSSL prerequisite OQ1 is unanswered here so GitNexus does not ship OpenSSL DLLs. Windows FTS now asks for a system OpenSSL 3 runtime instead of Git Bash PATH. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): inject the FTS vendor root and redact it on HTTP and MCP Path-loaded artifacts no longer vary with HOME. Tests pass an injected vendor tree and assert search warnings never leak a filesystem path. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(lbug): document load-only as the global FTS install default Analyze still overrides to auto. Packaged per-platform artifacts load before any network install on macOS, Windows, and Linux. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(lbug): format the FTS install-policy README table Prettier does not run on Markdown in pre-commit, so the U10 table wrap needs its own formatting commit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): skip FTS CREATE after a persisted native abort A recovered analyze run was retrying CREATE_FTS_INDEX from skipReason alone. Keep that skip until --repair-fts, fail closed on unsupported tuples, and honor the checkpoint warrant for park/repair. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): honor checkpoint flushed warrant and align FTS tests with packaged vendor A no-op CHECKPOINT must not satisfy the FTS park warrant, and CI still asserted HOME-only FTS isolation after analyze started path-LOADing the packaged artifact. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): accept a nonempty incremental write set in the #2790 recovery check FTS-phase recovery can incremental-add files (changed=0, added=1). That is not the #2790 empty-diff wipe skip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): compare FTS home versions to the core pin and tighten the publish filename gate Ladybug's ~/.lbdb/extension directory is the runtime/core version; treating it as the artifact version false-diagnosed skew. The publish guard now rejects a path-escaping filename the same way the fetch script does. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): seed FTS e2e fixtures from the packaged vendor artifact A machine with no ~/.lbdb copy should still run the vendor-survivorship cases; the seed no longer depends on HOME or a network install. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3274) Keep in-place FTS abort evidence after persist so a second CREATE abort cannot fail-open readers, and close the CLI, loader, embed, and e2e gaps the review called out. Note: full npm test hit Ladybug worker-pool startup failures under memory pressure; tsc and 180 targeted unit tests passed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3274) Run the vendored-path symlink guard on the OS matrix, put e2e HOME fixtures on Ladybug's real extension layout, pin the embed crash-WAL gate before the writable open, and let analyze writers park through missing-shadow recovery. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): keep --repair-fts CI green after vendored-first FTS Never-installed warning fixtures must not inspect a packaged vendor binary, and a failed dirty restamp must not abort an otherwise successful --repair-fts run. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): give the #1169 analyze e2e the same 90s Windows budget as its sibling The first #1169 persist-meta case was still on a 60s spawn/it budget and was killed banner-only on windows-latest after the FTS warning fixture no longer failed the shard first. Co-authored-by: Cursor <cursoragent@cursor.com> * test(ci): reweight Windows shards after the FTS e2e grew Vendored-first HOME fixtures pushed fts-extension-e2e to ~6 minutes on windows-latest, so the old 146s weight packed it with skills-e2e and blew the 20-minute watchdog. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
/**
|
|
* Tests for WAL corruption recovery in the connection pool (#1402).
|
|
*
|
|
* Mocks createLbugDatabase and fs to verify quarantine + retry behavior
|
|
* without needing a real LadybugDB instance or corrupted WAL file.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { connectionQueryMock, stderrWriteMock } = vi.hoisted(() => ({
|
|
connectionQueryMock: vi.fn(),
|
|
stderrWriteMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
default: {
|
|
stat: vi.fn().mockResolvedValue({}),
|
|
unlink: vi.fn().mockResolvedValue(undefined),
|
|
rename: vi.fn().mockResolvedValue(undefined),
|
|
readFile: vi.fn(async () => {
|
|
const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
throw err;
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@ladybugdb/core', () => ({
|
|
default: {
|
|
Database: vi.fn(),
|
|
Connection: vi.fn(function (this: any) {
|
|
this.close = vi.fn().mockResolvedValue(undefined);
|
|
this.query = connectionQueryMock;
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
loadFTSExtension: vi.fn().mockResolvedValue(true),
|
|
loadVectorExtension: vi.fn().mockResolvedValue(true),
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
|
createLbugDatabase: vi.fn(),
|
|
toNativeSafePath: vi.fn((p: string) => p),
|
|
LBUG_MAX_DB_SIZE: 1024,
|
|
WAL_RECOVERY_SUGGESTION:
|
|
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
|
isWalCorruptionError: vi.fn((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
return /corrupt(ed)?\s+wal|invalid\s+wal\s+record/i.test(msg);
|
|
}),
|
|
isStorageVersionMismatchError: vi.fn(() => false),
|
|
throwIfStorageVersionMismatch: vi.fn(),
|
|
sleep: vi.fn(async () => {}),
|
|
STORAGE_VERSION_MISMATCH_SUGGESTION: '',
|
|
}));
|
|
|
|
vi.mock('../../src/mcp/stdio-capture.js', () => ({
|
|
realStdoutWrite: vi.fn(),
|
|
realStderrWrite: stderrWriteMock,
|
|
setActiveStdoutWrite: vi.fn(),
|
|
getActiveStdoutWrite: vi.fn(() => vi.fn()),
|
|
}));
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import fs from 'fs/promises';
|
|
import { createLbugDatabase } from '../../src/core/lbug/lbug-config.js';
|
|
|
|
const { closeLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
|
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
|
const mockClose = vi.fn().mockResolvedValue(undefined);
|
|
|
|
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();
|
|
(fs.readFile as any).mockReset();
|
|
(fs.readFile as any).mockImplementation(async () => {
|
|
throw ENOENT_STAT;
|
|
});
|
|
mockInit.mockReset();
|
|
mockClose.mockReset();
|
|
connectionQueryMock.mockReset();
|
|
connectionQueryMock.mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
mockInit.mockResolvedValue(undefined);
|
|
mockClose.mockResolvedValue(undefined);
|
|
(fs.stat as any).mockImplementation(async (p: string) => statTinyOrphanWal(p));
|
|
(fs.rename as any).mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await closeLbug().catch(() => {});
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('retries with WAL quarantine on corrupted WAL init error', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-wal-recovery/lbug';
|
|
|
|
const badDb = makeMockDb();
|
|
const goodDb = makeMockDb();
|
|
badDb.init = vi.fn().mockRejectedValueOnce(new Error('Corrupted wal file'));
|
|
(createLbugDatabase as any).mockReturnValueOnce(badDb).mockReturnValueOnce(goodDb);
|
|
|
|
await initLbug('test-repo-init', dbPath);
|
|
|
|
expect(badDb.init).toHaveBeenCalledTimes(1);
|
|
expect(createLbugDatabase).toHaveBeenCalledTimes(2);
|
|
expect(createLbugDatabase).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
dbPath,
|
|
expect.objectContaining({
|
|
readOnly: true,
|
|
throwOnWalReplayFailure: false,
|
|
}),
|
|
);
|
|
expect(fs.rename).toHaveBeenCalledWith(
|
|
dbPath + '.wal',
|
|
expect.stringContaining('.wal.corrupt.'),
|
|
);
|
|
expect(stderrWriteMock).toHaveBeenCalledWith(
|
|
expect.stringContaining('WAL quarantined for test-repo-init'),
|
|
);
|
|
});
|
|
|
|
it('replays shadow pages with a temporary writable open before pooling read-only DBs', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-shadow-replay/lbug';
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
const writableDb = makeMockDb();
|
|
const readOnlyDb2 = makeMockDb();
|
|
connectionQueryMock
|
|
.mockRejectedValueOnce(
|
|
new Error(
|
|
"Runtime exception: Couldn't replay shadow pages under read-only mode. Please re-open the database with read-write mode to replay shadow pages.",
|
|
),
|
|
)
|
|
.mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
(createLbugDatabase as any)
|
|
.mockReturnValueOnce(readOnlyDb1)
|
|
.mockReturnValueOnce(writableDb)
|
|
.mockReturnValueOnce(readOnlyDb2);
|
|
|
|
await initLbug('test-repo-shadow-replay', dbPath);
|
|
|
|
expect(createLbugDatabase).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.anything(),
|
|
dbPath,
|
|
expect.objectContaining({ readOnly: true, throwOnWalReplayFailure: false }),
|
|
);
|
|
expect(createLbugDatabase).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.anything(),
|
|
dbPath,
|
|
expect.objectContaining({ throwOnWalReplayFailure: false }),
|
|
);
|
|
expect(createLbugDatabase).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.anything(),
|
|
dbPath,
|
|
expect.objectContaining({ readOnly: true, throwOnWalReplayFailure: false }),
|
|
);
|
|
expect(readOnlyDb1.close).toHaveBeenCalled();
|
|
expect(writableDb.close).toHaveBeenCalled();
|
|
expect(fs.rename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('quarantines WAL and reopens read-only when the Ladybug shadow sidecar is missing', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-shadow-missing/lbug';
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
const readOnlyDb2 = makeMockDb();
|
|
connectionQueryMock
|
|
.mockRejectedValueOnce(
|
|
new Error(`IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`),
|
|
)
|
|
.mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1).mockReturnValueOnce(readOnlyDb2);
|
|
|
|
await initLbug('test-repo-shadow-missing', dbPath);
|
|
|
|
expect(createLbugDatabase).toHaveBeenCalledTimes(2);
|
|
expect(readOnlyDb1.close).toHaveBeenCalled();
|
|
expect(fs.rename).toHaveBeenCalledWith(
|
|
dbPath + '.wal',
|
|
expect.stringContaining('.wal.missing-shadow.'),
|
|
);
|
|
});
|
|
|
|
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) => {
|
|
callback();
|
|
return 0 as any;
|
|
});
|
|
const dbPath = '/tmp/test-wal-recovery/lbug';
|
|
|
|
(createLbugDatabase as any).mockImplementation(() => {
|
|
throw new Error('Could not set lock on file');
|
|
});
|
|
|
|
try {
|
|
await expect(initLbug('test-repo-lock', dbPath)).rejects.toThrow();
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
|
|
expect(fs.rename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws with analyze suggestion after retry also fails', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-wal-recovery/lbug';
|
|
|
|
(createLbugDatabase as any)
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('Corrupted wal file');
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('Still broken');
|
|
});
|
|
|
|
await expect(initLbug('test-repo-fail', dbPath)).rejects.toThrow(/gitnexus analyze/);
|
|
expect(createLbugDatabase).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not reuse poisoned state after WAL failure', async () => {
|
|
const { initLbug, isLbugReady: ready } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-wal-recovery/lbug';
|
|
|
|
(createLbugDatabase as any)
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('Corrupted wal file');
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('Still broken');
|
|
});
|
|
|
|
await expect(initLbug('test-repo-nocache', dbPath)).rejects.toThrow();
|
|
|
|
expect(ready('test-repo-nocache')).toBe(false);
|
|
});
|
|
|
|
it('handles quarantine gracefully when .wal file does not exist', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-wal-recovery/lbug';
|
|
|
|
(fs.rename as any).mockRejectedValueOnce(new Error('ENOENT: no such file'));
|
|
|
|
(createLbugDatabase as any).mockImplementationOnce(() => {
|
|
throw new Error('Corrupted wal file');
|
|
});
|
|
|
|
await expect(initLbug('test-repo-enoent', dbPath)).rejects.toThrow(/gitnexus analyze/);
|
|
});
|
|
});
|
|
|
|
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();
|
|
(fs.readFile as any).mockReset();
|
|
(fs.readFile as any).mockImplementation(async () => {
|
|
throw ENOENT_STAT;
|
|
});
|
|
mockInit.mockReset();
|
|
mockClose.mockReset();
|
|
connectionQueryMock.mockReset();
|
|
connectionQueryMock.mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
mockInit.mockResolvedValue(undefined);
|
|
mockClose.mockResolvedValue(undefined);
|
|
(fs.stat as any).mockImplementation(async (p: string) => statTinyOrphanWal(p));
|
|
(fs.rename as any).mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await closeLbug().catch(() => {});
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
const enoent = (): NodeJS.ErrnoException => {
|
|
const e = new Error('ENOENT: peer already moved it') as NodeJS.ErrnoException;
|
|
e.code = 'ENOENT';
|
|
return e;
|
|
};
|
|
const fsErr = (code: string): NodeJS.ErrnoException => {
|
|
const e = new Error(`simulated ${code}`) as NodeJS.ErrnoException;
|
|
e.code = code;
|
|
return e;
|
|
};
|
|
const shadowError = (dbPath: string): Error =>
|
|
new Error(`IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`);
|
|
|
|
/**
|
|
* Make fs.stat ENOENT for the .wal path only — simulates "peer process
|
|
* already quarantined the WAL". Other paths (the main dbPath, .shadow)
|
|
* resolve normally so doInitLbug's existence check and preflight don't trip.
|
|
*/
|
|
const stubWalGoneAfterRename = (walPath: string): void => {
|
|
(fs.stat as any).mockImplementation((p: string) => {
|
|
if (p === walPath) return Promise.reject(enoent());
|
|
return Promise.resolve({ size: 128 });
|
|
});
|
|
};
|
|
|
|
it('treats ENOENT on rename as peer-handled when WAL is confirmed gone (openReadOnlyDatabase)', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-enoent-race/lbug';
|
|
|
|
stubWalGoneAfterRename(`${dbPath}.wal`);
|
|
(fs.rename as any).mockRejectedValueOnce(enoent());
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
const readOnlyDb2 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)).mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1).mockReturnValueOnce(readOnlyDb2);
|
|
|
|
await initLbug('test-repo-pool-enoent', dbPath);
|
|
|
|
expect(createLbugDatabase).toHaveBeenCalledTimes(2);
|
|
expect(readOnlyDb1.close).toHaveBeenCalled();
|
|
expect(fs.rename).toHaveBeenCalledWith(
|
|
`${dbPath}.wal`,
|
|
expect.stringContaining('.wal.missing-shadow.'),
|
|
);
|
|
});
|
|
|
|
it('classifies EACCES on rename with permission-specific message (openReadOnlyDatabase)', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-eacces/lbug';
|
|
|
|
(fs.rename as any).mockRejectedValueOnce(fsErr('EACCES'));
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath));
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1);
|
|
|
|
await expect(initLbug('test-repo-pool-eacces', dbPath)).rejects.toThrow(
|
|
/EACCES.*permission|permission.*EACCES|file-lock.*EACCES|EACCES.*file-lock/s,
|
|
);
|
|
});
|
|
|
|
it('classifies EPERM on rename with permission-specific message', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-eperm/lbug';
|
|
|
|
(fs.rename as any).mockRejectedValueOnce(fsErr('EPERM'));
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath));
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1);
|
|
|
|
await expect(initLbug('test-repo-pool-eperm', dbPath)).rejects.toThrow(/EPERM/);
|
|
});
|
|
|
|
it('classifies EBUSY on rename with permission-specific message (common on Windows under AV)', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-ebusy/lbug';
|
|
|
|
(fs.rename as any).mockRejectedValueOnce(fsErr('EBUSY'));
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath));
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1);
|
|
|
|
await expect(initLbug('test-repo-pool-ebusy', dbPath)).rejects.toThrow(/EBUSY/);
|
|
});
|
|
|
|
it('falls through to shadowSidecarRecoveryMessage for ENOSPC on rename', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-enospc/lbug';
|
|
|
|
(fs.rename as any).mockRejectedValueOnce(fsErr('ENOSPC'));
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath));
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1);
|
|
|
|
await expect(initLbug('test-repo-pool-enospc', dbPath)).rejects.toThrow(/Rebuild the index/);
|
|
});
|
|
|
|
it('defensive: ENOENT on rename but WAL still present → classified error (not silent peer-handled)', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const dbPath = '/tmp/test-pool-defensive/lbug';
|
|
|
|
// Note: NOT calling stubWalGoneAfterRename — fs.stat defaults to resolve.
|
|
(fs.rename as any).mockRejectedValueOnce(enoent());
|
|
|
|
const readOnlyDb1 = makeMockDb();
|
|
connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath));
|
|
(createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1);
|
|
|
|
// ENOENT → defensive branch sees WAL still present → throws classified error.
|
|
// Since ENOENT does not match permission codes, classifier falls through to
|
|
// 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();
|
|
});
|
|
|
|
it('refuses a large orphan WAL with FTS crash evidence before the native open', async () => {
|
|
const { initLbug } = await import('../../src/core/lbug/pool-adapter.js');
|
|
const { FtsReaderUnrepairableError } = await import('../../src/core/lbug/sidecar-recovery.js');
|
|
const dbPath = '/tmp/test-pool-fts-reader-refuse/lbug';
|
|
|
|
(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 };
|
|
});
|
|
(fs.readFile as any).mockResolvedValue(
|
|
JSON.stringify({
|
|
incrementalInProgress: {
|
|
startedAt: 1,
|
|
toWriteCount: 0,
|
|
phase: 'fts',
|
|
writePlan: 'in-place',
|
|
checkpointSucceeded: true,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(initLbug('test-repo-pool-fts-reader-refuse', dbPath)).rejects.toBeInstanceOf(
|
|
FtsReaderUnrepairableError,
|
|
);
|
|
expect(createLbugDatabase).not.toHaveBeenCalled();
|
|
expect(fs.rename).not.toHaveBeenCalled();
|
|
expect(fs.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never threads FTS crash evidence into the pool reader path', () => {
|
|
const src = readFileSync(
|
|
new URL('../../src/core/lbug/pool-adapter.ts', import.meta.url),
|
|
'utf8',
|
|
);
|
|
expect(src).toMatch(/Never pass crash evidence/);
|
|
expect(src).not.toMatch(/fts-inplace-checkpointed/);
|
|
});
|
|
});
|