gitnexus 1.6.9-rc.1 → 1.6.9-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,8 @@ import os from 'os';
12
12
  import { spawn } from 'child_process';
13
13
  import v8 from 'v8';
14
14
  import cliProgress from 'cli-progress';
15
- import { closeLbug } from '../core/lbug/lbug-adapter.js';
15
+ import { isLbugReady } from '../core/lbug/lbug-adapter.js';
16
+ import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
16
17
  import { isLbugCheckpointIoError, isWalCorruptionError, parseWalCheckpointThreshold, WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js';
17
18
  import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, assertAnalysisFinalized, } from '../storage/repo-manager.js';
18
19
  import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
@@ -508,6 +509,17 @@ export const analyzeCommand = async (inputPath, options) => {
508
509
  finally {
509
510
  restoreAnalyzeEnv(envSnap);
510
511
  }
512
+ // If analyzeCommandImpl returned via a soft `process.exitCode = 1` error path
513
+ // while LadybugDB native handles are still open, the event loop won't drain and
514
+ // the process would HANG (#2264 review P1). The full analyze paths skip-close the
515
+ // DB — handles are left open and reclaimed by process.exit — so a soft return
516
+ // after a real analyze must force the exit. The success path never reaches here
517
+ // (analyzeCommandImpl calls process.exit(0) itself); early-validation errors and
518
+ // unit tests that mock runFullAnalysis never open the DB, so isLbugReady() is
519
+ // false and the soft return is preserved.
520
+ if (isLbugReady()) {
521
+ process.exit(typeof process.exitCode === 'number' ? process.exitCode : 1);
522
+ }
511
523
  };
