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.
- package/dist/cli/analyze.js +30 -7
- package/dist/core/ingestion/cfg/visitors/python-harvest.d.ts +9 -11
- package/dist/core/ingestion/cfg/visitors/python-harvest.js +85 -52
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +5 -5
- package/dist/core/ingestion/taint/python-model.d.ts +10 -0
- package/dist/core/ingestion/taint/python-model.js +43 -0
- package/dist/core/ingestion/taint/typescript-model.d.ts +13 -5
- package/dist/core/ingestion/taint/typescript-model.js +20 -7
- package/dist/core/ingestion/workers/parse-worker.js +35 -27
- package/dist/core/lbug/conn-lock.d.ts +1 -0
- package/dist/core/lbug/conn-lock.js +61 -0
- package/dist/core/lbug/lbug-adapter.d.ts +21 -0
- package/dist/core/lbug/lbug-adapter.js +330 -196
- package/dist/core/lbug/shutdown-helpers.d.ts +18 -0
- package/dist/core/lbug/shutdown-helpers.js +36 -0
- package/dist/core/lbug/wal-checkpoint-driver.js +16 -0
- package/dist/core/lbug/wal-driver-state.d.ts +4 -0
- package/dist/core/lbug/wal-driver-state.js +23 -0
- package/dist/core/run-analyze.d.ts +9 -0
- package/dist/core/run-analyze.js +30 -5
- package/dist/server/analyze-job.js +6 -0
- package/dist/server/analyze-launch.js +7 -0
- package/dist/server/analyze-worker-core.d.ts +43 -0
- package/dist/server/analyze-worker-core.js +56 -0
- package/dist/server/analyze-worker.js +63 -32
- package/dist/storage/repo-manager.d.ts +14 -0
- package/dist/storage/repo-manager.js +24 -17
- package/package.json +1 -1
|
@@ -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;
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
855
|
+
return registryPathEquals(a, b);
|
|
849
856
|
});
|
|
850
857
|
if (pathMatch)
|
|
851
858
|
return pathMatch;
|
package/package.json
CHANGED