fix(cli): actionable diagnostics for non-4K page-size buffer manager failures (#2424)

This commit is contained in:
VL 2026-07-10 19:12:53 +01:00 • committed by GitHub
parent c5d8611fa1
commit 7835cd47bc
10 changed files with 693 additions and 2 deletions

View file

@ -36,6 +36,10 @@ const PLATFORM_LOGIC = [
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
'test/unit/cli-entry.test.ts',
'test/unit/platform-capabilities.test.ts',
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
'test/unit/lbug-config-pagesize.test.ts',
'test/unit/worker-pool-windows-quarantine.test.ts',
'test/unit/lbug-pool-fts-load.test.ts',
'test/unit/repo-manager.test.ts',

View file

@ -16,7 +16,10 @@ import cliProgress from 'cli-progress';
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
import {
getOsPageSize,
isLbugCheckpointIoError,
isLbugPageSizeFrameError,
isPageSizeAwareLadybug,
isWalCorruptionError,
parseWalCheckpointThreshold,
WAL_RECOVERY_SUGGESTION,
@ -37,6 +40,7 @@ import {
GitNexusRcError,
} from './analyze-config.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { glob } from 'glob';
@ -1635,6 +1639,54 @@ const analyzeCommandImpl = async (
return;
}
// Buffer-manager frame-release failure on non-4K-page kernels (#1231).
// LadybugDB <= 0.17.x assumed 4 KiB OS pages when releasing evicted
// frames; Raspberry Pi 5 (16 KiB kernel pages) and other arm64 systems
// crash mid-COPY with a raw native message. 0.18.0 detects the page size
// at runtime, so the actionable fix depends on which side of that
// boundary the installed @ladybugdb/core is.
if (isLbugPageSizeFrameError(err)) {
const pageSize = getOsPageSize();
const ladybug = getRuntimeFingerprint().ladybugdb;
const pageLine =
pageSize !== undefined && pageSize !== 4096
? ` Detected OS page size: ${pageSize} bytes (non-4K — e.g. Raspberry Pi 5 16K kernel, Asahi Linux).\n`
: '';
// The upgrade variant must not assert version facts about an unknown
// version — mirror the doctor-side wording rule (#2424 review R2).
const upgradeIntro =
ladybug === undefined
? ` The installed @ladybugdb/core version is unknown — it may predate the\n` +
` runtime OS-page-size detection added in 0.18.0.\n`
: ` The installed @ladybugdb/core (${ladybug}) assumes 4 KiB pages in its buffer\n` +
` manager.\n`;
const guidance = isPageSizeAwareLadybug(ladybug)
? ` The installed @ladybugdb/core (${ladybug}) already detects the OS page size at runtime,\n` +
` so this configuration was expected to work. Please report it:\n` +
` https://github.com/abhigyanpatwari/GitNexus/issues/1231\n` +
` and include: gitnexus --version, node --version, getconf PAGE_SIZE, uname -a,\n` +
` and the full error message above.\n`
: upgradeIntro +
` Upgrade GitNexus to a release that bundles @ladybugdb/core >= 0.18.0\n` +
` (gitnexus >= 1.6.9), which detects the OS page size at runtime:\n` +
` npm install -g gitnexus@latest\n` +
` Last-resort workaround on Raspberry Pi 5: boot the 4 KiB-page kernel\n` +
` (config.txt: kernel=kernel8.img), at the cost of Pi 5 optimizations.\n`;
// Embed the raw native text (indented, no stack) so "the full error
// message above" is fulfillable — same idiom as the LbugWipeError
// branch. The errno suffix and the 0.18.0 guard's frame/granule numbers
// are the discriminating triage content (#2424 review P2).
cliError(
` LadybugDB's buffer manager failed to release frame memory.\n` +
` ${msg.replace(/\n/g, '\n ')}\n` +
pageLine +
guidance,
{ recoveryHint: 'lbug-page-size', pageSize, ladybugVersion: ladybug },
);
process.exitCode = 1;
return;
}
// Local embedding runtime unsupported on this platform (macOS Intel ships no
// darwin/x64 ONNX native binding, #1515). The guard threw before importing
// transformers.js, so this is a clean, actionable GitNexus message. Checked

View file

@ -47,6 +47,7 @@ export type RecoveryHint =
| 'wal-corruption'
| 'wal-checkpoint-threshold'
| 'lbug-wipe-failed'
| 'lbug-page-size'
| 'heap-oom-respawn'
| 'native-worker-abort'
| 'hf-endpoint-unreachable'

View file

@ -13,6 +13,7 @@ import {
} from '../core/embeddings/runtime-install.js';
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
import { checkLbugNative, probeFtsExtensionLoad } from '../core/lbug/native-check.js';
import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js';
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { t } from './i18n/index.js';
@ -112,6 +113,39 @@ export function localEmbeddingDoctorStatus(opts: {
return { status: '✓ local embeddings supported', detail: null };
}
/**
* Page-size lines for the `doctor` Runtime section (#1231). Pure so the
* warning gate can be unit-tested without running the whole command (the
* `localEmbeddingDoctorStatus` precedent above) — but takes the probed
* values as plain params rather than injectable probes, because `undefined`
* is a *meaningful* pageSize state here (probe unavailable / win32) and
* would collide with a "not provided → use default" DI convention.
*
* Returns 0 lines (page size unknown), 1 line (page size), or 2 lines
* (page size + non-4K warning when the installed @ladybugdb/core does not
* detect the OS page size at runtime).
*/
export function pageSizeDoctorLines(
pageSize: number | undefined,
ladybugVersion: string | undefined,
): string[] {
if (pageSize === undefined) return [];
const lines = [` ${padDisplayEnd('page size', 10)}${pageSize}`];
if (pageSize > 4096 && !isPageSizeAwareLadybug(ladybugVersion)) {
// Don't assert "< 0.18.0" as fact when the version is unresolvable
// (#2424 review R2) — name the unknown state instead.
const versionClause =
ladybugVersion === undefined
? 'an unknown @ladybugdb/core version (may predate 0.18.0)'
: `@ladybugdb/core < 0.18.0`;
lines.push(
` ${padDisplayEnd('', 10)}⚠ non-4K page size with ${versionClause} — ` +
`'gitnexus analyze' may fail during COPY (#1231). Upgrade gitnexus (npm install -g gitnexus@latest).`,
);
}
return lines;
}
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
@ -123,6 +157,13 @@ export const doctorCommand = async () => {
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
// OS page size next to the LadybugDB version because the two interact:
// @ladybugdb/core < 0.18.0 assumed 4 KiB pages in its buffer manager and
// crashes mid-COPY on 16 KiB/64 KiB-page kernels (#1231). Literal label
// (like the 'native' line below) to avoid adding i18n keys.
for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) {
console.log(line);
}
const nativeCheck = checkLbugNative();
if (nativeCheck.ok) {
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);

View file

@ -355,6 +355,107 @@ export const isLbugCheckpointIoError = (err: unknown): boolean => {
return LBUG_CHECKPOINT_PERMISSIVE_RE.test(msg);
};
// ─── Ladybug non-4K page-size frame-release matcher (#1231) ─────────────────
//
// LadybugDB <= 0.17.x hardcoded a 4 KiB OS-page assumption in its buffer
// manager: evicting a frame released physical memory with
// `madvise(frame, frameSize, MADV_DONTNEED)` on 4 KiB-aligned frame
// addresses (verified by disassembling `VMRegion::releaseFrame` in
// @ladybugdb/core-linux-arm64 0.17.1 — `mov w2, #0x4` = MADV_DONTNEED,
// throw on non-zero return). On kernels with 16 KiB pages (Raspberry Pi 5
// default 2712 kernel, Asahi Linux) or 64 KiB pages (some enterprise arm64
// distros), madvise rejects addresses that are not multiples of the real
// page size with EINVAL, surfacing as:
// "Buffer manager exception: Releasing physical memory associated with a
// frame failed with error code -1: Invalid argument."
// which aborts `gitnexus analyze` mid-COPY.
//
// @ladybugdb/core 0.18.0 rewrote the release path with runtime OS-page-size
// detection and discard-granule-aligned madvise (new binary strings:
// "Failed to detect the operating system page size.", "Unsupported page
// size combination: frame size {}, discard granule size {}, frame group
// size {}."), so upgrading is the fix. The residual 0.18.0 guard
// ("Unsupported page size combination") is matched here too so exotic
// configurations receive the same actionable guidance instead of a raw
// native message.
const LBUG_FRAME_RELEASE_RE = /releasing physical memory associated with a frame failed/i;
const LBUG_PAGE_COMBO_RE = /unsupported page size combination/i;
/**
* True when `err` looks like the LadybugDB buffer manager failing to release
* frame memory — the failure mode of a 4 KiB page-size assumption on a
* 16 KiB/64 KiB-page kernel (#1231). Deliberately does NOT match the
* generic "buffer pool is full" exhaustion error, which is a sizing
* problem, not a page-size one.
*/
export const isLbugPageSizeFrameError = (err: unknown): boolean => {
if (!err) return false;
const msg = err instanceof Error ? err.message : String(err);
return LBUG_FRAME_RELEASE_RE.test(msg) || LBUG_PAGE_COMBO_RE.test(msg);
};
/**
* True when the given `@ladybugdb/core` version contains the runtime
* OS-page-size detection introduced in 0.18.0 (see the matcher comment
* above). Unknown/unparseable versions return false so callers err on the
* side of showing the upgrade hint.
*/
export const isPageSizeAwareLadybug = (version: string | undefined): boolean => {
if (!version) return false;
const m = /^(\d+)\.(\d+)/.exec(version.trim());
if (!m) return false;
const major = Number(m[1]);
const minor = Number(m[2]);
return major > 0 || minor >= 18;
};
// `undefined` = not probed yet; `null` = probed and unavailable. Cached
// because analyze error paths and doctor may both ask, and getconf forks.
let cachedOsPageSize: number | null | undefined;
/**
* OS memory page size in bytes, or `undefined` when it cannot be determined
* (Windows, missing getconf, sandboxed exec). Node exposes no page-size API,
* so this shells out to POSIX `getconf PAGE_SIZE` — same execFileSync shape
* as the Windows 8.3 short-path probe above, but with a tighter timeout and
* an explicit killSignal (see the options comment below).
*/
export const getOsPageSize = (): number | undefined => {
if (cachedOsPageSize !== undefined) return cachedOsPageSize ?? undefined;
if (process.platform === 'win32') {
// Windows allocation granularity is not what madvise alignment is about;
// the #1231 failure mode is POSIX-only.
cachedOsPageSize = null;
return undefined;
}
try {
// killSignal SIGKILL (first use in this repo): the default SIGTERM is
// catchable, so a signal-trapping child held the "5s" timeout for 9s in
// review reproduction — SIGKILL makes the timeout real for everything
// except a child stuck in uninterruptible I/O (D state). 2000ms, not
// 5000: doctor runs this probe on its happy path and real getconf
// answers in ~2ms, but keep margin for loaded Pi-class hardware — a
// too-tight ceiling would silently drop the very #1231 diagnostics this
// probe exists to provide (the catch caches the failure). (#2424 review)
const out = execFileSync('getconf', ['PAGE_SIZE'], {
encoding: 'utf-8',
timeout: 2000,
killSignal: 'SIGKILL',
stdio: ['ignore', 'pipe', 'pipe'],
});
const parsed = Number(out.trim());
cachedOsPageSize = Number.isInteger(parsed) && parsed > 0 ? parsed : null;
} catch {
cachedOsPageSize = null;
}
return cachedOsPageSize ?? undefined;
};
/** Exported only for unit tests — clears the getconf probe cache. */
export const _resetOsPageSizeCacheForTest = (): void => {
cachedOsPageSize = undefined;
};
type LbugModule = typeof lbug;
export interface LbugDatabaseOptions {

View file

@ -0,0 +1,265 @@
/**
* Tests for non-4K page-size buffer-manager error handling in `analyzeCommand`
* (#1231).
*
* On kernels with 16 KiB pages (Raspberry Pi 5, Asahi Linux) a
* @ladybugdb/core < 0.18.0 buffer manager fails to release evicted frames
* (madvise EINVAL) and analyze aborts mid-COPY with a raw native message.
* The CLI must catch that shape before the generic error path and render an
* actionable message: what the OS page size is, and whether the fix is
* upgrading (@ladybugdb/core < 0.18.0) or reporting (>= 0.18.0).
*
* Mirrors the mock shape of analyze-wal-error.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeFingerprint } from '../../src/core/platform/capabilities.js';
const runFullAnalysisMock = vi.fn();
vi.mock('../../src/core/run-analyze.js', () => ({
runFullAnalysis: runFullAnalysisMock,
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/lbug/lbug-adapter.js')>()),
closeLbug: vi.fn(async () => undefined),
closeLbugBeforeExit: vi.fn(async () => undefined),
isLbugReady: vi.fn(() => false),
}));
vi.mock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
assertAnalysisFinalized: vi.fn(async () => undefined),
}));
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(() => '/repo'),
hasGitDir: vi.fn(() => true),
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
getMaxFileSizeBannerMessage: vi.fn(() => null),
}));
// analyze.ts imports isHfDownloadFailure from hf-env.js, which in turn imports
// from gitnexus-shared (not linked in dev). Mock the module to break the chain.
vi.mock('../../src/core/embeddings/hf-env.js', () => ({
isHfDownloadFailure: vi.fn(() => false),
}));
// Pin the fingerprint so assertions do not depend on the dev environment's
// installed @ladybugdb/core.
const fingerprintMock = vi.fn(
(): RuntimeFingerprint => ({
platform: process.platform,
arch: process.arch,
node: process.version,
gitnexus: 'test',
ladybugdb: '0.17.1',
}),
);
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/platform/capabilities.js')>()),
getRuntimeFingerprint: fingerprintMock,
}));
// Pin the OS page size so the "Detected OS page size" line is deterministic
// (on 4 KiB dev/CI hosts the real probe would render nothing). The spread is
// load-bearing: analyze.ts also takes isWalCorruptionError /
// isLbugCheckpointIoError / isLbugPageSizeFrameError from this module, and
// the WAL/checkpoint branches run BEFORE the page-size branch in the same
// catch — a non-spread mock would stub them and reroute the test errors.
const getOsPageSizeMock = vi.fn((): number | undefined => 16384);
vi.mock('../../src/core/lbug/lbug-config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/lbug/lbug-config.js')>()),
getOsPageSize: getOsPageSizeMock,
}));
const PI5_COPY_ERROR =
'COPY failed for File: Buffer manager exception: Releasing physical memory ' +
'associated with a frame failed with error code -1: Invalid argument.';
describe('analyzeCommand non-4K page-size error handling (#1231)', () => {
// Capture the host's NODE_OPTIONS once so afterEach can restore it cleanly.
// Without the restore, beforeEach's append accumulated duplicate
// --max-old-space-size tokens across tests (analyze-worker-pool-size.test.ts
// pattern; #2424 review).
const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS;
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
getOsPageSizeMock.mockReset();
getOsPageSizeMock.mockReturnValue(16384);
process.exitCode = undefined;
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
afterEach(() => {
if (ORIGINAL_NODE_OPTIONS === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS;
}
});
it('recommends upgrading when @ladybugdb/core < 0.18.0 hits the frame-release error', async () => {
fingerprintMock.mockReturnValue({
platform: process.platform,
arch: process.arch,
node: process.version,
gitnexus: 'test',
ladybugdb: '0.17.1',
});
runFullAnalysisMock.mockRejectedValue(new Error(PI5_COPY_ERROR));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const records = cap.records();
const hint = records.find(
(r) => typeof r.msg === 'string' && r.msg.includes('failed to release frame memory'),
);
expect(hint).toBeDefined();
expect(hint?.msg).toContain('0.18.0');
expect(hint?.msg).toContain('npm install -g gitnexus@latest');
// The raw native error text is embedded so users can attach it to reports
// (#2424 review P2) — and the page-size line renders the mocked probe.
expect(hint?.msg).toContain(PI5_COPY_ERROR);
expect(hint?.msg).toContain('Detected OS page size: 16384 bytes');
// Structured fields flow to log aggregation (mirror analyze-wipe-error).
expect(hint).toMatchObject({
recoveryHint: 'lbug-page-size',
pageSize: 16384,
ladybugVersion: '0.17.1',
});
// Raw stack trace must NOT appear via cliError
const stackRecord = records.find(
(r) => typeof r.msg === 'string' && r.msg.includes('at analyzeCommand'),
);
expect(stackRecord).toBeUndefined();
cap.restore();
});
it('asks for a bug report when @ladybugdb/core >= 0.18.0 still hits the error', async () => {
fingerprintMock.mockReturnValue({
platform: process.platform,
arch: process.arch,
node: process.version,
gitnexus: 'test',
ladybugdb: '0.18.0',
});
runFullAnalysisMock.mockRejectedValue(new Error(PI5_COPY_ERROR));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const records = cap.records();
const hint = records.find(
(r) => typeof r.msg === 'string' && r.msg.includes('failed to release frame memory'),
);
expect(hint).toBeDefined();
expect(hint?.msg).toContain('issues/1231');
expect(hint?.msg).not.toContain('npm install -g gitnexus@latest');
// The report-a-bug path asks for "the full error message above" — the
// embedded raw text is what makes that instruction fulfillable.
expect(hint?.msg).toContain(PI5_COPY_ERROR);
expect(hint).toMatchObject({
recoveryHint: 'lbug-page-size',
pageSize: 16384,
ladybugVersion: '0.18.0',
});
cap.restore();
});
it.each([
['a 4 KiB host', 4096],
['an unavailable probe', undefined],
])('omits the page-size line on %s', async (_label, probed) => {
getOsPageSizeMock.mockReturnValue(probed);
runFullAnalysisMock.mockRejectedValue(new Error(PI5_COPY_ERROR));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
const hint = cap
.records()
.find((r) => typeof r.msg === 'string' && r.msg.includes('failed to release frame memory'));
expect(hint).toBeDefined();
expect(hint?.msg).not.toContain('Detected OS page size');
expect(hint?.msg).toContain(PI5_COPY_ERROR);
cap.restore();
});
it('names the unknown version instead of asserting facts about it', async () => {
fingerprintMock.mockReturnValue({
platform: process.platform,
arch: process.arch,
node: process.version,
gitnexus: 'test',
});
runFullAnalysisMock.mockRejectedValue(new Error(PI5_COPY_ERROR));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const hint = cap
.records()
.find((r) => typeof r.msg === 'string' && r.msg.includes('failed to release frame memory'));
expect(hint).toBeDefined();
// Unknown version must not read "(unknown) assumes 4 KiB pages" (#2424
// review R2) — name the unknown state, keep the upgrade instruction.
expect(hint?.msg).toContain('version is unknown');
expect(hint?.msg).not.toContain('(unknown) assumes');
expect(hint?.msg).toContain('npm install -g gitnexus@latest');
cap.restore();
});
it('does NOT route buffer-pool exhaustion through the page-size handler', async () => {
runFullAnalysisMock.mockRejectedValue(
new Error(
'COPY failed for File: Buffer manager exception: Unable to allocate memory! ' +
'The buffer pool is full and no memory could be freed!',
),
);
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const records = cap.records();
expect(
records.some(
(r) => typeof r.msg === 'string' && r.msg.includes('failed to release frame memory'),
),
).toBe(false);
cap.restore();
});
});

View file

@ -10,7 +10,7 @@
* - drive `analyzeCommand` with a mocked `runFullAnalysis` that throws
* - assert on process.exitCode and the logged output
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const runFullAnalysisMock = vi.fn();
@ -55,6 +55,12 @@ vi.mock('../../src/core/embeddings/hf-env.js', () => ({
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('analyzeCommand WAL corruption error handling', () => {
// Capture the host's NODE_OPTIONS once so afterEach can restore it cleanly.
// Without the restore, beforeEach's append accumulated duplicate
// --max-old-space-size tokens across tests (analyze-worker-pool-size.test.ts
// pattern; #2424 review).
const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS;
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
@ -63,6 +69,14 @@ describe('analyzeCommand WAL corruption error handling', () => {
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
afterEach(() => {
if (ORIGINAL_NODE_OPTIONS === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS;
}
});
it('surfaces a clean recovery message on a re-wrapped WAL corruption error', async () => {
// This error shape is what lbug-adapter throws after detecting WAL corruption
// in doInitLbug and re-wrapping it with the recovery suggestion.

View file

@ -14,7 +14,7 @@
* - drive `analyzeCommand` with a mocked `runFullAnalysis` that rejects
* - assert on process.exitCode and the captured logger records
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const runFullAnalysisMock = vi.fn();
@ -71,6 +71,12 @@ vi.mock('../../src/core/embeddings/hf-env.js', () => ({
}));
describe('analyzeCommand LadybugDB wipe-failure handling (#2409, tri-review 4669518496)', () => {
// Capture the host's NODE_OPTIONS once so afterEach can restore it cleanly.
// Without the restore, beforeEach's append accumulated duplicate
// --max-old-space-size tokens across tests (analyze-worker-pool-size.test.ts
// pattern; #2424 review).
const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS;
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
@ -81,6 +87,14 @@ describe('analyzeCommand LadybugDB wipe-failure handling (#2409, tri-review 4669
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
afterEach(() => {
if (ORIGINAL_NODE_OPTIONS === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS;
}
});
it('routes a wipe failure to the dedicated recovery hint, not the raw-stack fallback', async () => {
// Install the stderr spy BEFORE importing analyze.js: the module binds
// `realStderrWrite = process.stderr.write.bind(...)` at load time, so a

View file

@ -4,6 +4,7 @@ import {
doctorCommand,
localEmbeddingDoctorStatus,
padDisplayEnd,
pageSizeDoctorLines,
} from '../../src/cli/doctor.js';
describe('doctor output formatting', () => {
@ -127,6 +128,42 @@ describe('doctor embedding-runtime support status', () => {
});
});
describe('doctor page-size lines (#1231, #2424 review)', () => {
it('warns on a non-4K page size with a pre-0.18.0 @ladybugdb/core', () => {
const lines = pageSizeDoctorLines(16384, '0.17.1');
expect(lines).toHaveLength(2);
expect(lines[0]).toBe(` ${padDisplayEnd('page size', 10)}16384`);
// Byte-identical to the pre-extraction inline rendering — guards the
// helper extraction against output drift.
expect(lines[1]).toBe(
` ${padDisplayEnd('', 10)}⚠ non-4K page size with @ladybugdb/core < 0.18.0 — ` +
`'gitnexus analyze' may fail during COPY (#1231). Upgrade gitnexus (npm install -g gitnexus@latest).`,
);
});
it.each([
['page-size-aware LadybugDB', 16384, '0.18.0'],
['a 4 KiB page size', 4096, '0.17.1'],
])('prints the page size without a warning for %s', (_label, pageSize, version) => {
const lines = pageSizeDoctorLines(pageSize, version);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('page size');
expect(lines[0]).toContain(String(pageSize));
});
it('prints nothing when the page size is unknown', () => {
expect(pageSizeDoctorLines(undefined, '0.17.1')).toHaveLength(0);
});
it('names an unknown version instead of asserting "< 0.18.0" about it', () => {
const lines = pageSizeDoctorLines(16384, undefined);
expect(lines).toHaveLength(2);
expect(lines[1]).toContain('an unknown @ladybugdb/core version (may predate 0.18.0)');
expect(lines[1]).not.toContain('with @ladybugdb/core < 0.18.0');
expect(lines[1]).toContain('npm install -g gitnexus@latest');
});
});
describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => {
const ENV_KEYS = [
'GITNEXUS_EMBEDDING_URL',

View file

@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'child_process';
import {
_resetOsPageSizeCacheForTest,
getOsPageSize,
isLbugPageSizeFrameError,
isPageSizeAwareLadybug,
} from '../../src/core/lbug/lbug-config.js';
// Pass-through spy on the bare 'child_process' specifier (lbug-config.ts
// imports execFileSync from 'child_process', not 'node:child_process').
// Real behavior is preserved by default, so the live-probe test below still
// exercises the host getconf — including the real 16 KiB pages on the
// macos-arm64 CI matrix — while failure-path tests override single calls via
// mockImplementationOnce. Recipe: sibling-clone-drift.test.ts.
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});
const execFileSyncSpy = vi.mocked(execFileSync);
// getOsPageSize short-circuits before any exec on win32, so every
// exec-asserting test below is POSIX-only (skipIf pairs, no if-branching).
const onWindows = process.platform === 'win32';
// ─── #1231: non-4K page-size frame-release matcher ──────────────────────────
describe('isLbugPageSizeFrameError', () => {
it.each([
[
'exact Raspberry Pi 5 failure (issue #1231)',
'Buffer manager exception: Releasing physical memory associated with a frame failed with error code -1: Invalid argument.',
],
[
'wrapped by the node-COPY error path',
'COPY failed for File: Buffer manager exception: Releasing physical memory associated with a frame failed with error code -1: Invalid argument.',
],
[
'0.18.0 residual guard',
'Buffer manager exception: Unsupported page size combination: frame size 4096, discard granule size 65536, frame group size 16384.',
],
])('matches %s', (_label, msg) => {
expect(isLbugPageSizeFrameError(msg)).toBe(true);
expect(isLbugPageSizeFrameError(new Error(msg))).toBe(true);
});
it.each([
[
'buffer pool exhaustion (a sizing problem, not page size)',
'Buffer manager exception: Unable to allocate memory! The buffer pool is full and no memory could be freed!',
],
['8TB mmap failure (#785)', 'Buffer manager exception: Mmap for size 8796093022208 failed.'],
['WAL corruption', 'Runtime exception: Corrupted wal file. Read out invalid WAL record type.'],
['lock contention', 'Could not set lock on file : /path/to/db'],
['generic', 'Query failed'],
])('does NOT match %s', (_label, msg) => {
expect(isLbugPageSizeFrameError(msg)).toBe(false);
});
it('handles non-string input', () => {
expect(isLbugPageSizeFrameError(undefined)).toBe(false);
expect(isLbugPageSizeFrameError(null)).toBe(false);
expect(isLbugPageSizeFrameError(42)).toBe(false);
});
});
// ─── #1231: page-size-aware LadybugDB version gate ──────────────────────────
describe('isPageSizeAwareLadybug', () => {
it.each([
['0.18.0', true],
['0.18.0-dev.20260708', true],
['0.19.2', true],
['1.0.0', true],
['0.17.1', false],
['0.16.0', false],
['0.15.4', false],
])('%s -> %s', (version, expected) => {
expect(isPageSizeAwareLadybug(version)).toBe(expected);
});
it('returns false for unknown/unparseable versions (err on showing the upgrade hint)', () => {
expect(isPageSizeAwareLadybug(undefined)).toBe(false);
expect(isPageSizeAwareLadybug('')).toBe(false);
expect(isPageSizeAwareLadybug('unknown')).toBe(false);
expect(isPageSizeAwareLadybug('v0.18.0')).toBe(false);
});
});
// ─── #1231: OS page-size probe ───────────────────────────────────────────────
describe('getOsPageSize', () => {
afterEach(() => {
_resetOsPageSizeCacheForTest();
execFileSyncSpy.mockClear();
});
it.skipIf(onWindows)('returns a positive power-of-two page size on POSIX platforms', () => {
const pageSize = getOsPageSize();
expect(pageSize).toBeDefined();
expect(Number.isInteger(pageSize)).toBe(true);
// Every real page size is a power of two (4K, 16K, 64K, ...). log2 of a
// power of two is an integer; `?? 0` narrows without a cast or branch and
// Math.log2(0) is -Infinity, which fails isInteger.
expect(Number.isInteger(Math.log2(pageSize ?? 0))).toBe(true);
});
it.skipIf(!onWindows)('returns undefined on Windows without forking', () => {
expect(getOsPageSize()).toBeUndefined();
expect(execFileSyncSpy).not.toHaveBeenCalled();
});
it.skipIf(onWindows)('returns undefined when the probe cannot exec', () => {
execFileSyncSpy.mockImplementationOnce(() => {
throw new Error('spawnSync getconf ENOENT');
});
expect(getOsPageSize()).toBeUndefined();
});
it.skipIf(onWindows)('returns undefined on non-numeric probe output', () => {
execFileSyncSpy.mockImplementationOnce(() => 'unlimited');
expect(getOsPageSize()).toBeUndefined();
});
it.skipIf(onWindows)('returns undefined on empty probe output', () => {
execFileSyncSpy.mockImplementationOnce(() => '');
expect(getOsPageSize()).toBeUndefined();
});
it.skipIf(onWindows)('execs getconf with a SIGKILL-hardened 2s timeout', () => {
getOsPageSize();
expect(execFileSyncSpy).toHaveBeenCalledWith(
'getconf',
['PAGE_SIZE'],
expect.objectContaining({ timeout: 2000, killSignal: 'SIGKILL' }),
);
});
it.skipIf(onWindows)('returns undefined when the probe times out', () => {
// execFileSync's timeout kill surfaces as a throw with `signal` set and
// `status` null — the fail-safe must hold for the kill path too.
execFileSyncSpy.mockImplementationOnce(() => {
const err = new Error('spawnSync getconf ETIMEDOUT') as Error & {
signal: string;
status: null;
};
err.signal = 'SIGKILL';
err.status = null;
throw err;
});
expect(getOsPageSize()).toBeUndefined();
});
it.skipIf(onWindows)('probes at most once per process (cached)', () => {
expect(getOsPageSize()).toBe(getOsPageSize());
expect(execFileSyncSpy).toHaveBeenCalledTimes(1);
_resetOsPageSizeCacheForTest();
getOsPageSize();
expect(execFileSyncSpy).toHaveBeenCalledTimes(2);
});
});