Skip to content

feat: police helper-process drift instead of chasing OOM spikes - #865

Merged
ppat merged 3 commits into
mainfrom
watchdog-drift
Aug 18, 2026
Merged

feat: police helper-process drift instead of chasing OOM spikes#865
ppat merged 3 commits into
mainfrom
watchdog-drift

Conversation

@ppat

@ppat ppat commented Aug 18, 2026

Copy link
Copy Markdown
Owner

What this changes

The memory watchdog stops trying to prevent OOM kills and starts bounding the standing population of restartable helper processes. The graded L1–L4 shedding ladder and the RLIMIT_DATA ceilings are removed; the measurement underneath them is kept.

Why the old purpose is gone

  • Every recorded memcg kill is a spike: 70–220 MB/s, idle to dead inside a minute, with an agent session or node named as the victim in the kernel log — never a VS Code process.
  • A poll loop cannot win that race. A generic biggest-RSS killer beats the kernel only at a 0.3 s interval, loses at 0.5 s, and under the memory.oom.group = 1 that applied at the time was killed by the event it lost to.
  • The entire editor tree the ladder could shed is ~0.7 GiB — about five seconds of that growth rate. In a live spike the ladder climbed correctly and logged no-candidates: the runaway was not in the tree it managed.

What a poll loop is good at is MB-per-minute drift in the long-lived population — which is real, has been policed by hand for months, and had a python MCP server holding 1.66 GB at one of the kills.

Budgets

Per role, in PSS, each max(min(role budget, memory.max / 8), resting × 1.5):

Role Budget (8 GiB pod) Fresh Resting
extensionHost 1069 MiB (floor beat the 1024 share) 471 MB 713 MB
serverMain 512 MiB 160 MB 90 MB
tsserver 512 MiB (pod-share clamped from 768)
languageServer 256 MiB 54 MB
fileWatcher 256 MiB 34 MB 88 MB
extensionHelper 512 MiB
claudeHelper (MCP servers) 512 MiB

The operator's "512, maybe 256" instinct is applied unchanged to the helpers, where it is right. It is not applied to the extension host: fresh it is 471 MB, and at rest on a reconnected idle 8 GiB workspace it is 713 MB, with the tree at 1093 MB rather than the 727 MB this design originally cited. 512 would have fired on reconnection alone.

"Calibrated against fresh, deployed against resting" is the same error that produced a ceiling below what an idle file watcher already held, so the references are the resting figures and nothing else. The floor rule then does the work by itself: at 713 MB resting the extension host is lifted to 1069 MiB, above the 1024 MiB pod share, and the derivation reports each budget the floor lifted (floored=1) — that means the pod is too small to bound the role at its intended share, which is worth saying out loud. The fixture suite asserts the property (no budget at or below resting, at any pod size) rather than the arithmetic.

VmData is no longer used: it mattered only because RLIMIT_DATA accounts it.

Not becoming harmful

  • Dwell — ten minutes continuously over budget before anything happens, so a language server that balloons while indexing and hands the memory back is treated as load.
  • Circuit breaker — three kills of one role inside an hour disarm that role, loudly, and it stays disarmed. Eight kills across roles disarm everything. It never widens its own budget: a mechanism that raises the limit it enforces has stopped enforcing.
  • Age floor — nothing under five minutes old is killed; that shape is a spike, not drift.

What it may touch

Two populations, found two ways:

  1. The VS Code tree, by executable path under ~/.vscode-server (unchanged).
  2. Helpers an agent session spawned — chiefly MCP servers, invisible to the old tree-scoped selection. The walk descends from each session root and stops at a shell and at a change of session id. The session rule is measured: a session root has sid = its login shell's session, while every Bash tool call has pgid == sid == its own pid, because Claude Code detaches each one. That keeps an in-flight build out of the policed set even when the tool call's shell has exec'd itself away.

Identity guards (pid 1, the agent, tmux, session roots, agent payloads, watchdog kin) stay absolute. The two positional rules — the ptyHost subtree and "does not run VS Code's own binary" — now bound the editor selection only, so an MCP server is policed the same whether its session started under coder ssh or in a VS Code terminal.

Sweep log, and action lines that leave the pod

