Skip to content

feat: add observe-only memory watchdog to the workspace template - #859

Merged
ppat merged 10 commits into
mainfrom
feat/memory-watchdog
Aug 17, 2026
Merged

feat: add observe-only memory watchdog to the workspace template#859
ppat merged 10 commits into
mainfrom
feat/memory-watchdog

Conversation

@ppat

@ppat ppat commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What this adds

A userspace memory watchdog for the workspace pod, defaulting to observe-only: it measures, publishes headroom, and logs what it would have done. Switching it to enforce is a per-workspace parameter.

File Role
script-memory-watchdog.sh The watchdog. Pure bash + /proc + /sys/fs/cgroup, plus /usr/bin/sleep and (enforce mode only) /usr/bin/prlimit
script-memory-watchdog-test.sh Fixture tests, transcribed from a live tree. Run by hand and by the watchdog job in .github/workflows/test.yaml
scripts.tf coder_script for the watchdog daemon, and the weekly vscode-server GC schedule
parameters.tf / env.tf memory_watchdog_mode parameter → validated → WATCHDOG_MODE
coder-agent.tf Memory Headroom metadata block
configmap.tf / deployment.tf Mount the script at /memory-watchdog.sh
.github/workflows/test.yaml Repo-local watchdog job — deliberately not in lint.yaml: this guards process-selection safety, not style, and would be too easy to prune by anyone tidying linters

Why it can't be a cgroup limit

Verified in-pod: memory.oom.group = 1, so a cgroup OOM kills every process in the container as a group — the IDE, every tmux session and every long-running agent, together. oom_score_adj cannot pick a victim for the same reason.

memory.high or a child cgroup would fix this properly, but /sys/fs/cgroup is mounted ro, cgroup.subtree_control is empty, the cgroup namespace is private, and the container runs as an unprivileged uid with CapEff = 0. Getting write access means privileged: true or a read-write host mount, which DESIGN.md's Unprivileged by default rules out. Raising the pod limit moves the wall rather than removing it.

That leaves one strategy: never reach memory.max. Lowering another same-uid process's soft RLIMIT_DATA needs no capability, and since Linux 4.7 it bounds private writable anonymous mmap — so it caps V8 heap, ArrayBuffers and native malloc while leaving the PROT_NONE pointer-compression cage uncounted. The hard limit is never touched, so an inheriting shell restores itself with ulimit -d unlimited.

Three things that are load-bearing

The measurement disagrees with every stock reading, and that is the point. Headroom is memory.max − U, where U sums only the unreclaimable fields of memory.stat. On the 8 GiB pod at rest, memory.current reads 92%, the memory.stat kernel roll-up 42%, coder stat mem 63% — and true U is 23%, with memory.pressure full avg10 at 0.00 throughout. A memory.current > 85% trigger would fire permanently on an idle container. Thresholds are absolute bytes rather than percentages, because the page cache a workload needs is a property of the workload, not of the limit.

What counts as a VS Code process is decided by executable path, never by "is this node". A provisioned workspace has two unrelated node installations: VS Code's bundled one under ~/.vscode-server/cli/servers/Stable-<commit>/server/, and mise's on PATH, which is what repo tooling and the operator's agent sessions run on. There is no /usr/bin/node and nothing named node on PATH without dotfiles. A process is eligible only if its own argv[0] lives under ~/.vscode-server/. Matching on comm, on a basename, or on a cmdline substring would classify an agent session spawned by an extension — a child of the extension host, and not under ptyHost — as a sheddable editor helper, which is the exact outcome this design exists to prevent. comm is MainThread for every node process in a real tree, never node, because V8 renames its main thread; nothing keys off it.

Tree membership is not a safe kill criterion. tmux sessions and agent runs started from a VS Code integrated terminal are descendants of the server tree, via --type=ptyHost. The watchdog excises that fork and its entire subtree, in every server tree it finds — --reconnection-grace-time keeps a disconnected server alive for eight hours, so two concurrent trees is ordinary, and excision only happens inside a tree that was discovered. It independently refuses to signal coder agent, tmux, claude, chezmoi, pid 1 or itself; that name-based net is a second line of defence, not the primary one.

Why observe-only by default

The RLIMIT_DATA ceilings and the tier thresholds were derived from role and an 8 GiB budget, not from measurement. Too low silently kills a healthy extension host mid-edit; too high makes the mechanism inert. The watchdog writes a calibration CSV (~/.local/state/vscode-memory-watchdog/calibration.csv) with U and every component, PSI, and refault/pgscan rates. Set the numbers from a week of that, then switch the parameter.

