[BUG] Background worker dispatcher spawns into critically low memory — low-memory guard is advisory and never gates user-initiated spawns

Status Open
Reported on v2.1.246
Maintainer reply None cached
Activity 0 comments · opened Aug 27, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Summary

On a low-RAM host with no swap, switching or creating a project (FleetView) reliably hangs the CLI hard enough to require kill -9. The daemon's low-memory guard detects the condition, logs it, attempts no effective reclamation, and spawns a ~300 MB worker process anyway — with as little as 36 MB free.

The result is not an OOM kill but an unbounded page-thrash loop, so the process never dies on its own and the UI never returns.

Environment

  • Claude Code 2.1.246 (GIT_SHA 1ba9d2211ae14e591bd1d60451c217c51f415e86, BUILD_TIME 2026-08-25T18:33:51Z)
  • Linux 6.8.0 x86_64, KVM guest
  • 826 MB RAM, zero swap
  • Native installer build (single ~237 MB executable)
  • "tui": "fullscreen" in settings.json

What happens

Opening a second project in the multi-project view freezes the UI indefinitely. CPU goes to ~100%, but it is kernel time — kswapd reclaim, not the app computing. The process must be killed manually.

~/.claude/daemon.log shows the dispatcher recognising low memory and proceeding regardless, four separate times:

[bg] bg: low memory (77MB free)  — retiring settled workers before spawning 426b95c4
[bg] bg: low memory (163MB free) — retiring settled workers before spawning 872282d5
[bg] bg: low memory (43MB free)  — retiring settled workers before spawning d5141a80
[bg] bg settled d5141a80 (killed)
[bg] bg: low memory (36MB free)  — retiring settled workers before spawning d5141a80

Root cause

The guard in the tengu_bg_dispatch_low_mem branch of the worker dispatch path, decompiled from the bundle:

t(`bg: low memory (${Y}MB free) — retiring settled workers before spawning ${c.short}`),
x("tengu_bg_dispatch_low_mem", {free_mb: Y, handles: i.size, pressure_level: ee}),
Yt(e.storageV5).then((O) => {                          // (2) not awaited
  for (let H of i.values())
    H.retireIfSettled(zt, O).catch((f) => I(f))        // (1) settled workers only
})
// ...
if (c.source === "spare" && ie)                        // (3) only "spare" is ever dropped
  return t(`bg: low memory — skipping spare dispatch ${c.short}`), "dropped";

Three problems compound:

  1. retireIfSettled can only reclaim settled workers. When the user switches projects, the session being left is active, so the sweep frees nothing. In the exact situation where memory must be reclaimed, this call is a no-op.
  2. The sweep is not awaited. It runs on a detached .then(), so the spawn proceeds immediately even if reclamation would eventually have succeeded.
  3. The only hard refusal is gated on c.source === "spare". Speculative prewarm dispatches get dropped under pressure; user-initiated spawns are never gated. So the guard declines to act in precisely the case that matters, and the log line above is purely advisory.

Why it hangs rather than OOM-killing

Each claude worker is ~428 MB RSS: ~300 MB anonymous JS heap (parsed transcript and conversation objects) plus ~125 MB of mapped pages from the 237 MB executable.

With no swap, the anonymous 300 MB is unreclaimable. The kernel's only lever is file-backed pages — which includes the executable's own text. It evicts those, the process immediately major-faults them back from disk, and the cycle repeats. Measured on the affected host:

| Metric | Value |
|---|---|
| workingset_refault_file | 3,864,776,102 |
| pgscan_kswapd / pgsteal_kswapd | 4.71 B / 3.98 B |
| pgscan_direct | 4,602,907 |
| pgmajfault | 6,305,348 |
| /proc/pressure/memory full total | 4,503 s (~75 min fully stalled) |
| /proc/pressure/memory some total | 15,838 s (~4.4 h) |
| oom_kill | 1 |

pgscan_direct is every allocation blocking synchronously in reclaim — the frozen UI. The OOM killer mostly does not fire, because from its perspective memory is "reclaimable" file cache. So the process thrashes indefinitely instead of dying, which is why manual kill is the only exit.

