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:
Gergo Magyar 2026-06-21 08:27:04 +00:00
parent 8a6799c5ff
commit 8130e62586
13 changed files with 129 additions and 2 deletions

View file

@ -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 (

View file

@ -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', () => ({

View file

@ -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', () => ({

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

View file

@ -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) => ({

View file

@ -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 = ({

View file

@ -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', () => ({

View file

@ -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', () => ({

View file

@ -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', () => ({

View file

@ -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', () => ({

View file

@ -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', () => ({

View file

@ -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', () => ({

View file

@ -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', () => ({