From 8130e625868a2613b0d81da10d225e9202f889ea Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 21 Jun 2026 08:27:04 +0000 Subject: [PATCH] fix(cli): force exit on a soft error-return when LadybugDB handles are open (#2264 review P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm --- gitnexus/src/cli/analyze.ts | 13 ++- .../analyze-embedding-endpoint-flags.test.ts | 1 + .../unit/analyze-embeddings-limit.test.ts | 1 + .../analyze-finalize-failure-exits.test.ts | 103 ++++++++++++++++++ gitnexus/test/unit/analyze-gitnexusrc.test.ts | 5 +- .../test/unit/analyze-heap-respawn.test.ts | 1 + .../analyze-lbug-checkpoint-threshold.test.ts | 1 + .../analyze-local-embedding-error.test.ts | 1 + .../test/unit/analyze-no-stats-bridge.test.ts | 1 + .../analyze-respawn-progress-terminal.test.ts | 1 + gitnexus/test/unit/analyze-wal-error.test.ts | 1 + .../unit/analyze-worker-pool-size.test.ts | 1 + .../test/unit/analyze-worker-timeout.test.ts | 1 + 13 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/unit/analyze-finalize-failure-exits.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 75fa4a3e0..27bd9c6dc 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 ( diff --git a/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts b/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts index 69c51e34c..ee56e80b7 100644 --- a/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts +++ b/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-embeddings-limit.test.ts b/gitnexus/test/unit/analyze-embeddings-limit.test.ts index 6fbc5af56..cfd3ac0fd 100644 --- a/gitnexus/test/unit/analyze-embeddings-limit.test.ts +++ b/gitnexus/test/unit/analyze-embeddings-limit.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-finalize-failure-exits.test.ts b/gitnexus/test/unit/analyze-finalize-failure-exits.test.ts new file mode 100644 index 000000000..db63d6882 --- /dev/null +++ b/gitnexus/test/unit/analyze-finalize-failure-exits.test.ts @@ -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(); + } + }); +}); diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index cd299f870..807f3fce0 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -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) => ({ diff --git a/gitnexus/test/unit/analyze-heap-respawn.test.ts b/gitnexus/test/unit/analyze-heap-respawn.test.ts index 7d98ddbf4..1dea8b5ea 100644 --- a/gitnexus/test/unit/analyze-heap-respawn.test.ts +++ b/gitnexus/test/unit/analyze-heap-respawn.test.ts @@ -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 = ({ diff --git a/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts index de55d4e71..a8cdc082b 100644 --- a/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts +++ b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-local-embedding-error.test.ts b/gitnexus/test/unit/analyze-local-embedding-error.test.ts index 0b5e5de48..cbdc3dc3c 100644 --- a/gitnexus/test/unit/analyze-local-embedding-error.test.ts +++ b/gitnexus/test/unit/analyze-local-embedding-error.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index 629bfac02..dc87ae04e 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts b/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts index de90ae467..0532db50b 100644 --- a/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts +++ b/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-wal-error.test.ts b/gitnexus/test/unit/analyze-wal-error.test.ts index 5264dfd3a..a267ff538 100644 --- a/gitnexus/test/unit/analyze-wal-error.test.ts +++ b/gitnexus/test/unit/analyze-wal-error.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-worker-pool-size.test.ts b/gitnexus/test/unit/analyze-worker-pool-size.test.ts index 2c7a8bd3f..9e8a5830b 100644 --- a/gitnexus/test/unit/analyze-worker-pool-size.test.ts +++ b/gitnexus/test/unit/analyze-worker-pool-size.test.ts @@ -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', () => ({ diff --git a/gitnexus/test/unit/analyze-worker-timeout.test.ts b/gitnexus/test/unit/analyze-worker-timeout.test.ts index aebe587e9..830eda2cb 100644 --- a/gitnexus/test/unit/analyze-worker-timeout.test.ts +++ b/gitnexus/test/unit/analyze-worker-timeout.test.ts @@ -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', () => ({