# 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: ```fish 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`: ```bash 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`): ```bash 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: ```bash systemctl --user kill claude.slice ``` Watch live usage against the caps: ```bash systemd-cgtop --user systemctl --user status claude.slice ``` Tune without restarting running sessions (edit the unit file to persist): ```bash systemctl --user set-property claude.slice MemoryMax=16G ``` After editing the unit file: ```bash 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: ```bash 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: ```bash 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: ```bash ps -ao ppid,args | grep "^$(tmux display -p '#{pane_pid}') " # claude --session-id ``` Then check what actually landed on disk, which is the thing restore reads: ```bash grep -a claude ~/.local/share/tmux/resurrect/last | cut -f11 # :claude --session-id ``` ### 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: ```bash 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