Files
dotfiles/docs/claude-resource-limits.md

9.0 KiB
Raw Permalink Blame History

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, restoring Alt+SysRq+F as an in-kernel escape hatch that works even when userspace is fully starved
  • earlyoom (packaged in Fedora), which would also catch non-Claude runaways
  • enabling sshd for remote rescue from another device