Filenames only carry a time-of-day now (report_<asn>_HH-MM-SSZ)
since the date lives in the directory path; history lookups walk
the tree with rglob and sort by full path instead of basename,
since the basename alone no longer encodes recency across days.
The only feature in this batch adding a real dependency, approved up front
and landed the same way tcptrace/ndt7 already do: an optional extra
(uv sync --extra quic), imported lazily inside measure_quic() so a missing
install degrades to a clear skip warning instead of a crash.
Verified the actually-installed aioquic 1.3.0 API directly rather than
assuming. connect() takes local_port but no local_host/source-address
parameter at all, so --quic structurally cannot honor --interface -
_quic_section() says so explicitly rather than silently measuring the
OS-default path under a report claiming a forced adapter. Its context
manager doesn't yield until the handshake finishes, so unlike the TLS RTT
probe's two-connection subtraction trick, handshake time here is timed
directly. HandshakeCompleted's fields matched what was expected; dropped
a planned quic_version field since aioquic has no clean public API for
it rather than reach into a private attribute.
quic_verdict(quic_ok, tcp_ok) is the actual payoff: QUIC failing while
plain TCP to the same host succeeds means the network specifically
drops or throttles UDP/443, silently degrading Chrome/YouTube's HTTP/3
preference in a way no TCP-based diagnostic would ever catch. Both
failing is ordinary unreachability already covered elsewhere, so it
stays silent rather than duplicating that finding.
Opt-in --quic, excluded from --full - an optionally-installed extra must
never become a silent requirement. Added the quic extra to the release
workflow's wheel-install verification.
Live-tested against real cloudflare.com/google.com: real handshakes,
ALPN h3 negotiated, correct end-to-end timing.
This completes the 16-feature extension batch from
plans/zesty-zooming-wilkinson.md (gitignored, local planning doc).
Stayed unprivileged on both platforms deliberately. Windows: echo_once()
gained a df: bool param setting IP_FLAG_DF on the IP_OPTION_INFORMATION
struct's Flags field (present but unused until now), and classify_status()
learned IP_PACKET_TOO_BIG (11009) - the same unprivileged IcmpSendEcho2
API the rest of the ICMP tier already uses, no new capability needed.
Unix deliberately does not hand-roll raw ICMP echo requests - unprivileged
SOCK_DGRAM/IPPROTO_ICMP checksum and identifier handling varies enough
across kernel versions that shipping it without a Linux/macOS box to
verify on felt like the wrong risk to take. Shells out to system
ping -M do -s <size> (Linux) / ping -D -s <size> (macOS/BSD) instead,
the same subprocess-and-parse pattern traceroute.py's system tiers
already use. Matches "frag"/"too long" loosely in the output rather than
an exact phrase, since a locally-cached-PMTU failure and a genuine
on-path ICMP response word themselves differently across ping
implementations - same "parse by shape" principle traceparse.py already
established.
classify_pmtu() distinguishes a path that's just narrower (a frag-needed
signal was seen - PMTUD is working, normal behind a VPN) from an actual
blackhole (large packets vanish with no ICMP reply at all - the "SSH
connects then hangs" symptom). path.pmtu_below_iface_mtu flags separately
when the path MTU is narrower than the local NIC's own MTU, which is
exactly what makes this class of problem invisible locally.
Opt-in --pmtud, excluded from --full. Live-verified on Windows against
real cloudflare.com/google.com - found the full 1500-byte MTU in 11
probes each, matching the expected binary-search depth, correct probe
history in the JSON output.
Part of plans/zesty-zooming-wilkinson.md (gitignored).
Shipped half of the original design on purpose: generalizing BSD's alt:
same-TTL annotation across backends is impossible - mtr's JSON aggregates
cycles and discards per-cycle next-hop variance, icmp_win/icmplib send one
probe per TTL by construction, and the Linux system-traceroute parser
doesn't model multi-IP hops even though the text can contain them. A
finding that only ever fires on macOS/Linux-system-binary would be worse
than not having it.
New probes/ecmp.py::detect_ecmp() takes N independently-run TraceResults
for the same target and diffs which IP showed up at each TTL - pure and
backend-agnostic, since it never touches capability detection. Flags
path.ecmp (info, multiple next hops exist - normal on a load-balanced
network) and path.ecmp_asymmetric (warn, branches differ by >20ms - the
diagnostic case, since one degraded branch of a load-balanced pair causes
intermittent slowness a single traceroute would never catch).
--ecmp is opt-in and deliberately excluded from --full: it multiplies
traceroute traffic against the same targets by ecmp.runs (capped at 5)
across ecmp.max_targets (capped at 3), sequential and sharing one
semaphore, only after the main fan-out has finished.
Live-tested against 2 real targets x 3 runs each - ran cleanly, correctly
reported no divergence on this network.
Part of plans/zesty-zooming-wilkinson.md (gitignored).
New metrics.py: collect_metrics() does the per-section extraction a flat
dict walk can't (latency needs label/host/method, TLS label/host, path hops
target/ttl/ip/asn), producing a flat Metric list that render_prometheus()
and render_csv() each format in about 15 lines. Simpler than treating them
as two separate renderers. FORMAT_EXTENSIONS gained "prom"/"csv" as the
single wiring point both parse_formats() and Output._normalize_formats()
already key off of.
New history.py finds a previous JSON report for the same ASN by reusing
the exact filename convention exporter.report_filename() already writes,
so the lookup key can't drift from what's on disk. Wired into diagnose()
before write_report() - a compact vs-previous-run summary
(compare.py::render_diff_brief(), new) prints automatically whenever this
run writes JSON and an earlier one exists for the same network; silent on
every other case (first run, prior run wrote no JSON prints one note
instead of guessing). New --no-auto-diff escapes it per run,
history.auto_diff in config disables it entirely.
New trend.py aggregates N saved JSON reports into per-label sparkline
series via --trend N - read-only, and it doesn't touch the network to find
"this network's" history either: history.latest_key() just reads it off
the newest report filename already on disk.
Growing FORMAT_EXTENSIONS to 5 entries broke three pre-existing tests that
hardcoded the old 3-format set; fixed in this commit.
Live-tested: prom/csv artifacts render correctly, auto-diff fired on a
second run, --trend 3 produced readable output, --compare unaffected.
Part of plans/zesty-zooming-wilkinson.md (gitignored).
New Phase 0 in diagnose(): three generate_204-style probes run before every
other module, using detect-and-tag rather than short-circuit — a portal is
rarely total (DNS/ICMP often pass while only HTTP is intercepted), and an
aborted run would look identical to a dead link. On detection every later
phase still runs, tagged with a crit finding and a banner at the top of the
report.
Caught live before it shipped: detectportal.firefox.com/success.txt (one of
the original three default check URLs) returns 200 "success" on a genuinely
clean connection, which the generate_204-shaped classifier misread as a
portal redirect. Swapped for a second real generate_204 endpoint instead of
teaching the classifier a second success shape.
Also redesigned the speedtest server geo-sanity check after checking the
bundled Ookla CLI's actual JSON output: its server object never carries
lat/lon (documented schema, not a version gap), so speed.server_other_country
compares server_country against the client's own country instead of a
haversine distance that no tier can actually supply.
Part of plans/zesty-zooming-wilkinson.md (gitignored).
Every probe used to follow whatever the OS default route picked, with no way
to point the tool at a specific adapter for comparison (VPN vs raw) or to
confirm which one actually carries traffic on a multi-adapter machine.
New iface.py resolves an adapter name or IP to a BindTarget (pure, tested):
exact/case-insensitive/unique-substring name match, rejects APIPA-only,
link-local-only and down adapters. Wired through every httpx provider client,
the Ookla binary tier, ndt7, and the whole ping/traceroute cascade (icmplib,
mtr, unix traceroute, TCP connect, scapy) plus --watch. Windows icmp_win now
uses IcmpSendEcho2Ex (adds SourceAddress) only when a bind is forced, so the
unforced path is unchanged. Windows tracert has no IPv4 source-address option
(verified from its own usage text) so that tier is dropped when forced,
noted in the report rather than silently measuring the wrong path.
An unresolvable/down adapter aborts with every adapter listed; a resolvable
adapter with no route to the internet degrades gracefully instead of
aborting - that's a real diagnostic answer, not an error. netinfo's
observation of the OS's actual default route stays untouched, so the report
can show the forced adapter and the OS default side by side.
Verified live: forcing Tailscale (no internet route on this box) triggered
the preflight warning and degraded correctly; forcing Ethernet ran clean
end-to-end including a full speedtest+MTR cycle.
Also noted, not fixed: probes/traceroute.py's icmplib tier imports
async_traceroute, which doesn't exist in icmplib 3.0.4 - that tier has
always raised ImportError and silently fallen through. Found while
verifying the icmplib source-binding wiring live.
Every run used to write all three files (en.md, ru.md, json) unconditionally.
Default is now English Markdown only, sourced from output.formats in
config.yaml; --format/--ru/--json pick exactly what a run writes, and any
explicit --format replaces the default rather than adding to it (all/none
supported). write_report() moved from a fixed 3-positional-arg signature to
a {format: text} mapping so writes are conditional instead of always-three.
The en/ru Markdown cross-link header is now only set when both languages are
actually being written.
Thread a lang parameter through render_markdown and every section
helper so the same report dict can render an English or Russian
report; inline _t() calls carry the strings, findings fall back from
_ru fields to their English siblings. write_report now emits a third
.ru.md file alongside .json and .md, and render_markdown can stamp a
sibling cross-link line right after the H1.
unavailable() only ever read module.errors for the placeholder detail
text, so a deliberately skipped section (speed in --quick or target
mode, which reports its reason as a warning, not an error) always
fell through to the generic 'no data was collected for this section'
line even though the real reason ('speedtest skipped in target mode')
was sitting right there in the json. spec says a skipped speedtest
must say why, not just that it's missing.
target mode's filename fell back to the local machine's egress asn
unconditionally, so 'netcheck --target AS15169' wrote report_<local-asn>_*
instead of report_AS15169_*. meta.target already carries the right
subject when one was given; prefer it and fall back to the egress asn
only in auto mode.