Background tasks killed for "low memory" with 26 GB free on WSL2

Status Open
Reported on v2.1.261
Maintainer reply None cached
Activity 3 comments · opened Sep 6, 2026

What happened: Three consecutive background Bash tasks were terminated by the harness with «was stopped because the system is running low on memory», while free -m reported ~26,800 MB free and ~29,200 MB available out of 31,893 MB total. No kernel OOM events in dmesg. The killed processes were using ~200-500 MB each (Radiance/Python computations). Re-run identically in the foreground, they complete without problems.

Repro: On WSL2 (kernel 6.6.114.1-microsoft-standard-WSL2), launch a Bash tool command with run_in_background that runs Python/Radiance computations lasting a few minutes. The kill arrived within 1-2 minutes in 3 out of 6 cases (the other 3, identical, completed). dmesg shows «WSL (368) ERROR: No buffer space available @telemetry.cpp:190 (StartTelemetryAgent)» and periodic drop_caches from mini_init — the low-memory detection appears to misread WSL2's memory state.

Evidence:

  • task ids: bnwwtnmxg (completed), by5wat1od (killed), bdtksvw8h (killed), bkdarac7y (killed)
  • session: session_01SexjAPCvf78ocpu6yETwet, 2026-09-06 ~01:00-01:30 CET
  • request ids: req_011Cem9M3TtjkWAcSMejNtQf, req_011Cem9NZYRHKbom7newchci, req_011Cem9UGAZjS9kdsdnz1JVv
  • free -m right after the kills: 26-27 GB free
  • CLI 2.1.261, linux x64, WSL2, model claude-fable-5

View original on GitHub ↗

3 Comments

Serenisoft · 1 month ago

Third independent data point, same platform. Adding it because I was able to rule out three plausible causes by measurement, which narrows the search.

Environment

  • Claude Code 2.1.263
  • Kernel 6.6.87.2-microsoft-standard-WSL2, 62.8 GiB allocated to the VM

What was killed

Two background Bash tasks, both trivial until <check>; do sleep 30; done waiters holding essentially no memory. Both reported:

Background command "..." was stopped because the system is running low on memory

with task status killed. No user-visible work was lost, but the loops were the wait mechanism for a long-running build, so the session lost its completion signal.

Ruled out by measurement

  1. cgroup limits — not involved. This session runs inside a systemd scope with MemoryHigh=28G, MemoryMax=32G, MemorySwapMax=0. After the kills:

``
memory.current 3.3 GiB
memory.peak 12.1 GiB
memory.events low 0 high 0 max 0 oom 0 oom_kill 0
``
The scope never reached even the throttle threshold, and the kernel never killed anything in it.

  1. Kernel OOM killer — not involved. No oom-kill / Killed process entries in dmesg.
  1. os.freemem() — would not have fired. On this kernel it returns MemAvailable, which read 45.9 GiB. So a check based on os.freemem() cannot explain the kill.

Memory state at inspection

MemTotal      62.8 GiB
MemFree       12.0 GiB
MemAvailable  45.9 GiB
buff/cache    36.0 GiB
SwapTotal      4.0 GiB
SwapFree       0.29 GiB   <-- 93% of swap in use

Where this differs from #92228

That report hypothesises the guard reads MemFree where it should read MemAvailable. That does not fit here: MemFree was 12 GiB, which is unlikely to read as "low" under any plausible absolute or percentage threshold.

The one number that was genuinely near exhaustion on this machine is swap headroom (295 MiB of 4 GiB). That makes me suspect the heuristic factors in free swap, or some combined free-memory-plus-free-swap figure, rather than MemFree alone.

Weak supporting signal

The swap pressure turned out to come from 55 leaked testcontainers-labelled Postgres containers left running for 8 days; their shared memory had been swapped out. Removing them dropped swap usage from 3800 MiB to 878 MiB. I have not seen a kill since, but that is one observation over a short window, not a controlled result — I am reporting it as a lead, not as a reproduction.

What would settle it

If someone can say which field the check reads (MemFree, MemAvailable, swap headroom, PSI, or a combination), these three reports can be checked against it directly. A documented threshold, or any way to configure or disable it, would also help: I could not find one in settings-reference.md or errors.md.

CoolCold · 1 month ago

hitting multiple similar issues on my VPS, a lot of RAM (48GB total), plenty free/avail, yet getting those out of the blue which break a lot of flows