512
524
  const analyzeCommandImpl = async (inputPath, cliOptions) => {
513
525
  console.log('\n GitNexus Analyzer\n');
@@ -867,12 +879,17 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
867
879
  aborted = true;
868
880
  bar.stop();
869
881
  console.log('\n Interrupted — cleaning up...');
870
- closeLbug()
871
- .catch(() => { })
872
- .finally(async () => {
873
- const { flushLoggerSync } = await import('../core/logger.js');
874
- flushLoggerSync();
875
- process.exit(130);
882
+ // Bounded CHECKPOINT-then-exit (#2264 review P3): skip the native close (the
883
+ // LadybugDB destructor can double-free after --pdg writes), but don't hang
884
+ // behind a long --pdg COPY holding the connection lock — bound it so a single
885
+ // Ctrl-C stays responsive; the WAL replays on the next analyze. A second
886
+ // Ctrl-C (`if (aborted) process.exit(1)` above) remains the escape hatch.
887
+ void boundedCheckpointBeforeExit({
888
+ exitCode: 130,
889
+ beforeExit: async () => {
890
+ const { flushLoggerSync } = await import('../core/logger.js');
891
+ flushLoggerSync();
892
+ },
876
893
  });
877
894
  };
878
895
  process.on('SIGINT', sigintHandler);
@@ -964,6 +981,12 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
964
981
  // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
965
982
  // forwarded to the routes phase consumer scan.
966
983
  fetchWrappers: options.fetchWrappers,
984
+ // The CLI always process.exit()s after this returns (success path at the
985
+ // end of analyzeCommandImpl, error/interrupt paths via process.exit too),
986
+ // so the finalize close skips the native conn/db close — it can double-free
987
+ // in LadybugDB's ClientContext destructor after --pdg writes (#2264). The
988
+ // CHECKPOINT keeps the index durable; process exit reclaims the handles.
989
+ skipNativeCloseOnExit: true,
967
990
  }, {
968
991
  onProgress: (_phase, percent, message) => {
969
992
  updateBar(percent, message);
@@ -583,8 +583,11 @@ const processBatch = (files, onProgress) => {
583
583
  setLanguage(language, regularFiles[0].path);
584
584
  processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
585
585
  }
586
- catch {
587
- // parser unavailable skip this language group
586
+ catch (err) {
587
+ // A throw here drops the whole language group — surface it to the pool
588
+ // (#2264) instead of silently skipping. The old empty catch hid real
589
+ // extractor/parser failures, not just an unavailable grammar.
590
+ reportWarning(`Skipped ${regularFiles.length} ${language} file(s) after a processing error: ${err instanceof Error ? err.message : String(err)}`);
588
591
  }
589
592
  }
590
593
  else {
@@ -599,8 +602,10 @@ const processBatch = (files, onProgress) => {
599
602
  setLanguage(language, tsxFiles[0].path);
600
603
  processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
601
604
  }
602
- catch {
603
- // parser unavailableskip this language group
605
+ catch (err) {
606
+ // See abovesurface a tsx-group processing failure rather than
607
+ // silently dropping every file in it (#2264).
608
+ reportWarning(`Skipped ${tsxFiles.length} ${language} (tsx) file(s) after a processing error: ${err instanceof Error ? err.message : String(err)}`);
604
609
  }
605
610
  }
606
611
  else {
@@ -745,6 +750,23 @@ export function extractORMQueries(filePath, content, out) {
745
750
  // per file; this module does not re-export it. Downstream consumers
746
751
  // import the function and its types directly from `route-extractors/`.
747
752
  import { extractFastAPIRouterBindings } from '../route-extractors/fastapi-router-bindings.js';
753
+ /**
754
+ * Report a non-fatal worker issue to the pool over IPC so a caught error is not
755
+ * invisible to the operator (#2264). The pool logs it on the main thread AND
756
+ * resets the worker idle timer (so a worker grinding through failing files isn't
757
+ * falsely idle-evicted). Falls back to the local logger when there's no parent —
758
+ * this code also runs on the main thread in tests / the non-worker path. Fatal,
759
+ * group-aborting errors go through the message handler's
760
+ * `{ type: 'error', errorStack }` channel instead.
761
+ */
762
+ function reportWarning(message) {
763
+ if (parentPort) {
764
+ parentPort.postMessage({ type: 'warning', message });
765
+ }
766
+ else {
767
+ logger.warn(message);
768
+ }
769
+ }
748
770
  const processFileGroup = (files, language, queryString, result, onFileProcessed) => {
749
771
  let query;
750
772
  try {
@@ -752,13 +774,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
752
774
  query = new Parser.Query(lang, queryString);
753
775
  }
754
776
  catch (err) {
755
- const message = `Query compilation failed for ${language}: ${err instanceof Error ? err.message : String(err)}`;
756
- if (parentPort) {
757
- parentPort.postMessage({ type: 'warning', message });
758
- }
759
- else {
760
- logger.warn(message);
761
- }
777
+ reportWarning(`Query compilation failed for ${language}: ${err instanceof Error ? err.message : String(err)}`);
762
778
  return;
763
779
  }
764
780
  for (const file of files) {
@@ -799,7 +815,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
799
815
  });
800
816
  }
801
817
  catch (err) {
802
- logger.warn(`Failed to parse file ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
818
+ reportWarning(`Failed to parse file ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
803
819
  continue;
804
820
  }
805
821
  result.fileCount++;
@@ -809,7 +825,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
809
825
  matches = query.matches(tree.rootNode);
810
826
  }
811
827
  catch (err) {
812
- logger.warn(`Query execution failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
828
+ reportWarning(`Query execution failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
813
829
  continue;
814
830
  }
815
831
  const concreteTypedefRanges = buildConcreteTypedefDefinitionRanges(matches);
@@ -822,14 +838,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
822
838
  // see parsedfile-store.ts). parse-impl flushes `result.parsedFiles` to disk
823
839
  // per chunk and does NOT retain them in main-thread heap, so this no longer
824
840
  // costs ~1× the semantic model in RAM during parse.
825
- const parsedFile = extractParsedFile(provider, parseContent, file.path, (message) => {
826
- if (parentPort) {
827
- parentPort.postMessage({ type: 'warning', message });
828
- }
829
- else {
830
- logger.warn(message);
831
- }
832
- }, tree, scopeSourceKind);
841
+ const parsedFile = extractParsedFile(provider, parseContent, file.path, reportWarning, tree, scopeSourceKind);
833
842
  if (parsedFile !== undefined) {
834
843
  // Capture-time side-channel (#1983): `extractParsedFile` just ran the
835
844
  // provider's `emitScopeCaptures`, which (for C++ ADL/namespace marks,
@@ -884,11 +893,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
884
893
  }
885
894
  }
886
895
  catch (err) {
887
- const message = `CFG build failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
888
- if (parentPort)
889
- parentPort.postMessage({ type: 'warning', message });
890
- else
891
- logger.warn(message);
896
+ reportWarning(`CFG build failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
892
897
  }
893
898
  }
894
899
  result.parsedFiles.push(withChannels);
@@ -1575,7 +1580,10 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
1575
1580
  constraintsTag = templateConstraintsIdTag(parsedTemplateConstraints);
1576
1581
  }
1577
1582
  }
1578
- catch {
1583
+ catch (err) {
1584
+ // Optional C++ template-constraint enrichment: fall back to no tag, but
1585
+ // surface the failure (#2264) — matches the CFG-build warning above.
1586
+ reportWarning(`Template-constraint extraction failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
1579
1587
  parsedTemplateConstraints = undefined;
1580
1588
  constraintsTag = '';
1581
1589
  }
@@ -0,0 +1 @@
1
+ export declare const withConnLock: <T>(fn: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Serialize every operation on the shared singleton LadybugDB connection.
3
+ *
4
+ * LadybugDB is single-writer and its `Connection` is NOT safe for concurrent
5
+ * query execution: dispatching two queries on one connection at the same time
6
+ * lets two libuv workers mutate shared native engine state at once, corrupting
7
+ * the heap. This surfaced as `double free or corruption (out)` / SIGSEGV at the
8
+ * end of `analyze --pdg`, where the periodic WAL-checkpoint driver
9
+ * (`wal-checkpoint-driver.ts`) fired `CHECKPOINT` on the same connection a
10
+ * long-running PDG-table COPY was still using. `--pdg` makes those COPYs outlast
11
+ * the driver's 5 s tick, so the overlap (rare without `--pdg`) becomes reliable.
12
+ *
13
+ * Every singleton-`conn` helper in `lbug-adapter.ts` runs its full query +
14
+ * result-drain inside this lock, so the checkpoint driver, the bulk COPY, the
15
+ * embedding writeback, and the PDG edge deletes are mutually exclusive — the
16
+ * property that makes a strictly-serial workload stable.
17
+ *
18
+ * Implementation: a promise chain. Each caller installs a fresh unresolved tail,
19
+ * awaits the previous holder's tail, runs, then releases its own in `finally`
20
+ * (so a thrown op never wedges the connection). FIFO and non-reentrant: a wrapped
21
+ * helper MUST NOT call another wrapped helper — the inner call would await its own
22
+ * holder's tail and deadlock. The re-entry guard below catches this and throws
23
+ * instead of hanging. A boolean flag can't do this: a legitimately-queued
24
+ * top-level caller also runs while the lock is held, so only AsyncLocalStorage —
25
+ * which marks the *async context* of the running `fn` — distinguishes a true
26
+ * nested call from normal contention.
27
+ */
28
+ import { AsyncLocalStorage } from 'node:async_hooks';
29
+ let tail = Promise.resolve();
30
+ // Set (to `true`) only inside a holding `fn`'s async context. A withConnLock call
31
+ // that observes it set is a nested/re-entrant call from within a critical section.
32
+ const inCriticalSection = new AsyncLocalStorage();
33
+ export const withConnLock = async (fn) => {
34
+ if (inCriticalSection.getStore()) {
35
+ throw new Error('conn-lock re-entry: a withConnLock-wrapped helper called another wrapped ' +
36
+ 'helper, which would deadlock the single LadybugDB connection. Run the inner ' +
37
+ 'work outside the lock, or inline it. See src/core/lbug/conn-lock.ts.');
38
+ }
39
+ const prior = tail;
40
+ let release;
41
+ tail = new Promise((resolve) => {
42
+ release = resolve;
43
+ });
44
+ await prior;
45
+ try {
46
+ return await inCriticalSection.run(true, fn);
47
+ }
48
+ finally {
49
+ release();
50
+ }
51
+ };
52
+ /**
53
+ * Test-only: reset the lock chain to a fresh resolved tail. Production code has
54
+ * no reason to call this — a leaked-but-resolved tail is harmless — but unit
55
+ * tests want a clean chain per case.
56
+ *
57
+ * @internal
58
+ */
59
+ export const _resetConnLockForTests = () => {
60
+ tail = Promise.resolve();
61
+ };
@@ -176,6 +176,27 @@ export declare const tryFlushWAL: () => Promise<boolean>;
176
176
  * @see closeLbug — safeClose + module state reset (full teardown)
177
177
  */
178
178
  export declare const safeClose: () => Promise<void>;
179
+ /**
180
+ * CHECKPOINT for durability, then DELIBERATELY skip the native connection/database
181
+ * teardown. The name encodes the contract — there is no boolean flag to misuse:
182
+ * call this ONLY from a path that guarantees a `process.exit` immediately after
183
+ * (the CLI analyze success/SIGINT paths and the forked worker).
184
+ *
185
+ * LadybugDB's ClientContext/Connection destructor can double-free after large
186
+ * --pdg writes (gdb: `double free or corruption` in ClientContext::~ClientContext
187
+ * via NodeConnection::Close), aborting the process AFTER a fully-written,
188
+ * checkpointed index. flushWAL already persisted the data; process exit reclaims
189
+ * the native handles. We leave the handles referenced and module state intact so a
190
+ * GC finalizer cannot run the same destructor before exit, and any post-analyze
191
+ * read reuses the live connection. Mirrors the pool adapter's fire-and-forget
192
+ * native teardown (pool-adapter.ts) and the ONNX native-cleanup philosophy.
193
+ * Workaround for a LadybugDB engine bug (to be reported upstream).
194
+ *
195
+ * SAFETY: only valid when a process.exit is guaranteed to follow. Long-lived
196
+ * callers (MCP server, tests) leave `skipNativeCloseOnExit` unset, so
197
+ * runFullAnalysis closes for real via {@link closeLbug} — never this.
198
+ */
199
+ export declare const closeLbugBeforeExit: () => Promise<void>;
179
200
  export declare const closeLbug: () => Promise<void>;
180
201
  export declare const isLbugReady: () => boolean;
181
202
  /**