gitnexus 1.6.8-rc.27 → 1.6.8-rc.29

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.
Files changed (34) hide show
  1. package/hooks/claude/hook-db-lock-probe.cjs +341 -24
  2. package/package.json +1 -1
  3. package/scripts/cross-platform-tests.ts +1 -0
  4. package/web/assets/{agent-CJmGaI8F.js → agent-C01BpbCi.js} +91 -91
  5. package/web/assets/{architectureDiagram-UL44E2DR-D7Nzv8Z7.js → architectureDiagram-UL44E2DR-BETib0Ig.js} +1 -1
  6. package/web/assets/{chunk-LCXTWHL2-x5GBtTcn.js → chunk-LCXTWHL2-ECvRIdTA.js} +1 -1
  7. package/web/assets/{chunk-RG4AUYOV-5yz_7HGK.js → chunk-RG4AUYOV-htm8QilK.js} +1 -1
  8. package/web/assets/{classDiagram-KGZ6W3CR-Ce7th0vG.js → classDiagram-KGZ6W3CR-vJcJG7SW.js} +1 -1
  9. package/web/assets/{classDiagram-v2-72OJOZXJ-Cc0N-4aA.js → classDiagram-v2-72OJOZXJ-BCJH9y5K.js} +1 -1
  10. package/web/assets/{context-builder-_HS0v2ma.js → context-builder-C6wAZwss.js} +10 -2
  11. package/web/assets/{diagram-3NCE3AQN-6wUsUD7U.js → diagram-3NCE3AQN-DgbGdjct.js} +1 -1
  12. package/web/assets/{diagram-GF46GFSD-Disbhf7W.js → diagram-GF46GFSD-DkQKRTzf.js} +1 -1
  13. package/web/assets/{diagram-QXG6HAR7-BeFCG-ob.js → diagram-QXG6HAR7-fO9iaqTW.js} +1 -1
  14. package/web/assets/{diagram-WEQXMOUZ-Bd6Dgwiv.js → diagram-WEQXMOUZ-BFGWbiEm.js} +1 -1
  15. package/web/assets/{erDiagram-L5TCEMPS-sHox0Zi_.js → erDiagram-L5TCEMPS-IErmkD3i.js} +1 -1
  16. package/web/assets/{flowDiagram-H6V6AXG4-C0y_PyUc.js → flowDiagram-H6V6AXG4-BOtRGXXs.js} +1 -1
  17. package/web/assets/index-DKmSSk0F.js +626 -0
  18. package/web/assets/index-DeuHlyzm.css +2 -0
  19. package/web/assets/{infoDiagram-3YFTVSEB-K8pFihzq.js → infoDiagram-3YFTVSEB-DKRlh1as.js} +1 -1
  20. package/web/assets/{ishikawaDiagram-BNXS4ZKH-DnMJCWwI.js → ishikawaDiagram-BNXS4ZKH-B07zhx7A.js} +1 -1
  21. package/web/assets/{kanban-definition-75IXJCU3-D-4EXdJe.js → kanban-definition-75IXJCU3-_saj34_J.js} +1 -1
  22. package/web/assets/{mindmap-definition-2TDM6QVE-CC0qeqmA.js → mindmap-definition-2TDM6QVE-iyCoM6pR.js} +1 -1
  23. package/web/assets/{pieDiagram-CU6KROY3-D-53o4Ll.js → pieDiagram-CU6KROY3-DOeaMPvU.js} +1 -1
  24. package/web/assets/{requirementDiagram-JXO7QTGE-gNmDM8qN.js → requirementDiagram-JXO7QTGE-DMhAsO2o.js} +1 -1
  25. package/web/assets/{sequenceDiagram-VS2MUI6T-CxcTKXw1.js → sequenceDiagram-VS2MUI6T-CQZ5FzAV.js} +1 -1
  26. package/web/assets/{stateDiagram-7D4R322I-CjnhCRbL.js → stateDiagram-7D4R322I-kYVKDLkw.js} +1 -1
  27. package/web/assets/{stateDiagram-v2-36443NZ5-C5HPpkcW.js → stateDiagram-v2-36443NZ5-DvvdUOce.js} +1 -1
  28. package/web/assets/{timeline-definition-O6YCAMPW-BmDoRniN.js → timeline-definition-O6YCAMPW-CxBfIWWp.js} +1 -1
  29. package/web/assets/{vennDiagram-MWXL3ELB-S9b_NlgG.js → vennDiagram-MWXL3ELB-D0m2r7IU.js} +1 -1
  30. package/web/assets/{wardleyDiagram-CUQ6CDDI-Bbs9z_r0.js → wardleyDiagram-CUQ6CDDI-yBZJHfwp.js} +1 -1
  31. package/web/assets/{xychartDiagram-N2JHSOCM-FyWVcm04.js → xychartDiagram-N2JHSOCM-CQJMSAIO.js} +1 -1
  32. package/web/index.html +2 -2
  33. package/web/assets/index-COMMmbxW.css +0 -2
  34. package/web/assets/index-C_FZ3Bqu.js +0 -626
