I Put Claude Code in a Cgroup Jail Because It Kept Eating My Workstation

For two weeks, heracles — my main box, 128 GB of RAM, the machine I drive the whole fleet from — kept hanging solid and rebooting itself out from under me.

Not a crash you get to watch. A wedge. One second I'm working across a dozen terminals; the next the pointer won't move, the fans spin up like a jet on the apron, and every input I give the machine vanishes into a tarpit. Thirty seconds of that and the hardware watchdog gives up, yanks the reset line, and I'm looking at a login greeter with every tmux session, every browser tab, every train of thought I had going — gone.

It happened on the 13th. It happened again on the 26th. It happened on the 29th while I sat there and watched it die. Losing a day's context to a silent reboot is a special kind of rage, the kind that makes you want to physically fight a computer. So I stopped being angry at the symptom and went to find out what was actually killing my machine.

This is that story. It has a villain, and the villain is JavaScript. Obviously it's JavaScript.

The autopsy

Here's the thing about a machine that wedges hard: its own logs are useless, because the journal never gets flushed. heracles' on-disk journal just ends, mid-line, at the moment of death. So years ago I set up netconsole — the kernel screaming its dying words over UDP to a different box on the network before it loses the ability to do anything at all. ares catches heracles' last breath.

And heracles' last words, every single time, were the same:

oom-kill:constraint=CONSTRAINT_NONE,...,global_oom

A global out-of-memory. The kernel ran out of RAM, invoked the OOM killer, and the OOM killer — in its infinite wisdom — started executing whatever. Not the thing eating the memory. Just... things. wireplumber. My emacs daemon. The accessibility bus. Tiny 30-megabyte desktop services, shot one after another, each death freeing nothing, while the actual gluttons sat there untouched and the box spiraled into a reclaim stall until the watchdog put it out of its misery.

So: what filled 128 gigabytes? I ranked the processes from the OOM dump. The entire top of the list was one thing, over and over:

7.36 GB  .claude-unwrapped
7.35 GB  .claude-unwrapped
7.35 GB  .claude-unwrapped
   ...   (twenty of them, five to seven gigs each)

Twenty-odd Claude Code sessions, north of 100 GB combined. My AI assistant had eaten my workstation.

"Bun Pool 5 invoked oom-killer"

Now, a detail that made me tilt my head. The thread that actually tripped the final allocation was named Bun Pool 5. Bun. As in the JavaScript runtime.

I want to be precise about what I learned next, because it reframes everything. Claude Code is not a Node script. It's not a little CLI that shells out. Claude Code, as shipped, is a 257-megabyte single-file executable — the entire Bun runtime (v1.4.0) with JavaScriptCore baked in, plus the whole application bundle, bun build --compile'd into one enormous ELF binary. Bun Pool 5 wasn't some side process. It was Claude Code itself. Those worker threads are the program.

Which means every one of those 7-gigabyte lines was a single chat session's JavaScript heap.

Seven gigabytes. For a terminal program. That mostly sits there waiting for me to type.

The ratchet

It gets worse, and this is the part that made me put my head on the desk.

A few days later — after I'd started building the fix — I was mid-session, sitting at a prompt Claude had drawn for me, deciding how to answer. Hadn't typed anything in a while. And the session got killed. So I went and looked at what it had been holding at the moment of death. systemd logs it right there on the way out:

claude-1654491.scope: Consumed 25min CPU over 3h 18min wall clock time, 21.5G memory peak.

Twenty-one and a half gigabytes. Sitting idle. At a menu. For three hours. That one session was 83% of all the Claude memory on the box. A fresh session starts at about 400 megabytes. This thing had ratcheted to fifty times that and then just... held it, doing nothing, while I stared at a multiple-choice question.

And "ratcheted" is exactly the right word, because this is the specific technical sin, and I want to name it precisely instead of just yelling "JavaScript bad."

JavaScriptCore — like V8, like every one of these runtimes — allocates its heap in a big arena. When garbage collection runs, it frees your dead objects inside that arena. It reuses the space. What it does not do, essentially ever, is give the pages back to the operating system. So the arena is a one-way ratchet. The instant a session briefly needs 21 GB — you had it read a big file, or it captured a huge build log into its context, or you resumed a long conversation and it deserialized the whole transcript back into memory — the arena grows to fit. And then it stays that big. Forever. For the entire life of the process. The memory is "free" as far as the JS heap is concerned, and completely unavailable as far as the rest of the box is concerned.

This is not a bug in Claude Code, exactly. It's the water the entire ecosystem swims in. It's what you get when an industry decides that the correct runtime for a systems tool — a thing that runs on my machine, in my resource budget, next to my real work — is a browser engine with a garbage collector that treats "give memory back" as somebody else's problem. Ship it as a 257 MB binary, let the heap grow without bound, and if the user's workstation falls over under ten concurrent copies, well, buy more RAM. I have 128 gigabytes. It wasn't enough, because the software was written by people for whom "it works on my machine" is the entire quality bar.

