9.0 KiB
Claude Code resource limits
The problem this solves
Runaway subprocess trees under Claude Code — parallel vitest workers, tsc, eslint — periodically locked this machine up hard enough that rebooting was the only way out.
The cause is not CPU starvation. With 16 cores the scheduler timeslices through a busy load fine. It is memory exhaustion driving zram thrash: swap on this machine is 8 GiB of zram only, so swapping consumes physical RAM and burns CPU compressing, which frees less memory, which swaps harder. The kernel OOM killer fires far too late to save the interactive session, and sway stops being scheduled long enough to accept input.
At the time this was set up, nothing on the box caught that spiral:
| Mechanism | State |
|---|---|
kernel.sysrq |
16 — only sync permitted, so Alt+SysRq+F was blocked |
systemd-oomd |
inactive and disabled |
earlyoom |
not installed |
sshd |
inactive — no remote rescue path |
There was no escape hatch, which is why rebooting was the only recovery.
The fix
Every Claude session launched through dev-claude runs inside a transient systemd scope placed in claude.slice, which carries a hard memory ceiling. A runaway tree now hits that ceiling and is OOM-killed inside its own cgroup, while sway and everything else keep running.
This needs no root: cpu memory pids are already delegated to the user manager (Delegate=yes on user@1000.service).
Files involved
| File | Role |
|---|---|
home/.config/systemd/user/claude.slice |
The limits themselves |
home/.config/fish/functions/dev-claude.fish |
Places each session into the slice |
The launcher
dev-claude.fish builds a launch prefix once and uses it at all four launch sites:
set -l launch /usr/bin/env claude
if command -q systemd-run
set launch systemd-run --user --scope --quiet --collect --slice=claude.slice -- /usr/bin/env claude
end
Because systemd-run --scope execs rather than forks, the resulting process tree and command line are byte-identical to running claude directly. The only difference is which cgroup it lands in.
Three non-obvious things
These are the parts that will look wrong later and get "cleaned up". Don't.
1. /usr/bin/env is load-bearing
systemd-run resolves its target through PATH and rewrites argv[0] to an absolute path:
$ systemd-run --user --scope --quiet -- argvprobe.sh
argv0=[/tmp/argvprobe.sh] # not "argvprobe.sh"
~/.tmux.conf sets @resurrect-processes '"claude->dev-claude *"', and tmux-resurrect matches that with an anchored regex — scripts/process_restore_helpers.sh:
if [[ "$pane_full_command" =~ (^${match} ) ]] || [[ "$pane_full_command" =~ (^${match}$) ]]; then
So a bare systemd-run … -- claude makes the saved command /home/tgrosinger/.local/bin/claude --session-id …, which fails ^claude and silently stops restoring Claude panes — breaking the exact thing dev-claude exists to provide. There is no error; restore just quietly does nothing.
Interposing env fixes it, because env re-execs its target with argv[0] set to the bare name:
$ systemd-run --user --scope --quiet -- /usr/bin/env sleep 137
cmdline: [sleep 137] # bare argv[0], as if run directly
ppid: bash # systemd-run exec'd; no extra process in the tree
env also does the PATH lookup that command claude used to do here, so a fish function or alias named claude still cannot shadow the real binary.
2. dev-claude must not exec
This one bit once already: for months every Claude pane was saved, matched nothing, and restored nothing — silently.
tmux-resurrect's ps save strategy does not read the pane process. It reads the pane process's children (save_command_strategies/ps.sh):
ps -ao "ppid,args" | sed "s/^ *//" | grep "^${PANE_PID}" | cut -d' ' -f2-
exec makes Claude become the pane pid, so that grep returns Claude's own subprocesses instead of Claude. The saved pane_full_command came out as socat UNIX-LISTEN:/tmp/claude-http-….sock … — or empty, when nothing happened to be running — never claude --session-id …. Both fail ^claude , so @resurrect-processes never fires.
Running Claude as a plain foreground child keeps fish at the pane pid, which is exactly where resurrect looks:
$ fish -c 'echo $fish_pid; /usr/bin/env sleep 3' # no exec
286 285 fish -c …
317 286 sleep 3
-> resurrect saves: [sleep 3]
$ fish -c 'echo $fish_pid; exec /usr/bin/env sleep 3'
328 285 sleep 3
-> resurrect saves: []
The cost is that the pane returns to a fish prompt when Claude exits rather than closing. That is the intended trade — a dev layout does not collapse when a session ends.
3. command cannot be used in the fallback
The natural fallback is set -l launch command claude. While dev-claude still used exec, fish rejected it outright:
fish: The expanded command is a keyword.
set -l launch command echo; exec $launch ok
^~~~~~^
exec will not accept an expansion that begins with a builtin. That specific constraint is gone with exec, but /usr/bin/env claude stays in both branches: it gives the same shadowing guarantee, and keeping the two branches identical means the saved command line does not depend on whether systemd-run was found.
Limits, and why each one
Set on the slice, not on each scope, so all concurrent sessions share one budget. Per-scope limits would let four sessions claim 4 × 14 GiB and defeat the point.
| Setting | Value | Reason |
|---|---|---|
MemoryHigh |
10G |
Soft ceiling — throttle and reclaim first, so there's back pressure before anything dies |
MemoryMax |
14G |
Hard ceiling — OOM-kill inside the cgroup. Leaves ~16 GiB of 30 GiB for everything else, which normally sits near 13 GiB |
MemorySwapMax |
2G |
The zram-specific piece. Uncapped, the slice can push 8 GiB into zram, which itself occupies physical RAM — that's the thrash spiral |
CPUWeight |
50 |
Only bites under contention, so nothing slows down when the box is idle |
CPUQuota |
1200% |
Reserves ~4 of 16 cores for sway and the compositor unconditionally |
TasksMax |
2048 |
Bounds pid exhaustion from a runaway worker pool |
Operating it
Kill every Claude session at once, without touching anything else:
systemctl --user kill claude.slice
Watch live usage against the caps:
systemd-cgtop --user
systemctl --user status claude.slice
Tune without restarting running sessions (edit the unit file to persist):
systemctl --user set-property claude.slice MemoryMax=16G
After editing the unit file:
systemctl --user daemon-reload
Verifying it actually works
The check that matters. If the unit file is missing or daemon-reload was skipped, systemd silently auto-creates claude.slice with no limits — sessions launch fine, appear healthy, and are completely unbounded. There is no warning. Always confirm the real value:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/claude.slice/memory.max
# must print 15032385536, NOT "max"
Confirm a running session is actually inside the slice:
systemd-cgls --user-unit claude.slice
Confirm tmux-resurrect is unaffected — this is the string its ps save strategy reads. It must be non-empty, must name claude and not one of its subprocesses, and must show a bare claude, not an absolute path:
ps -ao ppid,args | grep "^$(tmux display -p '#{pane_pid}') "
# claude --session-id <uuid>
Then check what actually landed on disk, which is the thing restore reads:
grep -a claude ~/.local/share/tmux/resurrect/last | cut -f11
# :claude --session-id <uuid>
Testing containment safely
Do not force an OOM inside claude.slice to test it. The kernel picks the largest consumer in the cgroup, which is very likely to be a real session rather than the test process. Use an unrelated top-level slice instead — note that systemd treats - as a hierarchy separator, so a name like claude-test.slice would nest inside claude.slice and hit the same problem:
systemd-run --user --scope --quiet --collect --slice=capcheck.slice \
-p MemoryMax=512M -p MemorySwapMax=0 \
-- python3 -c "b=bytearray()
while True: b.extend(bytes(1<<20))"
Expect Killed, with the desktop unaffected.
Known gap
Typing plain claude instead of dev-claude bypasses the cap entirely. Closing that would mean adding a claude fish function delegating to dev-claude — safe from recursion, since env resolves the binary through PATH and never re-enters fish.
Deliberately not done here
Independent of this, and all requiring root:
kernel.sysrq=1, restoringAlt+SysRq+Fas an in-kernel escape hatch that works even when userspace is fully starvedearlyoom(packaged in Fedora), which would also catch non-Claude runaways- enabling
sshdfor remote rescue from another device