GitNexus/gitnexus/test/unit/analyze-pagesize-error.test.ts
VL 117587d543
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(cli): actionable diagnostics for non-4K page-size buffer manager failures (#2424)
2026-07-10 19:12:53 +01:00

265 lines
9.8 KiB
TypeScript

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