Killed sandboxed command leaks its SOCKS socket; main thread then spins on EPIPE at 100%+ CPU

Status Open
Reported on v2.1.226
Maintainer reply None cached
Activity 0 comments · opened Aug 11, 2026

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.

View original on GitHub ↗