Skip to content

Commit 90f6feb

Browse files
cansofgreaseclaude
andcommitted
Say when a blocked UDP port is why loss and jitter are missing
An iperf3 server that allows the port for TCP but not UDP measures throughput perfectly and never records packet loss or jitter. It is the most common way that happens, and until now nothing said so. The warning already tried to name this case, but it could only catch a server that answers and reports no packets. A firewall that drops rather than refuses gives the client nothing to answer with, so the pass waits out its window and the failure arrives looking like a busy server instead. The two facts that identify it were already in hand and never put together: TCP and UDP use the same host and same port, so TCP moving data while the UDP pass stalls leaves very little besides a filter treating the two differently. When that pair shows up, the log now names the likely cause and the rule to add. The README also described the UDP pass twice without ever saying its port has to be open for UDP. It says so now, including that runs taken while the port was closed have no loss or jitter to recover later. This came from a real install: 212 runs across five days recorded no loss and no jitter, warning every single time, with one missing firewall rule behind it. The website's iperf3 setup guide had the rule right all along. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c5c9a49 commit 90f6feb

3 files changed

Lines changed: 84 additions & 7 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,17 @@ stream. Set Ookla's retries to `0` and that fallback cannot run, so on a link th
680680
slow the upload - and with it the whole run - fails. The error says so and names the
681681
setting.
682682
683+
That UDP pass needs the iperf3 port open for **UDP as well as TCP** - the same
684+
port, both protocols (`ufw allow 5201/tcp` and `ufw allow 5201/udp`, or the
685+
equivalent security-group rules). Allowing only TCP is the usual reason a server
686+
reports throughput perfectly while loss and jitter stay blank forever: the
687+
control connection and both transfers are TCP and connect fine, and the UDP
688+
datagrams are dropped without a refusal, so the pass waits out its window and
689+
gives up. The daemon logs `iperf3 udp pass failed, loss and jitter unrecorded`
690+
each time, and when the run's TCP transfers succeeded it names the firewall as
691+
the likely cause. Nothing is retried later, so runs taken while the port was
692+
closed have no loss or jitter to recover.
693+
683694
For iperf3, the separate UDP loss/jitter pass probes the same direction you
684695
test: downstream normally, upstream for an upload-only run - so a one-direction
685696
test on an asymmetric line reports loss for the direction you asked about. That

