GitNexus/gitnexus/test/unit/analyze-finalize-failure-exits.test.ts
azizur100389 3f5ca8cdb7
fix(storage): guard stale file-lock reclamation (#3234)
* fix(storage): guard stale file-lock reclamation

* fix(storage): close lock recovery failure paths

* Address PR review feedback (#3234)

- Flush the lock-child stderr diagnostic before process.exit
- Treat explicit NaN timeouts as the default ceiling
- Document non-retryable guard timeouts on the worker IPC contract

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): stop mislabeling live lock waits as orphan recovery

A brief peer inspect must not attach guardPath or send operators to
RUNBOOK delete steps. Refuse lock-free embeddings sync, stop --watch
only on a true guard timeout, and drop an unreadable self-created
guard before failing closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3234)

- Verify lock/guard absence before degrading a denied main-lock create
- Launch the third contender from unlinkSync, not the dead rename path
- Document group-lock timeouts for unrecoverable guard leftovers

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: prettier index-lock reclaim guard tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): treat O_EXCL as the lock-file presence check

CodeQL flagged existsSync-then-wx on analyze.lock. Create with wx first and
only reclaim unreadable leftovers after grace, so a successor is never
unlinked from a lost race.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 11:47:12 +01:00

216 lines
9 KiB
TypeScript