The mode is per-workspace rather than per-template because the thresholds are absolute byte counts: a 4 GiB workspace sits permanently near L1 against numbers sized for 8 GiB. The watchdog logs a warning when it detects that mismatch rather than silently clamping.

Verification

Fixture tests — 94 assertions

Transcribed from a captured live tree, not hand-written. Each negative assertion is paired with the mutation that must flip it:

Mutation Failures
Restore the previous comm-preferring root selection 16
Remove the argv[0]-under-.vscode-server rule 3
Key roles on the joined cmdline instead of argv[0] 1
Remove the ptyHost subtree excision 3

CI proof that the harness can fail the build: this has now been demonstrated twice, most recently against test.yaml's watchdog job specifically — a fixture assertion was deliberately broken, the job went red on real CI, and the branch was restored and went green again on the next push.

Live workspace — durable evidence on the test Coder workspace

The table below is the only claim this PR makes about live behavior. It is backed by artifacts left on disk on test, not by a summary — everything in it can be re-checked by reading the files listed, without re-running anything.

A real VS Code client connection to test wasn't available in the session that produced this evidence, so the server tree itself is fabricated: real OS processes with argv/comm shaped to match a real VS Code server tree exactly (including the comm = MainThread V8 rename the design calls load-bearing), driven by the real, unmodified script-memory-watchdog.sh. Everything the watchdog does — reading the real cgroup, discovering the real process tree, calling real kill()/prlimit(2) — is real; only the "VS Code" side of the tree is a stand-in. Full disclosure of exactly what's real vs. fabricated, and one methodology bug this exercise found and fixed along the way (a fabrication path that accidentally collided with the watchdog's own self-protection rule and silently invalidated the first attempt), is in the evidence itself.

Left in place on test, inspectable without re-running anything:

  • ~/watchdog-live-evidence/README.txt — what's real vs. fabricated, and why
  • ~/watchdog-live-evidence/SUMMARY.txt — per-run findings
  • ~/watchdog-live-evidence/runs/{run1-observe-gradual,run2-enforce-gradual,run3-enforce-l4-jump}/ — before/after ps, before/after /proc/*/limits, spawn logs, and a snapshot of the watchdog's own state dir after each run
  • ~/.local/state/vscode-memory-watchdog/{actions.log,headroom,calibration.csv} — the watchdog's own live, cumulative state
Check Result Basis
Root/tree discovery on a real process tree Correct pid, comm = MainThread, tree = 8 procs live
Observe mode Ceilings and a tier-3 signal both logged with [observe]; /proc/*/limits and process liveness confirmed unchanged before and after live
Enforce ceilings Soft RLIMIT_DATA set per role via real prlimit(2), hard limit unlimited in every case, verified via /proc/*/limits after the run live
Enforce L3 Real SIGTERM to the extension-host role; confirmed genuinely killed (zombie state, RSS 0), real memory freed (cgroup memory.current dropped accordingly) live
Enforce L4 Real SIGTERM to both server roots plus the file watcher, heaviest RSS first, no role restriction, on the first sample (no debounce, by design) live
ptyHost and everything beneath it — the memory hog included Never signalled, never mentioned in the log, confirmed alive after every run including the L4 run live
A non-.vscode-server process inside the tree but outside ptyHost (an extension-spawned agent stand-in) Protected by path alone, confirmed alive after the L4 run live
memory.events.oom_group_kill 0 before and after every run, including the run where real cgroup U reached 3.33 GiB of a 4 GiB limit live
L2 (PSI/refault-corroborated shedding) Real PSI and refault rate stayed 0 throughout; L2's corroboration gate was never satisfied not exercised live — matches the known gap below
SIGKILL escalation (2 s grace after SIGTERM) Not exercised — the fabricated processes have no signal handler, so SIGTERM alone always sufficed not exercised live
A genuine VS Code client, real IPC-spawned extensionHost/ptyHost, real respawn-on-kill Not available in-session not tested

Also found by running it for real

  • Negative headroom projections printed as -27.-79 GiB.
  • Observe mode re-logged every proposed ceiling on every cycle, burying the tier transitions.
  • Two comments asserted python3, flock and awk were unavailable in the image. All three are present; the real constraint is only that PATH is brew-shadowed, so absolute /usr/bin/... paths are required. The bash-only implementation is kept — it forks nothing per scan — but the stated reason was wrong.

Other

terraform fmt -check, terraform validate, tflint, pre-commit run --all-files, and the dry-run release pipeline all clean.

Known gaps

  • The thresholds and ceilings are still uncalibrated. That is why the default is observe. The live runs above used values scaled for a 4 GiB pod to make the ladder reachable, which is not evidence that the shipped numbers are right.
  • L2 is hard to reach. It never fired under sustained low headroom without page-cache thrashing, and a fast allocator vaults past it to L3 via the projection term. That may be correct — the pod was not suffering — but it means the tier is rarer than the design implies.
  • Language-server role matching is a prefix/suffix guess. There is no common launch convention. A wrong guess is contained to one restartable helper, since roles are only consulted inside a server tree, outside the ptyHost subtree, and only for binaries under ~/.vscode-server/.
  • Native extension helpers get no ceiling. RLIMIT_DATA on a Go or Rust runtime is an abrupt abort rather than the recoverable V8 heap OOM the ceilings are premised on, and there is no measured number to pick. They are shed at L2 instead.
  • vscode-server-gc does not exist yet. The cron resource is a no-op until the script lands in the dotfiles repo.

@ppat
ppat force-pushed the feat/memory-watchdog branch from dd6dc9c to aef6bb2 Compare August 16, 2026 19:48
ppat added a commit to ppat/dotfiles that referenced this pull request Aug 16, 2026
…U overhead (#773)

* fix: vscode remote memory settings + stop leaking PII into remotePlatform

extensions.autoUpdate was inverted against its own comment (APPLICATION
scope, verified against microsoft/vscode's extensions.contribution.ts) --
this produced six concurrently-installed Claude Code extension versions on
the remote. Extends files.watcherExclude/search.exclude to cover the
~11 GB ~/.vscode-server tree, the brew symlink farm and mise's toolchain
store, caps tsserver memory, and narrows the git extension's background
repository scanning -- all confirmed RESOURCE/WINDOW scope, so they load
from this file (VS Code Remote reads User settings from the client for
every scope except MACHINE/MACHINE_OVERRIDABLE).

remote.SSH.remotePlatform previously hardcoded the deployment's real
domain and username in plaintext. One of the two host-key entries doesn't
parse under the Coder VS Code extension's current SSH authority format
(coder/vscode-coder's src/util/authority.ts) and predates it, so it's
dropped; the other is templated using the domain's existing bitwardenSecrets
UUID (already used elsewhere in this repo) plus a new coderUsername prompt.

Adds private_dot_local/bash/limits.bash to restore an unlimited soft
RLIMIT_DATA in every interactive shell, for ppat/coder#859's watchdog,
which stamps that limit on the VS Code server tree and relies on
inheriting terminals restoring themselves.

Roo Code stays in remote.SSH.defaultExtensions -- its removal is blocked
on the Mac-side backup in #770.

Ref: #772

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(github-actions): NUL-delimit the bitwardenSecrets fake-out grep|xargs

grep -rl | xargs (no -Z/-0) word-splits on spaces in paths. This repo's
own private_Application Support tree has one, and the previous commit
added the first *.tmpl file under it -- full-apply-test's "Fake out
bitwardenSecrets calls" step failed immediately once that path was
actually matched by --include='*.tmpl'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ppat
ppat force-pushed the feat/memory-watchdog branch from 5cab378 to f08f562 Compare August 17, 2026 00:10
@ppat
ppat force-pushed the feat/memory-watchdog branch 2 times, most recently from dcda985 to ba42013 Compare August 17, 2026 02:14
ppat and others added 6 commits August 17, 2026 02:31
The workspace pod's cgroup sets memory.oom.group=1, so a cgroup OOM kills every
process in the container as a group - the IDE, every tmux session and every
long-running agent, together. Victim selection via oom_score_adj cannot help for
the same reason, and memory.high or a child cgroup would need a writable
/sys/fs/cgroup, which the unprivileged-by-default stance rules out. The only
remaining strategy is to never reach memory.max.

script-memory-watchdog.sh samples the cgroup's genuinely unreclaimable memory and
publishes the headroom left before something has to die. It runs as a coder_script
daemon (there is no supervisor in this pod) with a pid-file guard so an agent
restart cannot leave two of them running.

It ships in observe mode: it measures, publishes headroom to the Coder UI, and
logs what it would have done, but sets no RLIMIT_DATA ceilings and sends no
signals. The ceilings and tier thresholds were derived from role and an 8 GiB
budget rather than from measurement, and are to be set from the calibration CSV
the watchdog is now collecting. Arming it is a one-line change to WATCHDOG_MODE
in env.tf.

Two things the implementation depends on, both covered by
script-memory-watchdog-test.sh:

- Headroom is memory.max minus the unreclaimable fields of memory.stat only.
  memory.current and the memory.stat kernel roll-up read 92% and 42% of the limit
  on a pod whose true unreclaimable share is 23%, so either would make the
  watchdog fire permanently on an idle container.
- Nothing in the --type=ptyHost subtree is ever signalled. Tree membership alone
  is not a safe kill criterion: tmux sessions and agent runs started from a VS
  Code integrated terminal are descendants of the server tree through ptyHost.

Also adds a weekly cron coder_script for a vscode-server GC; the script it calls
lands separately in the dotfiles repo and the resource is a no-op until then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alerting and notification routing are deliberately not wired on this
cluster. resources_monitoring is a notify-on-threshold mechanism, and it
evaluates (memory.current - inactive_file) / memory.max, which is
inflated by page cache -- so it would notify on an idle workspace.

The Memory Headroom metadata tile already surfaces the honest number
without pushing anything at anyone.
The watchdog had never been run against a real VS Code process tree. A live
capture falsified two assumptions it was built on.

comm is MainThread for every node process in the server tree, never node,
because V8 renames its main thread with prctl(PR_SET_NAME). Root selection
preferred comm=node and fell back to the first /proc-glob match, so on a real
tree the preference never fired and the fallback decided everything - electing
whichever matching process happened to sort first, which against a fixture
carrying a plausible decoy is a `cat` of the server path. Selection now ignores
comm entirely and keys on argv[0], the path VS Code launched the process with.
Every root is collected rather than one elected, because
--reconnection-grace-time keeps a disconnected server alive for eight hours, and
a pty host is only excised inside a tree that was discovered.

A provisioned workspace also has two unrelated node installations: VS Code's
bundled one under ~/.vscode-server, and mise's on PATH, which is what repo
tooling and long-running agent sessions run on. Anything asking "is this node"
would have classified an agent session spawned by an extension - a child of the
extension host, not of the pty host - as a sheddable editor helper. A process is
now eligible only if its own binary lives under ~/.vscode-server.

Native extension helpers such as terraform-ls get a role of their own. They are
shed at L2 like any other restartable helper but given no RLIMIT_DATA ceiling:
V8 turns ENOMEM into a fatal heap OOM the editor offers to recover from, while a
Go runtime turns it into an abrupt abort with no such affordance.

Also from the live runs: negative headroom projections printed as "-27.-79 GiB",
and observe mode re-logged every proposed ceiling every cycle, burying the tier
transitions the log exists to record. Two comments asserted that python3, flock
and awk were unavailable in the image; all three are present, and the real
constraint is only that PATH is brew-shadowed.

The fixtures are transcribed from a captured tree rather than hand-written, and
comm tolerance, the operator's runtime and multiple concurrent servers each have
an assertion paired with the mutation that flips it.
The mode was hardcoded in env.tf, so changing it meant a template release. It is
now a mutable coder_parameter defaulting to observe, offering only the two valid
modes, and passed through the same validate-then-use step as the list parameters
- an unrecognised value falls back to the inert mode rather than reaching the
agent.

Per-workspace rather than per-template because the tier thresholds are absolute
byte counts sized for an 8 GiB pod; the same setting sits permanently near L1 on
a 4 GiB one.
The suite guards the two rules that can cost the operator work - which processes
count as the editor's, and which of those may be signalled - and nothing ran it,
so it could rot silently while staying green in the one place it was consulted.

Repo-local rather than a reusable workflow because ppat/github-workflows has
nothing for "execute a test script"; the shellcheck workflow only lints. It
needs bash and a writable TMPDIR, builds fixtures under mktemp -d, touches no
network and finishes in seconds, so it is cheap to leave on and has nothing in
it to go flaky.
The watchdog suite guards process-selection safety, not code style, so
lumping it into lint.yaml as watchdog-tests buried a safety-critical check
where nobody browsing linters would expect to find it, and left it exposed
to being pruned by anyone tidying that file.

Give it its own repo-local workflow, following the same reasoning that kept
it repo-local before: ppat/github-workflows has no reusable "run this test
script" workflow. lint.yaml's shared detect-changes job exists to serve many
linters off one change matrix; a single test job has no need of that
machinery, so this instead follows release.yaml's simpler direct pull_request
paths filter, scoped to the watchdog script and to changes to this workflow
file itself - the same two-path trigger the job had under lint.yaml.
@ppat
ppat force-pushed the feat/memory-watchdog branch from ba42013 to 890e76b Compare August 17, 2026 02:33
ppat added 4 commits August 17, 2026 02:33
Temporary commit to verify the moved watchdog job still fails the build
when a fixture assertion breaks. Reverted in the next commit.
Stale after moving the job out of lint.yaml's watchdog-tests.
Two spots still pointed at the old watchdog-tests job in lint.yaml.
@ppat
ppat merged commit a0dce0b into main Aug 17, 2026
17 checks passed
@ppat
ppat deleted the feat/memory-watchdog branch August 17, 2026 19:46
@homelab-workflows-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.25.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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