- name
- debugging-network-issues
- description
- Investigates network, streaming and protocol failures with falsification-first layered experiments, the general fallback for unknown root causes. Use for ECONNRESET / RST_STREAM / SSE stalls, CDN/CGNAT timeouts or certificate errors, throughput collapse where everything returns 200 but transfers crawl, or LAN identity/topology mysteries. Not for known Tailscale/TUN/DNS/proxy-path conflicts (use tunnel-doctor).
# Debugging Network Issues
Evidence-driven investigation methodology for incidents where the obvious cause is probably wrong. Built from a real 5-hour production case (see [references/case-sse-rst-130s.md](references/case-sse-rst-130s.md)) where assumption-stacking wasted hours that a 10-minute layered experiment would have resolved.
Apply this skill when the user reports a network/streaming/protocol symptom and the investigator feels tempted to diagnose from one log line or one circumstantial data point. The skill's job is to slow that reflex down.
## Triage first — is this a known domain?
Before applying the general methodology below, check whether the symptom points at a stack that already has a dedicated skill in this repo. Those carry the domain-specific symptom→cause→fix tables this skill deliberately stays general about — start there, and come back here for methodology if the root cause turns out to be elsewhere.
| If the symptom is… | Start with |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| macOS Tailscale ⨯ proxy/VPN conflict (Shadowrocket / Clash / Surge): `tailscale ping` works but SSH/curl/git fails, `Connection closed by 198.18.x.x`, TUN DNS hijack, ~60s `getaddrinfo` resolver stall | **tunnel-doctor** |
| Cloudflare config: `ERR_TOO_MANY_REDIRECTS`, SSL-mode mismatch, DNS / proxy-status issues behind the orange cloud | **cloudflare-troubleshooting** |
| Windows App / AVD / W365 RDP connection quality: WebSocket instead of UDP Shortpath, high RTT, STUN/TURN interference | **windows-remote-desktop-connection-doctor** |
| Client-side proxy / VPN / TUN misrouting: one specific site fails with `ERR_CONNECTION_CLOSED` or `SSL_ERROR_SYSCALL`, other sites work, DNS returns fake/TUN IPs, and adding a PROXY rule did not help | **this skill** — read [references/case-proxy-tun-cname-override.md](references/case-proxy-tun-cname-override.md) first |
| TLS certificate-verification errors (`UNKNOWN_CERTIFICATE_VERIFICATION_ERROR`, a cert for the wrong site) or mid-handshake EOFs on **every** DIRECT-routed/domestic domain at once, while proxied domains work — and any proxy health watchdog still reports green | **tunnel-doctor** (TUN DIRECT split-brain step) |
| The proxy is reachable and the OS/Tailscale route is healthy, but bulk transfers are slow and changing the active exit/node changes the measured rate | **tunnel-doctor** — proxy node / exit / chain capacity branch |
| **Nothing errors — it is just slow.** Every request returns 200, latency and health checks look fine, but transfers crawl, bulk jobs overrun their estimates, or files arrive truncated | **this skill** — go straight to [Step 0.7](#step-07-throughput-collapse--when-nothing-errors-and-everything-is-slow); the error-driven steps have nothing to bite on |
| A whole group of smart-home / IoT devices is offline at once — often weeks after a router/AP swap or Wi-Fi settings change; the hub is "right there" but its children are all dead, plus some direct-Wi-Fi devices | **this skill** — read [references/case-iot-router-migration-wpa3.md](references/case-iot-router-migration-wpa3.md) first |
If none match — or you tried a domain skill and the evidence points elsewhere — continue below. The methodology generalizes to any multi-layer system.
> **Note for this skill specifically**: If the symptom is a Cloudflare 524/522 on a **large `POST` body** (e.g., `/<openrouter-path>` with `Content-Length` > 1 MB), the failure is often **upload time to origin exceeding Cloudflare's origin read timeout**, not backend slowness. Use the upload-vs-processing checklist below before assuming a backend stall.
## Core principles
### 1. Evidence over assumption
If you cannot point to a concrete artifact — log line, pcap frame, probe output, metric sample — you are guessing, not diagnosing. Before stating "X is the cause", require yourself to name the direct evidence. If it does not exist yet, add instrumentation (see [references/instrumentation-patterns.md](references/instrumentation-patterns.md)) or capture it (see [references/packet-capture-recipes.md](references/packet-capture-recipes.md)) before continuing.
### 2. Falsification over confirmation
N independent sources "confirming" a hypothesis does not make it true. One falsifying observation rules it out. Before acting on a hypothesis, answer:
> "What observation would make me abandon this hypothesis?"
If the answer is "nothing" or "I cannot think of one", the hypothesis is unfalsifiable and must not drive the investigation. If the answer is concrete, go look for that observation before committing to action.
### 3. Layered isolation
Multi-hop systems (client → CDN → LB → reverse proxy → app → upstream) concentrate bugs at the seams between layers. When a symptom could plausibly come from several layers, **do not reason about which layer; test**. Compare the same logical request across verified paths, name every changed hop, and attribute only the isolated difference. For intermittent failures, use a bounded return to the original condition before treating a successful variation as causal evidence. Read [references/layered-isolation-experiment.md](references/layered-isolation-experiment.md) for path design and A/B/A interpretation.
**The same technique isolates capacity, not just correctness.** When the symptom is a rate rather than an error, vary the *stack* instead of the *hop*: measure the same direction over two channels that share the network path but share no application code. It is the cheapest way to stop tuning an application that was never the bottleneck. Resist compressing the reading into "agreement means the path" — that holds only when both channels are *slow*; two fast channels mean the probe failed to reproduce the symptom and prove nothing at all. See Step 0.7 for how to read each of the four outcomes.
### 4. Counter-review before committing
Before committing to a root cause or shipping a fix, have independent reviewers challenge the conclusion — not confirm it. Agents are good at surfacing risks a single investigator did not think of; they are bad at weighing them. Apply the four-question filter (see [references/counter-review-pattern.md](references/counter-review-pattern.md)) to every finding before it shapes action.
### 5. A green health check certifies only the path it probes
Monitors watch the paths their authors thought to probe. Multi-plane systems — a TUN proxy with a DIRECT plane and a proxied plane, a service with a data plane and a control plane, a stack with a backend API and a web login page — fail one plane at a time, and a watchdog that probes only the other plane stays green through the entire outage. In the incident behind this principle, a proxy health daemon probed one overseas endpoint through the proxy every 5 minutes and logged "healthy" for 2+ hours while the direct plane was completely down.
Before accepting "the monitor says it's healthy" as evidence, ask: **which exact path does that check exercise?** Its green counts as evidence for that path alone. Enumerate the planes the system actually forwards or serves, and probe the failing one directly — the check that would have caught the outage is usually one curl away.
**A check also certifies only the *quantity* it is large enough to measure.** Path coverage is one axis; scale is the other, and it is the one that hides throughput collapse. A liveness probe returns a few hundred bytes, so its timing is dominated by handshake and round-trip — in the case study it answered in 40 ms all day, unchanged, across a 100× swing in the link's actual capacity, and it was not lying: it genuinely did what it measures. Same for `ping`/RTT, which times a small packet's round trip and says nothing about capacity.
The practical rule: **a probe measures the link only once transfer time dominates its total time.** So size it by the answer you need, not by convenience — if a probe returns in well under a second, essentially all of that was setup and you have measured setup. Sidestep the sizing question entirely by budgeting *time* instead of bytes (stream for N seconds, divide what arrived by N), which is what Step 0.7's commands do and why they stay cheap on a link that is already crawling. Extend the question to: **which path, and at what scale?**
### 6. Exhaust the instruments before asking the user — escalate only what only they can provide
A user's spoken observation ("the Wi-Fi drops for a few seconds", "some apps report connection errors") is a *lead*, not a question to hand back. Treat each open sub-question it contains as presumed observable, and search for the instrument before asking: monitor/probe ledgers (where a health daemon exists, its log holds the failure timetable of the paths it probes — in both directions: a green ledger never falsifies the user's observation (Principle 5), and a red one says nothing about paths it never probed), system logs (kernel link/disassociation events settle "did Wi-Fi physically drop"; per-process error bursts settle "which apps failed" only for apps that log there), and control channels on the same network (a second host over a different medium — a wired box with no proxy, a hotspot comparison, even the router's own status page; when no control channel exists, say so explicitly rather than treating the question as answered). When the instrument search comes back empty for a sub-question, the move is Step 4 — stand up instrumentation and wait for recurrence — not "ask the user to recall".
Only three categories legitimately reach the user mid-investigation:
1. **Decision rights** — production changes, restarts, spending, anything past your authorization boundary.
2. **Physical actions and first-person observations no instrument captured** — unplugging a box, reading a label password, whether the Wi-Fi icon itself disappeared on a device you have no telemetry for.
3. **Credentials or access you do not have** — a management password that exists only on a device label.
Observed 2026-09-10: a home-network investigation closed its report with four questions for the user. "Which apps reported errors" — its diagnostic content, a per-plane failure timetable — was already in the health daemon's ledger (the app-name list itself lives only on the user's screen, and adds no layer information the timetable lacks); "did Wi-Fi actually drop" was settled by twelve hours of kernel logs with zero disassociation events; both went to the user anyway. The fourth — "is the retired router still broadcasting" — is the honest counter-example: its ARP absence proved nothing (Trap 14: a probe certifies only the L2 domain it ran on), the platform redacted the Wi-Fi scan, so that one correctly reached the user as a five-second physical check.
For every question you hand the user, write one sentence in the report naming why no instrument can answer it and which of the three categories it falls in. If you cannot produce that sentence, the question is not exhausted — go back to the instruments; do not ask.
## Workflow
Copy this checklist into the investigation notes and check items off:
```
Investigation Progress:
- [ ] Step 0: Scope the symptom (exact error, exact times, who, who-not, what changed)
- [ ] Step 0.5: Verify the premise — does direct evidence show the symptom is actually happening?
- [ ] Step 0.6: **For large POST bodies: distinguish upload-timeout from processing-timeout** (see recipe below)
- [ ] Step 0.7: **If nothing errors and it is just slow: measure a rate, then two-channel it** (see recipe below)
- [ ] Step 1: Gather direct evidence at every hop before hypothesizing
- [ ] Step 2: Frame ≥3 hypotheses; for each, name (a) what falsifies it, (b) which layer boundary the intervention would target
- [ ] Step 3: Design a decisive experiment (for network: layered isolation)
- [ ] Step 4: Add instrumentation if evidence gaps block direct observation
- [ ] Step 5: Execute, record actual vs predicted
- [ ] Step 6: Counter-review before acting
- [ ] Step 7: Fix + re-run the same experiment to verify
- [ ] Step 8: Document wrong turns as teaching material
```
### Step 0: Scope
A tight scope is the difference between a 20-minute investigation and a 5-hour one. Before looking at anything, extract:
- **Exact error string** (copy-paste, not paraphrase). `socket closed` is not the same as `ECONNRESET` is not the same as `HTTP/2 RST_STREAM INTERNAL_ERROR (err 2)`.
- **Exact timestamps** (ISO-8601 with timezone, not "yesterday evening")
- **Reproducibility** (every time / intermittent / only specific users)
- **Who is affected, who is not** (differential observations narrow the search)
- **What changed recently** (deploys, config, upstream dependencies, client versions)
Distinguish symptom from diagnosis. "Slow" is not a symptom. "Request took 130.898s then returned HTTP/2 INTERNAL_ERROR" is.
**But do not let that sentence route a real incident into the bin.** It demands quantification, not an error code — and a whole failure family quantifies as a *rate* with no error code anywhere: every request returns 200, nothing resets, and the system is still unusable. "Slow" becomes a symptom the moment you write it as **bytes per second over a stated payload size** ("8.4 MB in 95 s = 0.09 MB/s, HTTP 200 throughout"). Once you have that number, Step 0.7 has a decisive experiment for it. What stays out of scope is the *unmeasured* complaint — "users say it feels slow" — which is Step 0.5's problem, not this one.
### Step 0.5: Verify the premise
Before investing in a full investigation, confirm the reported symptom is actually happening — not just inferred from downstream effects or user frustration. One cheap direct observation beats hours spent investigating a non-problem.
Ask: **"What direct evidence shows this symptom is real?"**
- If the user reports "timeout at 130s": is that from a timestamped log, a browser network panel, or a recollection?
- If the user reports "connection reset": did they see the packet or is it inferred from a retry spike?
- If the user reports "fails for some but not others": has it been reproduced in a controlled test, or is it anecdotal?
Acceptable premises:
- Log line with timestamp and error string
- Browser DevTools Network screenshot showing the failure
- Reproduction command that shows the symptom on demand
- Metrics chart showing the specific error count rising
Not sufficient as premise:
- "Users are saying it feels slow"
- "The alert fired but I did not check what actually failed"
- "Last week someone mentioned..."
If the premise fails verification, the fix is observation — not investigation. Add the missing telemetry, wait for the next occurrence with instrumentation in place, and return when you have real data. Resist the sunk-cost instinct to investigate anyway "since we are already here".
### Step 0.6: Upload-timeout vs processing-timeout for large POST bodies
For CDN-fronted `POST`/`PUT` endpoints with large bodies, the most common misdiagnosis is blaming backend slowness when the real problem is **time-to-upload-body exceeding the CDN/proxy origin timeout**.
Apply this sub-checklist when the symptom is a 524/522/504 on a request with `Content-Length > ~500 KB`:
1. **Locate the edge/reverse-proxy access log** (Caddy, nginx, Envoy, Cloudflare Logpush).
2. **Compare `bytes_read` (or equivalent) to `Content-Length`**:
- `bytes_read == Content-Length` and `status` is an error → likely backend/processing problem.
- `bytes_read < Content-Length` and the connection closed around the timeout window → **upload problem**.
3. **Check `duration` / `request_time` semantics**:
- Caddy `duration` = wall time from first byte read to response end.
- nginx `$request_time` = same.
- <upstream-capture-service> / app `request_time` = time backend spent processing after body was fully received.
- If proxy `duration` ≈ timeout but upstream `request_time` is short or never logged, the body upload is the bottleneck.
4. **Look for `status=0` (Caddy) or `-` (nginx)**:
- `status=0` means the proxy never wrote an HTTP response, usually because the downstream/client side closed first.
5. **Correlate with upstream logs**:
- If the request ID / ray ID / trace ID **does not appear** in upstream (<new-api-container>, <upstream-capture-service>, app) logs, the request never finished uploading.
**Example signature of an upload-timeout 524:**
```json
{
"status": 0,
"duration": 125.0,
"bytes_read": 4111422,
"request": {
"headers": { "Content-Length": ["6042141"] }
}
}
```
Interpretation: the proxy kept the connection for 125 s, read 4.1 MB of a 6 MB body, then Cloudflare closed it and returned 524.
**Example signature of a processing-timeout:**
```json
{
"status": 504,
"duration": 120.1,
"bytes_read": 6042141,
"request": { "headers": { "Content-Length": ["6042141"] } }
}
```
Interpretation: full body uploaded, but backend did not respond before proxy timeout → backend/processing problem.
### Step 0.7: Throughput collapse — when nothing errors and everything is slow
The recipe above (0.6) handles a *timeout* — an error code you can grep for. This one handles the family with **no error at all**: every request succeeds, every health check is green, and the system is unusable because bytes arrive at a fraction of the expected rate. It is the failure mode most likely to be misdiagnosed for hours, because the entire error-driven toolkit has nothing to bite on.
**Why the usual signals go green** — each of these was observed simultaneously with a 100×-degraded link:
| Signal | What it read | Why it is green anyway |
| --- | --- | --- |
| HTTP status | `200` on everything | Slow is not an error; the response completes, just late |
| Round-trip latency | 12 ms | RTT measures a small packet's round trip; it is **not** a capacity measurement |
| Liveness/health endpoint | `0.04 s` | Its response is handshake-sized, so its timing is all RTT and none of it transfer. **A probe that finishes before the link becomes the limiting factor cannot measure the link** — see Principle 5 |
| Transport status field | `active; direct <endpoint>` | Reports the **path type**, not the path's capacity — see trap 16 |
So the first move is to stop reading status and **measure a rate**.
For device or wired/Wi-Fi comparisons, load [device throughput fieldwork](references/device-throughput-fieldwork.md) before probing or changing association; bind the control/data paths, comparison and recovery window there.
**The decisive experiment: two independent channels, same direction, same time budget.**
This is Principle 3's layered isolation applied to capacity instead of correctness. Pick two channels that reach the same host over the same network path but share **no application code** — the point is that a result they agree on cannot be caused by either one's internals.
Both commands below are **time-budgeted, not size-budgeted**: they stream for a fixed number of seconds and report whatever arrived. That keeps the probe's cost constant no matter how bad the link is — a fixed 8 MB payload took under a second on the healthy path in the case study and about 90 seconds on the degraded one, and a degradation deeper than that scales the wait without bound, exactly when you can least afford it. It is also why the two numbers are comparable: same seconds, same direction, both measured **at the receiving end**.
```bash
BUDGET=20
# Channel A — the service under suspicion. Point it at the largest object you can name.
# --max-time aborts mid-transfer; -w still prints, and curl exits 28. That is a
# successful measurement, not a failure — read the rate, ignore the exit code here.
curl -s -o /dev/null --max-time "$BUDGET" \
-w 'A: %{size_download} B in %{time_total}s = %{speed_download} B/s\n' \
"http://<host>:<port>/<a-large-object>"
# Channel B — a completely different stack to the same host. `cat /dev/zero` streams
# until cut off, so the byte count is decided by the link, not by the payload size.
B=$(timeout "$BUDGET" ssh <host> 'cat /dev/zero' 2>/dev/null | wc -c)
echo "B: $B B in ${BUDGET}s = $((B / BUDGET)) B/s"
```
> **Do not substitute `dd`'s own summary line for channel B's number.** `dd if=/dev/zero … ` reports how fast it wrote into *its stdout*, which for a piped SSH transfer is absorbed by the pipe and SSH's channel window — it measures the local write, not the wire, and it never sees connection setup. Measured on the case study's healthy path, raw SSH moved 33.6 MB end-to-end at 44 MB/s while `dd` self-reported 54.9 MB/s on the same transfer. A sender-side rate is an upper bound on the wire rate, never a measurement of it. Time the whole invocation from the receiving end, as above.
**Expect the two channels to disagree in absolute terms — that is not a problem.** On the same healthy path, raw SSH measured ~44 MB/s while fetches through the media service measured 11–16 MB/s, because channel A pays for the service's own read and encode work on top of the wire. This is why the decision rule below is about **order of magnitude**, not equality: what you are testing is whether both channels sit in the same band, not whether they print the same number. Two channels that differ by 3× on a healthy link and both collapse to 0.1 MB/s on a degraded one have told you exactly what you needed.
Voir sur GitHub