Closed general

WslDistro workspace: one runaway thread can take down the whole VM. Add memory guardrails (earlyoom, per-thread cgroup cap, .wslconfig headroom)

Ray · 1mo ago ·closed by John Lauer

What happened

2026-09-11, my box, WslDistro runtime. A Claude Code thread ran a 14-job C++ build of KiCad (14 × cc1plus with a precompiled header, ~1.5 GB each) and at the same time a Python script that held a couple of GB of numpy image data. The VM is 16 GB with 4 GB swap. The kernel killed two of the processes (exit 137 / 144), then WSL itself went down: every terminal, code-server, every AI thread, all at once. Hydrogen had to bring the workspace back.

The agent was being greedy, but the point is that nothing in the workspace stops a single thread from taking the whole VM with it.

Why it can

In the WslDistro runtime the workspace is the VM, so there is no container cgroup around it. Checked from inside:

  • /sys/fs/cgroup/memory.max does not exist: nothing is limited.
  • no earlyoom / oomd: the kernel OOM killer only acts after the VM is already thrashing, and WSL2 tends to hang or reset before that.
  • ulimit -v / -m unlimited.
  • %USERPROFILE%\.wslconfig only has networkingMode=mirrored: no memory=, swap= or autoMemoryReclaim.
  • the RAM bar in the title bar shows usage against mem_limit_mb (16384 = the VM), but that limit is only reported, not enforced.

Both systemd (PID 1, systemd=true in /etc/wsl.conf) and cgroup v2 (nsdelegate) are available, so all of the fixes below are possible without changing the runtime.

Proposed guardrails, most value first

  1. earlyoom in the distro (golden image, or a setup step: apt install earlyoom, enable the unit). Kill the largest process at ~8 % free RAM / 50 % free swap, before the VM thrashes. --prefer '(cc1plus|ld|lld|rustc|python3|node)' --avoid '(code-server|sshd|systemd|adom|hydrogen)' so a compiler dies before the editor. Its -n hook could raise a Hydrogen toast ("earlyoom killed cc1plus, 2.1 GB"). This alone turns today's crash into one killed compiler.
  2. Per-thread memory cap. Spawn each AI thread / terminal under systemd-run --scope -p MemoryMax=<~70 % of the VM> -p MemorySwapMax=<some> (or one adom-threads.slice with MemoryHigh/MemoryMax). A runaway thread is OOM-killed inside its own cgroup; other threads and code-server survive. The stats tooltip could show the per-thread cap next to the VM total.
  3. .wslconfig headroom. Hydrogen already writes guiApplications=false merge-safely; do the same for memory= (host RAM minus ~6 GB, floor 8 GB), swap= (≥ 8 GB) and autoMemoryReclaim=gradual when the user has not set them. Keeps Windows and Hydrogen responsive when the VM saturates, so the restart card can appear instead of everything freezing.
  4. UI. RAM bar red plus a toast at ~90 %, and the "restart needed" badge when the VM has actually gone (I do not know whether that path already covers a VM reset, it may).
  5. Agent side. One line in the workspace CLAUDE.md telling agents to size parallel builds from RAM (-j$((RAM_GB/2))) and not to stack memory-heavy jobs. Soft, but free.

Reproduce

stress-ng --vm 8 --vm-bytes 90% --timeout 60 in any thread, or the real thing: a ninja -j14 KiCad build plus a numpy script over a few 7200×3300 PNGs.

Happy to test earlyoom thresholds and the systemd-run wrapper on my box; tell me which of 1–3 Hydrogen wants to own.

3 Replies

John Lauer · 1mo ago

Triaged with John today, and a correction to the premise first: the VM does have a cap (WSL2 gives it half the host RAM, 16 GB on a 32 GB box, plus swap), and the kernel did exactly what it should, it killed the two offenders. Nothing "went down": the VM thrashed for about ten minutes and came back on its own, which is what you saw when you waited. What was wrong was on our side: the banner told you the Windows WSL service was not responding and hinted at sleep and a Windows restart, none of which had been measured. The stats probe inside the distro exceeded its 3 s cap three times while the VM was thrashing, and that tripped the wedge flag.

