mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
216 lines
9 KiB
TypeScript
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);
|
|
});
|
|
});
|