How Network Doctor Works
The design has one governing idea: an unrelated failure must never hide a working path. Almost everything else follows from it.
A naive troubleshooter is a checklist run top to bottom, stopping at the first error. That produces the classic useless answer, “DNS failed”, when DNS is broken and your Wi-Fi is fine and the target is perfectly reachable by IP. You learn one fact and lose three.
Network Doctor instead runs a dependency graph with independent branches. Only a genuine prerequisite blocks a probe. Everything else runs anyway.
The probe graph
graph LR
IF["Interface"]
IF --> INET["Internet<br/>(TCP egress)"]
IF --> QUIC["QUIC / UDP 443"]
IF --> PROXY["Internet<br/>(env proxy)"]
IF --> EDNS["DNS<br/>(encrypted DoH/DoT)"]
IF --> PUB["DNS<br/>(public resolver)"]
IF --> WIFI["Wi-Fi network"]
IF --> DNS["DNS"]
DNS --> TCP["TCP (target)"]
DNS --> HTTP["HTTP :80"]
TCP --> PMTU["Path MTU"]
TCP --> TLS["TLS"]
TLS --> HTTPS["HTTPS"]
TCP --> BANNER["SSH / SMTP banner"]
Read the fan-out from Interface as the important part. Six of those branches
are deliberately independent of DNS, which is what keeps “DNS is down but the
internet is up” a diagnosable state rather than a dead end.
Which rows actually appear depends on the target. With no target you get the
generic branches only. With a target, the protocol rows are chosen by the port
and scheme: 443/8443 gets TLS + HTTPS + a plain HTTP row, 80 gets HTTP,
22 gets an SSH banner, 25/587 gets an SMTP banner.
The exhaustive probe table, with what each row passes on and its caveats, is in the README’s How it diagnoses section, which is kept beside the code.
Why each branch is separate
Each of these splits exists because two things that look alike are actually different faults with different fixes.
Direct egress vs. proxy egress. The native probes deliberately bypass any
configured proxy. So a corporate network where nothing works except through
HTTPS_PROXY reports “online via proxy” rather than “offline”: direct
egress fails, the proxy row passes, and the summary says so.
System DNS vs. public DNS. The second-opinion row queries a fixed public resolver directly. If it disagrees with your system resolver, that is evidence of split-horizon DNS or filtering, reported as a Warn, never a Fail, because disagreement is not by itself an error. If the public resolver cannot be reached at all, the row is N/A rather than a failure.
Plaintext DNS vs. encrypted DNS. Encrypted DNS hangs off the interface, not off the plaintext DNS row, because they are separate network capabilities. A network can happily carry port-53 DNS while blocking DoH and DoT, which is exactly the shape of “my terminal resolves fine but my browser cannot.” The row never falls back to port 53; falling back would answer a different question.
TCP vs. QUIC. TCP/443 working says nothing about UDP/443. The QUIC row
completes a real, certificate-validated HTTP/3 handshake, so a successful local
UDP send cannot masquerade as reachability. When TCP works and QUIC does not, the
verdict is degraded, not broken: browsers fall back to TCP, and the symptom is
usually slowness rather than an outage.
Path MTU hangs off the TCP connect, not off any protocol row. An MTU black hole breaks SSH and SMTP exactly as thoroughly as it breaks TLS, so attaching it to TLS would have made it invisible on the other three-quarters of targets.
Five states, and why Warn is not a failure
| State | Meaning | Counts as failure? |
|---|---|---|
| PASS | Worked. | No |
| WARN | Worked, but impaired. | No |
| FAIL | Did not work. | Yes |
| SKIP | A prerequisite failed, so it was never attempted. | No |
| N/A | Does not apply to this target. | No |
WARN earns its own state because “high latency”, “IPv6 is down but IPv4 works”,
and “your resolvers disagree” are all real findings that should not turn a green
run red. A monitoring script that treats every non-PASS as an outage would page
someone at 3am because a public resolver was slow.
SKIP and N/A are also deliberately distinct. SKIP means we could not ask:
DNS failed, so there was no address to connect to. N/A means the question does
not apply: you gave it an IP literal, so there is no name to resolve. Collapsing
them would lose the difference between a blocked path and an irrelevant one.
Two ideas that make the diagnosis trustworthy
No verdict depends on ICMP
Plenty of healthy hosts drop ping. A failed ping proves nothing, and a
successful one proves less than a TCP connect. So round-trip time is measured
from the TCP connect handshake instead, with no ICMP, no raw sockets, no root.
ping is still available as a drill-down tool, where it is evidence for a human
to interpret rather than an input to the verdict.
network vs. service is decided by evidence
The most valuable thing the tool tells you is whose problem it is, and it refuses
to guess. An unreachable target is blamed on the service only when direct
egress independently succeeded, that is, only when there is proof the network
works. With no working egress to compare against, the verdict is network,
because accusing a host you never reached would be a guess.
See Understanding Your Diagnosis for what each verdict means in practice.
Path MTU without root
This is the probe most worth understanding, because it finds a fault the usual tools cannot see at all.
The symptom. TCP connects fine, then the connection dies the moment either
side sends a real packet. This is the classic tunnel / VPN / PPPoE mystery: a path
MTU smaller than your interface’s, on a path that also filters the ICMP that
would have told you so. ping and curl cannot distinguish it from a hung
server, and confirming it normally means raw sockets and the DF flag.
The trick. The TCP handshake is the control. SYN and SYN-ACK are small enough to cross a narrowed link, so a completed connect already proves small packets get through. The probe then writes 24 KiB and asks the kernel how much of it the peer has acknowledged.
Acknowledgement is the only proof of forward progress an ordinary socket can offer, and it is a strict one: TCP fills segments from the front of the payload, so nothing can be acknowledged unless a full-size packet crossed.
| Observation | Result |
|---|---|
| Some of the payload acknowledged | Pass: full-size packets cross |
| Whole payload written, none acknowledged before the deadline | Warn: names the evidence and an MSS/MTU experiment |
| Peer hangs up first | N/A: inconclusive, and it will not guess |
Why a completed write is deliberately not the test. Linux treats the send buffer size as an accounting hint rather than a ceiling, so a socket reporting an 8 KiB buffer will still swallow a 24 KiB write whole without a byte reaching the wire, which is precisely what a black hole looks like from userspace. Linux and macOS therefore read the socket’s outstanding send queue directly. Windows exposes no equivalent query and falls back to inferring delivery from the send buffer, and the row says so: on Windows this probe can miss a black hole, though the TLS/HTTP timeouts beside it still show up.
It never Fails, on purpose. A peer that accepts a connection and then stops reading stalls the write the same way, and a normal socket cannot discover the exact path MTU when ICMP is filtered. So the row reports what it saw: bytes written, bytes acknowledged, the TCP MSS when the OS exposes it, and the local interface MTU as context, never as a measured path MTU. Only when this write and a protocol exchange both time out does the overall verdict call it a probable network-path problem.
This is the only probe that sends bulk data: 24 KiB per pass, of inert
self-labelling filler. Under --watch that is 24 KiB every five seconds.
What it contacts, and what it does not
Three fixed third-party endpoints are involved, and it is reasonable to want to know about them:
| Endpoint | Used by | What is sent |
|---|---|---|
Well-known anycast :443 addresses |
Internet (TCP egress) | A TCP connect, IPv4 and IPv6 independently |
connectivitycheck.gstatic.com (Google) |
Internet egress / captive portal, and QUIC | One small plain-HTTP GET, no body; and a QUIC handshake to UDP/443 with no application data |
cloudflare-dns.com at 1.1.1.1 / 2606:4700:4700::1111 (Cloudflare) |
DNS (encrypted DoH/DoT) | One A query per transport, per pass |
Notes that matter:
- The public-resolver row uses
8.8.8.8by default.--public-dnschanges it, and--public-dns ""removes the row entirely, so no query is sent, and the row is absent from both the TUI and the JSON. That is the switch for a strict egress policy or a privacy requirement. --public-dnsgoverns only the DNS second opinion. It is never reinterpreted as a DoH/DoT provider, and it does not change the egress or captive-portal endpoints.- Every probe is bounded by a per-probe timeout (
--timeoutoverrides it;netdoc --helpprints the default). - All of these ignore configured proxies, deliberately, because an environment proxy carrying the encrypted-DNS request would prove nothing about encrypted DNS.
Where next
- Understanding Your Diagnosis: turning a verdict into an action.
- Architecture: how this is organised in code.
- Troubleshooting and FAQ: rows that surprise people.
This page has one editable copy: edit it at the source.