I'm not going to fix their memory management. I can't; it's compiled into a quarter-gigabyte blob and there isn't even a heap-limit flag exposed. But I run NixOS, and I don't have to trust software. I can put it in a cage.

Building the cage

Here's the good news, and the reason I'll die on the NixOS hill: my entire fleet is one declarative repo. Every machine, every service, every dotfile, and — as of this saga — the exact resource jail every claude process runs inside. I don't SSH into a box and tweak things. I change the source and rebuild.

The first thing I tried was wrong, and I'm including it because pretending you got it right on the first go is how blogs lie to you. I added zram — compressed swap in RAM — figuring a swap cushion would let pressure build gracefully instead of slamming into a wall. Reader, it made the next crash worse. When your problem is a hot working set of live JavaScript heaps, compressing them into a different part of the same RAM buys you nothing and costs you CPU. At the next death, 56 GB of physical memory was consumed by zram's own compressed pages, on top of 60 GB of resident heaps, and the box thrashed itself to death faster than before. I ripped it back out. Wrong tool. Moving on.

The fix that actually worked has three parts, and they're all just cgroups — the Linux kernel's resource-accounting jails — wired up declaratively.

One: every Claude session runs in its own systemd scope with a hard memory cap. The wrapper launches claude inside a transient --user scope with MemoryMax=12G. A single session that ratchets past 12 gigs now hits the wall of its own cgroup and gets OOM-killed inside its own jail — the kernel kills that one process tree and nothing else. The bloated session executes itself. Twelve gigs is still obscene for a chat client, but it's a number that lets ten of them coexist on this box, and a session that blows through it was going to hurt me anyway.

Two: all of those scopes live under one parent claude.slice with an aggregate capMemoryMax at 66% of RAM. This is the one that actually addresses my real failure mode, which was never one runaway. It was twenty reasonable-looking sessions summing past 128 GB with no single one looking guilty. Now, all Claudes combined are bounded. When they collectively push the ceiling, a kill fires inside the slice — a Claude dies, never the compositor.

Three: I un-inverted the OOM priorities. This was an embarrassing bug of my own making. I'd biased claude toward being the preferred kill victim... but I'd set the bias too low. It turns out logind session services (wireplumber, emacs, the a11y bus) carry a higher OOM-priority by default than the timid value I'd picked, so the kernel would rather shoot a 30 MB audio daemon than a 7 GB Claude. That's exactly backwards, and it's why the desktop kept dying instead of the thing at fault. Fixed the numbers so a fat Claude is always first against the wall.

Belt and suspenders on top: systemd-oomd watching memory-pressure (PSI) on the user slice and killing the worst offender from userspace before the kernel's blunt global killer ever wakes up, plus earlyoom as a dumb backstop.

Did it work?

The night I finished it, that idle 21.5 GB session I told you about got killed. And here is the entire point of two weeks of work, in the logs:

kernel global-OOM events this boot:   0
systemd-oomd kill actions this boot:   1   ->  claude-1654491.scope
claude sessions still alive:           8
box reboots:                           0

One session died. The one holding 21.5 gigabytes for no reason. The other eight kept running. My desktop didn't even flicker. The machine that had hard-rebooted three times in two weeks took the exact same kind of memory pressure and handled it as a routine event — killed one recoverable chat session, which I brought right back with claude --resume, and stayed up. Uptime held. Nothing got kicked to a greeter. I didn't lose a day.

That's the difference between "the OS is a suggestion" and "the OS is in charge."

The part where I make it a graph

The last thing that bugged me: I only found the 21.5 GB session after it died, in a scope-exit line. I was blind to the ratchet while it was happening. So I wired Claude's cgroups into the Telegraf → InfluxDB → Grafana stack that already runs my fleet — every session's live memory, its high-water mark, its throttling events, sampled every 15 seconds, graphed next to everything else. Plus a little journal-scraper that turns every session teardown into an event so I can see how each one ended — clean exit, self-OOM'd on its own cap, or reaped by oomd — and backfills the peak history from those same scope-exit lines.

Now I can watch the ratchet climb in real time. When some future Claude session starts marching toward 12 gigs while sitting at a prompt, I'll see the line go up and to the right, and I'll know exactly which JavaScript heap to be furious at.

The lesson

You are not going to fix the memory management of software you didn't write, especially not when it's shipped as a quarter-gigabyte compiled blob by an ecosystem that considers releasing memory back to the OS a quaint premodern concern. That fight is unwinnable and it's not yours.

But you don't have to win it. The kernel has had the tools to contain a greedy process since forever — cgroups, memory limits, OOM scoring, PSI. The tragedy is how few people wire them up, because doing it by hand is fiddly and doesn't survive a reboot. That's the whole reason I build my machines the way I do: declaratively, in one repo, where "every claude runs in a 12-gig jail under a slice capped at two-thirds of RAM, and here's the exact OOM priority of every service" is fifteen lines of Nix that are true on every boot forever.

I can't make the JavaScript behave. But I can build a cage strong enough that it doesn't matter, and I can prove the cage holds, and I can graph the animal throwing itself against the bars.

Now if you'll excuse me, I have a chat session at 11 gigs and climbing.


Comments