GitNexus/gitnexus/test/unit/pool-wal-recovery.test.ts
Gergő Magyar 21a52af1d4
fix(lbug): ship FTS per-platform and recover in-place native aborts (#3274)
* 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>
2026-09-14 08:52:24 +01:00

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/);
});
});