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.
- package/hooks/claude/hook-db-lock-probe.cjs +341 -24
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +1 -0
- package/web/assets/{agent-CJmGaI8F.js → agent-C01BpbCi.js} +91 -91
- package/web/assets/{architectureDiagram-UL44E2DR-D7Nzv8Z7.js → architectureDiagram-UL44E2DR-BETib0Ig.js} +1 -1
- package/web/assets/{chunk-LCXTWHL2-x5GBtTcn.js → chunk-LCXTWHL2-ECvRIdTA.js} +1 -1
- package/web/assets/{chunk-RG4AUYOV-5yz_7HGK.js → chunk-RG4AUYOV-htm8QilK.js} +1 -1
- package/web/assets/{classDiagram-KGZ6W3CR-Ce7th0vG.js → classDiagram-KGZ6W3CR-vJcJG7SW.js} +1 -1
- package/web/assets/{classDiagram-v2-72OJOZXJ-Cc0N-4aA.js → classDiagram-v2-72OJOZXJ-BCJH9y5K.js} +1 -1
- package/web/assets/{context-builder-_HS0v2ma.js → context-builder-C6wAZwss.js} +10 -2
- package/web/assets/{diagram-3NCE3AQN-6wUsUD7U.js → diagram-3NCE3AQN-DgbGdjct.js} +1 -1
- package/web/assets/{diagram-GF46GFSD-Disbhf7W.js → diagram-GF46GFSD-DkQKRTzf.js} +1 -1
- package/web/assets/{diagram-QXG6HAR7-BeFCG-ob.js → diagram-QXG6HAR7-fO9iaqTW.js} +1 -1
- package/web/assets/{diagram-WEQXMOUZ-Bd6Dgwiv.js → diagram-WEQXMOUZ-BFGWbiEm.js} +1 -1
- package/web/assets/{erDiagram-L5TCEMPS-sHox0Zi_.js → erDiagram-L5TCEMPS-IErmkD3i.js} +1 -1
- package/web/assets/{flowDiagram-H6V6AXG4-C0y_PyUc.js → flowDiagram-H6V6AXG4-BOtRGXXs.js} +1 -1
- package/web/assets/index-DKmSSk0F.js +626 -0
- package/web/assets/index-DeuHlyzm.css +2 -0
- package/web/assets/{infoDiagram-3YFTVSEB-K8pFihzq.js → infoDiagram-3YFTVSEB-DKRlh1as.js} +1 -1
- package/web/assets/{ishikawaDiagram-BNXS4ZKH-DnMJCWwI.js → ishikawaDiagram-BNXS4ZKH-B07zhx7A.js} +1 -1
- package/web/assets/{kanban-definition-75IXJCU3-D-4EXdJe.js → kanban-definition-75IXJCU3-_saj34_J.js} +1 -1
- package/web/assets/{mindmap-definition-2TDM6QVE-CC0qeqmA.js → mindmap-definition-2TDM6QVE-iyCoM6pR.js} +1 -1
- package/web/assets/{pieDiagram-CU6KROY3-D-53o4Ll.js → pieDiagram-CU6KROY3-DOeaMPvU.js} +1 -1
- package/web/assets/{requirementDiagram-JXO7QTGE-gNmDM8qN.js → requirementDiagram-JXO7QTGE-DMhAsO2o.js} +1 -1
- package/web/assets/{sequenceDiagram-VS2MUI6T-CxcTKXw1.js → sequenceDiagram-VS2MUI6T-CQZ5FzAV.js} +1 -1
- package/web/assets/{stateDiagram-7D4R322I-CjnhCRbL.js → stateDiagram-7D4R322I-kYVKDLkw.js} +1 -1
- package/web/assets/{stateDiagram-v2-36443NZ5-C5HPpkcW.js → stateDiagram-v2-36443NZ5-DvvdUOce.js} +1 -1
- package/web/assets/{timeline-definition-O6YCAMPW-BmDoRniN.js → timeline-definition-O6YCAMPW-CxBfIWWp.js} +1 -1
- package/web/assets/{vennDiagram-MWXL3ELB-S9b_NlgG.js → vennDiagram-MWXL3ELB-D0m2r7IU.js} +1 -1
- package/web/assets/{wardleyDiagram-CUQ6CDDI-Bbs9z_r0.js → wardleyDiagram-CUQ6CDDI-yBZJHfwp.js} +1 -1
- package/web/assets/{xychartDiagram-N2JHSOCM-FyWVcm04.js → xychartDiagram-N2JHSOCM-CQJMSAIO.js} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-COMMmbxW.css +0 -2
- 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:
|
|
7
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
492
|
+
procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
|
|
266
493
|
} catch {
|
|
267
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
@@ -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',
|