gitnexus 1.6.8 → 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.
@@ -0,0 +1,18 @@
1
+ /** Default cap so a CHECKPOINT queued behind a long COPY can't wedge the signal. */
2
+ export declare const DEFAULT_EXIT_CLEANUP_TIMEOUT_MS = 2000;
3
+ export interface BoundedCheckpointExitOptions {
4
+ /** Exit code to terminate with (130 for SIGINT, 0 for a worker SIGTERM). */
5
+ exitCode: number;
6
+ /** Cap on the CHECKPOINT; defaults to {@link DEFAULT_EXIT_CLEANUP_TIMEOUT_MS}. */
7
+ timeoutMs?: number;
8
+ /** Report a CHECKPOINT failure (e.g. over IPC) rather than swallowing it. */
9
+ onFlushError?: (err: unknown) => void;
10
+ /** Run just before exit (e.g. flush the logger synchronously). */
11
+ beforeExit?: () => void | Promise<void>;
12
+ }
13
+ /**
14
+ * Best-effort CHECKPOINT bounded by a timeout, then exit. Never rejects — the
15
+ * exit always fires (in `finally`) even if the CHECKPOINT throws. Fire-and-forget
16
+ * from a signal handler (`void boundedCheckpointBeforeExit({...})`).
17
+ */
18
+ export declare function boundedCheckpointBeforeExit(opts: BoundedCheckpointExitOptions): Promise<void>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared bounded "checkpoint, then exit" cleanup for interrupt/cancel signals
3
+ * (#2264). The CLI SIGINT handler and the forked worker's SIGTERM handler both
4
+ * need to: CHECKPOINT the WAL for durability (skipping the native close — see
5
+ * closeLbugBeforeExit), but NOT hang behind an in-flight COPY that holds the
6
+ * connection lock, and then exit. Bounding the CHECKPOINT with a short timeout
7
+ * keeps a single Ctrl-C / cancel responsive; the WAL replays on the next analyze.
8
+ */
9
+ import { closeLbugBeforeExit } from './lbug-adapter.js';
10
+ /** Default cap so a CHECKPOINT queued behind a long COPY can't wedge the signal. */
11
+ export const DEFAULT_EXIT_CLEANUP_TIMEOUT_MS = 2000;
12
+ /**
13
+ * Best-effort CHECKPOINT bounded by a timeout, then exit. Never rejects — the
14
+ * exit always fires (in `finally`) even if the CHECKPOINT throws. Fire-and-forget
15
+ * from a signal handler (`void boundedCheckpointBeforeExit({...})`).
16
+ */
17
+ export async function boundedCheckpointBeforeExit(opts) {
18
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_EXIT_CLEANUP_TIMEOUT_MS;
19
+ const checkpoint = opts.checkpoint ?? closeLbugBeforeExit;
20
+ const exit = opts.exit ?? ((code) => process.exit(code));
21
+ let timer;
22
+ try {
23
+ await Promise.race([
24
+ checkpoint().catch((err) => opts.onFlushError?.(err)),
25
+ new Promise((resolve) => {
26
+ timer = setTimeout(resolve, timeoutMs);
27
+ }),
28
+ ]);
29
+ }
30
+ finally {
31
+ if (timer !== undefined)
32
+ clearTimeout(timer);
33
+ await opts.beforeExit?.();
34
+ exit(opts.exitCode);
35
+ }
36
+ }
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { logger } from '../logger.js';
35
35
  import { tryFlushWAL } from './lbug-adapter.js';
36
+ import { markWalDriverActive } from './wal-driver-state.js';
36
37
  import { isLbugCheckpointIoError } from './lbug-config.js';
37
38
  /**
38
39
  * Bounded retry budget. Total worst-case wall time is dominated by the
@@ -120,9 +121,21 @@ export const startWalCheckpointDriver = (options = {}) => {
120
121
  const periodMs = options.periodMs ?? DEFAULT_PERIOD_MS;
121
122
  let stopped = false;
122
123
  let inflight = null;
124
+ // Arm the streamQuery guard: while this driver runs, an unlocked streamQuery on
125
+ // the singleton connection could race a CHECKPOINT (#2264). Cleared in stop().
126
+ markWalDriverActive(true);
123
127
  const tick = async () => {
124
128
  if (stopped)
125
129
  return;
130
+ // Reentrancy guard: setInterval keeps firing on its fixed cadence even when
131
+ // the previous checkpoint has not settled (a CHECKPOINT can outlast the
132
+ // period during a large `--pdg` writeback). Without this, each overdue tick
133
+ // would queue another CHECKPOINT — they now serialize on the connection lock
134
+ // (lbug-adapter `withConnLock`), but letting them pile up is still pointless
135
+ // work and widens the window for a backlog at stop(). Skip while one is in
136
+ // flight; the next tick covers any WAL accumulated in the meantime.
137
+ if (inflight)
138
+ return;
126
139
  inflight = runCheckpointWithRetry()
127
140
  .then(() => undefined)
128
141
  .catch((err) => {
@@ -167,6 +180,9 @@ export const startWalCheckpointDriver = (options = {}) => {
167
180
  /* swallowed in tick() — surface path is the surrounding write */
168
181
  }
169
182
  }
183
+ // Disarm AFTER the in-flight CHECKPOINT drains — clearing it earlier would
184
+ // briefly let a streamQuery race the still-finishing CHECKPOINT (#2264).
185
+ markWalDriverActive(false);
170
186
  },
171
187
  };
172
188
  };
@@ -0,0 +1,4 @@
1
+ /** Toggled by the WAL-checkpoint driver's start (true) / stop (false). */
2
+ export declare const markWalDriverActive: (active: boolean) => void;
3
+ /** True while the manual WAL-checkpoint driver is running. @see streamQuery */
4
+ export declare const isWalDriverActive: () => boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared "is the manual WAL-checkpoint driver running?" flag (#2264).
3
+ *
4
+ * Lives in its own tiny module — NOT in lbug-adapter — on purpose: the
5
+ * wal-checkpoint-driver toggles it and lbug-adapter's `streamQuery` reads it, and
6
+ * putting it here keeps that one-bit coupling out of the big, heavily-mocked
7
+ * lbug-adapter surface. (Importing it from lbug-adapter forced every test that
8
+ * mocks lbug-adapter + loads the real driver to also stub the toggle — a brittle
9
+ * ripple this module avoids.)
10
+ *
11
+ * streamQuery is deliberately not wrapped in withConnLock (its per-row callback can
12
+ * re-enter the adapter), so it must refuse to run while the driver is live —
13
+ * otherwise its unlocked per-row reads could race a CHECKPOINT on the shared
14
+ * connection. The serve/read path never starts the driver, so this stays false
15
+ * there.
16
+ */
17
+ let walDriverActive = false;
18
+ /** Toggled by the WAL-checkpoint driver's start (true) / stop (false). */
19
+ export const markWalDriverActive = (active) => {
20
+ walDriverActive = active;
21
+ };
22
+ /** True while the manual WAL-checkpoint driver is running. @see streamQuery */
23
+ export const isWalDriverActive = () => walDriverActive;
@@ -142,6 +142,15 @@ export interface AnalyzeOptions {
142
142
  * consumer scan unchanged.
143
143
  */
144
144
  fetchWrappers?: string[];
145
+ /**
146
+ * The caller will `process.exit()` immediately after this analyze returns (the
147
+ * CLI `analyze` command). When set, the finalize/error close CHECKPOINTs for
148
+ * durability but skips the native `conn.close()`/`db.close()`, which can
149
+ * double-free in LadybugDB's `ClientContext` destructor after large `--pdg`
150
+ * writes (gdb-confirmed) — aborting the process AFTER a fully-written index.
151
+ * Process exit reclaims the handles. Long-lived callers (MCP server, tests)
152
+ * leave this unset so they get a real close. See `closeLbug`. */
153
+ skipNativeCloseOnExit?: boolean;
145
154
  }
146
155
  export interface AnalyzeResult {
147
156
  repoName: string;
@@ -13,11 +13,11 @@ import fs from 'fs/promises';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { runPipelineFromRepo } from './ingestion/pipeline.js';
15
15
  import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
16
- import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
16
+ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
17
17
  import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
18
18
  import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
19
19
  import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
20
- import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, cleanupOldKuzuFiles, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
20
+ import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, isRepoRegistered, cleanupOldKuzuFiles, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
22
22
  import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION, } from './ingestion/cfg/emit.js';
23
23
  import { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './ingestion/taint/propagate.js';
@@ -463,7 +463,20 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
463
463
  return true; // conservative on git failure
464
464
  }
465
465
  })();