@@ -3,14 +3,36 @@
3
3
  * with a command line that looks like a GitNexus MCP/serve server?
4
4
  *
5
5
  * Backends (no user-installed Sysinternals):
6
- * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
7
- * optional lsof fallback when proc scan finds nothing.
6
+ * - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three
7
+ * phases, cheapest first: (0) read /proc/<pid>/comm a tiny task->comm read
8
+ * that never touches the target's mm — and keep only PIDs whose comm is a
9
+ * plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX
10
+ * bytes of /proc/<pid>/cmdline via openSync+readSync (bounded, so a D-state
11
+ * holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter
12
+ * with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their
13
+ * /proc/<pid>/fd/* and compare dev+inode against the target lbug. The lbug
14
+ * handle is fd-visible (a @ladybugdb/core property), so this finds every real
15
+ * owner without scanning every fd of every process.
8
16
  * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
9
17
  * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
10
18
  * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
11
19
  *
12
- * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
13
- * PowerShell ETIMEDOUT (Windows), matching the hook contract.
20
+ * Fail matrix:
21
+ * - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted
22
+ * (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a
23
+ * deliberate change from the old "timeout -> fail-open then try lsof" path.
24
+ * End-to-end the busy-host outcome is unchanged: the old code's lsof fallback
25
+ * ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO
26
+ * failed closed there — the lsof leg only ever added 1-2s of dead work plus
27
+ * the orphan-storm risk it caused (#2163). What changes is that an overloaded
28
+ * host now self-throttles immediately (the throttle the incident needed)
29
+ * instead of paying for a doomed lsof. Mid-load hosts that used to fall
30
+ * through to a successful lsof now answer from the scan directly (faster) or,
31
+ * if even the scan can't finish in budget, fail closed (self-throttle) — a
32
+ * bounded, documented tradeoff, never an orphan.
33
+ * - macOS / other Unix: fail-open on most errors; fail-closed only on lsof
34
+ * ETIMEDOUT, matching the hook contract.
35
+ * - Windows: fail-closed only on PowerShell ETIMEDOUT.
14
36
  *
15
37
  * Unix subprocess containment contract (#2163):
16
38
  * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
@@ -46,6 +68,16 @@ function isGitNexusServerCommand(command) {
46
68
  return hasServerMode && hasGitNexus;
47
69
  }
48
70
 
71
+ // GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the
72
+ // Windows ps1-load warning already uses (===' 1' / ==='true') so there is one
73
+ // debug convention in this file, and writes via process.stderr.write (NOT a
74
+ // spawn) so it never perturbs the windowsHide spawn-count invariant.
75
+ function debugLog(msg) {
76
+ if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') {
77
+ process.stderr.write(`[GitNexus hook] ${msg}\n`);
78
+ }
79
+ }
80
+
49
81
  function resolveHookBinary(tool) {
50
82
  const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
51
83
  const fromEnv = process.env[envKey];
@@ -242,59 +274,325 @@ function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
242
274
  return false;
243
275
  }
244
276
 
245
- function readLinuxCmdline(pidStr) {
277
+ // The procfs root every Linux scan path reads from. Production is always /proc;
278
+ // GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree
279
+ // (comm + cmdline + fd symlinks) and assert the three-phase logic without
280
+ // scanning the real, ~hundreds-of-process /proc of the test host.
281
+ //
282
+ // Test-only gate (F4): the override is honored ONLY under a test runner —
283
+ // vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified;
284
+ // a production hook is `node <file>.cjs` with neither set). Without the gate, a
285
+ // production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an
286
+ // empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux
287
+ // owner detection silently OFF (fail-OPEN: augment races the real server for the
288
+ // lbug, the #1492 class). Gating to the test signal makes that leak inert in
289
+ // production (always /proc) while the fake-procfs unit tests, which run under
290
+ // vitest, still inject freely. Unset env (or non-test context) => /proc, so the
291
+ // production path is byte-for-byte the historical behavior.
292
+ function isTestContext() {
293
+ return (
294
+ process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test'
295
+ );
296
+ }
297
+ function getProcRoot() {
298
+ if (!isTestContext()) return '/proc';
299
+ const raw = process.env.GITNEXUS_HOOK_PROC_ROOT;
300
+ return raw && String(raw).trim() ? String(raw) : '/proc';
301
+ }
302
+
303
+ // Max bytes read from /proc/<pid>/cmdline in Phase 1. Bounded by default so a
304
+ // D-state holder wedged on mmap_lock, or a process with a pathological multi-MB
305
+ // argv, can't stall the hook. 16 KiB comfortably clears a realistic
306
+ // `node <abs path to .../node_modules/gitnexus/dist/cli/index.js> mcp` line
307
+ // (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
308
+ // enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
309
+ // tests; never goes below PROC_CMDLINE_FLOOR.
310
+ const PROC_CMDLINE_FLOOR = 4096;
311
+ function getCmdlineMaxBytes() {
312
+ const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
313
+ // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
314
+ // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
315
+ // default; trailing garbage ("8abc") now -> NaN -> default (stricter).
316
+ const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
317
+ if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n;
318
+ return 16384;
319
+ }
320
+
321
+ // Phase 0 comm prefilter. /proc/<pid>/comm is the kernel task->comm string,
322
+ // capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible
323
+ // chars, truncated by the kernel with no marker. So a process whose real name
324
+ // is longer than 15 chars shows a 15-char prefix here. The match below is
325
+ // therefore truncation-safe in BOTH directions (a whitelist name that is a
326
+ // prefix of comm, or comm that is a prefix of a whitelist name, both count) to
327
+ // guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino
328
+ // fd check is the real authority; Phase 0/1 only exist to skip the overwhelming
329
+ // majority (kernel threads, shells, editors) cheaply.
330
+ //
331
+ // The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server
332
+ // actually reports for comm. Observed on production hosts: the server renames
333
+ // its main thread, so comm reads `MainThread` (via @ladybugdb/core's
334
+ // worker_threads setup), NOT `node` — omitting it would blind the probe to
335
+ // every real server (#1492-class owner miss). We also keep the plausible
336
+ // launcher/runtime basenames in case a future build does not rename the thread.
337
+ // Conservative by design: over-collecting a few extra candidates only costs a
338
+ // bounded number of Phase 1 cmdline reads.
339
+ const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread'];
340
+ function commLooksLikeServer(comm) {
341
+ const c = comm.trim();
342
+ if (!c) return false;
343
+ for (const name of COMM_CANDIDATES) {
344
+ if (name === c || name.startsWith(c) || c.startsWith(name)) return true;
345
+ }
346
+ return false;
347
+ }
348
+
349
+ function readProcComm(procRoot, pidStr) {
246
350
  try {
247
- return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
351
+ return fs
352
+ .readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8')
353
+ .replace(/\0+/g, '')
354
+ .trim();
248
355
  } catch {
249
356
  return '';
250
357
  }
251
358
  }
252
359
 
253
- function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
360
+ // Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the
361
+ // "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand
362
+ // as a NON-candidate (both regexes are false on ''), so the Phase 1 caller
363
+ // `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN
364
+ // bug if it ever meant "I ran out of budget mid-read" (a real owner whose
365
+ // escalation timed out would be silently dropped, racing the lbug -> #1492). A
366
+ // unique Symbol can never collide with any cmdline string, so the caller can
367
+ // branch on it explicitly and map a mid-read timeout to the tri-state 'timeout'
368
+ // (fail-CLOSED) instead of swallowing it as a non-candidate.
369
+ const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
370
+
371
+ // Bounded /proc/<pid>/cmdline read for Phase 1. openSync+readSync (not
372
+ // readFileSync) so a D-state holder cannot stall the hook on a huge or
373
+ // never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv
374
+ // with NULs; convert to spaces for isGitNexusServerCommand.
375
+ //
376
+ // Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
377
+ // first path component while the `mcp`/`serve` mode token is the LAST argv, so
378
+ // a naive 4 KB read could clip the mode token off a server launched with a very
379
+ // long interpreter path and silently miss a real owner. We mitigate two ways:
380
+ // (a) the default cap (16 KiB) already clears realistic lines; (b) if the first
381
+ // read fills the cap AND already contains the `gitnexus` token but no mode
382
+ // token yet, we keep reading in bounded chunks (up to a hard ceiling) until the
383
+ // mode token appears or the file ends — so a genuine server is never missed for
384
+ // want of a few more bytes, while non-candidates still pay only the initial
385
+ // bounded read.
386
+ //
387
+ // Budget (F3): the escalation loop above is the one place a SINGLE pathological
388
+ // candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
389
+ // budget check, weakening the timeout contract. `outOfBudget` (the scan's shared
390
+ // deadline callback) is checked once per escalation iteration; on expiry we
391
+ // return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather
392
+ // than mistake the partial read for a non-candidate. Reads that simply can't
393
+ // open / error out still return '' (genuinely "not a readable candidate").
394
+ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
395
+ const file = path.join(procRoot, pidStr, 'cmdline');
396
+ let fd;
397
+ try {
398
+ fd = fs.openSync(file, 'r');
399
+ } catch {
400
+ return '';
401
+ }
402
+ try {
403
+ const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path
404
+ let collected = Buffer.alloc(0);
405
+ let offset = 0;
406
+ let chunkCap = cap;
407
+ for (;;) {
408
+ // allocUnsafe is safe here: readSync fills exactly [0, bytes), only
409
+ // buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that
410
+ // slice into `collected`, so the uninitialized tail never reaches decode.
411
+ const buf = Buffer.allocUnsafe(chunkCap);
412
+ const bytes = fs.readSync(fd, buf, 0, chunkCap, offset);
413
+ if (bytes <= 0) break;
414
+ collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
415
+ offset += bytes;
416
+ const text = collected.toString('utf8').replace(/\0+/g, ' ');
417
+ // Stop early when we can already decide "owner": has both the gitnexus
418
+ // token and a mode token. Keep going only when gitnexus is present but
419
+ // the mode token might be just past the boundary.
420
+ const hasGitNexus =
421
+ /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) ||
422
+ /node_modules[/\\]gitnexus[/\\]/.test(text);
423
+ const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
424
+ if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
425
+ if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
426
+ if (!hasGitNexus) break; // not a candidate; do not escalate the read
427
+ if (offset >= HARD_CEIL) break; // bounded escalation only
428
+ // Budget gate the escalation: a single huge-argv candidate must not burn
429
+ // the whole scan deadline before we re-check. Return the timeout sentinel
430
+ // (never '') so the caller fails closed instead of treating us as a
431
+ // non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always
432
+ // passes outOfBudget, so no presence guard is needed.
433
+ if (outOfBudget()) return CMDLINE_TIMEOUT;
434
+ chunkCap = cap; // keep reading more in cap-sized chunks
435
+ }
436
+ return collected.toString('utf8').replace(/\0+/g, ' ').trim();
437
+ } catch {
438
+ return '';
439
+ } finally {
440
+ try {
441
+ fs.closeSync(fd);
442
+ } catch {
443
+ /* ignore */
444
+ }
445
+ }
446
+ }
447
+
448
+ function resolveLinuxProcBudgetMs() {
254
449
  const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
255
- const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
450
+ // Gate on the STRING's emptiness, NOT the parsed number's truthiness — the
451
+ // old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and
452
+ // silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3"
453
+ // reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()`
454
+ // guard is load-bearing: without it a set-but-empty/whitespace value would be
455
+ // `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment
456
+ // permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a
457
+ // finite "0" still parses to an explicit, deterministic "no budget" =>
458
+ // immediate timeout. Non-numeric / unset => default 1200.
459
+ const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN;
460
+ if (!Number.isFinite(n)) return 1200;
461
+ return n; // may be <= 0, meaning "out of budget on the first check"
462
+ }
463
+
464
+ // Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline
465
+ // holds the target lbug fd), 'not-owned' (scan completed, no such owner), or
466
+ // 'timeout' (the per-scan budget was exhausted before a verdict). The name is
467
+ // pinned by a source-contract test; only the return TYPE changed (#2180:
468
+ // boolean -> tri-state, so the dispatcher can fail-closed on 'timeout').
469
+ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
470
+ const budget = resolveLinuxProcBudgetMs();
471
+ // A non-positive budget is an explicit, deterministic "no time to scan" =>
472
+ // immediate timeout (the #2180 test vector, and the only correct reading of
473
+ // the fixed parse: "0" must NOT mean 1200). Returning before any procfs read
474
+ // keeps it instantaneous regardless of host load.
475
+ if (budget <= 0) return 'timeout';
476
+ const procRoot = getProcRoot();
477
+ const cmdlineCap = getCmdlineMaxBytes();
256
478
  const start = Date.now();
