diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 504752af7..efc17568d 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -131,6 +131,14 @@ let _gitleaksAvailability: boolean | null = null; // suppress the permanent "not in PATH; scanning disabled" warning later. let _gitleaksSlowWarned = false; let _gitleaksAbsentWarned = false; +// Per-run cooldown: retrying a slow probe on EVERY file re-pays up to +// probe+retry (12s default) per file — an 887-file ingest on a loaded box +// spent hours asking the same slow question. After this many consecutive +// slow answers the run stops probing; the availability cache is still never +// written (slow != absent — the next PROCESS probes fresh). +const GITLEAKS_SLOW_PROBE_LIMIT = 3; +let _gitleaksConsecutiveSlow = 0; +let _gitleaksCooldownWarned = false; // Probe budgets. The first is short because the common answers (a real // gitleaks, or ENOENT) are both immediate; the second is generous because by @@ -176,17 +184,32 @@ function probeGitleaks(timeoutMs: number): GitleaksProbe { */ function gitleaksAvailable(): boolean { if (_gitleaksAvailability !== null) return _gitleaksAvailability; + if (_gitleaksConsecutiveSlow >= GITLEAKS_SLOW_PROBE_LIMIT) { + if (!_gitleaksCooldownWarned) { + _gitleaksCooldownWarned = true; + process.stderr.write( + "[gstack-memory-helpers] gitleaks did not answer in " + + `${GITLEAKS_SLOW_PROBE_LIMIT} consecutive probes; skipping the probe ` + + "for the rest of this run — remaining files go unscanned. Re-run when " + + "the machine is less loaded to scan them.\n" + ); + } + return false; + } let probe = probeGitleaks(_probeMs); if (probe === "slow") probe = probeGitleaks(_retryMs); if (probe === "ok") { _gitleaksAvailability = true; + _gitleaksConsecutiveSlow = 0; return true; } if (probe === "slow") { - // No cache write: leave the question open for the next call. + // No cache write: leave the question open for the next call — but count + // it, so a persistently loaded box stops paying probe+retry per file. + _gitleaksConsecutiveSlow++; if (!_gitleaksSlowWarned) { _gitleaksSlowWarned = true; process.stderr.write( @@ -578,6 +601,8 @@ export function _resetGitleaksAvailabilityCache(): void { _gitleaksAvailability = null; _gitleaksSlowWarned = false; _gitleaksAbsentWarned = false; + _gitleaksConsecutiveSlow = 0; + _gitleaksCooldownWarned = false; _probeMs = GITLEAKS_PROBE_MS; _retryMs = GITLEAKS_RETRY_MS; } diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts index 1b06a4303..c22e4c611 100644 --- a/test/gstack-memory-helpers.test.ts +++ b/test/gstack-memory-helpers.test.ts @@ -276,6 +276,34 @@ exit 2 } }); + it("stops probing after 3 consecutive slow answers (per-run cooldown), never caching unavailability", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-test-")); + const binDir = join(dir, "bin"); + const log = join(dir, "calls.log"); + const file = join(dir, "clean.txt"); + writeFileSync(file, "no secrets here\n"); + // Empty marker: EVERY call hangs, so both budgets expire on each probe. + fakeGitleaks(binDir, log, ""); + try { + _setGitleaksProbeTimeouts(800, 800); + // Three slow rounds: each pays probe+retry (2 spawns), each unscanned. + for (let i = 0; i < 3; i++) { + const r = withFakeOnPath(binDir, () => secretScanFile(file)); + expect(r.scanner).toBe("missing"); + } + const probesAtLimit = versionProbes(log); + expect(probesAtLimit).toBe(6); + // Fourth file: cooldown short-circuits — no spawn, still unscanned, + // and the question stays open for the NEXT process (cache never set). + const fourth = withFakeOnPath(binDir, () => secretScanFile(file)); + expect(fourth.scanner).toBe("missing"); + expect(versionProbes(log)).toBe(probesAtLimit); + expect(_gitleaksCacheState()).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("caches an absent binary, so it is probed once per process", () => { const dir = mkdtempSync(join(tmpdir(), "gstack-test-")); const binDir = join(dir, "empty-bin");