fix(memory-helpers): per-run cooldown bounds the slow-gitleaks probe cost

Retrying a slow probe per FILE (#2715's slow!=absent split) re-paid up to
probe+retry (12s default) per file — an 887-file ingest on a loaded box
spent hours re-asking the same slow question. After 3 consecutive slow
answers the run stops probing and warns once that remaining files go
unscanned; the availability cache is still never written, so the next
process probes fresh. Slow/absent discrimination is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-01 16:26:15 +00:00
co-authored by Claude Fable 5
parent cce6955932
commit 8712a1f692
2 changed files with 54 additions and 1 deletions
+26 -1
View File
@@ -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;
}
+28
View File
@@ -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");