479
+ const outOfBudget = () => Date.now() - start > budget;
480
+
257
481
  let targetStat;
258
482
  try {
259
483
  targetStat = fs.statSync(dbPathAbs);
260
484
  } catch {
261
- return false;
485
+ // Caller already existsSync'd the path; a stat failure here is a transient
486
+ // race, treat as no owner (historical semantics).
487
+ return 'not-owned';
262
488
  }
489
+
263
490
  let procEntries;
264
491
  try {
265
- procEntries = fs.readdirSync('/proc', { withFileTypes: true });
492
+ procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
266
493
  } catch {
267
- return false;
494
+ return 'not-owned';
268
495
  }
496
+
497
+ // Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a
498
+ // GitNexus server, without touching any fd yet.
499
+ const candidates = [];
269
500
  for (const ent of procEntries) {
270
- if (Date.now() - start > budget) return false;
501
+ if (outOfBudget()) return 'timeout';
271
502
  if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
272
503
  const pid = Number.parseInt(ent.name, 10);
273
504
  if (!Number.isFinite(pid) || pid === myPid) continue;
274
- const fdDir = path.join('/proc', ent.name, 'fd');
505
+
506
+ // Phase 0: cheap comm prefilter.
507
+ const comm = readProcComm(procRoot, ent.name);
508
+ if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip
509
+ if (!commLooksLikeServer(comm)) continue;
510
+
511
+ // Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter.
512
+ if (outOfBudget()) return 'timeout';
513
+ const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget);
514
+ // F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a
515
+ // Symbol, never a string). Fail CLOSED on it rather than letting it fall
516
+ // through isGitNexusServerCommand as a non-candidate — a real owner whose
517
+ // escalation timed out must not be silently dropped (would fail-OPEN).
518
+ if (cmdline === CMDLINE_TIMEOUT) return 'timeout';
519
+ if (!isGitNexusServerCommand(cmdline)) continue;
520
+ candidates.push(ent.name);
521
+ }
522
+
523
+ // Phase 2: only now stat the fds of the (typically 0-2) survivors.
524
+ for (const pidStr of candidates) {
525
+ if (outOfBudget()) return 'timeout';
526
+ const fdDir = path.join(procRoot, pidStr, 'fd');
275
527
  let fds;
276
528
  try {
277
529
  fds = fs.readdirSync(fdDir);
278
- } catch {
530
+ } catch (err) {
531
+ // F1: the old code returned 'owned' for EVERY non-ENOENT error. That was
532
+ // a correctness bug: /proc/<pid>/fd is owner-only (mode 0500), so a
533
+ // cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1
534
+ // (its cmdline matches) and then EACCES'es here — yet its dev+ino was
535
+ // NEVER compared against THIS lbug. Claiming 'owned' lets it permanently,
536
+ // silently suppress augment for a repo it does not actually lock. We now
537
+ // distinguish the failure shapes (all still fail-closed where we can't
538
+ // prove non-ownership, but 'timeout' is the HONEST verdict for
539
+ // "inconclusive", not the false-positive 'owned'):
540
+ const code = err && err.code;
541
+ if (code === 'ENOENT') {
542
+ // Process raced away between the candidate scan and now -> genuinely no
543
+ // longer an owner. Move on.
544
+ continue;
545
+ }
546
+ if (code === 'EACCES' || code === 'EPERM') {
547
+ // Permission-denied fd dir: cannot read fds, so ownership is
548
+ // UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps
549
+ // timeout -> true, same protective skip as before) WITHOUT lying that we
550
+ // confirmed ownership. Do NOT degrade to not-owned/fail-open: if this
551
+ // really is the owner, fail-open re-opens the #1492 lbug race; augment
552
+ // is optional context, so a conservative skip costs little.
553
+ debugLog(
554
+ `fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` +
555
+ `unverifiable, probe inconclusive -> fail-closed (timeout)`,
556
+ );
557
+ return 'timeout';
558
+ }
559
+ if (code === 'EIO' || code === 'ESTALE') {
560
+ // Genuine transient I/O against this candidate's fd dir — not evidence
561
+ // it does NOT hold the lbug. Treat as inconclusive and fail closed
562
+ // (timeout) rather than continue, so a real owner mid-I/O-blip is not
563
+ // dropped (would fail-open).
564
+ debugLog(
565
+ `fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` +
566
+ `probe inconclusive -> fail-closed (timeout)`,
567
+ );
568
+ return 'timeout';
569
+ }
570
+ // Any other shape (ENOTDIR — fd path is not a directory at all, so this
571
+ // is not a plausible live-procfs owner — and the long tail) is treated as
572
+ // "this candidate is not an owner": move to the next candidate instead of
573
+ // the old blanket 'owned'. If no other candidate owns the lbug the scan
574
+ // ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means
575
+ // the fd entry is structurally not a real /proc/<pid>/fd.
576
+ debugLog(
577
+ `fd dir not a readable directory for candidate pid ${pidStr} ` +
578
+ `(${code || 'unknown'}); treating candidate as non-owner -> continue`,
579
+ );
279
580
  continue;
280
581
  }
281
- let holds = false;
282
582
  for (const fd of fds) {
283
- if (Date.now() - start > budget) return false;
583
+ if (outOfBudget()) return 'timeout';
284
584
  try {
285
585
  const st = fs.statSync(path.join(fdDir, fd));
286
586
  if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
287
- holds = true;
288
- break;
587
+ return 'owned';
289
588
  }
290
589
  } catch {
291
- /* ignore */
590
+ /* fd raced closed; ignore */
292
591
  }
293
592
  }