466
- if (!dirty) {
466
+ // Registration wrinkle around the fast path (#2264). A prior
467
+ // `analyze --name X` that hit a name collision writes meta.json (meta-save
468
+ // runs before registerRepo) then fails before registering, leaving the
469
+ // index up-to-date but UNREGISTERED. When the user re-runs with
470
+ // --allow-duplicate-name they explicitly want it registered, so fall
471
+ // through to the pipeline (which registers it, honoring the flag) instead
472
+ // of early-returning an unregistered repo the flag could never heal.
473
+ // For a PLAIN analyze we deliberately do NOT self-heal: an up-to-date but
474
+ // unregistered repo early-returns here and the CLI's assertAnalysisFinalized
475
+ // surfaces it as a hard failure (#1169) rather than silently registering a
476
+ // possibly half-finalized index. `isRepoRegistered` is only read on the
477
+ // opt-in branch so the common fast path keeps its single-stat cost.
478
+ const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath));
479
+ if (!dirty && !healUnregistered) {
467
480
  await ensureGitNexusIgnored(repoPath);
468
481
  return {
469
482
  // `resolveRepoIdentityRoot` collapses worktree roots to the
@@ -1152,7 +1165,11 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1152
1165
  // Stop the manual checkpoint driver before closeLbug so its
1153
1166
  // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
1154
1167
  await walCheckpointDriver.stop();
1155
- await closeLbug();
1168
+ // CLI callers (about to process.exit) skip the native close to dodge a
1169
+ // LadybugDB destructor double-free after --pdg writes — closeLbugBeforeExit
1170
+ // CHECKPOINTs for durability then leaves the handles for process exit to
1171
+ // reclaim (#2264). Long-lived callers close for real.
1172
+ await (options.skipNativeCloseOnExit ? closeLbugBeforeExit() : closeLbug());
1156
1173
  progress('done', 100, 'Done');
1157
1174
  return {
1158
1175
  repoName: projectName,
@@ -1173,7 +1190,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1173
1190
  /* swallow — surface path is the rethrow below */
1174
1191
  }
1175
1192
  try {
1176
- await closeLbug();
1193
+ // Skip the native close on the error path too: a real conn.close() after
1194
+ // large --pdg writes can itself abort in LadybugDB's ClientContext
1195
+ // destructor (#2264 review P2), turning an actionable exit-1 into a raw
1196
+ // SIGABRT. closeLbugBeforeExit leaves the handles open, but the CLI catch
1197
+ // now force-exits when isLbugReady() (analyze.ts, #2264 review P1), so the
1198
+ // process still terminates — no hang, no abort. flushWAL keeps the partial
1199
+ // index durable; process exit reclaims the handles. Long-lived callers
1200
+ // (skipNativeCloseOnExit unset) close for real.
1201
+ await (options.skipNativeCloseOnExit ? closeLbugBeforeExit() : closeLbug());
1177
1202
  }
1178
1203
  catch {
1179
1204
  /* swallow */
@@ -63,6 +63,12 @@ export class JobManager {
63
63
  const job = this.jobs.get(id);
64
64
  if (!job)
65
65
  return;
66
+ // Once a job is terminal (complete/failed) its outcome is immutable — drop any
67
+ // later update so a worker `complete` racing a SIGTERM-driven `error` (or vice
68
+ // versa) can't flip a reported result (#2264 P3). The transition INTO a terminal
69
+ // state still applies because `job.status` is not yet terminal at that point.
70
+ if (this.isTerminal(job.status))
71
+ return;
66
72
  Object.assign(job, update);
67
73
  if (this.isTerminal(job.status)) {
68
74
  job.completedAt = job.completedAt ?? Date.now();
@@ -52,6 +52,13 @@ export function createLaunchAnalysisWorker(deps) {
52
52
  stderrChunks = stderrChunks.slice(-4096);
53
53
  });
54
54
  child.on('message', (msg) => {
55
+ // Ignore any message once the job is terminal — a late worker message (a
56
+ // SIGTERM-driven `error` after `complete`, or vice versa) must not
57
+ // re-release the repo lock or flip the reported status. Mirrors the `exit`
58
+ // handler guard below; pairs with the worker's terminal-claim (#2264 P3).
59
+ const current = jobManager.getJob(job.id);
60
+ if (!current || current.status === 'complete' || current.status === 'failed')
61
+ return;
55
62
  if (msg.type === 'progress') {
56
63
  jobManager.updateJob(job.id, {
57
64
  status: 'analyzing',
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Side-effect-free core of the analyze worker's message handler.
3
+ *
4
+ * Extracted from `analyze-worker.ts` — a `fork()` entry module whose top-level
5
+ * `process.on(...)` handlers and `ready` handshake make it unsafe to import in a
6
+ * unit test. This module has no top-level side effects and takes its collaborators
7
+ * by dependency injection, so the worker's run → finalize → report contract is
8
+ * unit-testable without spawning a process. The entry module wires the real deps
9
+ * and owns the `process.exit` lifecycle.
10
+ *
11
+ * The `import type ... typeof import(...)` forms below are erased at runtime, so
12
+ * importing this module does NOT load `run-analyze`, `repo-manager`, or the entry
13
+ * worker — only the lightweight `analyze-worker-ipc` projection helper.
14
+ */
15
+ import type { AnalyzeOptions } from '../core/run-analyze.js';
16
+ import type { WorkerMessage } from './analyze-worker.js';
17
+ export interface WorkerAnalysisDeps {
18
+ runFullAnalysis: typeof import('../core/run-analyze.js').runFullAnalysis;
19
+ assertAnalysisFinalized: typeof import('../storage/repo-manager.js').assertAnalysisFinalized;
20
+ send: (msg: WorkerMessage) => void;
21
+ /**
22
+ * Claim the single terminal-outcome slot. Returns `true` for the first caller
23
+ * (which may then send its `complete`/`error`) and `false` for every caller
24
+ * after — so a SIGTERM cancellation and a near-simultaneous completion can't
25
+ * both report a terminal outcome (#2264 P3). See {@link createTerminalClaim}.
26
+ */
27
+ claimTerminal: () => boolean;
28
+ }
29
+ /**
30
+ * Run the analysis and report the outcome to the parent over IPC. Reports at most
31
+ * one terminal message (`complete` or `error`) — and none if a cancellation
32
+ * already claimed the terminal slot — and never throws; the caller schedules
33
+ * `process.exit` after this resolves.
34
+ */
35
+ export declare function runWorkerAnalysis(repoPath: string, options: AnalyzeOptions, deps: WorkerAnalysisDeps): Promise<void>;
36
+ /**
37
+ * Create the single-use terminal-outcome claim shared by the worker's message
38
+ * handler and its SIGTERM handler. The first call returns `true`; every later
39
+ * call returns `false`. This is the coordination point that prevents a cancel and
40
+ * a completion from both reporting a terminal status (#2264 P3). Single-threaded
41
+ * JS guarantees the check-and-set is atomic (no preemption mid-call).
42
+ */
43
+ export declare function createTerminalClaim(): () => boolean;
@@ -0,0 +1,56 @@
1
+ import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
2
+ /**
3
+ * Run the analysis and report the outcome to the parent over IPC. Reports at most
4
+ * one terminal message (`complete` or `error`) — and none if a cancellation
5
+ * already claimed the terminal slot — and never throws; the caller schedules
6
+ * `process.exit` after this resolves.
7
+ */
8
+ export async function runWorkerAnalysis(repoPath, options, deps) {
9
+ let terminal;
10
+ try {
11
+ const result = await deps.runFullAnalysis(repoPath,
12
+ // This worker force-exits right after reporting, so skip the native close
13
+ // (it can double-free in LadybugDB's ClientContext destructor after --pdg
14
+ // writes); flushWAL still persists the index, process.exit reclaims handles.
15
+ { ...options, skipNativeCloseOnExit: true }, {
16
+ onProgress: (phase, percent, message) => deps.send({ type: 'progress', phase, percent, message }),
17
+ onLog: (message) => deps.send({ type: 'progress', phase: 'log', percent: -1, message }),
18
+ });
19
+ // P2 (#2264): a half-finalized repo — meta.json written but the global
20
+ // registry entry missing (e.g. a prior collision-aborted run, or a wiped
21
+ // registry) — must NOT be reported as a successful analysis. Mirror the CLI's
22
+ // assertAnalysisFinalized guard so the worker surfaces it as an error instead
23
+ // of a false `complete` that leaves the repo invisible to list_repos.
24
+ await deps.assertAnalysisFinalized(repoPath);
25
+ // Send a JSON-safe projection, NOT the raw result: the IPC channel is
26
+ // default-JSON serialization and `result.pipelineResult` carries the live
27
+ // KnowledgeGraph. See analyze-worker-ipc.ts.
28
+ terminal = { type: 'complete', result: projectAnalyzeResultForIpc(result) };
29
+ }
30
+ catch (err) {
31
+ // Report the failure to the parent over IPC (the parent surfaces the message).
32
+ const message = err instanceof Error ? err.message : 'Analysis failed';
33
+ terminal = { type: 'error', message };
34
+ }
35
+ // P3 (#2264): only report if a SIGTERM cancellation hasn't already claimed the
36
+ // terminal slot — otherwise a cancel near the finish line would report the
37
+ // analysis as `complete` over the top of the cancellation.
38
+ if (deps.claimTerminal())
39
+ deps.send(terminal);
40
+ }
41
+ /**
42
+ * Create the single-use terminal-outcome claim shared by the worker's message
43
+ * handler and its SIGTERM handler. The first call returns `true`; every later
44
+ * call returns `false`. This is the coordination point that prevents a cancel and
45
+ * a completion from both reporting a terminal status (#2264 P3). Single-threaded
46
+ * JS guarantees the check-and-set is atomic (no preemption mid-call).
47
+ */
48
+ export function createTerminalClaim() {
49
+ let claimed = false;
50
+ return () => {
51
+ if (claimed)
52
+ return false;
53
+ claimed = true;
54
+ return true;
55
+ };
56
+ }
@@ -11,28 +11,63 @@
11
11
  * Child -> Parent: { type: 'error', message: string }
12
12
  */
13
13
  import { runFullAnalysis } from '../core/run-analyze.js';
14
- import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
15
- import { closeLbug } from '../core/lbug/lbug-adapter.js';
14
+ import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js';
15
+ import { assertAnalysisFinalized } from '../storage/repo-manager.js';
16
+ import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
16
17
  function send(msg) {
18
+ // No try/catch: if the IPC channel is gone, process.send throws
19
+ // (ERR_IPC_CHANNEL_CLOSED) and that failure must NOT be swallowed. Every caller
20
+ // schedules its process.exit inside a `finally`, so a throw here still tears the
21
+ // worker down deterministically instead of wedging the event loop (#2264 P3).
17
22
  process.send?.(msg);
18
23
  }
19
- // Catch uncaught exceptions and unhandled rejections report to parent
24
+ // Single terminal-outcome slot shared by the message handler and the SIGTERM
25
+ // handler: whoever claims it first reports its complete/error; the other skips its
26
+ // terminal send, so a cancel near the finish line can't also report success and a
27
+ // late SIGTERM can't flip an already-reported job (#2264 P3).
28
+ const claimTerminal = createTerminalClaim();
29
+ // Catch uncaught exceptions and unhandled rejections — report them to the parent
30
+ // over IPC (the same channel the analysis path uses), then exit. The report runs
31
+ // in `try` and the exit in `finally` so a throw from send() on a closed channel
32
+ // can't skip the exit and leave the worker wedged (#2264 review P3).
20
33
  process.on('uncaughtException', (err) => {
21
- send({ type: 'error', message: err?.message || 'Uncaught exception in worker' });
22
- setTimeout(() => process.exit(1), 500);
34
+ try {
35
+ const message = err instanceof Error ? err.message : 'Uncaught exception in worker';
36
+ send({ type: 'error', message });
37
+ }
38
+ finally {
39
+ setTimeout(() => process.exit(1), 500);
40
+ }
23
41
  });
24
42
  process.on('unhandledRejection', (reason) => {
25
- send({ type: 'error', message: reason?.message || 'Unhandled rejection in worker' });
26
- setTimeout(() => process.exit(1), 500);
27
- });
28
- // Handle graceful shutdown — notify parent before exit
29
- process.on('SIGTERM', async () => {
30
- send({ type: 'error', message: 'Analysis cancelled (worker received SIGTERM)' });
31
43
  try {
32
- await closeLbug();
44
+ const message = reason instanceof Error ? reason.message : 'Unhandled rejection in worker';
45
+ send({ type: 'error', message });
46
+ }
47
+ finally {
48
+ setTimeout(() => process.exit(1), 500);
49
+ }
50
+ });
51
+ // Handle cancellation / timeout shutdown (analyze-job.ts `cancelJob` sends
52
+ // SIGTERM). Bounded CHECKPOINT-then-exit shared with the CLI SIGINT path (#2264):
53
+ // skip the native close (the LadybugDB destructor can double-free after --pdg
54
+ // writes), but don't block behind the in-flight COPY's connection lock — so a
55
+ // single cancel can't abort or hang the worker. A CHECKPOINT failure is reported
56
+ // to the parent over IPC, not swallowed; the exit always fires.
57
+ process.on('SIGTERM', () => {
58
+ // Only report the cancellation if the analysis hasn't already reported a
59
+ // terminal outcome (#2264 P3) — otherwise this would flip an already-complete
60
+ // job to failed. The cleanup + exit below run regardless.
61
+ if (claimTerminal()) {
62
+ send({ type: 'error', message: 'Analysis cancelled (worker received SIGTERM)' });
33
63
  }
34
- catch { }
35
- process.exit(0);
64
+ void boundedCheckpointBeforeExit({
65
+ exitCode: 0,
66
+ onFlushError: (err) => {
67
+ const message = err instanceof Error ? err.message : 'Worker checkpoint failed during SIGTERM';
68
+ send({ type: 'error', message });
69
+ },
70
+ });
36
71
  });
37
72
  // Listen for start command from parent — guarded against re-entry
38
73
  let started = false;
@@ -41,25 +76,21 @@ process.on('message', async (msg) => {
41
76
  return;
42
77
  started = true;
43
78
  try {
44
- const result = await runFullAnalysis(msg.repoPath, msg.options, {
45
- onProgress: (phase, percent, message) => {
46
- send({ type: 'progress', phase, percent, message });
47
- },
48
- onLog: (message) => {
49
- send({ type: 'progress', phase: 'log', percent: -1, message });
50
- },
79
+ // The run finalize report contract lives in the side-effect-free
80
+ // analyze-worker-core seam (unit-testable without this entry module's
81
+ // process.on side effects). It reports exactly one terminal message and
82
+ // never throws.
83
+ await runWorkerAnalysis(msg.repoPath, msg.options, {
84
+ runFullAnalysis,
85
+ assertAnalysisFinalized,
86
+ send,
87
+ claimTerminal,
51
88
  });
52
- // Send a JSON-safe projection, NOT the raw result: the IPC channel is
53
- // default-JSON serialization and `result.pipelineResult` carries the live
54
- // KnowledgeGraph (wasteful to materialize, silently corrupted by JSON, and
55
- // a BigInt/circular value would throw and mis-report this success as a
56
- // failure). See analyze-worker-ipc.ts.
57
- send({ type: 'complete', result: projectAnalyzeResultForIpc(result) });
58
89
  }
59
- catch (err) {
60
- send({ type: 'error', message: err?.message || 'Analysis failed' });
90
+ finally {
91
+ // LadybugDB's native module prevents clean exit — force it (same reason the
92
+ // CLI uses process.exit(0)). In `finally` so the exit still fires even if the
93
+ // report above throws on a closed IPC channel (#2264 review P3).
94
+ setTimeout(() => process.exit(0), 500);
61
95
  }
62
- // LadybugDB's native module prevents clean exit — force it
63
- // (same reason the CLI uses process.exit(0))
64
- setTimeout(() => process.exit(0), 500);
65
96
  });
@@ -39,6 +39,13 @@ export type { BranchSummary };
39
39
  * so the registry stabilises over analyze/re-analyze cycles.
40
40
  */
41
41
  export declare const canonicalizePath: (p: string) => string;
42
+ /**
43
+ * Compare two already-canonicalised registry paths. Case-insensitive on Windows
44
+ * (its filesystem is), case-sensitive elsewhere. Both arguments must already be
45
+ * run through {@link canonicalizePath}; this is the single comparison the registry
46
+ * lookups/dedup/finalize checks all share so they answer identically.
47
+ */
48
+ export declare const registryPathEquals: (a: string, b: string) => boolean;
42
49
  export interface RepoMeta {
43
50
  repoPath: string;
44
51
  lastCommit: string;
@@ -476,6 +483,13 @@ export declare class AnalysisNotFinalizedError extends Error {
476
483
  readonly kind: "AnalysisNotFinalizedError";
477
484
  constructor(repoPath: string, storagePath: string, missing: 'meta' | 'registry-entry', registryPath: string);
478
485
  }
486
+ /**
487
+ * True when the global registry already contains an entry whose canonical path
488
+ * matches `repoPath`. Uses the same canonical, case-folded (Windows) comparison
489
+ * as {@link assertAnalysisFinalized} so "is it registered?" answers identically
490
+ * at the analyze fast-path gate and at the finalize assertion. Pure read.
491
+ */
492
+ export declare const isRepoRegistered: (repoPath: string) => Promise<boolean>;
479
493
  /**
480
494
  * Verify that a successful `analyze` call actually produced an indexed,
481
495
  * registered repo on disk. Two checks, both strictly required:
@@ -54,6 +54,13 @@ export const canonicalizePath = (p) => {
54
54
  return resolved;
55
55
  }
56
56
  };
57
+ /**
58
+ * Compare two already-canonicalised registry paths. Case-insensitive on Windows
59
+ * (its filesystem is), case-sensitive elsewhere. Both arguments must already be
60
+ * run through {@link canonicalizePath}; this is the single comparison the registry
61
+ * lookups/dedup/finalize checks all share so they answer identically.
62
+ */
63
+ export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
57
64
  /**
58
65
  * Bumped whenever incremental-indexing invariants change incompatibly.
59
66
  * v2: `BasicBlock.callees` column added (statement-precise inter-procedural
@@ -445,7 +452,7 @@ export const registerRepo = async (repoPath, meta, opts) => {
445
452
  // to a stable key instead of throwing.
446
453
  const a = canonicalizePath(e.path);
447
454
  const b = canonicalInput;
448
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
455
+ return registryPathEquals(a, b);
449
456
  });
450
457
  const existing = existingIdx >= 0 ? entries[existingIdx] : null;
451
458
  // Precedence: explicit --name > preserved alias > remote-inferred > basename.
@@ -548,9 +555,7 @@ export const registerRepo = async (repoPath, meta, opts) => {
548
555
  const fresh = await readRegistry();
549
556
  const freshIdx = fresh.findIndex((e) => {
550
557
  const a = canonicalizePath(e.path);
551
- return process.platform === 'win32'
552
- ? a.toLowerCase() === canonicalInput.toLowerCase()
553
- : a === canonicalInput;
558
+ return registryPathEquals(a, canonicalInput);
554
559
  });
555
560
  const freshExisting = freshIdx >= 0 ? fresh[freshIdx] : null;
556
561
  let merged;
@@ -591,8 +596,7 @@ export const unregisterRepo = async (repoPath) => {
591
596
  // `resolveRegistryEntry` post-#1003 review.
592
597
  const resolved = canonicalizePath(repoPath);
593
598
  const entries = await readRegistry();
594
- const matches = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
595
- const filtered = entries.filter((e) => !matches(canonicalizePath(e.path), resolved));
599
+ const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
596
600
  await writeRegistry(filtered);
597
601
  };
598
602
  /**
@@ -605,9 +609,8 @@ export const unregisterRepo = async (repoPath) => {
605
609
  */
606
610
  export const removeBranchIndex = async (repoPath, branch) => {
607
611
  const resolved = canonicalizePath(repoPath);
608
- const matches = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
609
612
  const entries = await readRegistry();
610
- const idx = entries.findIndex((e) => matches(canonicalizePath(e.path), resolved));
613
+ const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
611
614
  if (idx < 0)
612
615
  return false;
613
616
  const entry = entries[idx];
@@ -708,6 +711,17 @@ export class AnalysisNotFinalizedError extends Error {
708
711
  this.name = 'AnalysisNotFinalizedError';
709
712
  }
710
713
  }
714
+ /**
715
+ * True when the global registry already contains an entry whose canonical path
716
+ * matches `repoPath`. Uses the same canonical, case-folded (Windows) comparison
717
+ * as {@link assertAnalysisFinalized} so "is it registered?" answers identically
718
+ * at the analyze fast-path gate and at the finalize assertion. Pure read.
719
+ */
720
+ export const isRepoRegistered = async (repoPath) => {
721
+ const entries = await readRegistry();
722
+ const canonicalInput = canonicalizePath(path.resolve(repoPath));
723
+ return entries.some((e) => registryPathEquals(canonicalizePath(e.path), canonicalInput));
724
+ };
711
725
  /**
712
726
  * Verify that a successful `analyze` call actually produced an indexed,
713
727
  * registered repo on disk. Two checks, both strictly required:
@@ -731,14 +745,7 @@ export const assertAnalysisFinalized = async (repoPath) => {
731
745
  catch {
732
746
  throw new AnalysisNotFinalizedError(resolved, storagePath, 'meta', getGlobalRegistryPath());
733
747
  }
734
- const entries = await readRegistry();
735
- const canonicalInput = canonicalizePath(resolved);
736
- const isWin = process.platform === 'win32';
737
- const found = entries.some((e) => {
738
- const a = canonicalizePath(e.path);
739
- return isWin ? a.toLowerCase() === canonicalInput.toLowerCase() : a === canonicalInput;
740
- });
741
- if (!found) {
748
+ if (!(await isRepoRegistered(resolved))) {
742
749
  throw new AnalysisNotFinalizedError(resolved, storagePath, 'registry-entry', getGlobalRegistryPath());
743
750
  }
744
751
  };
@@ -845,7 +852,7 @@ export const resolveRegistryEntry = (entries, target) => {
845
852
  const pathMatch = entries.find((e) => {
846
853
  const a = canonicalizePath(e.path);
847
854
  const b = canonicalTarget;
848
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
855
+ return registryPathEquals(a, b);
849
856
  });
850
857
  if (pathMatch)
851
858
  return pathMatch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8",
3
+ "version": "1.6.9-rc.2",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",