internal/speedtest/iperf.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -767,12 +767,36 @@ func (i *Iperf) Run(ctx context.Context) (Result, error) {
767767
}
768768
} else if udpErr != nil && i.Log != nil {
769769
// The run still succeeds without loss/jitter, so this failure is
770-
// invisible everywhere else. The message distinguishes the three cases
771-
// that actually happen: UDP blocked upstream ("no datagrams"), the
772-
// server rejecting the UDP pass ("authorization failed"), and a busy
773-
// server - which need different answers from support.
774-
i.Log.Warn("iperf3 udp pass failed, loss and jitter unrecorded",
775-
"err", i.withEnvHint(udpErr), "downstream", downstream, "rate_mbps", rate)
770+
// invisible everywhere else. The message distinguishes the cases that
771+
// actually happen: UDP blocked upstream ("no datagrams"), the server
772+
// rejecting the UDP pass ("authorization failed"), and a busy server -
773+
// which need different answers from support.
774+
//
775+
// "no datagrams" only catches a server that ANSWERS and reports zero
776+
// packets - iperf3 has to finish and print its JSON to be counted that
777+
// way. A firewall that DROPs rather than REJECTs (the default for ufw
778+
// and most cloud security groups) sends the datagrams into silence, so
779+
// iperf3 never returns, the per-run deadline fires, and the failure
780+
// arrives here as a stall instead. That is why the stall is worth a
781+
// hint of its own: on its own it reads like a busy server.
782+
//
783+
// What makes it diagnosable is the pair. TCP and UDP use the same host
784+
// and port here, so TCP moving data while UDP stalls leaves almost
785+
// nothing but a filter that treats the two protocols differently. Both
786+
// figures are the run's own, measured moments earlier.
787+
hint := ""
788+
if errors.Is(udpErr, errStalled) && (res.DownloadMbps > 0 || res.UploadMbps > 0) {
789+
hint = "TCP moved data on this host and port while the UDP pass stalled - " +
790+
"the far end is most likely dropping UDP (open the same port for UDP, e.g. ufw allow 5201/udp)"
791+
}
792+
if hint != "" {
793+
i.Log.Warn("iperf3 udp pass failed, loss and jitter unrecorded",
794+
"err", i.withEnvHint(udpErr), "downstream", downstream, "rate_mbps", rate,
795+
"likely_cause", hint)
796+
} else {
797+
i.Log.Warn("iperf3 udp pass failed, loss and jitter unrecorded",
798+
"err", i.withEnvHint(udpErr), "downstream", downstream, "rate_mbps", rate)
799+
}
776800
}
777801
}
778802
// Anything spent beyond what got MEASURED - a retried direction's earlier
@@ -1291,9 +1315,16 @@ func iperfBindArgs(bind string) []string {
12911315
// keeps its own error. The message deliberately avoids the transient-error keywords: the
12921316
// stall already ate the full window, so a retry costs another one and rarely helps (same
12931317
// policy as "no data transferred").
1318+
//
1319+
// errStalled carries that verdict for callers who can say something useful about
1320+
// WHY it stalled. The wording is unchanged - the sentinel is the whole message
1321+
// text, so "%w (killed after 8s)" still reads "transfer stalled (killed after
1322+
// 8s)" - it is just matchable now instead of only printable.
1323+
var errStalled = errors.New("transfer stalled")
1324+
12941325
func stalledErr(err error, rctx, ctx context.Context, budget time.Duration) error {
12951326
if err != nil && errors.Is(rctx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
1296-
return fmt.Errorf("transfer stalled (killed after %v)", budget)
1327+
return fmt.Errorf("%w (killed after %v)", errStalled, budget)
12971328
}
12981329
return err
12991330
}

internal/speedtest/iperf_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,3 +881,38 @@ func TestAvailableCongestionControlFreeBSD(t *testing.T) {
881881
t.Errorf("darwin: got %v, want nil", got)
882882
}
883883
}
884+
885+
// A firewall that DROPs UDP is the single most common reason loss and jitter go
886+
// missing, and it is the case the "no datagrams" branch cannot see: that branch
887+
// needs iperf3 to finish and report zero packets, but a drop gives it nothing to
888+
// finish on, so the per-run deadline fires and it arrives as a stall instead.
889+
// Real instance: 212 consecutive iperf3 runs recorded no loss and no jitter over
890+
// five days, every one of them warning "transfer stalled (killed after 8s)"
891+
// while the same run's TCP passes moved ~495/480 Mbps. The cause was one missing
892+
// ufw rule for 5201/udp; nothing in the warning pointed there.
893+
//
894+
// The message text is unchanged - the sentinel IS the text - so this also guards
895+
// that errStalled stays wrapped rather than being rebuilt as a plain string, which
896+
// is what would silently switch the hint back off.
897+
func TestStalledErrIsMatchableAndKeepsItsWording(t *testing.T) {
898+
expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
899+
defer cancel()
900+
<-expired.Done()
901+
902+
err := stalledErr(errors.New("signal: killed"), expired, context.Background(), 8*time.Second)
903+
if err == nil {
904+
t.Fatal("stalledErr returned nil for a deadline kill")
905+
}
906+
if got, want := err.Error(), "transfer stalled (killed after 8s)"; got != want {
907+
t.Errorf("message = %q, want %q - callers and operators both read this string", got, want)
908+
}
909+
if !errors.Is(err, errStalled) {
910+
t.Error("a stall is not errors.Is(errStalled): the UDP pass cannot tell a firewall " +
911+
"drop from a busy server without it, so the firewall hint goes silent")
912+
}
913+
// A non-stall failure must not borrow the verdict: "no datagrams" means the
914+
// server answered and reported nothing, which is a different fix.
915+
if errors.Is(errors.New("no datagrams"), errStalled) {
916+
t.Error("an unrelated error matched errStalled")
917+
}
918+
}

0 commit comments

Comments
 (0)