~/.local/state/vscode-memory-watchdog/sweep.log (rotating, plus sweep.latest, summary, top): one row per policed process and per unmanaged process above 32 MB — role, PSS, RSS, age, budget, seconds over budget, which guard claimed it, and a stable identity breadcrumb (an MCP server's module, not python3). Secrets in argv are redacted at the point of writing.

Actions also go to Loki, for free. The log agent tails container stdout and nothing else — but PID 1 in this container is the coder agent, so /proc/1/fd/1 is container stdout. Action lines written there arrive labelled by namespace, pod and container with no new infrastructure and no configuration anywhere.

  • What goes: kills, refusals, disarms, the budgets in force at startup, and one census line an hour (policed count, over-budget count, kills to date, headroom, and the largest helper as a share of its budget — the trend series for the thing this watchdog bounds).
  • What stays local: the per-process sweep. Dozens of rows a minute do not belong in a log pipeline, and a test asserts they never reach stdout so a later change cannot quietly send them.
  • Format: logfmt beginning with component=memory-watchdog, so {namespace="coder"} |= "component=memory-watchdog" | logfmt works against a very chatty agent stream without anyone adding a stream label. Free text is quoted into detail= by the writer, not by call sites, so a forgotten quote cannot turn a sentence into five bogus fields.
  • Best-effort: one failed write disables the path, records why once locally, and changes nothing else. A watchdog that died because logging failed would be worse than any bug it prevents.
  • Observe mode emits event=would-kill, which is exactly the evidence needed to answer "would this have fired too often" before arming it.

Verified end to end: the daemon's own lines from the test workspace are in Loki and parse into 18 logfmt fields. (See the correction added under ## Evidence on 2026-08-21.)

Mode

memory_watchdog_mode becomes observe / enforce (helpers — the new default) / enforce-all (adds extension host and serverMain, which restart visibly).

A defect this introduced, and the guard for it

The first run of the fixture suite after the stdout path was added — before the harness set its seam — wrote fixture event=kill role=extensionHost pss_mb=1907 lines into the live workspace's container log, and thus into Loki, describing kills that never happened in the exact format a post-mortem would trust. Nothing was signalled (kill is shadowed in the suite). A correcting note was appended to the same stream, and load_watchdog now sets WATCHDOG_STDOUT_PATH and the suite exits if the seam did not take. A test fixture that can write into production telemetry is a defect in the test.

Evidence

Fixtures: 276 assertions, negative cases paired with the mutation that must flip them; kill is shadowed so fixture pids cannot signal real processes, and stdout is redirected to a temporary file.

Live, on the disposable test workspace, enforce with the dwell shortened:

  • a drifted 739 MB helper was killed three times as its supervisor respawned it;
  • the role disarmed on the third kill with the loop message; the fourth incarnation is still running;
  • a detached 400 MB tool call and both session roots were never policed;
  • a 132 MB helper inside its budget was never touched;
  • the real VS Code serverMain classified correctly and read 73 MB PSS;
  • the daemon's event=census and event=budget lines reached Loki and parsed cleanly, showing budget_mb=1069 floored=1 for the extension host against a pod_share_mb=512 on that 4 GiB pod.

Correction, added 2026-08-21 (#882): the claims above are not corroborated by the retained Loki record. Querying the full history ({namespace="coder"} |= "component=memory-watchdog", 2026-07-01 onward) finds no event=kill/event=would-kill/event=refused/event=disarmed line and no occurrence of the string 739 anywhere, for any workspace, at any time before 2026-08-21. No workspace named test ever emitted a kill or disarm line at all. The budget_mb=1069 floored=1 ... pod_share_mb=512 figures above aren't distinguishing evidence either: every disposable test_mode workspace since has printed that exact event=budget line at startup regardless of whether anything was killed, because floored=1 was a constant at every pod size until #881 fixed it — its presence here says nothing about this specific drill. The only kill/disarm lines anywhere near this PR's merge time are on the operator's live workspace, self-corrected by the daemon at the time as event=note reason=fixture-leak (fixture pids 41/61, 1907/1583 MB — the defect described two sections up under "A defect this introduced") — not a drill result.

This doesn't prove the drill above didn't happen: a drill run correctly through the fixture harness's required WATCHDOG_STDOUT_PATH seam would, correctly, leave nothing in Loki to find, which the record can't distinguish from a drill that never ran. But the specific figures here, and "verified end to end... in Loki" above, aren't things a reader can check against anything. TESTING.md's drill section (as of #882) carries a verified account of a different, later drill instead, with matching evidence in both Loki and the pod's local actions.log. The rest of this PR — budgets, dwell/breaker design, the two policed populations, the action-log delivery mechanism itself — is unaffected.

Two things found live and fixed: budgets printed as 0M in the summary (an associative-array subscript inside $(( )) reads the key, not the variable), and the walk originally policed a detached tool call until the session rule was added.

Where acute protection now sits

singleProcessOOMKill is in effect: a pod created after the kubelet rollout reads memory.oom.group = 0, so an OOM takes the offending process only. A container created before it still reads 1, because the value is fixed when the container is created — which is why an earlier revision of this description claimed the opposite from a single old-pod sample. The kernel is the first line for the acute case; the runway this PR protects is the second.

That setting was applied to the nodes by hand and exists in no repository, so a node rebuild or the Talos migration would silently restore all-or-nothing OOM behaviour — and this template's stated rationale for not attempting acute protection would quietly become wrong. Raised as ppat/homelab-ops-kubernetes-clusters#948 and referenced from DESIGN.md.

Other corrections to the premises this was started from

  • No spec exists for exporting role-bucketed metrics or top-N slots to Prometheus/Loki — searched both cluster repos, all plan documents, and the issues of four repos. The local sweep log is therefore the whole deliverable, not a stopgap.

Open question, deliberately not answered here

Whether Claude Code respawns a stdio MCP server it did not kill itself. VS Code demonstrably respawns every helper in its own set; the agent side is assumed and untested, because testing it needs a real stdio MCP server. If it turns out not to, the fix is one parameter value (observe) or one budget override, not a code change — and the first day of sweep.log will show how often it would matter.

Supersedes the calibration half of #860; the observe→enforce decision that issue tracks is now this parameter.

The memory watchdog was built to prevent a cgroup OOM by shedding VS Code
processes before the limit. That premise did not survive measurement:

- every recorded kill is a 70-220 MB/s spike, idle to dead inside a minute,
  with an agent session or node named as the victim, never a VS Code process;
- a poll loop cannot win that race - a biggest-RSS killer beats the kernel only
  at a 0.3s interval and is killed by oom.group when it loses;
- the whole editor tree the ladder could shed is ~0.7 GiB, five seconds of that
  growth, and in a live spike the ladder climbed correctly and then logged
  no-candidates because the runaway was not in the tree it managed.

So the graded L1-L4 ladder and the RLIMIT_DATA ceilings are removed, and the
measurement they were built on is kept. What replaces them is the thing a poll
loop is actually good at: bounding the standing population of restartable
helpers, which the operator has been policing by hand for months (a python MCP
server held 1.66 GB at one of the kills). The goal is runway, not rescue.

What it now does:

- per-role PSS budgets, each clamped between a share of the pod's memory.max
  and 1.5x the role's measured resting size, so a budget can never land below
  what a role demonstrably needs - the defect that reached enforce-readiness
  twice before;
- a kill needs ten minutes of continuous over-budget dwell, so a language
  server that balloons while indexing and hands the memory back survives;
- three kills of one role inside an hour disarm that role, with a loud log
  line. The kill loop, not the wrong kill, is what would make this harmful;
- a second policed population: helpers an agent session spawned, chiefly MCP
  servers, which live nowhere near ~/.vscode-server. The walk stops at shells
  and at a change of session id - measured, because Claude Code detaches every
  Bash tool call into its own session, which keeps in-flight work out even when
  the tool call's shell has exec'd itself away;
- a durable per-process sweep log with identity breadcrumbs and argv redaction.
  The previous version computed this table every cycle and threw it away, which
  is why every post-mortem in this investigation was unanswerable.

Identity guards stay absolute; the two positional rules (ptyHost subtree, "not
VS Code's own binary") now bound the editor selection only, so an MCP server is
treated the same whether its session came from coder ssh or a VS Code terminal.

memory_watchdog_mode gains a third value: observe / enforce (helpers, the new
default) / enforce-all (adds the extension host and server, which restart
visibly).

Exercised on the test workspace as well as by fixtures: in enforce mode the
watchdog killed a drifted 739 MB helper three times as its supervisor respawned
it, disarmed the role on the third, and left the fourth incarnation, a detached
400 MB tool call and both session roots untouched.
ppat added 2 commits August 18, 2026 00:24
…in effect

memory.oom.group is fixed for a container when the kubelet creates it, so a
long-lived workspace still reads 1 while a pod created after the rollout reads
0. Sampling only the former is what produced the wrong claim; both were
re-checked, and a freshly created pod reads 0.

The design is unchanged - a poll loop still cannot see a 43-second event, and
drift policing is still the right job for one - but the acute case now rests on
the kernel, with runway as the second line rather than the only one.

That kubelet setting was applied to the nodes by hand and exists in no
repository, so a rebuild or the Talos migration would silently restore
all-or-nothing OOM behaviour. Raised as
ppat/homelab-ops-kubernetes-clusters#948 and referenced from DESIGN.md, because
this template's rationale now depends on it.
… resting size

Two changes, both from measurement.

**Action lines now leave the pod.** The cluster's log agent tails container
stdout only, but PID 1 in this container is the coder agent, so /proc/1/fd/1 is
that stdout: writing there needs no new infrastructure and no configuration
anywhere, and the lines arrive in Loki labelled by namespace, pod and container.
Only actions take that route - kills, refusals, disarms, the budgets in force,
and one census line an hour. The per-process sweep stays in the local file, and
a test asserts that it does: dozens of rows a minute do not belong in a log
pipeline.

Every line is logfmt beginning with component=memory-watchdog, so
`|= "component=memory-watchdog" | logfmt` works against a very chatty stream
without adding a stream label; free text is quoted into detail= by
record_action rather than at each call site. The write is best-effort - one
failure disables the path, records why once locally, and changes nothing else.
Observe mode emits what it would have done, which is the evidence needed before
arming this anywhere.

**Budgets are re-anchored on resting rather than fresh measurements.** A fresh
extension host is 471 MB PSS; the same process on a reconnected, idle 8 GiB
workspace holds 713 MB, with the tree at 1093 MB rather than the 727 MB this
design cited. "Calibrated against fresh, deployed against resting" is the error
that produced a file-watcher ceiling below what an idle file watcher held, and
it had crept back in. RESTING_ROLE now carries resting figures, which lifts the
extension host to 1069 MiB on both pod sizes via the existing floor rule, and
the derivation reports which budgets the floor lifted (floored=1) - that means
the pod is too small to bound the role at its intended share, which is worth
saying rather than hiding.

Also: the fixture suite now refuses to run unless its stdout seam is set. The
first run after the stdout path was added wrote fixture kill lines into the live
workspace's container log and thus into Loki, describing kills that never
happened in the format a post-mortem would trust. Nothing was signalled (kill is
shadowed there), a correcting note was appended to the same stream, and a test
fixture that can write to production telemetry is now a hard failure.

Verified live from the test workspace: the daemon's own census and budget lines
appear in Loki and parse into 18 logfmt fields, with budget_mb=1069 floored=1
for the extension host against a 512 MiB pod share.
@ppat
ppat merged commit 2777ce4 into main Aug 18, 2026
17 checks passed
@ppat
ppat deleted the watchdog-drift branch August 18, 2026 01:28
@homelab-workflows-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.26.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

ppat added a commit that referenced this pull request Aug 21, 2026
* fix: govern the in-tree helpers the watchdog could not name

role_of returned `other` for three real VS Code helpers on the live tree, and
compute_policed skips `other`, so they had no budget, no dwell clock, no signal
and no warning: a tamasfe.even-better-toml server.js at 280 MB PSS (already
above the 256 MiB languageServer budget a matching pattern would have given
it), tsserver's typingsInstaller.js at 61 MB, and the built-in
markdown-language-features server at 54 MB.

Widening the pattern list is the easy fix and the wrong one - launch shapes are
each extension's own choice, so the list is a permanent race against a
vendor-controlled vocabulary that fails silently, one extension at a time. The
default inside the tree is inverted instead: on VS Code's own runtime,
unprotected and unnamed becomes role `treeHelper`. Two things buy off the risk
that creates rather than assuming it away:

  - treeHelper is in a new UNKNOWN_ROLES tier, armed only under enforce-all,
    because its 512 MiB budget is a reasoned guess and no member has been
    measured at rest. Under enforce - the template default - a coverage gap
    arrives as `event=would-kill armed=no` in the container log, not as
    silence. The axis is calibration, not blast radius.
  - a direct child of a server root is excluded structurally. Core forks
    (fileWatcher, extensionHost, ptyHost) are exactly the direct children of
    server-main.js and no extension helper is, so a release that renames
    --type=extensionHost yields a process that is unmanaged and loud rather
    than one carrying a guessed 512 MiB budget against the 685 MB it holds.

Sweep-log identity for the role is <extension>/<script>, because a dozen
extensions all ship a file called server.js and `server` answers nothing.

Four smaller corrections, each with the assertion that pins it:

  - extensionHost's declared budget was 1024 MiB against its own 1069 MiB
    resting floor, so `floored=1` was true at 4, 8, 16 and 64 GiB alike -
    a property of the constants reported as a property of the pod. The
    declared number is now the one always in force: no effective budget
    changes at any pod size, and the flag now flips off at 16 GiB. The suite
    asserts the invariant (no declared budget below its own floor) rather
    than the flag on one pod.
  - disarming a role now finishes an escalation already in flight and starts
    no new one. A drill found the opposite by accident: the breaker tripped
    between SIGTERM and SIGKILL and the process was left alive, over budget
    and half-signalled, recorded as neither killed nor spared. record_kill is
    not called again - it is the same kill - and the line says after_disarm.
  - the "nothing policed" warning is per episode of blindness rather than a
    one-shot latch over a lifetime high-water mark, so a blind spot arriving
    on day two of a pod's life is no longer silent.
  - `2>/dev/null` now precedes the input redirect it silences on every
    per-process /proc read. The old order opened the file first, so a process
    exiting mid-sweep produced 160 of the 161 lines in boot.log. Harmless,
    but it is noise that would hide a real error. memory.stat/memory.max are
    deliberately left alone: a missing cgroup file is worth seeing.

Not done, deliberately: budgets stay per-process rather than per-role-aggregate.
The only multi-process role here is tsserver (two, at 112 + 141 MB against
768 MiB each); a role total makes "which sibling dies" arbitrary; and it does
not compose with a dwell clock keyed per pid:starttime, which is what separates
load from drift. The cheap half is done instead - role_pss_mb and role_peak_mb
in the hourly census - so the evasion, and a stale RESTING_ROLE, become visible
as data. RESTING_ROLE stays hand-measured: a watchdog that re-derived its floor
from observation would raise the limit it enforces exactly when the thing it
enforces against grew.

Every new assertion is paired with the mutation that flips it red, and all
eight were run: removing the treeHelper branch (10 fail), the direct-child
guard (3), the argv[0] condition (5), arming treeHelper under enforce (3),
reverting the declared budget (2), restoring the old disarm ordering (2), the
one-shot visibility latch (2), and the identity breadcrumb (3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197hA8JPwGMX8wufiQiy6oz

* docs: correct and evidence the memory watchdog's kill-path claims

TESTING.md's drill account is replaced with a verified one: verbatim
actions.log lines from the 2026-08-21 disposable-workspace drill, the
dwell-vs-age distinction between its two kills, the negative space
(untouched detached tool-call stand-in and in-budget helper), what the
compressed thresholds do and don't cost the result, and what the drill
did not exercise.

It also checks PR #865's merged claim that a "739 MB helper" drill was
"verified end to end... in Loki" against the retained Loki record.
Neither an event=kill/disarmed line nor the string 739 appears anywhere
in that record; the only kill/disarm lines near the PR's merge time are
the already-documented fixture-suite stdout leak, and no workspace
named test ever emitted one. The claim is not corroborated, though a
seam-respecting drill would also leave no trace, so the record cannot
rule the description in or out - only narrow what is checkable.

calibration.csv's du_bytes_per_s column is an instantaneous per-sample
rate, not a drift rate: naively averaged it overstates drift roughly
fourfold (19.8 kB/s vs the 18.4 MB/hour a long census window gives).
Documented next to the column's declaration in the script and as a
CLAUDE.md gotcha, since nothing else guards a reader from the same
misreading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197hA8JPwGMX8wufiQiy6oz

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant