feat: derive watchdog thresholds from the pod and make its guards structural - #863
Merged
Conversation
…uctural The memory watchdog shipped with tier thresholds and RLIMIT_DATA ceilings as absolute byte constants sized for an 8 GiB pod. On the 4 GiB workspace it detected its own miscalibration and warned rather than misbehaving, which was the right failure but left the mechanism usable at exactly one pod size. Both are now derived from memory.max: one critical reserve C = memory.max/10, clamped to [384 MiB, 1 GiB], with the ladder as fixed multiples of it, and ceilings as sixteenths of memory.max clamped to [512 MiB, 4 GiB]. At 8 GiB this reproduces the hand-tuned numbers it replaces, which is the only calibration point that existed. Below roughly 3 GiB the ladder cannot fit inside the pod at all, so enforce mode refuses itself and says why rather than shedding the editor continuously. The never-signal guards were substring matches over the joined command line and had over-matched twice, once protecting every process in a test harness because the harness path contained the string being matched. They are now keyed the same way positive selection already is: comm, argv[0]'s basename, whole path segments of argv elements, and - for the watchdog's own identity - pid ancestry rather than a name at all. Each guard records which rule claimed a process, and the tests assert that every rule is individually reachable. Frequency is now treated as part of correctness, since an editor that dies every fifteen minutes gets the watchdog switched off. Each rung fires at most once per excursion below L1 and recovery is what re-arms it; the fixed 180s cooldown is replaced by a settle window that no longer blocks escalation. Observe mode publishes the shed rate enforce mode would have produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found by running the watchdog against a live 4 GiB workspace and against a reproduction of the failure the pod actually has, rather than against fixtures. Each has a regression test built from the measurement. A stale pidfile disarmed the watchdog permanently. When the pod is OOM-killed the watchdog dies by SIGKILL, its EXIT trap never runs, and its pidfile survives on the NFS-backed home. `acquire_singleton` treated a failed O_EXCL create as proof another instance held the lock, so every restart afterwards logged "another instance is already running" and exited - leaving the pod unwatched from the first kill onward. Liveness now decides, and identity is the script's own path as a whole argv element rather than a substring. Ceilings were computed against the wrong quantity. RLIMIT_DATA accounts `data_vm`, not RSS, and on a V8 process these differ by an order of magnitude: the live extension host held 1004 MB of data against 497 MB resident, the file watcher 622 MB against 66 MB. The derived file-watcher ceiling was 512 MB - below what an idle file watcher already held - so enforcing it would have killed a healthy process on its next allocation and on every restart after. A ceiling is now a growth allowance above observed usage, and a role that could only be capped above memory.max is reported instead of capped. The poll interval could not see the event it exists for. A runaway growing at the production rate took the test pod from idle to OOMKilled in 43 seconds; the watchdog ran in enforce mode throughout, never left L0, and logged nothing, because a 10-second idle interval gives four samples and a three-sample debounce cannot climb three rungs in four samples. The interval is now keyed on measured dU/dt rather than on tier, which is the lagging indicator of the thing being raced. Re-run against the identical spike, the ladder climbed L0 to L3 to L4 as designed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
🎉 This PR is included in version 2.26.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
The memory watchdog shipped in observe mode with hand-picked constants and
name-matched safety guards. This makes both derived and structural, fixes three
defects found by running it against a live workspace, and — more importantly —
records what the live runs showed about whether the mechanism addresses the
failure this pod actually has.
1. Thresholds and ceilings derive from the pod
Tier thresholds and
RLIMIT_DATAceilings were absolute bytes sized for an8 GiB pod. On the 4 GiB workspace the watchdog detected its own miscalibration
and warned — the right failure, but it left the mechanism usable at one pod size.
Both now come from
memory.max. One critical reserveC = memory.max / 10,clamped to
[384 MiB, 1 GiB]; the ladder isL4 = C,L3 = 1.5C,L2 = 2.5C,L1 = 4C. Ceilings are sixteenths ofmemory.max, clamped to[512 MiB, 4 GiB].memory.maxAt 8 GiB this reproduces the hand-tuned numbers it replaces (0.75/1.25/2.00/3.00),
which is the only calibration point that existed. The cap answers the original
objection to percentages — a fraction alone would hand 16 GiB a 4 GiB "critical"
reserve. Below roughly 3 GiB the ladder cannot fit inside the pod at all, so
enforce mode refuses itself and logs why rather than shedding the editor forever.
2. The never-signal guards are structural
They were substring matches over the joined command line and had over-matched
twice —
*/claude*on a scratchpad path containing/claude, and*memory-watchdog*on a test harness whose own directory path contained it,which silently protected every process in that harness and left two full runs
green while asserting nothing.
Now:
comm,argv[0]'s basename, and whole path segments of argv elements —the same kind of rule positive selection already used. The watchdog's own
identity is its pid, ancestors and descendants, not a name. Every guard records
which rule claimed a process, and the tests assert each rule is individually
reachable, because a guard nothing can trigger is untested rather than correct.
3. Frequency is treated as part of correctness
An editor that dies every fifteen minutes gets the watchdog switched off, and a
watchdog that is switched off protects nothing. Each rung now fires at most once
per excursion below L1 and only recovery re-arms it; the fixed 180 s cooldown is
replaced by a settle window that no longer blocks escalation (it used to demote
L3 to L1 for three minutes after an L2 shed — exactly when escalation mattered).
A shed below L4 must free at least 128 MiB or it is declined and logged. Observe
mode publishes the shed rate enforce mode would have produced, so the question
"would this have been tolerable?" is answerable before enabling anything.
4. Three defects found on the live pod
EXIT trap → pidfile survives on NFS → every restart logged "another instance is
already running" and exited. The pod was unwatched from the first kill onward.
Liveness now decides, not the existence of a file.
RLIMIT_DATAaccountsdata_vm, not RSS. Live at rest: extension host 1004 MB data / 497 MBresident; file watcher 622 MB / 66 MB. The derived file-watcher ceiling of
512 MB was below what an idle file watcher already held. A ceiling is now a
growth allowance above observed usage.
Live results (4 GiB test workspace)
Full evidence, reproducible, on that workspace at
~/watchdog-live-evidence/(
2026-08-17-README.mdindexes it). The script used there is byte-identical tothis branch.
VmData; ptyHost untouched; hard limits untouched; L0 throughout; zero shedsRLIMIT_DATA1.5 GiB on itRangeErrorat 960 MB after 10.5 s, process died alone, container never reached its limitno-candidates tier=L3 tree=0 eligible=0. OOMKilled anywayRun A is why the interval is now keyed on measured
dU/dtrather than on tier:four samples cannot satisfy a three-sample debounce and climb three rungs. Run C
confirms the fix and, in the same breath, shows its limit — seeing the event only
helps if the growth is inside the tree the watchdog manages.
Run C's
no-candidatesline is the new observability doing its job: an actingtier that can do nothing now says so every cycle, with the census. Without it,
run C would have looked exactly like run A's silence — which is how the second
guard bug hid.
What this does not fix, stated plainly
The kernel's own OOM records for this workspace name a Claude Code session as the
victim in every kill in the retention window, with one process reaching 7.3 GiB
of anonymous RSS in an 8 GiB cgroup. That window is biased — VS Code was being
deliberately kept away from that workspace — so it is not an exoneration of the
editor. But two things follow regardless:
buys seconds against a runaway of that size. The ladder is a brake.
RLIMIT_DATAcap, pointed at the runaway itself. It is preventive, so it neverraces the spike. Verified for both node and Bun: the allocation fails inside
the offending process with an ordinary catchable error.
Extending ceilings beyond the editor tree is therefore the obvious next step, and
it is deliberately not in this PR because it decides what may happen to the
operator's own long-running sessions.
Also measured, and a dead end worth recording:
BUN_JSC_forceRAMSizeandBUN_JSC_gcMaxHeapSizemade no difference to Bun 1.3.14's allocation ceiling(608 / 640 / 608 / 608 MB across the variants). Bun reports
totalmem 121.6 GBinside the cgroup, and node sizes its heap at 2240 MB from the same illusion, but
the Bun env-var route does not correct it.
Verification
script-memory-watchdog-test.sh: 169 assertions, run by thewatchdogjob in.github/workflows/test.yaml. New coverage: the derivation across pod sizesincluding both degenerate ends; the historical over-match paths asserted not
protected, paired with proof they are otherwise reachable; every guard rule
asserted individually reachable; rung-per-excursion and settle-window
behaviour; ceilings never below observed usage; interval chosen by rate; stale
pidfile takeover, with the mutation that must flip it.
pre-commit run --all-filespasses (hadolintSC3037/DL3066 on the Dockerfileis pre-existing and untouched).
Disruption
The test workspace was OOM-killed several times producing runs A–C, which killed
the VS Code session attached to it each time. It has been restored to the
released observe-mode watchdog. Nothing ran against any other workspace.