294
- if (!holds) continue;
295
- if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
296
593
  }
297
- return false;
594
+
595
+ return 'not-owned';
298
596
  }
299
597
 
300
598
  function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
@@ -370,8 +668,13 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
370
668
  }
371
669
 
372
670
  if (process.platform === 'linux') {
373
- if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
374
- return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
671
+ // #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED
672
+ // (overloaded host self-throttles — the throttle the orphan-storm incident
673
+ // needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same
674
+ // hosts anyway, only slower and with the orphan risk). 'not-owned' is the
675
+ // only false. See the fail matrix in the file header.
676
+ const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid);
677
+ return verdict !== 'not-owned';
375
678
  }
376
679
 
377
680
  return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
@@ -379,6 +682,13 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
379
682
 
380
683
  module.exports = {
381
684
  hasGitNexusDbLockedByGitNexusServer,
685
+ // Exported for white-box unit tests that must assert the tri-state verdict
686
+ // ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses
687
+ // timeout and owned to the same boolean true, so the boolean API alone cannot
688
+ // distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The
689
+ // Probe interface already declares this optional. Linux-only by contract; the
690
+ // name is pinned by a source-contract test.
691
+ linuxProcScanFindGitNexusServer,
382
692
  // #2163 follow-up: the hook adapters wrap the augment CLI in the same
383
693
  // guard. Returns a self-tested wrapper path — the built-in candidates are
384
694
  // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
@@ -391,4 +701,11 @@ module.exports = {
391
701
  // override to an absolute path. Returns null when the wrapper is
392
702
  // disabled/unavailable. Never call on win32 (see its JSDoc).
393
703
  resolveUnixGuardTimeout,
704
+ // Exported for white-box unit tests of the numeric-env parsing (#2183 review):
705
+ // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
706
+ // guard that keeps a set-but-empty budget on the 1200 default instead of an
707
+ // immediate fail-closed timeout. Tested directly because the values are
708
+ // otherwise only observable indirectly through scan timing/escalation.
709
+ getCmdlineMaxBytes,
710
+ resolveLinuxProcBudgetMs,
394
711
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.27",
3
+ "version": "1.6.8-rc.29",
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",
@@ -37,6 +37,7 @@ const PLATFORM_LOGIC = [
37
37
  'test/unit/repo-manager.test.ts',
38
38
  'test/unit/repo-manager-finalize-invariant.test.ts',
39
39
  'test/unit/hooks.test.ts',
40
+ 'test/unit/hook-db-lock-probe.test.ts',
40
41
  'test/unit/cursor-hook.test.ts',
41
42
  'test/unit/sidecar-recovery.test.ts',
42
43
  'test/unit/pool-wal-recovery.test.ts',