vi

blog

Mac compressed pages jumped from 5% to 30% because of a log-wait loop

A Claude Code subagent waited on an e2e log with a loop that reads /dev/zero. One bash process grew to 47.7 GB and the Mac compressed page count jumped from 5% to 30% of its limit while RAM still showed 71% available.

Published
macOSClaude CodeKernel PanicBash
A terminal window with a loop reading all-zero input, and 2 gauges below it, the available RAM bar still long and the compressor page count bar filled with a gradient only about a third of the way to the limit

On 2 Nov 2026 the number of pages in my Mac's compressor jumped from 5% to 30% of the limit within minutes, while RAM still showed 71% available. The culprit was a bash process spawned by a Claude Code subagent to wait on a log, and vmmap reported a Physical footprint of 47.7 GB for it, on a 32 GB machine.

This is the third post in a series on the macOS compressed pages limit, after the data.kalloc.1024 kernel panic and the Playwright WebKit case. This time the machine did not panic, but the cause is nothing like the first two.

13:05 to 13:14: compressed pages from 5% to 30%, RAM still 71% available

The machine is a 32 GB Mac Studio on macOS 26, with about 10 Claude Code sessions running in parallel in tmux. A monitoring dashboard reported "compressed pages" going from 5% to 27 to 30% of the limit between roughly 13:05 and 13:14.

That limit is sysctl vm.compressor.pages_compressed_limit, which is 13,192,448 pages on this machine. 85% is where my monitor script asks for a restart, and 100% is a panic, like the one on 31 Oct.

Nothing else looked wrong at the same time:

MetricValue
Available RAM71%
Memory pressurelevel 1
Compressor size6.5 GB
IOSurface384, flat

A flat IOSurface count means this was not WebKit, unlike 31 Oct. The only odd line in the top-memory list was bash 5.3G.

The culprit: a subagent's log-wait loop

A subagent in one of the project sessions was waiting for the e2e suite to finish, so it wrote this loop:

bash
for i in $(seq 1 55); do
  grep -qE "passed" e2e-full.log && break
  read -t 9 < /dev/zero || true
done

It meant to sleep 9 seconds between log checks. It avoided sleep because the Claude Code harness blocks a sleep that runs in the foreground, so the model invented another way to wait.

Why bash reached 47.7 GB while RAM stayed free

read reads until it sees a newline. /dev/zero only returns zero bytes and never a newline, so bash keeps collecting zero bytes into its buffer for the whole 9 seconds of each iteration, until -t 9 cuts it off.

vmmap --summary on that bash process reported a Physical footprint of 47.7 GB. RSS was only 5.3 GB, matching the bash 5.3G line in the top-memory list, because the rest had been compressed.

Pages full of zeros compress extremely well. That is why RAM still looked free, the compressor only grew to 6.5 GB, and only the page count in the compressor shot up.

Tracing the process with ps and vmmap

When compressed pages climb while RAM and IOSurface look normal, find the child bash of the claude process:

bash
ps -axo pid,ppid,rss,etime,command -m | head
vmmap --summary <pid> | grep "Physical footprint"

The command line of that bash shows read -t 9 < /dev/zero right away. The -m flag sorts by memory, and etime says how long it has been running.

Killing it was blocked: wait for the loop to end

A kill on the bash process was blocked by Claude Code's auto mode, with the reason "Interfere With Workloads".

At 13:14 the loop ended and compressed pages fell back to 7%. From 13:14 to 13:19 was the subagent's last wait: compressed pages rose to 15%, then fell to 6%.

The fix: a hook that blocks reads from /dev/zero, not a prompt instruction

Telling the subagent "don't wait with /dev/zero" is not enough, because an instruction in a prompt does not stop an agent that is looking for a way around a rule. A hook does, subagents included.

A PreToolUse hook named ram-gate now always denies Bash commands that read /dev/zero or /dev/urandom into read or into a variable:

  • read … < /dev/zero
  • cat /dev/zero | read
  • $(< /dev/zero)
  • $(cat /dev/urandom)

Bounded commands still pass: head -c N /dev/urandom, dd … count=N.

The cost: the hook matches strings in the command, so the same text inside a command as plain text (a grep pattern, a heredoc) is blocked by mistake.

How to wait properly

  • Run the long command in the background and wait for the notification when it exits.
  • Or use Monitor with an until loop that checks the condition.

Lessons: free RAM lies, hard rules belong in a hook

  • A "free RAM" figure lies when the data is all zeros. Watch the compressed page count against its limit, not just the compressor size.
  • The same rising page count can come from the kernel (IOSurface in the 31 Oct case) or from a user process. ps plus vmmap tells the two apart in seconds.
  • Agents invent ways around rules: with sleep blocked, it became read < /dev/zero.
  • A rule that must be enforced belongs in a hook, because a hook stops subagents and a prompt instruction does not.

Related posts

all posts →