API connection spins on EPIPE at 100% CPU after a macOS content-filter change — socket is never closed, replies stall
When a macOS content filter is enabled, disabled or restarted, the kernel fails every later write on each pre-existing TCP socket with EPIPE and leaves the socket open. Claude Code never closes its API connection when that happens. It retries the write on the dead socket forever: one full core per session, and API replies that never arrive (API Error: Connection lost mid-response, API Error: The response stopped arriving).
This looks like the same defect as #93398 — a writable socket that returns EPIPE is retried instead of being marked dead — with a different trigger. There it is a leaked sandbox SOCKS socket; here it is Claude Code's own connection to api.anthropic.com. So every running session on a Mac breaks whenever an endpoint-security product installs, updates, or toggles its network content filter — a routine event on managed Macs.
Environment
- Claude Code 2.1.263, 2.1.267, 2.1.271, 2.1.282, 2.1.283 (every running session was affected)
- macOS 15.6.1 (24G90), arm64
- Two content filters already active; a third was enabled when the failures began
What happened
One content filter was enabled at a known moment. From the next second:
- 8 long-lived Claude Code connections to
160.79.104.10:443(api.anthropic.com), across ~10 mostly idle sessions, each took 650,000–680,000 failed writes over 68 minutes (~165 per second per socket). The kernel logged every one:
``text``
cfil_inp_log:6174 <CFIL: outbound TCP data dropped (0x40000003 -> 0x40000007)>: [48908 2.1.283] <TCP out so … age 631> … faddr 160.79.104.10
- Each affected session sat at ~98% CPU.
sampleon one showed the main thread in__sendtofor 86% of samples (2,187 of 2,530), with the socket's send queue empty (netstatsendQ=0) — the kernel rejects each write before it reaches the send buffer, so the socket always polls writable. - The same Mac's Chrome and Slack sockets hit the same condition and logged 2 failed writes each before closing and reconnecting.
- The failures stopped at the exact second the newly enabled filter was removed (the socket's recorded filter bitmask matched again), and the sessions dropped back to idle CPU.
- ~1,500 kernel log lines per second across the sessions also pegged
diagnosticdfor everylog streamconsumer on the machine.
Why the kernel does this
XNU records, per socket, the bitmask of content filters that applied when it was attached. On every write, cfil_sock_data_out (bsd/net/content_filter.c) compares it with the current one:
/* Drop pre-existing TCP sockets when filter state changed */
new_filter_control_unit = necp_socket_get_content_filter_control_unit(so);
if (new_filter_control_unit > 0 && new_filter_control_unit != so->so_cfil->cfi_filter_control_unit && ...) {
if (DO_PRESERVE_CONNECTIONS || (so->so_cfil->cfi_filter_policy_gencount == necp_socket_get_policy_gencount(so))) {
so->so_cfil->cfi_filter_control_unit = new_filter_control_unit;
} else {
cfil_log_printf(so, so->so_cfil, "CFIL: outbound TCP data dropped (0x%x -> 0x%x)", ...);
return EPIPE;
}
}
The stored unit is not updated on that path, so every subsequent write fails the same way until the process closes the socket. Inbound data is dropped likewise, which is why an in-flight streaming reply just stops. There is no public macOS API to preserve existing connections across the change (a private opt-in exists, and it only applies when every active filter sets it), so the client has to handle it.
Reproduce
- Have one or more Claude Code sessions that have talked to the API recently (an idle session with a pooled connection is enough).
- Enable, disable, or update any content-filter network extension — install or update an endpoint-security product, or toggle an app's filter in System Settings → Network → Filters.
- Watch:
``bash``
sudo log stream --predicate 'process == "kernel" AND eventMessage CONTAINS "TCP data dropped"'
top -o cpu
The Claude Code pids repeat CFIL: outbound TCP data dropped (0x… -> 0x…) and sit near 100% CPU until the session is restarted. Browsers show a couple of lines and recover.
Expected
Treat EPIPE / ECONNRESET from a write as fatal for that socket: close it, evict it from the keep-alive / HTTP/2 pool, and retry the request on a fresh connection (which passes through the current filter set and works). And stop re-arming the write on a socket that has already failed that way.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