CLAUDE LABJP
SUNSET — The legacy Workbench and experimental prompt tool APIs retire tomorrow, August 17, one day outLATEST — Version 2.1.233, released August 15, is current: GitLab merge request URLs now work with --worktree and the claude agents view, where MRs appear as !NSECURITY — Windows paths written with the NT \??\ device prefix no longer bypass UNC validation, closing an NTLM credential-leak vectorTODO — Todo and task tracking tools are off by default on Opus 4.8, Sonnet 5, Fable 5, Mythos 5, and newer models; set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 to bring them backFORK — Version 2.1.232, released August 14, makes subagent_type: 'fork' the default, so a forked subagent inherits the full conversation and prompt cacheMENTION — Typing @ in the prompt now mentions another Claude session by name, and SendMessage reaches that session directlySUNSET — The legacy Workbench and experimental prompt tool APIs retire tomorrow, August 17, one day outLATEST — Version 2.1.233, released August 15, is current: GitLab merge request URLs now work with --worktree and the claude agents view, where MRs appear as !NSECURITY — Windows paths written with the NT \??\ device prefix no longer bypass UNC validation, closing an NTLM credential-leak vectorTODO — Todo and task tracking tools are off by default on Opus 4.8, Sonnet 5, Fable 5, Mythos 5, and newer models; set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 to bring them backFORK — Version 2.1.232, released August 14, makes subagent_type: 'fork' the default, so a forked subagent inherits the full conversation and prompt cacheMENTION — Typing @ in the prompt now mentions another Claude session by name, and SendMessage reaches that session directly
Articles/Claude Code
Claude Code/2026-08-16Intermediate

A Runaway Build Dies Very Differently Under cgroup Than Under ulimit

Claude Code v2.1.233 added opt-in memory cgroup support for Bash tool commands on Linux. Capping virtual address space with ulimit -v and capping physical memory with a cgroup produce completely different failures — and only one of them lets your Node build start at all.

Claude Code221Bash ToolMemory6LinuxTroubleshooting12

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.

MechanismWhat it capsWhat happens at the limitScope
ulimit -v (RLIMIT_AS)Virtual address space reservationsThe allocation call fails; the process stays aliveThe shell and its children
cgroup v2 memory.maxPhysical memory actually in useIf reclaim fails, the OOM killer sends SIGKILLEvery 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 -vAllocated before MemoryErrorOverhead
256 MiB232 MiB~24 MiB
512 MiB492 MiB~20 MiB
1024 MiB1000 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 -vExit code of node -e "0"
512 MiB133 (startup fails)
640 MiB133 (startup fails)
704 MiB133 (startup fails)
768 MiB0 (starts)
896 MiB0 (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 MemoryError in 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Claude Code2026-07-16
Your Overnight Session Wakes Up at 3GB — Four Places Memory Piles Up, and How to Tell Them Apart
The Claude Code process I left running overnight had grown to 3.4GB of resident memory by morning. Here are the four accumulation sources closed in 2.1.209, how to separate what's left in your own setup by sampling RSS slope, and a watchdog pattern that folds a session before it hurts.
Claude Code2026-06-27
Will It Stay Light When You Run It Unattended? Observing and Capping Claude Code's Long-Session Memory
How to keep long, unattended Claude Code sessions from slowly getting heavier — with a tiny ps-based RSS sampler, a rolling-baseline watchdog, and session segmentation, shown with working scripts and a before/after comparison.
Claude Code2026-05-24
Recovering from Claude Code's 'Tool result could not be submitted'
What 'Tool result could not be submitted' really means in Claude Code, and the practical recovery steps I rely on after years of running indie apps with 50M+ downloads through it.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →