Skip to content

Turns fail on network path change while host is online — identical commute workload degraded 0% → 33% since May (Desktop, bundle 2.1.219) #82242

Description

@renelaerke

Follow-up to support incident d2142d0e-0119-4c35-a18d-761171637be4 (filed earlier,
without this evidence). A condensed version of this has also been submitted through the
in-app reporter — see the note in §7 about why that channel is unreliable for exactly this
class of bug.

Environment: Claude Code bundle 2.1.219 running inside Claude Desktop (not the terminal
CLI). Versions cited are bundle versions from [CCD] Initialized with version /
Using Claude Code binary at …; the Desktop app's own version does not appear in these
logs. macOS, Darwin 25.3.0, Apple Silicon.

Evidence: entirely local — ~/Library/Logs/Claude/main*.log (five rotations,
2026-04-18 → 2026-07-29), ~/Library/Application Support/Claude/Network Persistent State,
and ~/.claude/projects/*/*.jsonl. No server-side visibility. All figures are reproducible
with the commands in the appendix. Log lines are de-duplicated throughout — the app writes
every line twice.

TL;DR. On an identical, repeated commute workload the rate of turns that never receive
any response has gone from 0% (May) to 33% (now). It is not signal quality: turns fail
less in a known reception dead zone than in good coverage. The trigger is the source IP
changing — a mobile handover, or walking to an adjacent meeting room on corporate wi-fi.
The app reports net::ERR_INTERNET_DISCONNECTED and blames the user's connection while the
host is demonstrably online. The cheapest fix already exists in the codebase, applied to
exactly one call site (§4.2).


1. Executive summary

Since mid-May the reporter has run a naturally controlled experiment: commuting most
Tuesdays and Thursdays, working in Claude Code on the same two projects, in two fixed
windows — 06:45–07:25 and 16:25–17:10. Same route, same laptop, same hotspot, same kind of
work. Measured against the app's own hadFirstResponse telemetry, the rate of turns that
never received any response at all has risen steadily:

Period Days Cycles No response Rate
May (05-05, 05-07) 2 19 0 0%
June 5 70 6 9%
July, pre-vacation (07-07, 07-09) 2 18 5 28%
July, post-vacation (07-28) 1 12 4 33%

Identical workload, identical route, one third of turns now fail. Across all sessions
(not just commutes) the same metric moves 2% → 5% → 6% → 9% from April to July over ~3,600
cycles.

The user's link is not the problem, and this is demonstrable twice over. First, during
the 2026-07-28 incident the reporter was concurrently reading mail, browsing the web and
streaming YouTube on the same laptop and the same interface, uninterrupted — only Claude
Desktop failed. Second, the route crosses a known reception dead zone, and turns fail
less often there (7%) than in the rest of the commute (10%); in May, 13 turns crossed it
with zero failures (§2.1). Signal quality does not predict failure. Yet the app reports
net::ERR_INTERNET_DISCONNECTED and tells the user to "Check your internet connection and
try again."

The variable that does predict failure is the source IP changing — which is why weak-
but-stable coverage is survivable and well-covered territory, where the network constantly
re-steers the device between cells and bands, is not.

There are two independent failure modes (§3):

  • Mode A — the request never leaves. hadFirstResponse=false; attempts wait 9–32
    seconds, receive nothing, and are abandoned; retried every one to two minutes. This is
    what the table above measures and what has degraded.
  • Mode B — a live stream is killed mid-flight. hadFirstResponse=true then
    ECONNRESET. Measured instance: 6m39s of generated output destroyed. Occurs even when
    the app's network state is entirely correct.

Mode A wastes the session; Mode B destroys work already produced. They need different
fixes, and no client-side workaround exists for either (§6).


2. The controlled series in detail

Tue/Thu commute windows, 06:45–07:25 (AM) and 16:25–17:10 (PM). De-duplicated — the app
writes every log line twice. means the app was running but no Claude Code cycles
occurred in that window. No log coverage exists for 2026-05-11 → 2026-05-28 (rotation gap),
and the reporter was on leave 2026-07-13 → 2026-07-26.

Date Day W Cycles No-resp Unhealthy Max Mean Disc.min
05-05 Tue AM 3 0 0 539s 267s 0
05-05 Tue PM 5 0 1 464s 242s 1
05-07 Thu AM 5 0 0 247s 112s 0
05-07 Thu PM 6 0 0 575s 216s 0
06-02 Tue AM 4 2 0 732s 375s 0
06-02 Tue PM 12 1 0 10495s* 960s 7
06-09 Tue AM 3 0 0 100s 60s 0
06-09 Tue PM 4 0 0 462s 191s 2
06-11 Thu PM 12 0 0 334s 97s 0
06-23 Tue AM 11 0 0 258s 121s 1
06-23 Tue PM 19 0 3 161s 34s 4
06-30 Tue PM 5 3 0 472s 126s 2
07-07 Tue AM 7 0 0 419s 212s 0
07-07 Tue PM 6 3 0 302s 121s 2
07-09 Thu AM 5 2 0 228s 92s 0
07-28 Tue AM 12 4 1 490s 116s 0

* a 10,495s (2.9h) cycle on 06-02 PM is almost certainly an idle session left open, not a
real turn; it inflates that row's mean and is excluded from no consideration elsewhere.

Two observations that matter for interpreting this:

The degradation predates the vacation. July pre-vacation (28%) and post-vacation (33%)
are effectively the same. Nothing changed while the reporter was away; the decline built
through June and had reached roughly its current level by 07-07/07-09. The subjective
sense that this is "new" is explained by two weeks away removing gradual habituation — not
by a recent deploy.

Single windows are unreliable; only the series is evidence. 07-07 AM was 0 of 7 while
07-07 PM was 3 of 6. An earlier draft of this report used 07-07 AM as a "clean baseline"
against 07-28 AM and was wrong to. Window-level variance is large, which is exactly why the
repeated fixed-scenario design above is the right instrument.

2.1 The failures do not happen where the signal is worst

The route contains a known dead zone — open country with exceptionally poor reception,
crossed at roughly 07:15 (±5 min) outbound and 16:40 (±5 min) inbound. Classifying each
turn by whether its interval (log timestamp minus reported duration) overlapped that
stretch:

Period Dead zone Rest of window
May 0 / 13 (0%) 0 / 41 (0%)
June 1 / 22 (5%) 5 / 48 (10%)
July pre-vacation 1 / 5 (20%) 4 / 12 (33%)
July post-vacation 1 / 3 (33%) 2 / 8 (25%)
All periods 3 / 43 = 7% 11 / 109 = 10%

Turns do not fail more where reception is worst — marginally less, though that
difference is within noise at these sample sizes. In May, 13 turns crossed the worst
reception on the entire route with zero failures.

The app is nonetheless fully aware of the bad reception. Normalised per minute of window,
inside the dead zone versus outside:

Event Dead zone Rest Per-minute density
ERR_INTERNET_DISCONNECTED 37 73 1.6×
fetch failed 17 34 1.6×
ECONNRESET 2 0
ERR_NETWORK_CHANGED 0 0

So background syncs fail 1.6× more densely where the signal is poor, exactly as expected —
while turn failures do not follow that gradient at all.

Breaking the commute into segments by where each turn was sent (log timestamp minus
duration) shows failures distributed across the entire route, including the opening minutes
in good reception. June onwards, i.e. the degraded era:

Segment Failures Rate
06:45–06:55 departure, good reception 1/8 12%
06:55–07:10 en route 2/16 12%
07:10–07:20 dead zone 2/10 20%
07:20–07:25 arrival 3/9 33%
16:25–16:35 departure, good reception 3/14 21%
16:35–16:45 dead zone 1/14 7%
16:45–17:00 en route 3/17 18%
17:00–17:10 arrival 0/11 0%

The evening commute fails three times more often in its first ten minutes, with good
reception, than in the dead zone. No segment is failure-free. Per-segment counts are small
(8–17 turns), so no individual pair is significant on its own — but the distribution is
flat with respect to reception quality, and the reporter independently confirms perceiving
errors from the very start of the commute in well-covered territory.

This dissociation is the core of the report. Weak-but-stable signal is survivable: TCP
retransmits absorb latency and packet loss, and a single weak cell with nothing to hand
over to keeps the connection's 4-tuple intact for minutes. What destroys a turn is the
source IP changing — and handovers are if anything more frequent where coverage is
good, because the network continuously re-steers the device between cells, bands and
carriers. The same reasoning explains why the reporter hits this while stationary,
simply walking from a desk to an adjacent meeting room: intra-ESSID AP roaming changes the
BSSID and usually the DHCP lease. Motion is not required, and neither is a bad signal —
only a re-homing network.

It follows that "poor connectivity" is the wrong frame for this bug, and that
"Check your internet connection and try again" (§4.1) is not merely unhelpful but points
at the one variable that demonstrably does not predict failure.


3. Two independent failure modes

3.1 Mode A — the request never leaves the machine

hadFirstResponse=false. From [CCD CycleHealth], 2026-07-28 morning, six consecutive
failures:

07:17:36  (25s, hadFirstResponse=false)
07:22:44  (29s, hadFirstResponse=false)
07:23:19  (30s, hadFirstResponse=false)
07:25:25  (32s, hadFirstResponse=false)
07:26:50  (20s, hadFirstResponse=false)
07:27:35  (20s, hadFirstResponse=false)

Each attempt waits 20–32s, receives nothing, is abandoned; attempts are spaced one to two
minutes apart. These timestamps match the duplicated prompt entries in the session
transcript exactly, confirming each retry is a full re-send of the turn. In the same hour
the transcript holds 30 user entries and 0 assistant entries. On 07-28 evening one
attempt gave up after just 9 seconds.

A 9–32 second wait that returns nothing is the signature of writing into a socket that is
already dead — no RST comes back when the old path simply vanished.

3.2 Mode B — a live stream destroyed mid-flight

2026-07-27 at 16:45:43:

16:45:43 [warn] [CCD CycleHealth] local_… api_error (success):
                API Error: Unable to connect to API (ECONNRESET)
16:45:43 [info] [CCD CycleHealth] unhealthy cycle for local_…
                (399s, hadFirstResponse=true, reason=api_error)

hadFirstResponse=true — the response was streaming normally and was destroyed 6 minutes
39 seconds in
. There were zero ERR_INTERNET_DISCONNECTED events in that window, and
other turns in the same window completed at 820s and 679s. The app's network state was
correct throughout. This is raw architectural exposure: one TCP connection held for the
whole of a long generation, cut by a path change.

Genuine disruption did occur that evening (ERR_NAME_NOT_RESOLVED at 16:51:41, a real DNS
failure). The app absorbed most of it and lost exactly one long turn — which is the correct
behaviour for an unresumable stream, and why Mode B is a design limit rather than a bug.

Prioritisation consequence. The client fixes in §5.0–§5.1 resolve Mode A and would have
saved the 07-28 morning entirely. They would not have saved the 07-27 evening turn. Only
a resumable stream (§5.3) fixes Mode B — and Mode B is the mode that discards work that
already exists.


4. Mechanism

4.1 The app reports no internet while the host is online

main1.log, 2026-07-28 06:37:07–06:37:14 — one event, seven seconds, every subsystem:

06:37:07 [error] [EventLogging] POST threw: net::ERR_INTERNET_DISCONNECTED
06:37:07 [warn]  [plan-usage] fetch failed
06:37:07 [error] [growthbook] network error: net::ERR_INTERNET_DISCONNECTED
06:37:07 [warn]  [my-access] fetch failed
06:37:07 [error] [RemotePluginManager] Sync failed: net::ERR_INTERNET_DISCONNECTED
06:37:13 [warn]  [oauth] network error on fetch, flushing connection pool and retrying
06:37:13 [error] OAuth token refresh network error: net::ERR_INTERNET_DISCONNECTED
06:37:13 [error] Cannot get base query config: oauth failed (network_error)
06:37:13 [error] [CCD] Failed to warm session local_8bed95f4…:
                 Unable to start session. Check your internet connection and try again.
06:37:14 [error] [SkillsPlugin] Sync failed: net::ERR_INTERNET_DISCONNECTED

Host connectivity was verifiably intact (mail, web, YouTube). ERR_INTERNET_DISCONNECTED
is what Chromium returns before attempting a socket, when NetworkChangeNotifier
reports connection type NONE. Nothing re-validates that belief against reality.

Note also that auth sits in the turn critical path: path change → OAuth refresh fails →
Cannot get base query configFailed to warm session. A momentary gap does not merely
interrupt a response, it blocks the session from starting. Reproduced 2026-07-29 08:40:01.

4.2 The stale-connection fix exists — for OAuth only

All 18 flushing connection pool and retrying lines in four months of logs are [oauth].
Not one belongs to the API or streaming path:

2026-04-29 17:05:15  [oauth] … net::ERR_NETWORK_CHANGED
2026-06-02 06:37:26  [oauth] … net::ERR_INTERNET_DISCONNECTED   (×3 that morning)
2026-07-06 06:50:17  [oauth] … net::ERR_INTERNET_DISCONNECTED   (×2)
2026-07-10 08:24:03  [oauth] … net::ERR_INTERNET_DISCONNECTED   (×3)
2026-07-28 06:37:13  [oauth] … net::ERR_INTERNET_DISCONNECTED
2026-07-29 08:40:01  [oauth] … net::ERR_INTERNET_DISCONNECTED

Flush the socket pool, then retry, is the correct remedy for reusing a dead pooled
connection after a path change. Somebody already diagnosed this and implemented it for
token refresh and nowhere else. This is the cheapest actionable item in the report.

4.3 Structural exposure

Each turn re-uploads the full conversation context — prompt caching saves server compute,
not bytes on the wire — then holds a single HTTP/2 connection open for the entire
streamed response, seconds to minutes. Failure probability scales with time-on-wire, so
long sessions are structurally more fragile, and every retry re-uploads the whole payload.

From Network Persistent State: 84 of 88 known servers negotiate HTTP/2; 22 advertise h3
via alt-svc, none of them Anthropic API hosts. So the API path is HTTP/2 over TCP — no
connection migration, and pooled sockets that outlive the path beneath them.

Weak supporting signal, possibly a coincidence: within a single day the PM window is worse
than the AM window on 07-07 (0% → 50%), consistent with accumulated context enlarging the
upload. 07-28 does not follow the pattern.

4.4 A secondary observation that does not explain the above

ERR_NETWORK_CHANGED — the error that should invalidate pooled sockets, and what the
[oauth] flush fires on — has declined as a share of all disconnect events:

Month ERR_NETWORK_CHANGED ERR_INTERNET_DISCONNECTED Ratio
2026-04 52 1194 4.4%
2026-05 33 1248 2.6%
2026-06 21 2085 1.0%
2026-07 5 625 0.8%

Last occurrence anywhere: 2026-07-08, coinciding with bundle 2.1.197 → 2.1.202.

However, this cannot be the cause of the commute failures. Across the entire Tue/Thu
series in §2, ERR_NETWORK_CHANGED appears essentially never — including in May, when the
failure rate was 0%. Its absence therefore does not distinguish working periods from
failing ones. Recorded here as an anomaly worth checking, not as an explanation. Bundle
timeline for reference: 2.1.187 (06-27→07-01), 2.1.197 (07-01→07-08), 2.1.202 (07-08→07-10),
2.1.205 (07-10→07-18), 2.1.209 (07-18→07-20), 2.1.215 (07-20→07-27), 2.1.219 (07-27→now).


5. Requested changes, in priority order

  1. Never fail a user-visible request on cached connectivity state. Treat
    CONNECTION_NONE as a hint, not a verdict: attempt the request anyway, or probe
    reachability first. ERR_INTERNET_DISCONNECTED outnumbers ERR_NETWORK_CHANGED 45:1
    (5157 vs 114) across four months, and §4.1 shows the belief can be flatly wrong.
  2. Flush the connection pool and retry on the API and streaming paths, exactly as
    [oauth] already does (§4.2). Small, well-precedented, and targets Mode A directly.
  3. Bisect the client between 2026-05-07 and 2026-07-09. §2 brackets the regression on a
    controlled workload: 0% in early May, 9% through June, 28% by 07-07. Bundle versions in
    that range are 2.1.121 → 2.1.202. This is a gradual erosion, so look for accumulated
    changes to timeouts, connection reuse, or retry policy rather than one bad commit.
  4. Resumable response streams. An event cursor so a reconnect resumes mid-generation
    instead of regenerating; HTTP standardises this as SSE Last-Event-ID. The only fix
    for Mode B
    , and the mode that destroys already-generated work.
  5. Delta upload against server-side conversation state. Let a turn reference prior
    context by ID and ship only new content, collapsing the upload window to near-constant
    regardless of session age (§4.3).
  6. Retry policy. Jittered backoff across the timescale handovers actually take
    (~5–60s), re-validating connectivity between attempts instead of repeating a doomed
    request. Note each retry currently re-uploads the entire context.
  7. Take auth out of the turn critical path (§4.1) — refresh ahead of expiry with
    generous skew so a momentary gap cannot produce Failed to warm session.
  8. Consider HTTP/3 for the API path (§4.3). QUIC connection IDs survive an IP change by
    design. Lower priority than 0–2, which are bugs rather than architecture.
  9. Fix the user-facing message. "Check your internet connection and try again" is false
    here and cost the reporter a morning debugging the wrong layer.

6. No client-side workaround exists — tested

On 2026-07-28 between 17:25 and 18:15 the reporter switched between several VPNs
specifically to test mitigation. Result: 2 of 6 cycles still received no response
(17:41:53 at 90s, 17:44:14 after 9s).

More telling is what is absent: bringing a VPN up or down necessarily rebuilds the default
route and adds or removes a utun interface, yet the window contains zero
ERR_NETWORK_CHANGED events. The app registered none of those deliberate, repeated path
changes.

A stable tunnel (WireGuard/Tailscale) can only help Mode B, by holding the inner IP
constant beneath a long stream. It cannot help Mode A, which is the app failing on its
own bookkeeping however stable the path is. Switching between tunnels is strictly worse,
since each switch is another path change. And no setting reaches this: ~/.claude/settings.json
has no network keys, and in Desktop the app owns the transport.


7. Secondary defects

  • The in-app bug reporter fails during the outage it exists to report. Twelve
    submissions across two incidents, all feedback_id=null, failure=network_error
    (2026-07-28 07:12:29–07:17:35, seven attempts; 17:45:02–17:52:24, three attempts — the
    first with descLen=1315, i.e. a detailed report, then two shorter retries). The
    reporter was trying to file this very incident. Consequence: this bug class is
    structurally under-reported to Anthropic
    , because affected users are blocked precisely
    when they try to report. Feedback must be persisted to disk and retried later.
  • api_error (success) — a transport failure logged with a success qualifier.
  • healthy cycle … hadFirstResponse=false — cycles that produced no response at all
    are recorded as healthy (all six in §3.1). If aggregate telemetry counts these as
    healthy, the entire degradation in §1 is invisible on Anthropic's own dashboards.
  • 34-minute diagnostic blackout. Between 06:37:15 and 07:11:16 on 2026-07-28 the log
    contains nothing but periodic memory lines, while the user retried repeatedly.
  • Every log line is written twice.

8. Reproduction

  1. macOS, Claude Code bundle 2.1.219 in Claude Desktop, Wi-Fi plus an iPhone hotspot.
  2. Open a session and accumulate real context (an hour of work).
  3. Issue a turn whose response streams for more than ~30 seconds.
  4. Mid-stream, change the path: toggle Wi-Fi off/on, or carry the laptop between two APs on
    one corporate ESSID. Walking to an adjacent meeting room is sufficient.
  5. In parallel, keep a YouTube stream or a ping running to prove the host link is
    healthy.
  6. Observed: Mode A — the turn dies, retries wait 9–32s and receive nothing, log shows
    ERR_INTERNET_DISCONNECTED fan-out across all subsystems and possibly
    Failed to warm session; or Mode B — the stream dies mid-generation with ECONNRESET.
    Host connectivity is fine throughout.
  7. Expected: the request is attempted regardless of cached network state, the pool is
    flushed on failure, and the stream resumes from its last delivered event.

9. Limitations

  • hadFirstResponse=false may conflate transport failures with user-initiated
    interrupts.
    The app does not appear to distinguish them, so absolute rates are an upper
    bound. Since the metric definition is constant across the whole series, the trend is
    unaffected. Separating the two causes is itself request §7.
  • Sample sizes per period in §1 are modest (19 / 70 / 18 / 12 cycles). The trend is
    monotonic and the workload is genuinely repeated, but no single period is decisive.
    July post-vacation is one window.
  • No log coverage 2026-05-11 → 2026-05-28 (rotation gap), so the May baseline rests on two
    days that slightly predate the stated start of the commute pattern.
  • Individual windows vary widely (07-07 AM 0/7 vs 07-07 PM 3/6); earlier drafts of this
    report over-read single windows and were corrected.
  • In §2.1, a turn is assigned to the dead zone if its interval touches it, so long turns
    that mostly ran elsewhere are counted as dead-zone. This biases the dead-zone group
    toward long turns — i.e. toward higher expected failure — which strengthens rather than
    weakens the finding that it is not worse. The 7% vs 10% gap is not itself significant
    (3/43 vs 11/109); the claim being made is only that failures show no dead-zone
    concentration, plus the May 0/13 result.
  • Dead-zone boundaries are the reporter's recollection (±5 min around 07:15 and 16:40), not
    GPS-derived.
  • The 2026-07-08 / bundle-2.1.202 correlation in §4.4 rests on 5 July occurrences and is
    explicitly contradicted as a cause by the May data. Starting point only.
  • ERR_* counts are unique log lines, not distinct network events — one event fans out
    across subsystems (§4.1). Ratios compare like with like.
  • The NetworkChangeNotifier attribution is inference from Chromium's documented semantics
    for the error code plus observed host connectivity. Confirming it needs a
    --log-net-log capture or Anthropic's own instrumentation.
  • Purely local evidence. No server-side view; no claim about intent or any specific deploy.

10. Appendix — regenerate

# Controlled Tue/Thu commute series (§1, §2)
cd ~/Library/Logs/Claude && python3 - <<'PY'
import re,datetime,collections
WINS={'AM':('06:45','07:25'),'PM':('16:25','17:10')}
seen=set(); rec=collections.defaultdict(lambda: collections.defaultdict(list))
for f in ('main4.log','main3.log','main2.log','main1.log','main.log'):
    for line in open(f,errors='ignore'):
        if not line.startswith('2026-'): continue
        d,hm=line[:10],line[11:16]
        w=next((n for n,(a,b) in WINS.items() if a<=hm<=b),None)
        if not w or line in seen: continue
        seen.add(line)
        m=re.search(r'CycleHealth\].*?\((\d+)s, hadFirstResponse=(true|false)',line)
        if m: rec[d][w].append(m.group(2)=='true')
for ds in sorted(rec):
    if datetime.date.fromisoformat(ds).weekday() not in (1,3): continue
    for w,c in sorted(rec[ds].items()):
        print(ds,w,f"{sum(1 for x in c if not x)}/{len(c)}")
PY

# Error-type distribution (§5.0)
cd ~/Library/Logs/Claude && for p in ERR_INTERNET_DISCONNECTED ERR_NETWORK_CHANGED \
  ERR_CONNECTION_CLOSED ERR_CONNECTION_RESET ERR_TIMED_OUT; do \
  printf '%-30s %s\n' "$p" "$(grep -h $p main*.log | sort -u | wc -l)"; done

# Pool-flush call sites (§4.2) — all 18 are [oauth]
cd ~/Library/Logs/Claude && grep -h 'connection pool' main*.log | sort -u

# Negotiated protocols (§4.3)
python3 -c "import json,os;d=json.load(open(os.path.expanduser( \
  '~/Library/Application Support/Claude/Network Persistent State'))); \
  s=d['net']['http_server_properties']['servers']; \
  print(len(s),'servers',sum('supports_spdy' in x for x in s),'h2', \
  sum('alternative_service' in x for x in s),'h3')"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions