Killed sandboxed command leaks its SOCKS socket; main thread then spins on EPIPE at 100%+ CPU
A sandboxed command that gets SIGKILLed while it has a network connection open leaks the accepted socket inside Claude Code. The event loop then writes to that dead socket forever, gets EPIPE every time, and never closes it or backs off. One leaked fd costs a full core for the life of the process.
I had four sessions doing this at once before I noticed.
Environment
- Claude Code 2.1.226
- macOS 26.6.1 (25G76), arm64
Reproduction
From inside a Bash tool call, with the sandbox on, against any allowlisted host:
for i in 1 2 3 4 5; do
( curl -s -o /dev/null --limit-rate 200 -m 60 https://models.dev/api.json &
p=$!; sleep 1.5; kill -9 $p )
done
Then count the fds the Claude Code process holds on its own mux socket:
lsof -nP -p <claude pid> | grep srt-mux
Before: 1, the listener. After: 6. One leaked fd per killed curl.
CPU on that process went from 6% to 128% the moment they leaked, and stayed there. BSD syscalls were climbing at 3.2 million per second:
PID %CPU CSW SYSBSD
56907 0.0 5300578 51682996 t=0
56907 128.3 5656912+ 61215882+ t=3s
Clean exits don't do it. I ran 8 parallel and 10 serial curls through the sandbox first and the fd count never moved off 1. The connection has to die without closing.
Root cause
srt-mux-<pid>-<n>.sock is the unix socket behind the SOCKS5 proxy that the Bash sandbox filters network access through. Every sandboxed command that opens a connection becomes a peer on it.
When the SOCKS client dies mid-stream, the relay's accepted socket stays open. kqueue keeps reporting it writable, the loop writes, the kernel returns EPIPE, nothing marks the socket dead, and it goes around again.
fs_usage -f network on a session that had been stuck for seven hours. All 4000 calls in the capture failed:
18:37:21.887882 sendto F=34 [ 32] 0.000001 2.1.226.601646
18:37:21.887882 sendto F=23 [ 32] 0.000001 2.1.226.601646
18:37:21.887883 sendto F=45 [ 32] 0.000001 2.1.226.601646
18:37:21.887884 sendto F=44 [ 32] 0.000001 2.1.226.601646
18:37:21.887886 sendto F=26 [ 32] 0.000001 2.1.226.601646
18:37:21.887887 sendto F=35 [ 32] 0.000001 2.1.226.601646
[ 32] is EPIPE. Six fds, round-robin, 667 attempts each, over a 7.055 ms window. All six point at that process's own srt-mux-37591-0.sock.
sample on the same process, 3991 samples on the main thread:
| samples | share | frame |
|---|---|---|
| 2225 | 56% | kevent64, from three call sites in one loop body |
| 781 | 20% | __sendto |
| 683 | 17% | __ulock_wake |
| ~300 | 7% | everything else, including all JS |
93% of the thread is three syscalls, and almost no JavaScript runs. The kevent64 calls return immediately instead of sleeping, which is what makes it a spin rather than an idle wait.
After seven hours, both CSW and SYSBSD in top had saturated at INT32_MAX:
PID %CPU CSW SYSBSD SYSMACH #TH
37591 135.2 2147483647 2147483647 10874951+ 49/2
SYSMACH moved 437 over the same two seconds, so it really is all BSD syscalls.
Extra fds track the CPU
Counting fds on each process's own srt-mux-<pid>-0.sock. A healthy session holds one, the listener. Anything past that is a connection whose peer is gone.
| PID | fds | CPU |
|---|---|---|
| 37591 | 7 | 135% |
| 36403 | 5 | 127% |
| 14989 | 2 | 142% |
| 56907 | 1 | 6.5% |
| 8276 | 1 | 10.8% |
| 63270 | 1 | 2.9% |
One leaked fd is enough to peg a core. I also had a session sitting at 2 fds and 6.6% CPU, where the second peer was still alive, so the count alone isn't the signal. What matters is whether the peer has gone away.
Expected
sendto returning EPIPE should close the fd and drop it from the event loop. If there's a reason to retry at all, it needs a backoff and a give-up.
Impact
Nothing about this is visible from inside the session. It keeps working normally while holding a core hostage, and no external tool can close the fds, so the only fix is killing the session mid-task.
It stacks up. I was down four cores before I thought to open Activity Monitor. Anyone running long PR or CI loops is making hundreds of sandboxed gh and git calls, and one of those getting killed on a timeout is all it takes.
Happy to run whatever else is useful on a live one - fs_usage, sample, dtrace, a build with symbols. I can reproduce it on demand now.