gitnexus 1.6.8-rc.17 → 1.6.8-rc.18

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.
@@ -284,18 +284,32 @@ function buildAfterToolContext(input) {
284
284
  }
285
285
 
286
286
  function runAugment(gitNexusDir, cwd, pattern) {
287
- if (hasGitNexusServerOwner(gitNexusDir)) {
288
- // Normal skip path: the MCP server owns the DB. Stay silent for strict
289
- // hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
287
+ // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
288
+ // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
289
+ // as the augment, or concurrent sessions fan out unbounded probe
290
+ // subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
291
+ // buildAfterToolContext before this — moving the acquire any earlier would
292
+ // churn slot files on tool calls that never probe.
293
+ const release = acquireHookSlot(gitNexusDir);
294
+ if (!release) {
295
+ // Normal skip path: all per-repo hook slots are held by concurrent
296
+ // sessions. Stay silent for strict hook runners (issue #1913); surface
297
+ // the reason only under GITNEXUS_DEBUG.
290
298
  if (isDebugEnabled()) {
291
- process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
299
+ process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
292
300
  }
293
301
  return '';
294
302
  }
295
- const release = acquireHookSlot(gitNexusDir);
296
- if (!release) return '';
297
- const cliPath = resolveCliPath();
298
303
  try {
304
+ if (hasGitNexusServerOwner(gitNexusDir)) {
305
+ // Normal skip path: the MCP server owns the DB. Stay silent for strict
306
+ // hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
307
+ if (isDebugEnabled()) {
308
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
309
+ }
310
+ return '';
311
+ }
312
+ const cliPath = resolveCliPath();
299
313
  const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
300
314
  if (!child.error && child.status === 0) {
301
315
  return extractAugmentContext(child.stderr || '');
@@ -259,22 +259,35 @@ function handlePreToolUse(input) {
259
259
 
260
260
  const pattern = extractPattern(toolName, toolInput);
261
261
  if (!pattern || pattern.length < 3) return;
262
- if (hasGitNexusServerOwner(gitNexusDir)) {
263
- // Normal skip path: the MCP server owns the DB, so the CLI augment would
264
- // contend on the lock. Stay silent for strict hook runners (issue #1913);
265
- // surface the reason only when diagnostics are explicitly requested.
262
+
263
+ // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
264
+ // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
265
+ // as the augment, or concurrent sessions fan out unbounded probe
266
+ // subprocesses. Keep the acquire right after the cheap guards above —
267
+ // moving it earlier would churn slot files on tool calls that never probe.
268
+ const release = acquireHookSlot(gitNexusDir);
269
+ if (!release) {
270
+ // Normal skip path: all per-repo hook slots are held by concurrent
271
+ // sessions. Stay silent for strict hook runners (issue #1913); surface
272
+ // the reason only when diagnostics are explicitly requested.
266
273
  if (isDebugEnabled()) {
267
- process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
274
+ process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
268
275
  }
269
276
  return;
270
277
  }
271
278
 
272
- const release = acquireHookSlot(gitNexusDir);
273
- if (!release) return;
274
-
275
- const cliPath = resolveCliPath();
276
279
  let result = '';
277
280
  try {
281
+ if (hasGitNexusServerOwner(gitNexusDir)) {
282
+ // Normal skip path: the MCP server owns the DB, so the CLI augment would
283
+ // contend on the lock. Stay silent for strict hook runners (issue #1913);
284
+ // surface the reason only when diagnostics are explicitly requested.
285
+ if (isDebugEnabled()) {
286
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
287
+ }
288
+ return;
289
+ }
290
+ const cliPath = resolveCliPath();
278
291
  const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
279
292
  if (!child.error && child.status === 0) {
280
293
  result = extractAugmentContext(child.stderr || '');
@@ -11,6 +11,22 @@
11
11
  *
12
12
  * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
13
13
  * PowerShell ETIMEDOUT (Windows), matching the hook contract.
14
+ *
15
+ * Unix subprocess containment contract (#2163):
16
+ * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
17
+ * wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
18
+ * is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
19
+ * survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
20
+ * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
21
+ * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
22
+ * wrapper off deterministically; any other value is adopted only when it
23
+ * exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
24
+ * THROUGH to the built-in candidate list (first self-test pass wins), so
25
+ * no malformed value of any shape can silently disable orphan containment.
26
+ * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
27
+ * ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
28
+ * A probe before that first query is therefore always false — a known,
29
+ * pre-existing race, not a bug in this probe.
14
30
  */
15
31
 
16
32
  const fs = require('fs');
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
46
62
  return tool;
47
63
  }
48
64
 
65
+ // Sentinel:
66
+ // undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
67
+ // string = self-tested coreutils timeout/gtimeout path (use as wrapper)
68
+ // null = no usable wrapper (disabled, none found, or self-test failed)
69
+ let unixGuardTimeoutCache;
70
+
71
+ /**
72
+ * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
73
+ * (#2163). Dead code on Windows (the win32 dispatch returns earlier).
74
+ *
75
+ * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
76
+ * wrapper off; any other value is only a CANDIDATE — an existing file path
77
+ * is tried first, but it must pass the `-k` self-test to be adopted. On any
78
+ * failure (non-existent path, directory, non-executable file, wrapper
79
+ * without `-k` support, …) resolution falls through to the built-in
80
+ * candidates below, tried in order, first self-test pass wins. This is
81
+ * strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
82
+ * GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
83
+ * value of ANY shape can silently disable orphan containment.
84
+ *
85
+ * Lazy self-test: candidates are probed only when the lsof/ps fallback is
86
+ * first reached, and the result is memoized. A candidate is adopted only
87
+ * when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
88
+ * not support the coreutils `-k` flag — busybox <1.34, toybox, broken
89
+ * symlinks — which would otherwise exit with a usage error without ever
90
+ * running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
91
+ * into fail-open (#1492 regression). Only when EVERY candidate fails does
92
+ * the probe fall back to the unwrapped status quo (memoized null).
93
+ * busybox ≥1.34 passes the test and is fully usable (capability, not
94
+ * identity, decides).
95
+ */
96
+ function passesGuardSelfTest(guard) {
97
+ try {
98
+ const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
99
+ encoding: 'utf-8',
100
+ timeout: 3000,
101
+ stdio: ['ignore', 'ignore', 'ignore'],
102
+ windowsHide: true,
103
+ });
104
+ return !selfTest.error && selfTest.status === 0;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function resolveUnixGuardTimeout() {
111
+ if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
112
+ unixGuardTimeoutCache = null;
113
+ const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
114
+ const trimmed = fromEnv ? String(fromEnv).trim() : '';
115
+ if (trimmed === 'disabled') return unixGuardTimeoutCache;
116
+ const candidates = [];
117
+ if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
118
+ for (const builtin of [
119
+ '/usr/bin/timeout',
120
+ '/bin/timeout',
121
+ '/opt/homebrew/bin/gtimeout',
122
+ '/usr/local/bin/gtimeout',
123
+ ]) {
124
+ try {
125
+ if (fs.existsSync(builtin)) candidates.push(builtin);
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ }
130
+ for (const candidate of candidates) {
131
+ if (passesGuardSelfTest(candidate)) {
132
+ unixGuardTimeoutCache = candidate;
133
+ break;
134
+ }
135
+ }
136
+ return unixGuardTimeoutCache;
137
+ }
138
+
49
139
  function resolveWindowsPowerShellPath() {
50
140
  const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
51
141
  if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
188
278
  }
189
279
 
190
280
  function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
281
+ const guard = resolveUnixGuardTimeout();
191
282
  const lsofPath = resolveHookBinary('lsof');
192
- const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
283
+ // The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
284
+ // SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
285
+ // SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
286
+ // fail-closed contract are untouched. The wrapper only matters once this
287
+ // hook process has been SIGKILLed and can no longer deliver that SIGTERM.
288
+ const [lsofCmd, lsofArgs] = guard
289
+ ? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
290
+ : [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
291
+ const lsof = spawnSync(lsofCmd, lsofArgs, {
193
292
  encoding: 'utf-8',
194
293
  timeout: 1000,
195
294
  stdio: ['ignore', 'pipe', 'ignore'],
196
295
  windowsHide: true,
197
296
  });
198
297
  if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
298
+ // Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
299
+ // result shapes, verified against coreutils 9.1:
300
+ // - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
301
+ // SELF-RAISES the signal, so spawnSync reports {status: null, signal}
302
+ // with no .error (spawnSync's own ETIMEDOUT was handled above). The
303
+ // same shape appears when this hook is frozen >2s (SIGSTOP, laptop
304
+ // suspend) and the guard expires while it sleeps. By construction, a
305
+ // guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
306
+ // is a budget/kill outcome.
307
+ // - 124: budget expired and the child exited after the plain SIGTERM.
308
+ // - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
309
+ // or a child SIGKILLed externally (e.g. the OOM killer).
310
+ if (guard && lsof.status === null && lsof.signal) return true;
311
+ if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
199
312
 
200
313
  const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
201
314
  const psPath = resolveHookBinary('ps');
202
315
  for (const pid of pids) {
203
316
  if (Number(pid) === myPid) continue;
204
- const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
317
+ const [psCmd, psArgs] = guard
318
+ ? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
319
+ : [psPath, ['-p', pid, '-o', 'command=']];
320
+ const ps = spawnSync(psCmd, psArgs, {
205
321
  encoding: 'utf-8',
206
322
  timeout: 500,
207
323
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
211
327
  if (ps.error.code === 'ETIMEDOUT') return true;
212
328
  continue;
213
329
  }
330
+ // Same guard-mediated-death mapping as the lsof call above (signal-death
331
+ // from the -k escalation or a frozen hook; 124 budget expiry; 137 only
332
+ // for exit-code-propagating wrappers / external SIGKILL).
333
+ if (guard && ps.status === null && ps.signal) return true;
334
+ if (guard && (ps.status === 124 || ps.status === 137)) return true;
214
335
  if (isGitNexusServerCommand(ps.stdout || '')) return true;
215
336
  }
216
337
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.17",
3
+ "version": "1.6.8-rc.18",
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",