This document explains what the tool does on the wire and how to read its
results accurately. Both mtu_path_test.py and mtu_path_test.sh
implement the same probing model — anywhere this doc says "the tool", both
apply unless called out.
For each target, the tool runs two phases:
- Discovery — a single
traceroutebuilds the list of hops between you and the target. - Probe — for every responsive hop, the tool sweeps packet sizes and sends two ICMP echo requests per size: one with the Don't-Fragment bit set, one without. The pair tells us, at every size, whether the path needs fragmentation, where it would happen, and whether fragmentation actually delivers the packet end-to-end.
The output is rendered as a vertical path diagram with two bars per hop and a fragmentation-point line.
traceroute -w 2 -q 1 -m <max-hops> <target>
-w 2— wait 2 seconds for each probe.-q 1— one probe per TTL (we don't need three; we're not measuring jitter).-m— caps the hop count.
Output is parsed line by line. Two formats are handled:
hostname (1.2.3.4) ...— both hostname and IP captured.1.2.3.4 ...— IP only.
A hop that responds with only * * * is recorded as unreachable and
skipped during probing. This usually means an intermediate device drops or
rate-limits ICMP — common in carrier networks — and is not in itself a
problem.
The sweep is fine-grained near the standard 1500-byte MTU and
coarse-grained above it. With the defaults
(--start 1300 --fine-pivot 1500 --fine-step 1 --step 500 --end 9000):
fine zone: 1300, 1301, 1302, ... , 1499, 1500 (step=1)
coarse zone: 2000, 2500, ... , 9000 (step=500)
Why the asymmetry? Most real-world breakage clusters around the Ethernet 1500 boundary (PPPoE −8 here, GRE −24 there, IPSec −anywhere from 50 to 130). One-byte resolution in that window pinpoints the exact link MTU. Up in jumbo-frame territory you usually only care whether a path supports the size at all, so 500-byte steps are plenty.
If you want a flat sweep, set --start 1500 (skips the fine zone) or
--fine-step to whatever resolution suits.
For every size in the list the tool issues two ping invocations:
ping <DF-flag> -c 1 -W <wait> -s <payload> <hop-ip> # DF set
ping -c 1 -W <wait> -s <payload> <hop-ip> # DF clear
- The DF-set probe sets the Don't Fragment bit. If any link on the path has a smaller MTU, the packet is dropped and the offending router emits an ICMP "Fragmentation Needed" (Type 3 / Code 4) message back to us.
- The DF-clear probe lets routers fragment freely. If the destination is reachable, you get an echo reply. This confirms that fragmented delivery actually works for that size.
-c 1sends exactly one packet per probe.-s <payload>sets the ICMP data payload size, not the total IP packet size.
The Linux/macOS implementations check the OS once at startup (bash:
uname -s; Python: platform.system()) and adapt their ping calls.
The PowerShell implementation runs only on Windows and uses the very
different ping.exe / tracert.exe flag set:
| Concern | macOS BSD ping | Linux iputils ping | Windows ping.exe |
|---|---|---|---|
| DF bit flag | -D |
-M do |
-f |
| Packet size flag | -s <payload> |
-s <payload> |
-l <payload> |
| Count flag | -c 1 |
-c 1 |
-n 1 |
| Wait flag / units | -W <ms> |
-W <sec> |
-w <ms> |
| Source-address bind | -S <addr> |
-I <addr-or-iface> |
-S <addr> |
| Bind interface (alias) | -b <iface> (boundif) |
-I <iface> |
(resolve to addr, then -S) |
| traceroute bind | -i <iface> |
-i <iface> |
-S <addr> (tracert.exe) |
| traceroute hop cap | -m N |
-m N |
-h N |
--timeout-ms is always specified in milliseconds at the user level; the
scripts divide by 1000 (rounded up, min 1) before passing it to iputils.
So --timeout-ms 1500 always means "1.5 seconds" regardless of platform.
--iface is plumbed through both ping and traceroute; pick the right
one for multi-homed hosts (laptop with WiFi+Ethernet, server with
management+data NICs). Without it the kernel routes per its routing
table, which may not be the path you actually want to test.
Windows note: ping.exe doesn't have a "bind to interface" flag, only
-S <source-addr>. The PowerShell script accepts either an IPv4 address
or a NIC alias (e.g. "Ethernet", "Wi-Fi") for -Iface and resolves
the alias to its primary IPv4 address via Get-NetIPAddress before
passing it to ping -S. The same source address is passed to
tracert -S so both phases use the same egress.
Windows note (2): Windows ping.exe does not embed the next-hop link
MTU in its "Packet needs to be fragmented but DF set" message the way
iputils and BSD ping do — newer versions name the offending router (e.g.
Reply from 10.0.0.5: Packet needs to be fragmented but DF set.) but
the link MTU is usually absent. The PowerShell script reports
(link MTU N) only when present, otherwise just the router IP.
Windows note (3): file encoding. mtu_path_test.ps1 ships with a
UTF-8 BOM. This is required for Windows PowerShell 5.1, which reads
BOM-less script files as Windows-1252 by default and chokes on the
multi-byte Unicode characters used in the rendered bars (█, ░,
═, │, ▼). PowerShell 7+ reads UTF-8 by default and works either
way. Don't strip the BOM if you fork the script — and don't edit it in
old Notepad which silently drops it. Modern VS Code, PowerShell ISE,
and Notepad++ all preserve it correctly.
Windows note (4): automatic-variable name collisions. PowerShell
reserves a set of read-only globals ($Host, $Args, $Input,
$Error, $Matches, $_, $True, $False, $Null, $PID,
$LASTEXITCODE, $Home, $PSHome). Assigning to any of them at
function scope throws Cannot overwrite variable X because it is read-only or constant. at runtime — the parser doesn't catch it. The
script avoids these (uses $pingArgs, $trArgs, $hostName, etc.).
If you extend the script, run Invoke-ScriptAnalyzer mtu_path_test.ps1
to surface any new collisions before they bite.
Success detection accepts any of 1 packets received (BSD), 1 received
(iputils), or bytes from as a positive signal — the wording differs
between the two stacks.
ping -s N produces a packet of:
N (data) + 8 (ICMP header) + 20 (IPv4 header) = N + 28 bytes on the wire
The user-facing --start / --end / --fine-pivot values are total
IPv4 packet bytes, so the script subtracts 28 before calling ping:
payload = total_size - 28
That way --end 9000 actually tests 9000-byte IP packets — i.e. real jumbo
frame territory — without you having to do the arithmetic.
When the DF probe fails because a link is too small, the offending router
sends an ICMP error containing its own address and the next-hop MTU.
On macOS ping surfaces it as:
36 bytes from 10.0.0.5: frag needed and DF set (MTU 1500)
Linux iputils uses:
From 10.0.0.5 icmp_seq=1 Frag needed and DF set (mtu = 1500)
The script parses both forms with a single regex and records:
frag_router— the router IP that returned the error.frag_router_mtu— the link MTU it advertised.
That router is the fragmentation point — i.e. where the silent fragmentation would have happened if DF were off. We snapshot the first such occurrence per hop sweep.
ICMP rate-limiting causes random single-packet drops on otherwise-healthy paths. To filter that noise:
- Each probe is retried up to
--retriestimes (default 2 retries, i.e. up to 3 attempts). - A definitive result short-circuits retries: a successful echo, or a parsed "Frag Needed" message. We never retry to overturn one of those.
- A "no reply" outcome is retried.
The sweep stops at any size where both probes fail. That's a real reachability or filtering boundary, not just an MTU constraint, and probing larger sizes won't tell us anything new. With the default sweep this is usually the saving grace — most hops fail-out long before churning through all 216 sizes.
Each hop ends up with:
max_no_frag— largest size whose DF probe succeeded (no fragmentation needed up to here).frag_at— first size whose DF probe failed (fragmentation begins).frag_router,frag_router_mtu— who fragments and what link MTU they reported, if captured.max_with_frag— largest size whose DF-clear probe succeeded (fragmentation actually works to here).first_real_fail— first size where both DF and DF-clear failed.df_baseline_failed/nodf_baseline_failed— even the smallest tested size failed.
[ 2] ● 10.0.0.1
│ no-frag : ████░░░░░░░░░░░░░░░░░░░░░░░░ max=2000
│ w/ frag : ████████████████████████████ max=9000
│ frag pt : 2500 → via 10.0.0.5 (link MTU 1500)
- no-frag bar — how far up the tested range packets went without needing fragmentation.
- w/ frag bar — how far up the range fragmented packets still got through.
- frag pt — first size where DF failed, and (when captured) the router that announced it can't carry that size on its outgoing link.
- A
(router silent — ICMP rate-limited or filtered)note means we detected fragmentation is required (DF fails, non-DF works) but no router told us who. That's an ICMP-policy artifact, not a script bug. frag pt : none in tested range(green) means no DF probe ever failed — the real ceiling could be higher; raise--endif you want to know.- A red
hard fail at Nline means both DF and DF-clear failed at size N — reachability or filtering issue, not an MTU one.
The summary at the bottom of each target shows:
- No-frag Path MTU — minimum
max_no_fragacross probed hops. Largest size that traversed the whole path without needing fragmentation. - Frag-OK Path MTU — minimum
max_with_fragacross probed hops. Largest size that still got through end-to-end with fragmentation allowed. - First fragmentation — earliest hop in the path where fragmentation begins, with the router IP and link MTU when reported.
While probing a hop, both implementations write a single-line status to
stderr that updates in place via \r and \e[K:
[ 2] 10.0.0.1 probing [37/216] size=1336 DF=OK non-DF=OK
It's gated on the stderr TTY check ([[ -t 2 ]] in bash;
sys.stderr.isatty() in Python), so when stderr is redirected (CI, tee,
log capture) the progress is silenced and the scripts emit clean
line-oriented output suitable for scripting. There's no flag for it; it
just turns on whenever you're at a real terminal.
By default both implementations write a per-target log:
<sanitized-target>_<YYYYMMDD>_<HHMMSS>.<ext>
Mechanism differs by language but the result is identical:
- bash wraps each target's body in a function whose stdout is piped
through
tee >(strip_ansi > "$file"). ANSI sequences are stripped viased -E 's/\x1B\[[0-9;]*[A-Za-z]//g'. - Python swaps
sys.stdoutfor a small_Teeobject that fans every write to (a) the original stdout and (b) an_AnsiStrippingFilewrapper around the log handle. The wrapper applies the same ANSI-stripping regex before the bytes hit disk.
Live progress goes to stderr in both cases, never piped through the file sink, so it doesn't clutter the log.
All targets in one invocation share a single timestamp captured at start
(RUN_TS in bash; a datetime.now() snapshot in Python), so sibling
files sort together. Disable with --no-save; relocate with --out-dir;
change the extension with --out-ext. After all targets finish, both
scripts print a Saved logs: recap listing the files written.
The Python implementation has a pytest suite under tests/ covering all
the pure helpers — there's no network mocking, just direct unit tests on:
_build_sizes()— sweep-list construction across default, no-fine, only-fine, custom-step, and off-step-end cases._FRAG_RE— both BSD and iputils "Fragmentation Needed" wording, plus negative cases (normal echo reply, timeout output).bar(),_sanitize_target(),_ping_w(),_AnsiStrippingFile,_Tee, andload_targets_from_file().
Run locally:
pip install pytest
pytest # 36 cases, ~50 msGitHub Actions CI (.github/workflows/ci.yml) runs:
- The pytest suite on a Python
{3.9, 3.11, 3.12}×{ubuntu-latest, macos-latest}matrix. bash -n,--helpand--versionsmoke checks, plus ShellCheck (warning level) on the bash script.
The bash script doesn't have a parallel unit-test framework; its behavioral parity with Python is verified by hand against the same set of cases (the size-builder smoke test in particular has produced identical outputs on both sides).
- It does not report a hop's interface MTU directly — except where the
ICMP error explicitly carried it (then
link MTU Nreflects the advertised value). - It does not see fragments on the wire. It infers fragmentation from
the DF/non-DF outcome pair, not from observing fragmented IP packets.
For raw fragment counting you'd need
tcpdumpor a raw socket; that's outside the scope of this script. - It does not differentiate ICMP "Frag Needed" filtering from absence of fragmentation. A DF failure with no parsed router IP could be either silent drop or filtered ICMP error. The DF-clear success tells you the path still works; the silent router just isn't advertising itself.
- It does not test asymmetric paths. ICMP replies come back over whatever return path the network chooses; if that direction has a smaller MTU you'll see DF failures even though the forward path would have allowed the size.
- It does not test IPv6. The overhead math and
pingflags differ; this version assumes IPv4.
- Fine step (
--fine-step): smaller = more accurate fragmentation point, more probes. Default 1 byte gives exact MTU resolution between--startand--fine-pivot. - Coarse step (
--step): controls resolution above--fine-pivot. Drop to 100 or 50 to pin breakage in the jumbo region. - Pivot (
--fine-pivot): move it up if you also want fine resolution through the next-common boundary (e.g. 4470 for legacy POS). - Timeout (
--timeout-ms): increase for high-latency WAN paths (satellite, transoceanic). Default 1500 ms is fine for most enterprise paths. - Retries (
--retries): raise on lossy links to filter rate-limit noise; lower to make sweeps faster on a clean LAN. - Range (
--start/--end): set--startlow (e.g. 1280) when you suspect a sub-1500 path; raise--endpast 9000 only if you have real 9216-MTU jumbo paths to test.