An unattended job on my machine once ended partway through a build, quietly.
Disk space was my first suspect. As an indie developer I keep several repositories — App Store side projects and the sites I publish — on one machine, so ENOSPC is not a rare accident here. But there was plenty of room left that time, so the culprit was somewhere else.
Memory was next on the list. When a build grabs a few gigabytes for a moment, it takes the working session on the same machine down with it.
The Claude Code v2.1.233 changelog mentions CLAUDE_CODE_TOOL_MEMORY_LIMIT, which puts Bash tool commands into a Linux memory cgroup so that, in its words, a runaway build cannot stall the session.
So where should the ceiling sit on my own machine? And does a ceiling actually protect the session? I spent an afternoon on Ubuntu 22.04 (kernel 6.8) finding out, and the answer turned out to depend almost entirely on where you put the ceiling.
Capping address space is not the same as capping memory
Linux gives you two broad ways to constrain a process, and they behave nothing alike.
| Mechanism | What it caps | What happens at the limit | Scope |
|---|---|---|---|
ulimit -v (RLIMIT_AS) | Virtual address space reservations | The allocation call fails; the process stays alive | The shell and its children |
cgroup v2 memory.max | Physical memory actually in use | If reclaim fails, the OOM killer sends SIGKILL | Every process in the cgroup |
The first mechanism politely declines a request. The second one kills you for using too much.
What a program does after being declined is entirely up to that program — and that is where the interesting part lives.
Under ulimit -v, Python failed politely
I started with a Python process that grabs memory in small pieces.
( ulimit -v 524288; python3 -c "
b = []
n = 0
try:
while True:
b.append(bytearray(4 * 1024 * 1024))
n += 4
except MemoryError:
print(n)
" )ulimit -v takes KiB, so 524288 is 512 MiB. Three runs produced the same number:
492
492
492
Of the 512 MiB ceiling, 492 MiB was usable. The missing ~20 MiB is what the interpreter itself had already reserved before my loop ran at all. Repeating the measurement at other ceilings showed that overhead staying roughly constant:
ulimit -v | Allocated before MemoryError | Overhead |
|---|---|---|
| 256 MiB | 232 MiB | ~24 MiB |
| 512 MiB | 492 MiB | ~20 MiB |
| 1024 MiB | 1000 MiB | ~24 MiB |
That constant matters when you are picking a number. The ceiling is not a budget for your workload; it is a budget for your workload plus whatever the runtime reserved on the way in. On a small ceiling that overhead is a tenth of everything you have.
The number matters less than the shape of the failure: the process caught a plain MemoryError, cleaned up after itself, and chose its own exit code. Nothing was killed.
For a Python script you control, ulimit -v is a reasonable way to manufacture a well-behaved failure.
Under the same cap, Node never even started
Applying that same 512 MiB to Node.js (v22.22.3) went very differently.
( ulimit -v 524288; node -e "console.log('started')" )No started. Exit code 133. The first meaningful line on stderr:
# Fatal process out of memory: Failed to reserve virtual memory for CodeRange
No try / catch was involved. V8 reserves a large slab of virtual address space during startup, so the process died before any build code ran.
I bisected to find the boundary:
ulimit -v | Exit code of node -e "0" |
|---|---|
| 512 MiB | 133 (startup fails) |
| 640 MiB | 133 (startup fails) |
| 704 MiB | 133 (startup fails) |
| 768 MiB | 0 (starts) |
| 896 MiB | 0 (starts) |
On this machine, 768 MiB was the floor just to run an empty script.
Raise the ceiling to 2 GiB and runtime allocation failures do become catchable:
caught@1056MiB Array buffer allocation failed
Only the startup reservation is fatal. Everything after it behaves like an ordinary exception.
The practical conclusion is a strong one. Putting a low ulimit -v on a Node toolchain — npm, Vite, webpack, Metro — does not tame runaway builds. It kills healthy builds at startup instead. "Let's just cap the build at 512 MB" is not a plan that survives contact with V8.
Seen in that light, the decision to build CLAUDE_CODE_TOOL_MEMORY_LIMIT on cgroups rather than rlimits reads as the obvious one.
Under a cgroup, failure arrives as SIGKILL
cgroup v2's memory.max watches physical memory. Virtual reservations pass straight through, so Node starts normally, and the limit only bites when memory is genuinely in use.
At that point the kernel sends SIGKILL. It cannot be caught. The shell reports exit code 137 (128 + 9).
bash -c 'sleep 20 & P=$!; kill -9 $P; wait $P'
echo $?137
For anyone running unattended work, that difference is mostly a difference in how you read logs:
- Exit code 137 means look at memory first.
- An instant death like 133 points at address space limits or container configuration.
- An exception such as
MemoryErrorin the log means the process survived long enough to explain itself.
Retry logic changes too. A SIGKILLed build leaves nothing behind, so retrying it unchanged simply gets it killed at the same point. Raising the ceiling or lowering concurrency has to come first.
It is also worth knowing that a cgroup does not kill on the first byte over the line. The kernel tries to reclaim memory first — page cache, clean pages, anything it can drop — and only escalates when reclaim cannot keep up. In practice that means a build sitting just under the ceiling gets slower rather than killed, because the cache it was relying on keeps getting evicted. A build that looks mysteriously sluggish under a limit is often telling you the ceiling is a little too tight rather than fine.
One honest caveat about my setup: the sandbox runs as an unprivileged user, so creating a directory under /sys/fs/cgroup returns Permission denied, and systemd-run --user has no user bus to talk to. I could confirm the exit-code convention but not exercise the cgroup path itself. On your own machine, the reliable test is to point a disposable build at something like systemd-run --user --scope -p MemoryMax=2G -- npm run build and let it get killed once on purpose.
One more note, accurate as of August 16, 2026: CLAUDE_CODE_TOOL_MEMORY_LIMIT appears in the changelog but is not yet listed on the environment variables reference page. The accepted value format may change, so check the primary source before you set it — which is why this article does not assert one.
Two decisions worth making before you set a ceiling
Once you decide to impose a limit, only two things really need deciding.
First, the number itself. Measure what one healthy build actually uses, then start at 1.5 to 2 times that figure. Then multiply by the number of builds that can run at once and confirm the total stays under your physical memory. In setups with several unattended jobs, skipping that multiplication is how you end up with limits in place and a machine that still crawls. Staggering start times is often cheaper than lowering every ceiling, since two heavy builds rarely need to overlap.
Second, what failure should look like. Decide where an exit code of 137 gets recorded and how it surfaces. Without that decision, unattended work produces mornings where something "just ended." Stopping silent failures in general is covered in the run that logged success while producing nothing, and if you are isolating Bash tool errors more broadly, Claude Code Bash tool execution errors is the closer companion piece.
If you do one thing today, run your heaviest build once under /usr/bin/time -v and write down Maximum resident set size. Every argument about the right ceiling starts from that single line.
I had been watching disk space and never measuring memory. If you have the same gap, I hope this saves you a confusing morning.