● Background command "Check whether the directory survived the killed rm and how big it is" was stopped because the system is running low on memory

● Noted. The sweep killed even the directory check;

I wonder what it measures as "low memory", could it be tied to high load average numbers?

Started to happen ~ 1 week ago +- couple of days

vizbyarcher · 1 month ago

Root cause found: this is a one-shot false memory-pressure event from a Linux kernel PSI bug, and Bun re-arms it each time a session starts a background shell. It has nothing to do with MemFree or MemAvailable.

The chain (each link read from source)

  1. Claude Code's reaper (read from the 2.1.268–2.1.272 binaries). Each background Bash task registers process.on("memoryPressure"). On the event it kills the task with reason memory_pressure, unless one of these holds:
  • the session is non-interactive;
  • the user interacted in the last 30 minutes (userPresence.lastInteractionTime);
  • the main loop is busy;
  • a local_agent, remote_agent, in_process_teammate or local_workflow task is live.

The off switch is the documented CLAUDE_CODE_DISABLE_BG_SHELL_PRESSURE_REAP=1.

  1. Bun (process.on("memoryPressure"), oven-sh/bun#32594, Bun 1.4). On Linux it writes the PSI trigger some 150000 2000000 to /proc/pressure/memory, falling back to the cgroup's memory.pressure, and emits on EPOLLPRI. The watcher is armed on the first listener and disarmed on the last removal. So every time a session goes from zero background shells to one, a new trigger is created.
  2. The kernel (kernel/sched/psi.c, torvalds/linux master as of 2026-09-15). psi_trigger_create() seeds every trigger's window with the PSI_POLL total:

``c
window_reset(&t->win, sched_clock(), group->total[PSI_POLL][t->state], 0);
...
t->aggregator = privileged ? PSI_POLL : PSI_AVGS;
``

  • An unprivileged trigger (a 2 s window, no CAP_SYS_RESOURCE) is evaluated in update_triggers(group, now, PSI_AVGS) as window_update(&t->win, now, total[PSI_AVGS][state]), i.e. the PSI_AVGS total minus a PSI_POLL start value.
  • total[PSI_POLL] only advances while a privileged (rtpoll) trigger exists.
  • So at the first stall update after creation, the computed "growth" is roughly all the memory stall accumulated since boot, far above 150 ms. The trigger fires whatever the real stall was — once, or twice when another stall follows within the next window (the inflated growth is carried into prev_growth for one window).

Measured. Environment: WSL2, kernel 6.18.33.2-microsoft-standard-WSL2, a 94 GiB VM, CC 2.1.272.

  • Three independent watchers registered Bun's exact trigger as an unprivileged user.
  • Each got exactly one event, at the first stall after it was created.
  • The actual some growth around those events was 115 µs, 0 µs and 30 µs, with MemFree at 79 GB and no direct reclaim, kswapd scan or compaction.
  • None got a further event in the next 15+ minutes.

Controlled cycles. The same trigger was created and closed three times. Each fired at its first stall after arming (0.1 s, 60.1 s and 2.5 s after arming) with 687 µs, 520 µs and 0 µs of real some growth. One of the three fired a second time within its next window, on 562 µs of growth; the other two stayed silent for 240 s. In all, six of six newly created triggers fired falsely.

Why it looks random. A kill needs the false event to land after 30 minutes of user idleness, with the turn ended and no subagent running. The event waits for the first stall after the trigger was armed. On a quiet machine that can be minutes later, often when the background job itself starts heavy I/O. That matches the reports here: high MemAvailable, low MemFree, WSL2/Debian/Ubuntu, and even trivial sleep waiters being killed.

Workaround. Set "CLAUDE_CODE_DISABLE_BG_SHELL_PRESSURE_REAP": "1" in settings.json → env.

Suggested fixes

  • Claude Code: confirm the pressure before reaping. For example, read /proc/pressure/memory and require the some total to have grown by at least the threshold within the last window. Alternatively, keep one listener for the session's lifetime, so the trigger is not re-armed for every background shell.
  • Bun: drop an event when the file's some total grew by less than the threshold since arming or since the previous event (reported as oven-sh/bun#42783).
  • Kernel: seed the window from the trigger's own aggregator (PSI_AVGS for unprivileged triggers).