/**
* Regression test for the #2264 review P1: when a full analyze succeeds (which
* skip-closes LadybugDB, leaving native handles open) and a post-finalize step
* THEN throws, the CLI's outer catch soft-returns (`process.exitCode = 1`). With
* native handles open, the event loop can't drain — the process would HANG. The
* `analyzeCommand` wrapper now force-exits when `isLbugReady()` is true after the
* soft return. This test drives that exact path and asserts termination.
*
* Test-safety: when `isLbugReady()` is false (the default in every analyze unit
* test that mocks run-analyze — the DB is never opened), the wrapper must NOT
* force-exit, preserving the soft return those tests rely on.
*
* Worker-safety (#2264 CI): the module is imported ONCE and the mocks are driven
* per-test via `mockReturnValue`. The earlier `vi.resetModules()` + per-test
* `await import('analyze.js')` re-instrumented the ENTIRE analyze module graph on
* every test; under `--coverage` on the memory-constrained CI runner that
* OOM/crashed the forked worker ("Worker exited unexpectedly"), even though it
* passed locally. `analyzeCommand` also installs global fatal handlers
* (installFatalHandlers) that call the REAL process.exit(1); we keep process.exit
* spied for the whole file so one firing can't kill the worker, strip the handlers
* it added in afterAll, and reset process.exitCode so the worker exits clean.
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
const {
runFullAnalysisMock,
assertAnalysisFinalizedMock,
isLbugReadyMock,
AnalysisNotFinalizedError,
} = vi.hoisted(() => {
class AnalysisNotFinalizedError extends Error {
storagePath = '.gitnexus';
}
return {
runFullAnalysisMock: vi.fn(),
assertAnalysisFinalizedMock: vi.fn(),
isLbugReadyMock: vi.fn(() => false),
AnalysisNotFinalizedError,
};
});
vi.mock('../../src/core/run-analyze.js', () => ({ runFullAnalysis: runFullAnalysisMock }));
vi.mock('../../src/cli/ai-context.js', () => ({
generateAIContextFiles: vi.fn(async () => ({ files: [] as string[] })),
refreshBaseRefLine: vi.fn(async () => ({ files: [] as string[] })),
}));
vi.mock('../../src/cli/skill-gen.js', () => ({ generateSkillFiles: vi.fn() }));
vi.mock('../../src/cli/cli-message.js', () => ({ cliError: vi.fn() }));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
closeLbug: vi.fn(async () => undefined),
closeLbugBeforeExit: vi.fn(async () => undefined),
isLbugReady: isLbugReadyMock,
}));
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,
assertAnalysisFinalized: assertAnalysisFinalizedMock,
}));
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(() => '/repo'),
hasGitDir: vi.fn(() => true),
getDefaultBranch: vi.fn(() => null),
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
getMaxFileSizeBannerMessage: vi.fn(() => null),
}));
// Imported ONCE (not re-imported per test) — see the worker-safety note above.
import { analyzeCommand } from '../../src/cli/analyze.js';
import { cliError } from '../../src/cli/cli-message.js';
import { IndexLockTimeoutError, type LockRecord } from '../../src/storage/index-lock.js';
describe('analyzeCommand — finalize-failure must terminate, not hang (#2264 P1)', () => {
// Snapshot the fatal-handler listeners present BEFORE this file ran (vitest's
// own) so afterAll strips only the ones installFatalHandlers added.
const baselineUnhandled = process.listeners('unhandledRejection');
const baselineUncaught = process.listeners('uncaughtException');
let exitSpy: MockInstance<typeof process.exit>;
let savedNodeOptions: string | undefined;
beforeAll(() => {
// analyzeCommand calls ensureHeap(), which RE-EXECS the process — spawning
// `node <heap-flags> <argv>` where argv is vitest's, killing the forked
// worker — UNLESS NODE_OPTIONS already carries a heap cap (analyze.ts:498).
// Locally a high V8 heap-size-limit also short-circuits it (analyze.ts:501),
// which is why this only crashed on the memory-constrained CI runner. Pre-set
// the cap so ensureHeap returns early — the same workaround cli-e2e uses
// (#2264 CI). Restored in afterAll so a reused worker's later files are clean.
savedNodeOptions = process.env.NODE_OPTIONS;
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
// Mock process.exit for the WHOLE file — a fatal handler firing between tests
// (after a per-test spy would have been restored) can't really exit.
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});
afterAll(() => {
// Strip the handlers installFatalHandlers added BEFORE restoring the real
// process.exit, so no stray rejection during teardown fires a real exit. Only
// remove non-baseline (vitest's own) listeners.
process
.listeners('unhandledRejection')
.filter((l) => !baselineUnhandled.includes(l))
.forEach((l) => process.removeListener('unhandledRejection', l));
process
.listeners('uncaughtException')
.filter((l) => !baselineUncaught.includes(l))
.forEach((l) => process.removeListener('uncaughtException', l));
exitSpy.mockRestore();
process.env.NODE_OPTIONS = savedNodeOptions ?? '';
process.exitCode = 0;
});
beforeEach(() => {
exitSpy.mockClear();
runFullAnalysisMock.mockReset();
// Full analysis succeeded (NOT the alreadyUpToDate fast path) → skip-closed.
runFullAnalysisMock.mockResolvedValue({
repoName: 'repo',
repoPath: '/repo',
stats: {},
alreadyUpToDate: false,
ftsRepairedOnly: false,
pipelineResult: { communityResult: undefined },
});
assertAnalysisFinalizedMock.mockReset();
// Post-finalize check throws (the documented silent-finalize state).
assertAnalysisFinalizedMock.mockRejectedValue(new AnalysisNotFinalizedError('not finalized'));
isLbugReadyMock.mockReset();
process.exitCode = undefined;
});
afterEach(() => {
// Don't leak a non-zero exit code to the forked worker's natural exit.
process.exitCode = 0;
});
it.each([undefined, '/repo/.gitnexus/analyze.lock.guard'])(
'renders the correct lock recovery instructions for guard=%s',
async (guardPath) => {
vi.mocked(cliError).mockClear();
const holder: LockRecord = {
v: 1,
pid: 123,
hostname: 'host',
startTime: null,
token: 'owner',
invocationId: 'owner',
acquiredAt: '',
};
const error = new IndexLockTimeoutError(holder, 30000, guardPath === undefined, guardPath);
runFullAnalysisMock.mockRejectedValue(error);
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
if (guardPath) {
expect(cliError).toHaveBeenCalledWith(error.message, {
recoveryHint: 'index-lock-guard-recovery',
guardPath,
});
expect(error.message).not.toContain('GITNEXUS_INDEX_LOCK_TIMEOUT_MS');
expect(error.message).toContain('quiesced recovery');
} else {
expect(cliError).toHaveBeenCalledWith(
expect.stringContaining('GITNEXUS_INDEX_LOCK_TIMEOUT_MS'),
{ recoveryHint: 'index-lock-timeout', holderPid: 123 },
);
}
expect(assertAnalysisFinalizedMock).not.toHaveBeenCalled();
},
);
it('force-exits when native handles are still open (isLbugReady true)', async () => {
isLbugReadyMock.mockReturnValue(true);
await analyzeCommand(undefined, {});
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('does NOT force-exit when no handles are open (isLbugReady false) — soft return preserved', async () => {
isLbugReadyMock.mockReturnValue(false);
await analyzeCommand(undefined, {});
expect(exitSpy).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('forwards a pre-set process.exitCode rather than the hardcoded fallback', async () => {
// The alreadyUpToDate path returns WITHOUT setting process.exitCode or calling
// process.exit (unlike the error catch, which always sets exitCode=1), so the
// wrapper's force-exit must forward whatever exitCode is already set — proving
// `process.exit(process.exitCode ?? 1)` reads exitCode and doesn't hardcode 1.
// isLbugReady is forced true to drive the wrapper's force-exit on this path.
isLbugReadyMock.mockReturnValue(true);
runFullAnalysisMock.mockResolvedValue({
repoName: 'repo',
repoPath: '/repo',
stats: {},
alreadyUpToDate: true,
ftsRepairedOnly: false,
pipelineResult: { communityResult: undefined },
});
assertAnalysisFinalizedMock.mockResolvedValue(undefined);
process.exitCode = 2;
await analyzeCommand(undefined, {});
expect(exitSpy).toHaveBeenCalledWith(2);
});
});