What ships, one change with #52:

  1. wsl.exe never observes the workspace again, it only starts and stops the distro. Health comes over HTTP (code-server's health URL, the adom-vscode extension port, the relay), and the message names exactly what did not answer and for how long, never a cause.
  2. One surface: the banner inside the VS Code pane with Reconnect. The toast on top goes.
  3. CPU, GPU, RAM and the kernel's OOM-kill counter come from Win32 and one HTTP request, no wsl.exe. The meter ticks every 2 s and pauses while the window is hidden.
  4. code-server and the relay get the lowest OOM kill priority so a runaway build or render dies first; terminal shells reset their own priority so AI processes stay killable.
  5. When the OOM counter rises, an info toast names the killed process and its size, so you know it happened without being interrupted.

No earlyoom, no per-thread caps, no memory watchdog, by John's call: Linux is the safety net, and the fix is to stop misreporting it. #14 and #15 are closed into this.

John Lauer · 1mo ago

Shipped in Hydrogen 1.0.387 (needs adom-vscode 1.1.59 in the workspace, which ships with it). Closing.

First, the correction to the premise, confirmed: the VM is capped (WSL2 gives it half the host RAM, 16 GB on a 32 GB box, plus swap) and the kernel killed the offenders exactly as it should. Nothing "went down" that needed us; the VM thrashed and recovered on its own. What was wrong was our messaging, and that is what this fixes.

What shipped:

  1. The meter never spawns wsl.exe. CPU, RAM and the kernel OOM count come from the extension's GET /stats over HTTP. Verified on arav-rog: stats_source "distro", real numbers, over HTTP.
  2. One surface. The "Windows' WSL service is not responding, restart Windows" toast is gone. A stalled editor backend shows the VS Code pane's Reconnect banner, and its text now names what did not answer instead of guessing at sleep.
  3. Out-of-memory protection. code-server and the relay units carry OOMScoreAdjust=-900 so a runaway build or render is killed first; interactive terminal shells reset their own score to 0 (via /etc/profile.d) so the AI processes they spawn stay killable, while the relay's non-interactive login shell keeps its protection.
  4. The notice you asked for: when the kernel frees memory by ending a process, an info toast names it (process and size when the kernel log is readable). Info level, never a banner.
  5. The meter ticks every 2s and pauses while the window is hidden.

#14 (the oom_score_adj idea) and #15 (the flight recorder) are closed into this: the priority piece from #14 shipped here, and the flight recorder stays out by decision.

The unit changes apply on the next launch after this update, when ah rewrites the code-server and relay units.

Ray · 1mo ago

Thanks for the correction on the premise; the capped VM plus a kernel doing its job is a better model than the one I filed with, and the messaging changes are the right fix.

One observation from inside the workspace, offered as data rather than a reopen: the container half of item 3 is not in effect on my box yet.

adom-vscode extension           1.1.76          (past the 1.1.59 you named)
systemctl show code-server -p OOMScoreAdjust   -> OOMScoreAdjust=0
MainPID 161 /proc/161/oom_score_adj            -> 0
/etc/systemd/system/code-server.service.d/     -> one drop-in, Environment= only
/etc/profile.d/                                -> 01-locale-fix.sh, hd-env.sh; no oom_score_adj reset

So code-server here is still as killable as anything else, and an interactive shell is already 0 with nothing resetting it. My guess is that the drop-in and the profile.d file ride in the container image rather than the extension, and this workspace was re-imaged on 09-10 to v27-full, three days before 1.0.387 shipped. If that is right, what triggers the pickup: a new image, or does something in the workspace install them on update?

Not reopening, since the Windows half is verified and this may be my box waiting for an image. Happy to re-check here once you say what should deliver it.

Log in to reply.