gitnexus 1.6.10-rc.18 → 1.6.10-rc.19

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/README.md CHANGED
@@ -535,6 +535,8 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t
535
535
  | `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
536
536
  | `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
537
537
  | `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
538
+ | `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
539
+ | `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
538
540
 
539
541
  ### Graph cleanup tuning
540
542
 
@@ -243,5 +243,5 @@ export declare const en: {
243
243
  readonly 'help.option.group.contracts.type': "Filter by contract type";
244
244
  readonly 'help.option.group.contracts.repo': "Filter by repo";
245
245
  readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
246
- readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
246
+ readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
247
247
  };
@@ -243,5 +243,5 @@ export const en = {
243
243
  'help.option.group.contracts.type': 'Filter by contract type',
244
244
  'help.option.group.contracts.repo': 'Filter by repo',
245
245
  'help.option.group.contracts.unmatched': 'Show only unmatched contracts',
246
- 'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
246
+ 'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
247
247
  };
@@ -244,7 +244,7 @@ export declare const cliResources: {
244
244
  readonly 'help.option.group.contracts.type': "Filter by contract type";
245
245
  readonly 'help.option.group.contracts.repo': "Filter by repo";
246
246
  readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
247
- readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
247
+ readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
248
248
  };
249
249
  readonly 'zh-CN': {
250
250
  'common.notIndexed': string;
@@ -243,5 +243,5 @@ export const zhCN = {
243
243
  'help.option.group.contracts.type': '按契约类型过滤',
244
244
  'help.option.group.contracts.repo': '按仓库过滤',
245
245
  'help.option.group.contracts.unmatched': '仅显示未匹配契约',
246
- 'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
246
+ 'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
247
247
  };
@@ -12,6 +12,27 @@ import { markCppInlineNamespaceRange } from './inline-namespaces.js';
12
12
  import { extractCppTemplateConstraints } from './constraint-extractor.js';
13
13
  import { captureCppMemberLookupFacts } from './member-lookup.js';
14
14
  import { CPP_BRACED_INIT_TYPE_PREFIX } from './conversion-rank.js';
15
+ import { logger } from '../../../logger.js';
16
+ /**
17
+ * Per-file wall-clock budget for the capture-emit loop (#2432). A worker
18
+ * thread stuck in this loop cannot be terminated safely (terminating a
19
+ * thread mid-N-API call aborts the whole process with Napi::Error), so the
20
+ * loop must bound itself: on breach we return the captures accumulated so
21
+ * far with a warning — degraded coverage for one file, never a crash or a
22
+ * thrown error (a throw here would make the language-group catch drop every
23
+ * remaining file in the batch).
24
+ *
25
+ * `GITNEXUS_CPP_CAPTURE_BUDGET_MS`: unset/invalid/negative → 20000; explicit
26
+ * 0 → expires immediately (deterministic test hook).
27
+ */
28
+ const CPP_CAPTURE_BUDGET_DEFAULT_MS = 20_000;
29
+ function cppCaptureBudgetMs() {
30
+ const raw = process.env.GITNEXUS_CPP_CAPTURE_BUDGET_MS;
31
+ if (raw === undefined || raw === '')
32
+ return CPP_CAPTURE_BUDGET_DEFAULT_MS;
33
+ const parsed = Number(raw);
34
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : CPP_CAPTURE_BUDGET_DEFAULT_MS;
35
+ }
15
36
  export function emitCppScopeCaptures(sourceText, filePath, cachedTree) {
16
37
  let tree = cachedTree;
17
38
  if (tree === undefined) {
@@ -21,10 +42,27 @@ export function emitCppScopeCaptures(sourceText, filePath, cachedTree) {
21
42
  }
22
43
  const rawMatches = getCppScopeQuery().matches(tree.rootNode);
23
44
  const out = [];
45
+ // #2432: reset the per-file lookup index. The identifier-argument type
46
+ // lookups below used to re-walk the AST per identifier (full-tree DFS in
47
+ // isKnownEnumName, per-scope declaration scans) — O(calls × args × treeSize)
48
+ // per file, 151s on a 194KB file that parses in 46ms. The index makes each
49
+ // lookup O(1) after a single lazily-built pass.
50
+ resetCppFileLookupIndex();
24
51
  // Track ranges where typedef-struct/enum was captured as its concrete type
25
52
  // so we can suppress the duplicate @declaration.typedef match.
26
53
  const concreteTypedefRanges = new Set();
54
+ // #2432: per-file deadline for the loop below (see cppCaptureBudgetMs).
55
+ // Checked every 64 matches — post-index a single iteration is microseconds,
56
+ // so the check granularity costs nothing and bounds the drift past the
57
+ // deadline to well under a second.
58
+ const budgetMs = cppCaptureBudgetMs();
59
+ const deadline = Date.now() + budgetMs;
60
+ let matchIndex = 0;
27
61
  for (const m of rawMatches) {
62
+ if ((matchIndex++ & 63) === 0 && Date.now() >= deadline) {
63
+ logger.warn({ filePath, budgetMs, processedMatches: matchIndex - 1, totalMatches: rawMatches.length }, `C++ capture extraction exceeded its ${budgetMs}ms budget for ${filePath}; returning partial captures for this file (#2432).`);
64
+ break;
65
+ }
28
66
  const grouped = {};