A larger single-file executable makes this worse, not better: the bigger the mapped text segment, the more there is to evict and re-fault.

Related issues

  • #74429 (open) — bg daemon memory governor kills mid-flight workers on 16 GB Macs (gates spawns on os.freemem() under-read). Same code path, quoting the same log line, but the inverse complaint: there the governor fires far too eagerly because os.freemem() under-reads on macOS (79 MB reported with 6.4 GB genuinely free; once at 1565 MB free). This report is the opposite failure — on Linux with genuinely 36 MB free, the trigger is correct and the action behind it is a no-op. Worth fixing together: damping the trigger to resolve #74429 in isolation would make this case strictly worse, since the guard already does nothing when it fires legitimately. The trigger and the response need fixing independently.
  • #72719 (closed) — Background workers crash-loop under memory pressure but CLI only reports "exit 1 before init". Its Expected Behavior asked that the daemon "avoid respawning indefinitely into the same low-memory condition." The behaviour above, captured on 2.1.246, shows spawns still proceeding at 36 MB free, so that appears not to have been implemented. Its log excerpt also shows an escalation this host never reached — low memory persists after shedding non-pinned — retiring pinned settled workers as a last resort — which is still settled-only, corroborating that active workers are never shed. It crash-looped rather than hung because that host had swap.
  • #87891 (open) — daemon never reaps stale workers or unclaimed spares. Under-reclamation at a different site (restart adoption, not spawn-time gating).
  • #85104 (open) — Claude Desktop (macOS): main process hard-wedges when free RAM is low; WarmLifecycle keeps spawning session children with no memory backpressure. The same failure class one layer up, in a different subsystem: there the spawn path has no memory gate at all, here it has one that fires correctly and then does nothing. Its measurements corroborate the footprint used above — ~500 MB per warm session child, and main-process wedges beginning at 0.6–1.1 GB of genuinely free RAM on a 48 GB machine. It also had to distinguish free-including-cache from genuinely-free memory, which is precisely the distinction that makes #74429 an under-read and this report a correct read.
  • #88116 (open) — background bg-spare workers never release memory after jobs complete, monotonic RSS. A third under-reclamation site, alongside #87891.
  • #60311 (closed) — desktop app, one unreaped claude process per session at ~200–300 MB until posix_spawnp fails with ENOMEM. Same shape one layer up, and an independent measurement of per-session footprint consistent with the ~300 MB anonymous heap measured here.

Suggested fix

  • Extend the "dropped" return past c.source === "spare" so user-initiated dispatches are gated too, surfacing a clear message ("not enough memory to open another project") instead of hanging.
  • await the retire sweep and re-check free memory before deciding to spawn.
  • Consider a floor derived from actual worker footprint (~300 MB anon + resident text) rather than only comparing against a low-water mark.
  • Consider retiring active non-foreground workers under pressure, not just settled ones.

Workaround

Add swap. A 2 GB swapfile plus vm.swappiness = 100 converts the hard hang into ordinary slowness — the higher swappiness biases reclaim toward anonymous pages so executable text stays resident. This does not fix the dispatcher behaviour; it just gives the kernel somewhere to put pages when a worker is spawned that should not have been.

What Should Happen?

The dispatcher should refuse to spawn — with a visible error in the UI rather than a silent hang — when free memory is below what a worker needs. A guard that measures 36 MB free and then launches a 300 MB process is worse than no guard, because it produces a log line implying the condition was handled.

Error Messages/Logs

Steps to Reproduce

  1. Boot a Linux host with ~800 MB RAM and no swap (swapoff -a).
  2. Start Claude Code CLI and build up a session with a large transcript (~3 MB / ~1600 JSONL lines reproduces it).
  3. Open the multi-project view and switch to, or create, a second project.
  4. Observe: UI freezes at the point the new project is dispatched (when creating, at the name prompt). kswapd pegs a core. cat /proc/pressure/memory shows full avg10 climbing. daemon.log shows the low memory (NNMB free) line immediately before the hang.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.247

Platform

Other

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Xterm

Additional Information

_No response_

View original on GitHub ↗