feat: add observe-only memory watchdog to the workspace template - #859
Merged
Conversation
ppat
force-pushed
the
feat/memory-watchdog
branch
from
August 16, 2026 19:48
dd6dc9c to
aef6bb2
Compare
This was referenced Aug 16, 2026
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
force-pushed
the
feat/memory-watchdog
branch
from
August 17, 2026 00:10
5cab378 to
f08f562
Compare
ppat
force-pushed
the
feat/memory-watchdog
branch
2 times, most recently
from
August 17, 2026 02:14
dcda985 to
ba42013
Compare
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
force-pushed
the
feat/memory-watchdog
branch
from
August 17, 2026 02:33
ba42013 to
890e76b
Compare
Temporary commit to verify the moved watchdog job still fails the build when a fixture assertion breaks. Reverted in the next commit.
…fail" This reverts commit 5a01950.
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.
Contributor
|
🎉 This PR is included in version 2.25.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 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.
script-memory-watchdog.sh/proc+/sys/fs/cgroup, plus/usr/bin/sleepand (enforce mode only)/usr/bin/prlimitscript-memory-watchdog-test.shwatchdogjob in.github/workflows/test.yamlscripts.tfcoder_scriptfor the watchdog daemon, and the weeklyvscode-serverGC scheduleparameters.tf/env.tfmemory_watchdog_modeparameter → validated →WATCHDOG_MODEcoder-agent.tfMemory Headroommetadata blockconfigmap.tf/deployment.tf/memory-watchdog.sh.github/workflows/test.yamlwatchdogjob — deliberately not inlint.yaml: this guards process-selection safety, not style, and would be too easy to prune by anyone tidying lintersWhy 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_adjcannot pick a victim for the same reason.memory.highor a child cgroup would fix this properly, but/sys/fs/cgroupis mountedro,cgroup.subtree_controlis empty, the cgroup namespace is private, and the container runs as an unprivileged uid withCapEff = 0. Getting write access meansprivileged: trueor a read-write host mount, whichDESIGN.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 softRLIMIT_DATAneeds 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 withulimit -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, whereUsums only the unreclaimable fields ofmemory.stat. On the 8 GiB pod at rest,memory.currentreads 92%, thememory.statkernelroll-up 42%,coder stat mem63% — and trueUis 23%, withmemory.pressure full avg10at0.00throughout. Amemory.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 onPATH, which is what repo tooling and the operator's agent sessions run on. There is no/usr/bin/nodeand nothing namednodeonPATHwithout dotfiles. A process is eligible only if its ownargv[0]lives under~/.vscode-server/. Matching oncomm, 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.commisMainThreadfor every node process in a real tree, nevernode, 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-timekeeps 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 signalcoder 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_DATAceilings 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) withUand 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:
comm-preferring root selectionargv[0]-under-.vscode-serverruleargv[0]CI proof that the harness can fail the build: this has now been demonstrated twice, most recently against
test.yaml'swatchdogjob 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
testCoder workspaceThe 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
testwasn'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 thecomm = MainThreadV8 rename the design calls load-bearing), driven by the real, unmodifiedscript-memory-watchdog.sh. Everything the watchdog does — reading the real cgroup, discovering the real process tree, calling realkill()/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/afterps, 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 statecomm = MainThread, tree = 8 procs[observe];/proc/*/limitsand process liveness confirmed unchanged before and afterRLIMIT_DATAset per role via realprlimit(2), hard limitunlimitedin every case, verified via/proc/*/limitsafter the runSIGTERMto the extension-host role; confirmed genuinely killed (zombie state, RSS 0), real memory freed (cgroupmemory.currentdropped accordingly)SIGTERMto both server roots plus the file watcher, heaviest RSS first, no role restriction, on the first sample (no debounce, by design).vscode-serverprocess inside the tree but outside ptyHost (an extension-spawned agent stand-in)memory.events.oom_group_kill0before and after every run, including the run where real cgroupUreached 3.33 GiB of a 4 GiB limit0throughout; L2's corroboration gate was never satisfiedAlso found by running it for real
-27.-79 GiB.python3,flockandawkwere unavailable in the image. All three are present; the real constraint is only thatPATHis 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
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.~/.vscode-server/.RLIMIT_DATAon 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-gcdoes not exist yet. The cron resource is a no-op until the script lands in the dotfiles repo.