diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 0cf17ab13..75fa4a3e0 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1168,7 +1168,10 @@ const analyzeCommandImpl = async ( aborted = true; bar.stop(); console.log('\n Interrupted — cleaning up...'); - closeLbug() + // process.exit(130) follows, so skip the native close (LadybugDB destructor + // can double-free after --pdg writes, #2264); the CHECKPOINT inside closeLbug + // still flushes the WAL. + closeLbug({ skipNativeClose: true }) .catch(() => {}) .finally(async () => { const { flushLoggerSync } = await import('../core/logger.js'); @@ -1273,6 +1276,12 @@ const analyzeCommandImpl = async ( // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual); // forwarded to the routes phase consumer scan. fetchWrappers: options.fetchWrappers, + // The CLI always process.exit()s after this returns (success path at the + // end of analyzeCommandImpl, error/interrupt paths via process.exit too), + // so the finalize close skips the native conn/db close — it can double-free + // in LadybugDB's ClientContext destructor after --pdg writes (#2264). The + // CHECKPOINT keeps the index durable; process exit reclaims the handles. + skipNativeCloseOnExit: true, }, { onProgress: (_phase, percent, message) => { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 1a8ba18b2..b0154f233 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1900,7 +1900,23 @@ export const safeClose = async (): Promise => { } }; -export const closeLbug = async (): Promise => { +export const closeLbug = async (options: { skipNativeClose?: boolean } = {}): Promise => { + if (options.skipNativeClose) { + // The caller is about to exit the process (CLI analyze success/error/SIGINT + // all end in process.exit). CHECKPOINT for durability, then DELIBERATELY skip + // conn.close()/db.close(): LadybugDB's ClientContext/Connection destructor can + // double-free after large --pdg writes (gdb: `double free or corruption` in + // ClientContext::~ClientContext via NodeConnection::Close), aborting the + // process AFTER a fully-written, checkpointed index. flushWAL above already + // persisted the data; process exit reclaims the native handles. We leave + // conn/db referenced and module state intact so a GC finalizer cannot run the + // same destructor before exit, and any post-analyze read reuses the live + // connection. Mirrors the pool adapter's fire-and-forget native close + // (pool-adapter.ts) and the ONNX native-cleanup philosophy. This is a + // workaround for a LadybugDB engine bug — see the upstream report. + await flushWAL(); + return; + } await safeClose(); currentDbPath = null; ftsLoaded = false; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index cf22cf09e..e3edc4335 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -232,6 +232,15 @@ export interface AnalyzeOptions { * consumer scan unchanged. */ fetchWrappers?: string[]; + /** + * The caller will `process.exit()` immediately after this analyze returns (the + * CLI `analyze` command). When set, the finalize/error close CHECKPOINTs for + * durability but skips the native `conn.close()`/`db.close()`, which can + * double-free in LadybugDB's `ClientContext` destructor after large `--pdg` + * writes (gdb-confirmed) — aborting the process AFTER a fully-written index. + * Process exit reclaims the handles. Long-lived callers (MCP server, tests) + * leave this unset so they get a real close. See `closeLbug`. */ + skipNativeCloseOnExit?: boolean; } export interface AnalyzeResult { @@ -1549,7 +1558,10 @@ export async function runFullAnalysis( // Stop the manual checkpoint driver before closeLbug so its // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT. await walCheckpointDriver.stop(); - await closeLbug(); + // CLI callers (about to process.exit) skip the native close to dodge a + // LadybugDB destructor double-free after --pdg writes — the CHECKPOINT inside + // closeLbug keeps the index durable (#2264). Long-lived callers close for real. + await closeLbug({ skipNativeClose: options.skipNativeCloseOnExit }); progress('done', 100, 'Done'); @@ -1570,7 +1582,9 @@ export async function runFullAnalysis( /* swallow — surface path is the rethrow below */ } try { - await closeLbug(); + // Same native-close skip on the error path for CLI callers — they + // process.exit too, and the native close can double-free (#2264). + await closeLbug({ skipNativeClose: options.skipNativeCloseOnExit }); } catch { /* swallow */ } diff --git a/gitnexus/test/integration/lbug-conn-serialization.test.ts b/gitnexus/test/integration/lbug-conn-serialization.test.ts index 36951affe..eb611c995 100644 --- a/gitnexus/test/integration/lbug-conn-serialization.test.ts +++ b/gitnexus/test/integration/lbug-conn-serialization.test.ts @@ -72,6 +72,18 @@ withTestLbugDB('conn-serialization', () => { expect(lockSpy.mock.calls.length).toBeGreaterThanOrEqual(filePathTables.length); expect(result).toMatchObject({ deletedNodes: 0 }); }); + + it('closeLbug({ skipNativeClose }) checkpoints but leaves the connection open (#2264 close-crash)', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.closeLbug({ skipNativeClose: true }); + // The native conn/db are deliberately NOT torn down — that avoids LadybugDB's + // ClientContext destructor double-free after --pdg writes. The connection + // stays ready and queryable (the CHECKPOINT made the index durable; process + // exit reclaims the handles on the CLI path). + expect(adapter.isLbugReady()).toBe(true); + const rows = await adapter.executeQuery('RETURN 1 AS one'); + expect(rows).toHaveLength(1); + }); }); });