29
67
  // Parallel tag -> captured SyntaxNode map. The tree-sitter query already
30
68
  // hands us each matched node as `c.node`, so anchors resolve via a
@@ -960,6 +998,47 @@ function inferCppBracedInitType(node) {
960
998
  ? `${CPP_BRACED_INIT_TYPE_PREFIX}${first}:${elementTypes.length}`
961
999
  : `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:${elementTypes.length}`;
962
1000
  }
1001
+ let fileLookupIndex = {
1002
+ enumNames: null,
1003
+ scopeDecls: new Map(),
1004
+ fnParams: new Map(),
1005
+ };
1006
+ function resetCppFileLookupIndex() {
1007
+ fileLookupIndex = { enumNames: null, scopeDecls: new Map(), fnParams: new Map() };
1008
+ }
1009
+ /**
1010
+ * First-declaration-wins map of the scope's `declaration` children that
1011
+ * carry a concrete (non-placeholder) type and a nameable declarator —
1012
+ * exactly the entries the replaced per-identifier scans could match.
1013
+ */
1014
+ function scopeDeclarationsFor(scope) {
1015
+ const cached = fileLookupIndex.scopeDecls.get(scope.id);
1016
+ if (cached !== undefined)
1017
+ return cached;
1018
+ const decls = new Map();
1019
+ for (let i = 0; i < scope.childCount; i++) {
1020
+ const stmt = scope.child(i);
1021
+ if (stmt === null || stmt.type !== 'declaration')
1022
+ continue;
1023
+ const typeNode = stmt.childForFieldName('type');
1024
+ if (typeNode === null)
1025
+ continue;
1026
+ if (typeNode.type === 'placeholder_type_specifier')
1027
+ continue;
1028
+ const declarator = stmt.childForFieldName('declarator');
1029
+ if (declarator === null)
1030
+ continue;
1031
+ const nameChild = declaredNameNode(declarator);
1032
+ if (nameChild === null)
1033
+ continue;
1034
+ const name = extractDeclaratorLeafName(nameChild);
1035
+ if (name === '' || decls.has(name))
1036
+ continue;
1037
+ decls.set(name, stmt);
1038
+ }
1039
+ fileLookupIndex.scopeDecls.set(scope.id, decls);
1040
+ return decls;
1041
+ }
963
1042
  /**
964
1043
  * Look up the declared type of a variable by scanning sibling declarations
965
1044
  * in the enclosing compound_statement (function body). Handles:
@@ -991,27 +1070,14 @@ function lookupDeclaredTypeForIdentifier(identNode) {
991
1070
  const paramType = lookupFunctionParameterType(scope, varName);
992
1071
  if (paramType !== '')
993
1072
  return paramType;
994
- // Scan declarations in the scope for a matching variable name
995
- for (let i = 0; i < scope.childCount; i++) {
996
- const stmt = scope.child(i);
997
- if (stmt === null || stmt.type !== 'declaration')
998
- continue;
999
- const typeNode = stmt.childForFieldName('type');
1000
- if (typeNode === null)
1001
- continue;
1002
- // Skip auto/placeholder types — those need chain-follow, not literal
1003
- if (typeNode.type === 'placeholder_type_specifier')
1004
- continue;
1005
- // Check init_declarator children for the variable name
1006
- const declarator = stmt.childForFieldName('declarator');
1007
- if (declarator === null)
1008
- continue;
1009
- const nameChild = declaredNameNode(declarator);
1010
- if (nameChild !== null && extractDeclaratorLeafName(nameChild) === varName) {
1011
- return normalizeCppTypeText(typeNode.text);
1012
- }
1013
- }
1014
- return '';
1073
+ // Indexed scope-declaration lookup (#2432; was a per-identifier scan).
1074
+ const stmt = scopeDeclarationsFor(scope).get(varName);
1075
+ if (stmt === undefined)
1076
+ return '';
1077
+ const typeNode = stmt.childForFieldName('type');
1078
+ if (typeNode === null)
1079
+ return '';
1080
+ return normalizeCppTypeText(typeNode.text);
1015
1081
  }
1016
1082
  function lookupDeclaredTypeClassForIdentifier(identNode) {
1017
1083
  const varName = identNode.text;
@@ -1026,28 +1092,20 @@ function lookupDeclaredTypeClassForIdentifier(identNode) {
1026
1092
  const paramTypeClass = lookupFunctionParameterTypeClass(scope, varName, identNode);
1027
1093
  if (paramTypeClass !== undefined)
1028
1094
  return paramTypeClass;
1029
- for (let i = 0; i < scope.childCount; i++) {
1030
- const stmt = scope.child(i);
1031
- if (stmt === null || stmt.type !== 'declaration')
1032
- continue;
1033
- const typeNode = stmt.childForFieldName('type');
1034
- if (typeNode === null)
1035
- continue;
1036
- if (typeNode.type === 'placeholder_type_specifier')
1037
- continue;
1038
- const declarator = stmt.childForFieldName('declarator');
1039
- if (declarator === null)
1040
- continue;
1041
- const nameChild = declaredNameNode(declarator);
1042
- if (nameChild === null || extractDeclaratorLeafName(nameChild) !== varName)
1043
- continue;
1044
- const typeClass = classifyCppParameterType(typeNode.text, nameChild.text, stmt.text.replace(/;\s*$/, ''));
1045
- if (isKnownEnumName(identNode, typeClass.base)) {
1046
- return { ...typeClass, base: `enum:${typeClass.base}` };
1047
- }
1048
- return typeClass;
1095
+ // Indexed scope-declaration lookup (#2432; was a per-identifier scan).
1096
+ const stmt = scopeDeclarationsFor(scope).get(varName);
1097
+ if (stmt === undefined)
1098
+ return unknownTypeClass('unknown');
1099
+ const typeNode = stmt.childForFieldName('type');
1100
+ const declarator = stmt.childForFieldName('declarator');
1101
+ const nameChild = declarator !== null ? declaredNameNode(declarator) : null;
1102
+ if (typeNode === null || nameChild === null)
1103
+ return unknownTypeClass('unknown');
1104
+ const typeClass = classifyCppParameterType(typeNode.text, nameChild.text, stmt.text.replace(/;\s*$/, ''));
1105
+ if (isKnownEnumName(identNode, typeClass.base)) {
1106
+ return { ...typeClass, base: `enum:${typeClass.base}` };
1049
1107
  }
1050
- return unknownTypeClass('unknown');
1108
+ return typeClass;
1051
1109
  }
1052
1110
  function lookupFunctionParameterType(scope, varName) {
1053
1111
  const param = findEnclosingFunctionParameter(scope, varName);
@@ -1078,27 +1136,45 @@ function findEnclosingFunctionParameter(scope, varName) {
1078
1136
  let node = scope.parent;
1079
1137
  while (node !== null) {
1080
1138
  if (node.type === 'function_definition' || node.type === 'function_declarator') {
1081
- const fnDecl = node.type === 'function_declarator'
1082
- ? node
1083
- : findFirstDescendantOfType(node, 'function_declarator');
1084
- const params = fnDecl?.childForFieldName('parameters') ?? null;
1085
- if (params !== null) {
1086
- for (let i = 0; i < params.namedChildCount; i++) {
1087
- const param = params.namedChild(i);
1088
- if (param === null || param.type !== 'parameter_declaration')
1089
- continue;
1090
- const declarator = param.childForFieldName('declarator');
1091
- if (declarator !== null && extractDeclaratorLeafName(declarator) === varName) {
1092
- return param;
1093
- }
1094
- }
1095
- }
1096
- return null;
1139
+ return enclosingFunctionParametersFor(node)?.get(varName) ?? null;
1097
1140
  }
1098
1141
  node = node.parent;
1099
1142
  }
1100
1143
  return null;
1101
1144
  }
1145
+ /**
1146
+ * First-wins map of a function's `parameter_declaration`s by declarator
1147
+ * leaf name (#2432; was a per-identifier scan). `null` when the function
1148
+ * has no parameter list — the caller returns null without walking further
1149
+ * up, preserving the replaced scan's early-return.
1150
+ */
1151
+ function enclosingFunctionParametersFor(fnNode) {
1152
+ const cached = fileLookupIndex.fnParams.get(fnNode.id);
1153
+ if (cached !== undefined)
1154
+ return cached;
1155
+ const fnDecl = fnNode.type === 'function_declarator'
1156
+ ? fnNode
1157
+ : findFirstDescendantOfType(fnNode, 'function_declarator');
1158
+ const params = fnDecl?.childForFieldName('parameters') ?? null;
1159
+ let index = null;
1160
+ if (params !== null) {
1161
+ index = new Map();
1162
+ for (let i = 0; i < params.namedChildCount; i++) {
1163
+ const param = params.namedChild(i);
1164
+ if (param === null || param.type !== 'parameter_declaration')
1165
+ continue;
1166
+ const declarator = param.childForFieldName('declarator');
1167
+ if (declarator === null)
1168
+ continue;
1169
+ const name = extractDeclaratorLeafName(declarator);
1170
+ if (name === '' || index.has(name))
1171
+ continue;
1172
+ index.set(name, param);
1173
+ }
1174
+ }
1175
+ fileLookupIndex.fnParams.set(fnNode.id, index);
1176
+ return index;
1177
+ }
1102
1178
  function declaredNameNode(declarator) {
1103
1179
  if (declarator.type !== 'init_declarator')
1104
1180
  return declarator;
@@ -1126,24 +1202,31 @@ function normalizeCppTypeText(text) {
1126
1202
  function isKnownEnumName(node, typeName) {
1127
1203
  if (typeName === '' || typeName === 'unknown')
1128
1204
  return false;
1129
- let root = node;
1130
- while (root.parent !== null)
1131
- root = root.parent;
1132
- const stack = [root];
1133
- while (stack.length > 0) {
1134
- const cur = stack.pop();
1135
- if (cur.type === 'enum_specifier') {
1136
- const name = cur.childForFieldName('name');
1137
- if (name?.text === typeName)
1138
- return true;
1139
- }
1140
- for (let i = 0; i < cur.childCount; i++) {
1141
- const child = cur.child(i);
1142
- if (child !== null)
1143
- stack.push(child);
1205
+ // One full-tree DFS per FILE (lazy), not per identifier argument — the
1206
+ // per-identifier walk here was the dominant cost of #2432 (87s of a 151s
1207
+ // extraction on a file that parses in 46ms).
1208
+ if (fileLookupIndex.enumNames === null) {
1209
+ let root = node;
1210
+ while (root.parent !== null)
1211
+ root = root.parent;
1212
+ const names = new Set();
1213
+ const stack = [root];
1214
+ while (stack.length > 0) {
1215
+ const cur = stack.pop();
1216
+ if (cur.type === 'enum_specifier') {
1217
+ const name = cur.childForFieldName('name');
1218
+ if (name !== null)
1219
+ names.add(name.text);
1220
+ }
1221
+ for (let i = 0; i < cur.childCount; i++) {
1222
+ const child = cur.child(i);
1223
+ if (child !== null)
1224
+ stack.push(child);
1225
+ }
1144
1226
  }
1227
+ fileLookupIndex.enumNames = names;
1145
1228
  }
1146
- return false;
1229
+ return fileLookupIndex.enumNames.has(typeName);
1147
1230
  }
1148
1231
  /**
1149
1232
  * Detect whether a `namespace_definition` AST node is inline.
@@ -135,6 +135,19 @@ export interface WorkerPoolOptions {
135
135
  pdg?: boolean;
136
136
  /** Per-function source-line cap for worker-side CFG construction (0 ⇒ no cap). */
137
137
  pdgMaxFunctionLines?: number;
138
+ /**
139
+ * Max wall time `terminate()` waits for a retired worker that has NOT yet
140
+ * reached a JS-visible safe point before giving up on terminating it
141
+ * (#2432). Terminating a worker thread that is inside an N-API call aborts
142
+ * the whole process (`Napi::Error` → `std::terminate` → SIGABRT) — and the
143
+ * same abort fires at plain process exit, so the drain is what makes
144
+ * shutdown safe. On expiry the worker is left running (unref'd, with its
145
+ * at-safe-point terminate listener still armed) and a diagnostic is logged.
146
+ * Default 30000ms — above the C++ capture budget
147
+ * (`GITNEXUS_CPP_CAPTURE_BUDGET_MS`, 20000ms) so the drain converges for
148
+ * the known pathological class. 0 ⇒ no wait (test hook).
149
+ */
150
+ shutdownDrainMs?: number;
138
151
  }
139
152
  export declare class WorkerPoolDispatchError extends Error {
140
153
  /**
@@ -193,6 +206,7 @@ interface ResolvedWorkerPoolOptions {
193
206
  maxRespawnsPerSlot: number;
194
207
  maxCumulativeTimeoutMs: number;
195
208
  consecutiveFailureThreshold: number;
209
+ shutdownDrainMs: number;
196
210
  }
197
211
  export declare function resolveWorkerPoolOptions(options?: WorkerPoolOptions, poolSize?: number): ResolvedWorkerPoolOptions;
198
212
  /**
@@ -231,6 +231,8 @@ function nonNegativeInteger(value) {
231
231
  ? Math.floor(parsed)
232
232
  : undefined;
233
233
  }
234
+ /** See {@link WorkerPoolOptions.shutdownDrainMs}. */
235
+ const DEFAULT_SHUTDOWN_DRAIN_MS = 30_000;
234
236
  export function resolveWorkerPoolOptions(options = {}, poolSize) {
235
237
  const subBatchIdleTimeoutMs = positiveInteger(options.subBatchIdleTimeoutMs) ??
236
238
  positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS) ??
@@ -252,6 +254,9 @@ export function resolveWorkerPoolOptions(options = {}, poolSize) {
252
254
  consecutiveFailureThreshold: positiveInteger(options.consecutiveFailureThreshold) ??
253
255
  positiveInteger(process.env.GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD) ??
254
256
  Math.max(DEFAULT_CONSECUTIVE_FAILURE_THRESHOLD_FLOOR, poolSize ?? 0),
257
+ shutdownDrainMs: nonNegativeInteger(options.shutdownDrainMs) ??
258
+ nonNegativeInteger(process.env.GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS) ??
259
+ DEFAULT_SHUTDOWN_DRAIN_MS,
255
260
  };
256
261
  }
257
262
  /**
@@ -608,13 +613,50 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
608
613
  // Set by `terminate()` (below). Also read by the self-healing startup loop so
609
614
  // a terminate during startup aborts pending backoff/retries (#1741).
610
615
  let terminated = false;
616
+ /** Resolves `true` when `promise` settles within `ms`, else `false`. The
617
+ * timer is unref'd so an expiring drain never holds the process open. */
618
+ const settledWithin = (promise, ms) => {
619
+ if (ms <= 0)
620
+ return Promise.resolve(false);
621
+ return new Promise((resolve) => {
622
+ const timer = setTimeout(() => resolve(false), ms);
623
+ timer.unref?.();
624
+ void promise.then(() => {
625
+ clearTimeout(timer);
626
+ resolve(true);
627
+ });
628
+ });
629
+ };
611
630
  const terminateTrackedWorkers = async (liveWorkers) => {
612
631
  const retired = Array.from(retiredWorkers);
613
632
  await Promise.all([
614
633
  ...liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)),
615
- ...retired.map((record) => record.terminate()),
634
+ ...retired.map(async (record) => {
635
+ // #2432: a retired worker that has not reached a JS-visible safe
636
+ // point may be inside an N-API call — terminating it aborts the
637
+ // WHOLE process (`Napi::Error` → std::terminate → SIGABRT). Drain:
638
+ // wait (bounded) for its safe point; on expiry leave it running —
639
+ // it is unref'd and its at-safe-point terminate listener stays
640
+ // armed — and log which file wedged it.
641
+ if (!record.safeToTerminate) {
642
+ const drained = await settledWithin(record.safePoint, poolOptions.shutdownDrainMs);
643
+ if (!drained) {
644
+ logger.warn({
645
+ workerIndex: record.workerIndex,
646
+ reason: record.reason,
647
+ drainMs: poolOptions.shutdownDrainMs,
648
+ }, `Worker ${record.workerIndex} is still inside native code after the ` +
649
+ `${poolOptions.shutdownDrainMs}ms shutdown drain; leaving it un-terminated ` +
650
+ `to avoid a native abort (#2432). It will be terminated at its next safe point.`);
651
+ return;
652
+ }
653
+ }
654
+ await record.terminate();
655
+ }),
616
656
  ]);
617
- retiredWorkers.clear();
657
+ // Undrained records stay tracked so a repeated shutdown call can retry
658
+ // their (now possibly safe) terminate; record.terminate() removes each
659
+ // drained record via its cleanup.
618
660
  };
619
661
  for (let i = 0; i < size; i++) {
620
662
  workers[i] = spawnAndCapture(workerUrl);
@@ -788,6 +830,18 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
788
830
  const retireWorkerAfterTimeout = (worker, workerIndex, reason) => {
789
831
  let cleaned = false;
790
832
  let terminateStarted = false;
833
+ let resolveSafePoint;
834
+ const safePoint = new Promise((resolve) => {
835
+ resolveSafePoint = resolve;
836
+ });
837
+ // A message/messageerror proves the worker is executing JS again; an
838
+ // exit/error means the thread is gone. Either way `worker.terminate()`
839
+ // can no longer land mid-N-API call (#2432), so shutdown's drain may
840
+ // stop waiting.
841
+ function markSafeToTerminate() {
842
+ record.safeToTerminate = true;
843
+ resolveSafePoint();
844
+ }
791
845
  function cleanupRetired() {
792
846
  if (cleaned)
793
847
  return;
@@ -806,6 +860,7 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
806
860
  await worker.terminate().catch(() => undefined);
807
861
  }
808
862
  function terminateWhenBackInJs() {
863
+ markSafeToTerminate();
809
864
  void terminateRetired();
810
865
  }
811
866
  function onRetiredMessage(raw) {
@@ -816,8 +871,14 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
816
871
  terminateWhenBackInJs();
817
872
  }
818
873
  }
819
- const onRetiredError = () => cleanupRetired();
820
- const onRetiredExit = () => cleanupRetired();
874
+ const onRetiredError = () => {
875
+ markSafeToTerminate();
876
+ cleanupRetired();
877
+ };
878
+ const onRetiredExit = () => {
879
+ markSafeToTerminate();
880
+ cleanupRetired();
881
+ };
821
882
  const onRetiredMessageError = () => terminateWhenBackInJs();
822
883
  const record = {
823
884
  worker,
@@ -825,6 +886,8 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
825
886
  reason,
826
887
  cleanup: cleanupRetired,
827
888
  terminate: terminateRetired,
889
+ safeToTerminate: false,
890
+ safePoint,
828
891
  };
829
892
  retiredWorkers.add(record);
830
893
  worker.on('message', onRetiredMessage);
@@ -890,8 +953,25 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
890
953
  const liveWorkers = workers.slice();
891
954
  for (let i = 0; i < workers.length; i++)
892
955
  workers[i] = undefined;
956
+ // #2432: a live worker with a job in flight may be inside an N-API
957
+ // call — direct terminate risks the same native abort as the retired
958
+ // case. Route busy workers through the retire path (terminate at
959
+ // their next JS-visible safe point); idle workers are parked in the
960
+ // JS event loop and terminate safely right away.
961
+ const idleWorkers = [];
962
+ for (let i = 0; i < liveWorkers.length; i++) {
963
+ const worker = liveWorkers[i];
964
+ if (worker === undefined)
965
+ continue;
966
+ if (busySlots.has(i)) {
967
+ retireWorkerAfterTimeout(worker, i, 'circuit breaker tripped with job in flight');
968
+ }
969
+ else {
970
+ idleWorkers.push(worker);
971
+ }
972
+ }
893
973
  activeSlots.clear();
894
- void terminateTrackedWorkers(liveWorkers);
974
+ void terminateTrackedWorkers(idleWorkers);
895
975
  };
896
976
  const maybeDone = () => {
897
977
  if (stopped)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.18",
3
+ "version": "1.6.10-rc.19",
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",