[BUG] Event loop busy-waits ~30s at ~1.3 cores when the embedded resolver has no usable nameservers (EPOLLERR on UDP sockets never serviced); interactive startup then hard-fails with misleading ETIMEOUT — amplifies #78529
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Sidebar / apology: I'm dumping in the text for the issue as recommended by Fable 5 Max. I spent hours on this already and I have a viable workaround, so I can't justify spending more time coaxing its text into this GH Issue format.
-----------------------------------------
Preflight checklist
- [x] I have searched existing issues. #78529 documents the trigger family (embedded resolver ends up with no usable nameservers while the system resolver works); I've commented there with an independent trigger and traces. Per the single-bug guideline, this report covers the distinct runtime and startup-flow defects that trigger exposes: the event-loop CPU spin, the unbounded/uncached blocking startup fetch, and the inaccurate preflight error.
- [x] This is a single bug report (the three defects share one mechanism and one reproduction).
- [x] I am using the latest version of Claude Code (2.1.220;
claude updatereports up to date).
Summary
When the embedded (Bun) resolver parses zero usable nameservers from /etc/resolv.conf — whether because the file has none (this host) or because inline comments defeat the parser (#78529) — it falls back to querying 127.0.0.1:53. On ordinary Linux hosts nothing listens there, so the connected UDP sockets receive ICMP port-unreachable and enter EPOLLERR. That condition is level-triggered and the event loop never consumes or deregisters it, so epoll_pwait2 returns immediately on every call despite the loop requesting sane 12–16 ms timeouts. The result, on every cold start:
claude -p "..."succeeds but takes ~33 s wall / ~40 CPU-seconds (~1.3 cores pinned; ~89,000 epoll calls/s; 5.68 M futex + 2.84 M epoll_pwait2 calls per run) instead of ~2.5 s — every run, because the failed fetch is never persisted.- The interactive REPL's connectivity preflight expires mid-spin and exits with
Failed to connect to api.anthropic.com: ETIMEOUTplus the supported-countries hint — while the process holds a healthy ESTABLISHED TLS connection toapi.anthropic.com:443, made via the OS resolver path that works fine in the same process.
Because the doomed lookup belongs to a state-gated startup fetch, warm local state masks the problem entirely (likely why this family is underreported), and there is a first-run deadlock: on an affected host the interactive setup can never complete, so the masking state can never be written by the current version. The only escape found here was completing one session on 2.1.150 — after which the identical 2.1.220 binary works, with /etc/resolv.conf untouched.
Even with the #78529 resolver-parsing fix, defects (a) spin-on-EPOLLERR, (b) blocking, failure-uncached startup fetch, and (c) inaccurate preflight error remain latent; they deserve their own tracking.
Regression window: 2.1.150 unaffected, 2.1.220 affected, same host and $HOME, minutes apart ⇒ introduced in (2.1.150, 2.1.220]. #78529's report on 2.1.212 suggests the resolver change landed by 2.1.212. Happy to bisect exactly via npm.
Environment
| | |
|---|---|
| Claude Code | 2.1.220 (npm-global). Binary bin/claude.exe, ELF x86-64, dynamically linked, not stripped, BuildID sha1=788318c9115981678ca1a25f40cdb3b39df71403 |
| OS | Arch Linux, kernel 7.1.3-arch2-2 x86_64, glibc 2.43+r37, systemd 261.1 |
| Hardware | 32-core (Zen 5), NVMe, machine otherwise idle (load avg ~0.26) |
| Auth | CLAUDE_CODE_OAUTH_TOKEN from claude setup-token (Max plan). Stall is auth-independent (also occurred with an expired stored OAuth credential; that run took ~71 s before surfacing the 401). |
| Network | Residential dual-stack; api.anthropic.com answers curl in 60–110 ms on v4 and v6; a 300 KB POST to /v1/messages returns 401 (dummy key) in 0.22 s — reachability, PMTU, both address families verified healthy. |
Trigger condition on this host (variant of #78529)
/etc/resolv.conf is a static comments-only file — zero nameserver lines — while system-wide resolution works via nss-resolve → systemd-resolved (varlink), so no glibc-based tool ever notices:
$ cat /etc/resolv.conf
# Resolver configuration file.
# See resolv.conf(5) for details.
$ grep '^hosts' /etc/nsswitch.conf
hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns
$ resolvectl status | head -4
Global
Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: foreign
Current DNS Server: 9.9.9.9#dns.quad9.net
#78529's trigger (nameserver lines with trailing inline comments) reduces to the same internal state: zero usable nameservers. Note the fixes differ: tolerant parsing repairs #78529's hosts but cannot repair this one — there are no lines to salvage — which is why "fall back to the system resolver when zero nameservers parse" is the necessary resolver-side behavior.
Steps to reproduce (deterministic)
On any systemd-resolved distro:
# 1. Nameserver-less resolv.conf; system resolution keeps working via nss-resolve:
sudo cp /etc/resolv.conf /etc/resolv.conf.bak
printf '# no nameservers\n' | sudo tee /etc/resolv.conf
getent hosts api.anthropic.com # still resolves — precondition satisfied
# 2. Cold-state run (fresh HOME isolates the state condition; token avoids the login flow):
export CLAUDE_CODE_OAUTH_TOKEN=... # from `claude setup-token`
time env -i HOME=$(mktemp -d) TERM=xterm PATH=/usr/bin \
CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_CODE_OAUTH_TOKEN" claude -p "say hi"
# → ~33 s and ~40 CPU-seconds instead of ~2.5 s. Repeats identically on every cold run.
# 3. Interactive first run:
HOME=$(mktemp -d) claude
# → "checking connectivity ..." → exits ~30 s with the ETIMEOUT banner; setup can never complete.
(A mount --bind of a comments-only file over /etc/resolv.conf in a mount namespace reproduces without touching the real file.)
Expected vs actual
Expected: resolver falls back to getaddrinfo (which works in-process — see traces); a non-essential fetch neither blocks startup for 30 s nor repeats at full cost every run; the preflight reports what actually failed.
Actual (2.1.220, cold state — fresh $HOME):
real 0m33.066s
user 0m21.267s
sys 0m18.300s
Interactive mode, verbatim:
Unable to connect to Anthropic services
Failed to connect to api.anthropic.com: ETIMEOUT
Please check your internet connection and network settings.
Note: Claude Code might not be available in your country. Check supported countries at
https://anthropic.com/supported-countries
Version 2.1.150, same host, same $HOME, minutes apart from a slow 2.1.220 run:
real 0m2.573s
user 0m0.310s
sys 0m0.052s
The stall is invariant (31.8–33.3 s across ~15 cold-state runs) under: clean environment (env -i), fresh $HOME, empty cwd, taskset -c 0-3, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 (that flag does not prevent the doomed fetch). claude doctor is fast (0.77 s) — the cost is in session/network init, not binary startup.
Diagnosis
1. The API is reachable the whole time. During the interactive hang, the process's only TCP socket is a healthy, idle, established connection to the very host the eventual error names:
ESTAB 0 0 [local]:59518 [2607:6bc0::10]:443 users:(("claude",fd=12)) timer:(keepalive,57sec,0)
2. Syscall profile of one cold -p "say hi" run (strace -f -c):
% time seconds usecs/call calls errors syscall
77.40 57.573366 10 5680003 31642 futex
21.67 16.118784 5 2840163 epoll_pwait2
0.87 0.649766 1299 500 read
(everything else negligible)
3. The event loop requests sane timeouts but can never sleep (strace -f -e trace=epoll_pwait2, mid-stall):
epoll_pwait2(3, [...], 1024, {tv_sec=0, tv_nsec=15853093}, [], 8) = 3
epoll_pwait2(3, [{EPOLLERR, data=0x800021e7a82a878}, {EPOLLERR, data=0x800021e7a82a8a0}, ...], 1024, {tv_sec=0, tv_nsec=14117833}, [], 8) = 3
epoll_pwait2(3, [{EPOLLERR, data=0x800021e7a82a878}, {EPOLLERR, data=0x800021e7a82a8a0}], 1024, {tv_sec=0, tv_nsec=14097315}, [], 8) = 2
...
Timeouts are nonzero and descending (15.9 → 12.9 ms — a normal next-timer-deadline computation), but two registrations are permanently ready with EPOLLERR and never read, closed, or EPOLL_CTL_DELed. Not the timeout=0 pattern of #21889. The kernel behaves correctly (standalone C test on this host: 10/10 epoll_pwait2/epoll_pwait calls honor a 100 ms timeout when nothing is ready; immediate return on pending level-triggered events is required behavior).
4. The stuck fds are the resolver's UDP fallback sockets (strace -f -e trace=socket,connect,getsockopt):
346405 connect(11, {AF_UNIX, sun_path="/run/systemd/resolve/io.systemd.Resolve"}, 42) = 0 ← nss-resolve varlink: works
346405 connect(11, {AF_INET6, port=443, "2607:6bc0::10"}, 28) = 0 ← API connect: works
346400 connect(12, {AF_INET, port=53, "127.0.0.1"}, 16) = 0 ← embedded resolver fallback
346400 connect(13, {AF_INET, port=53, "127.0.0.1"}, 16) = 0
346400 connect(15, {AF_INET, port=53, "127.0.0.1"}, 16) = 0
... (dozens more across fds 11/12/13/15/16/19 over the run)
Nothing listens on 127.0.0.1:53 (resolved's stub is 127.0.0.53, unadvertised in foreign mode). Connected UDP + ICMP port-unreachable ⇒ EPOLLERR, matching the stuck registrations. Live mid-stall:
$ ss -uapn | grep claude
ESTAB 0 0 127.0.0.1:49902 127.0.0.1:53 users:(("claude",pid=346400,fd=11))
ESTAB 0 0 127.0.0.1:56898 127.0.0.1:53 users:(("claude",pid=346400,fd=13))
ESTAB 0 0 127.0.0.1:57671 127.0.0.1:53 users:(("claude",pid=346400,fd=15))
ESTAB 0 0 127.0.0.1:39484 127.0.0.1:53 users:(("claude",pid=346400,fd=12))
(TCP sockets to the API meanwhile poll clean — repeated getsockopt(SO_ERROR) = 0.)
5. Local state gates the doomed fetch (masking + deadlock). /etc/resolv.conf identical throughout:
| Sequence | Version | Local state | Result |
|---|---|---|---|
| ~15 runs over two days | 2.1.220 | real $HOME | 32–33 s every run; interactive exits with ETIMEOUT |
| then one session | 2.1.150 | same $HOME | 2.57 s; interactive REPL completed the trust-folder/first-run step |
| immediately after | 2.1.220 | same $HOME (updated by that session) | stall gone; interactive works |
| control, same hour | 2.1.220 | fresh empty $HOME | 33.07 s |
Implications: the fetch is conditional on local state; its failure is never persisted (cold hosts pay full price every run); warm state masks the bug entirely; and 2.1.220 alone cannot escape (interactive setup dies before it could write the masking state). Inferred model consistent with everything above: 2.1.150 performs the fetch via the OS resolver (works here) and cached its success; the lookup later moved to the embedded resolver, which on such hosts can never succeed. A candidate identity is the remote-config/feature-flag load (~/.claude.json holds cachedGrowthBookFeatures with *_ttl_hours fields) — implying affected-but-masked hosts silently never refresh flags, and the interactive hard-failure will relapse when cached state expires. I can capture the DNS QNAMEs from the UDP payloads (strace -e trace=sendto -s 400) to name the host on request.
<!-- TODO before filing (optional strengtheners — delete this comment if not run):
1) Confirmation that fixing resolv.conf fixes cold-start:
"Confirmed: after sudo ln -rsf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf,
a fresh-$HOME 2.1.220 run completes in ~2.5 s and interactive first-run succeeds."
2) DNS QNAME capture naming the fetched hostname (strace -e trace=sendto -s 400 on a cold run).
3) A measured warm-state 2.1.220 timing (currently stated qualitatively as "stall gone"). -->
Impact
- Cold-state hosts: +30 s and ~40 CPU-seconds per
-pinvocation; interactive mode hard-fails permanently (first-run deadlock — the masking state can only be written by a session that completes). Escapes found: an older version, or manual state transplantation. - Warm-state hosts: work today, but if the gating state has a TTL (the cache fields suggest it does), interactive startup will hard-fail again whenever it expires — "worked yesterday" failures.
- The error text misdirects users to network/geo debugging that cannot succeed; on this host it motivated an extensive, by-construction-fruitless network investigation before syscall tracing found the resolver.
- The trigger is silent and plausibly common (Arch/minimal installs missing the resolv.conf symlink; config-managed files as in #78529), since every glibc-based tool masks it indefinitely.
Suggested fixes (separable)
- Resolver: with zero parsed nameservers, fall back to the system resolver (
getaddrinfo) — never127.0.0.1:53. (Complements #78529's parsing fix; either alone leaves one trigger class broken.) - Event loop (likely Bun/uSockets): consume or deregister EPOLLERR on UDP sockets so a failed resolver — or any refused-UDP scenario — can't pin the loop. Filed here since the runtime is vendored; happy to help route upstream.
- Startup flow: don't let the state/flags fetch block session start for 30 s; persist fetch failure with backoff so cold hosts don't pay full price every run.
- Preflight accuracy: don't report "Failed to connect to api.anthropic.com: ETIMEOUT" (plus the country hint) while holding an established connection to that host; distinguish internal-DNS failure from reachability.
Workarounds (for anyone else hitting this)
- Point
resolv.confat the resolved stub:sudo ln -rsf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf(or add any validnameserverline). - No root: complete one interactive session on 2.1.150 (
npm i -g @anthropic-ai/claude-code@2.1.150), then return to@latest— the state written by that session masks the bug until it expires. - Or stay pinned on 2.1.150 with
DISABLE_AUTOUPDATER=1.
Related issues
- #78529 — same root cause, different trigger (inline comments defeat the nameserver parser vs. no nameserver lines at all); I've posted these traces there as corroboration.
- #72706, #71824 — same symptom family per #78529 (embedded resolver fails on Anthropic domains while the system resolver works: ESERVFAIL on HPC; EREFUSED on all Anthropic domains).
- #21889 — event-loop busy-wait, but via
epoll_pwait(timeout=0); here the timeouts are nonzero and the spin comes from unserviced EPOLLERR. - #18280 — idle CPU / futex churn via a 16 ms timerfd; different source, same symptom family.
- #8243 — no-backoff retry storm when a telemetry hostname is DNS-blocked; related "lookup-path failure handled pathologically" family.
I can bisect the exact regressing version between 2.1.150 and 2.1.220, capture the DNS QNAMEs to name the fetched host, and re-run any instrumentation on request — the cold-state reproduction is 100 % reliable on this host.
What Should Happen?
See above
Error Messages/Logs
Steps to Reproduce
See above.
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.150
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Xterm
Additional Information
_No response_