mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
fix(cli): force exit on a soft error-return when LadybugDB handles are open (#2264 review P1)
The full-analysis success path skip-closes LadybugDB (handles left open, reclaimed by process.exit). If a post-finalize step (assertAnalysisFinalized) then throws, the outer catch soft-returns (process.exitCode = 1) — and with native handles open the event loop never drains, so the process HANGS instead of exiting 1. Guard once at the analyzeCommand wrapper, after the try/finally: if isLbugReady() (handles still open) the analyze actually ran and we must force the exit. The success path never reaches here (analyzeCommandImpl process.exit(0)s itself); early-validation errors and unit tests that mock runFullAnalysis never open the DB (isLbugReady() false), so the soft return is preserved. Adds analyze-finalize-failure-exits.test.ts (force-exits when handles open; does NOT when they aren't). The analyze-*.test.ts that mock lbug-adapter now also mock isLbugReady (vitest throws on accessing an undefined export of a mocked module). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
This commit is contained in:
parent
8a6799c5ff
commit
8130e62586
13 changed files with 129 additions and 2 deletions
|
|
@ -13,7 +13,7 @@ import os from 'os';
|
|||
import { spawn } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
||||
import { closeLbug, isLbugReady } from '../core/lbug/lbug-adapter.js';
|
||||
import {
|
||||
isLbugCheckpointIoError,
|
||||
isWalCorruptionError,
|
||||
|
|
@ -738,6 +738,17 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
} finally {
|
||||
restoreAnalyzeEnv(envSnap);
|
||||
}
|
||||
// If analyzeCommandImpl returned via a soft `process.exitCode = 1` error path
|
||||
// while LadybugDB native handles are still open, the event loop won't drain and
|
||||
// the process would HANG (#2264 review P1). The full analyze paths skip-close the
|
||||
// DB — handles are left open and reclaimed by process.exit — so a soft return
|
||||
// after a real analyze must force the exit. The success path never reaches here
|
||||
// (analyzeCommandImpl calls process.exit(0) itself); early-validation errors and
|
||||
// unit tests that mock runFullAnalysis never open the DB, so isLbugReady() is
|
||||
// false and the soft return is preserved.
|
||||
if (isLbugReady()) {
|
||||
process.exit(typeof process.exitCode === 'number' ? process.exitCode : 1);
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeCommandImpl = async (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
103
gitnexus/test/unit/analyze-finalize-failure-exits.test.ts
Normal file
103
gitnexus/test/unit/analyze-finalize-failure-exits.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } 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),
|
||||
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),
|
||||
}));
|
||||
|
||||
describe('analyzeCommand — finalize-failure must terminate, not hang (#2264 P1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
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;
|
||||
});
|
||||
|
||||
it('force-exits when native handles are still open (isLbugReady true)', async () => {
|
||||
isLbugReadyMock.mockReturnValue(true);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
try {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
await analyzeCommand(undefined, {});
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT force-exit when no handles are open (isLbugReady false) — soft return preserved', async () => {
|
||||
isLbugReadyMock.mockReturnValue(false);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
try {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
await analyzeCommand(undefined, {});
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -39,7 +39,10 @@ vi.mock('../../src/cli/ai-context.js', () => ({
|
|||
}));
|
||||
vi.mock('../../src/cli/skill-gen.js', () => ({ generateSkillFiles: generateSkillFilesMock }));
|
||||
vi.mock('../../src/cli/cli-message.js', () => ({ cliError: cliErrorMock }));
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ closeLbug: vi.fn(async () => undefined) }));
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi.fn((repoPath: string) => ({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ vi.mock('os', async () => {
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockSpawnExit = ({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ vi.mock('../../src/cli/cli-message.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
isLbugReady: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue