include $(TOPDIR)/rules.mk
PKG_NAME:=librespeed-cli
-PKG_VERSION:=1.0.12
+PKG_VERSION:=1.0.13
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/librespeed/speedtest-cli/tar.gz/v${PKG_VERSION}?
-PKG_HASH:=2813ff49a8bc99687e70599212e05f0d995cd7f685e1202e80eea4ff58767301
+PKG_HASH:=5ad938b61e3edc0ca95e2ccff0c06e97a69383f3cbb0243bd47b21b9865f9f55
PKG_MAINTAINER:=Michal Vasilek <michal.vasilek@nic.cz>
PKG_LICENSE:=LGPL-3.0-only
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/librespeed/speedtest-cli
+# Without these the binary reports no name and no version at all, which leaves
+# --version printing an empty string where the version belongs. The v prefix
+# matches what the upstream release build produces, so the same version does
+# not read differently depending on where the binary came from. BuildDate is
+# left unset on purpose: the only value available here is a timestamp, and
+# baking one in would make the build unreproducible.
+GO_PKG_LDFLAGS_X:= \
+ $(GO_PKG)/defs.ProgName=$(PKG_NAME) \
+ $(GO_PKG)/defs.ProgVersion=v$(PKG_VERSION)
include $(INCLUDE_DIR)/package.mk
include ../../lang/golang/golang-package.mk
+++ /dev/null
-From b8fda15c50575ad7f1086fc8b0561b98e65c9c1e Mon Sep 17 00:00:00 2001
-From: user <user@linuxorg.org>
-Date: Wed, 18 Dec 2024 21:36:23 +0800
-Subject: [PATCH] fix start serverIdx in pingList when some servers are down
-
----
- speedtest/speedtest.go | 4 ++++
- 1 file changed, 4 insertions(+)
-
---- a/speedtest/speedtest.go
-+++ b/speedtest/speedtest.go
-@@ -321,6 +321,10 @@ func SpeedTest(c *cli.Context) error {
-
- // get the fastest server's index in the `servers` array
- var serverIdx int
-+ for serverIdx = range pingList {
-+ break
-+ }
-+
- for idx, ping := range pingList {
- if ping > 0 && ping <= pingList[serverIdx] {
- serverIdx = idx
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Date: Fri, 7 Aug 2026 09:42:39 +0200
+Subject: [PATCH] style: gofmt helper.go and speedtest.go
+
+Import order, --simple block indentation and const alignment. No behaviour
+change; split out to keep the next commit's diff readable.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ speedtest/helper.go | 16 ++++++++--------
+ speedtest/speedtest.go | 4 ++--
+ 2 files changed, 10 insertions(+), 10 deletions(-)
+
+--- a/speedtest/helper.go
++++ b/speedtest/helper.go
+@@ -16,8 +16,8 @@ import (
+ "github.com/briandowns/spinner"
+ "github.com/gocarina/gocsv"
+ "github.com/librespeed/speedtest-cli/defs"
+- "github.com/librespeed/speedtest-cli/report"
+ "github.com/librespeed/speedtest-cli/output"
++ "github.com/librespeed/speedtest-cli/report"
+ "github.com/urfave/cli/v2"
+ )
+
+@@ -113,14 +113,14 @@ func doSpeedTest(c *cli.Context, servers
+ }
+
+ // print result if --simple is given
+- if c.Bool(defs.OptionSimple) {
+- if c.Bool(defs.OptionBytes) {
+- useMebi := c.Bool(defs.OptionMebiBytes)
+- output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi))
+- } else {
+- output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue)
++ if c.Bool(defs.OptionSimple) {
++ if c.Bool(defs.OptionBytes) {
++ useMebi := c.Bool(defs.OptionMebiBytes)
++ output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi))
++ } else {
++ output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue)
++ }
+ }
+- }
+
+ // print share link if --share is given
+ var shareLink string
+--- a/speedtest/speedtest.go
++++ b/speedtest/speedtest.go
+@@ -33,8 +33,8 @@ const (
+ defaultTelemetryShare = "/results/"
+
+ forceNothing = 0
+- forceHttps = 1
+- forceHttp = 2
++ forceHttps = 1
++ forceHttp = 2
+ )
+
+ type PingJob struct {
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Date: Fri, 7 Aug 2026 09:42:39 +0200
+Subject: [PATCH] fix: fix data race, goroutine leak, non-TTY output and escape
+ injection
+
+- BytesCounter.total mixed atomic and plain access; now an atomic.Uint64.
+- Download/Upload leaked in-flight goroutines past "break Loop"; the done
+ signal is now ctx-aware and both wait on a WaitGroup before reading the
+ counter.
+- Results rode on spinner.FinalMSG, which never prints without a TTY, so
+ piping produced no output at all. Printed via output.WriteUI now, and the
+ spinner moved off stdout onto stderr.
+- Server-supplied strings were printed raw, allowing ANSI escapes and a
+ forged --list entry. output.Sanitize strips C0/DEL/C1 at every display
+ site; url.Parse only rejects ASCII controls, so C1 still needs stripping.
+- BytesCounter.Read and its reader/pos/lock fields became unreachable when
+ Upload started building a body reader per request; dropped.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/bytes_counter.go | 47 ++++-------------
+ defs/server.go | 112 +++++++++++++++++++++++++++++------------
+ output/output.go | 24 +++++++++
+ speedtest/helper.go | 15 +++---
+ speedtest/speedtest.go | 12 +++--
+ 5 files changed, 129 insertions(+), 81 deletions(-)
+
+--- a/defs/bytes_counter.go
++++ b/defs/bytes_counter.go
+@@ -1,55 +1,34 @@
+ package defs
+
+ import (
+- "bytes"
+ "fmt"
+ "io"
+ "math/rand/v2"
+- "sync"
+ "sync/atomic"
+ "time"
+ )
+
+-// BytesCounter implements io.Reader and io.Writer interface, for counting bytes being read/written in HTTP requests
++// BytesCounter implements the io.Writer interface, for counting bytes being read/written in HTTP requests.
++// It is only ever plugged into an io.TeeReader, so the transfer itself is driven by the wrapped reader.
+ type BytesCounter struct {
+ start time.Time
+- pos int
+- total uint64
++ total atomic.Uint64
+ payload []byte
+- reader io.ReadSeeker
+ mebi bool
+ uploadSize int
+-
+- lock *sync.Mutex
+ }
+
+ func NewCounter() *BytesCounter {
+- return &BytesCounter{
+- lock: &sync.Mutex{},
+- }
++ return &BytesCounter{}
+ }
+
+ // Write implements io.Writer
+ func (c *BytesCounter) Write(p []byte) (int, error) {
+ n := len(p)
+- atomic.AddUint64(&c.total, uint64(n))
++ c.total.Add(uint64(n))
+ return n, nil
+ }
+
+-// Read implements io.Reader
+-func (c *BytesCounter) Read(p []byte) (int, error) {
+- c.lock.Lock()
+- n, err := c.reader.Read(p)
+- c.total += uint64(n)
+- c.pos += n
+- if c.pos == c.uploadSize {
+- c.resetReader()
+- }
+- c.lock.Unlock()
+-
+- return n, err
+-}
+-
+ // SetBase sets the base for dividing bytes into megabyte or mebibyte
+ func (c *BytesCounter) SetMebi(mebi bool) {
+ c.mebi = mebi
+@@ -62,7 +41,7 @@ func (c *BytesCounter) SetUploadSize(upl
+
+ // AvgBytes returns the average bytes/second
+ func (c *BytesCounter) AvgBytes() float64 {
+- return float64(c.total) / time.Since(c.start).Seconds()
++ return float64(c.total.Load()) / time.Since(c.start).Seconds()
+ }
+
+ // AvgMbps returns the average mbits/second
+@@ -99,17 +78,9 @@ func (c *BytesCounter) Payload() []byte
+ return c.payload
+ }
+
+-// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field, and sets the `reader` field to
+-// read from it
++// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field
+ func (c *BytesCounter) GenerateBlob() {
+ c.payload = getRandomData(c.uploadSize)
+- c.reader = bytes.NewReader(c.payload)
+-}
+-
+-// resetReader resets the `reader` field to 0 position
+-func (c *BytesCounter) resetReader() (int64, error) {
+- c.pos = 0
+- return c.reader.Seek(0, 0)
+ }
+
+ // Start will set the `start` field to current time
+@@ -119,12 +90,12 @@ func (c *BytesCounter) Start() {
+
+ // Total returns the total bytes read/written
+ func (c *BytesCounter) Total() uint64 {
+- return atomic.LoadUint64(&c.total)
++ return c.total.Load()
+ }
+
+ // CurrentSpeed returns the current bytes/second
+ func (c *BytesCounter) CurrentSpeed() float64 {
+- return float64(c.total) / time.Since(c.start).Seconds()
++ return float64(c.total.Load()) / time.Since(c.start).Seconds()
+ }
+
+ // SeekWrapper is a wrapper around io.Reader to give it a noop io.Seeker interface
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -11,8 +11,10 @@ import (
+ "math"
+ "net/http"
+ "net/url"
++ "os"
+ "path"
+ "strconv"
++ "sync"
+ "time"
+
+ "github.com/briandowns/spinner"
+@@ -61,7 +63,7 @@ func (s *Server) IsUp() bool {
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil || len(b) > 0 {
+- output.WriteDebug("Failed when parsing get IP result: %s\n", b)
++ output.WriteDebug("Failed when parsing get IP result: %s\n", output.Sanitize(string(b)))
+ return false
+ }
+ // only return online if the ping URL returns nothing and 200
+@@ -76,7 +78,7 @@ func (s *Server) ICMPPingAndJitter(count
+ }()
+
+ if s.NoICMP {
+- output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", s.Name)
++ output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", output.Sanitize(s.Name))
+ return s.PingAndJitter(count + 2)
+ }
+
+@@ -126,7 +128,7 @@ func (s *Server) ICMPPingAndJitter(count
+
+ if len(stats.Rtts) == 0 {
+ s.NoICMP = true
+- output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", s.Name, u.Hostname())
++ output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", output.Sanitize(s.Name), output.Sanitize(u.Hostname()))
+ return s.PingAndJitter(count + 2)
+ }
+
+@@ -226,29 +228,43 @@ func (s *Server) Download(silent bool, u
+
+ downloadDone := make(chan struct{}, requests)
+
++ var wg sync.WaitGroup
++
+ doDownload := func() {
++ defer wg.Done()
++
+ reqClone := req.Clone(ctx)
+ resp, err := http.DefaultClient.Do(reqClone)
+ if err != nil {
+ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
+ output.WriteDebug("Failed when making HTTP request: %s\n", err)
+ }
+- } else {
+- defer resp.Body.Close()
++ return
++ }
++ defer resp.Body.Close()
+
+- if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil {
+- if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
+- output.WriteDebug("Failed when reading HTTP response: %s\n", err)
+- }
++ if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil {
++ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
++ output.WriteDebug("Failed when reading HTTP response: %s\n", err)
+ }
++ }
+
+- downloadDone <- struct{}{}
++ // let the main loop start a replacement request, but never block on it
++ // once the test is over, otherwise this goroutine is leaked
++ select {
++ case downloadDone <- struct{}{}:
++ case <-ctx.Done():
+ }
+ }
+
++ spawnDownload := func() {
++ wg.Add(1)
++ go doDownload()
++ }
++
+ counter.Start()
+ if !silent {
+- pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond)
++ pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr))
+ pb.Prefix = "Downloading... "
+ pb.PostUpdate = func(s *spinner.Spinner) {
+ if useBytes {
+@@ -259,18 +275,21 @@ func (s *Server) Download(silent bool, u
+ }
+
+ pb.Start()
++ // print the rate ourselves instead of via pb.FinalMSG: the spinner only
++ // prints it when it was actually running, which it isn't when stderr is
++ // not a terminal
+ defer func() {
++ pb.Stop()
+ if useBytes {
+- pb.FinalMSG = fmt.Sprintf("Download rate:\t%s\n", counter.AvgHumanize())
++ output.WriteUI("Download rate:\t%s\n", counter.AvgHumanize())
+ } else {
+- pb.FinalMSG = fmt.Sprintf("Download rate:\t%.2f Mbps\n", counter.AvgMbps())
++ output.WriteUI("Download rate:\t%.2f Mbps\n", counter.AvgMbps())
+ }
+- pb.Stop()
+ }()
+ }
+
+ for i := 0; i < requests; i++ {
+- go doDownload()
++ spawnDownload()
+ time.Sleep(200 * time.Millisecond)
+ }
+ timeout := time.After(duration)
+@@ -281,10 +300,14 @@ Loop:
+ cancel()
+ break Loop
+ case <-downloadDone:
+- go doDownload()
++ spawnDownload()
+ }
+ }
+
++ // let the cancelled requests unwind before reading the counter, so the
++ // result doesn't change under us while it's being reported
++ wg.Wait()
++
+ return counter.AvgMbps(), counter.Total(), nil
+ }
+
+@@ -301,8 +324,9 @@ func (s *Server) Upload(noPrealloc, sile
+
+ if noPrealloc {
+ output.WriteUI("Pre-allocation is disabled, performance might be lower!\n")
+- counter.reader = &SeekWrapper{rand.Reader}
+ } else {
++ // each request reads from this shared payload; without it they stream
++ // straight from crypto/rand instead
+ counter.GenerateBlob()
+ }
+
+@@ -318,7 +342,11 @@ func (s *Server) Upload(noPrealloc, sile
+
+ uploadDone := make(chan struct{}, requests)
+
++ var wg sync.WaitGroup
++
+ doUpload := func() {
++ defer wg.Done()
++
+ var bodyReader io.Reader
+ if noPrealloc {
+ bodyReader = &SeekWrapper{rand.Reader}
+@@ -336,21 +364,34 @@ func (s *Server) Upload(noPrealloc, sile
+ uploadReq.Header.Set("Accept-Encoding", "identity")
+
+ resp, err := http.DefaultClient.Do(uploadReq)
+- if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
+- output.WriteDebug("Failed when making HTTP request: %s\n", err)
+- } else if err == nil {
+- defer resp.Body.Close()
+- if _, err := io.Copy(io.Discard, resp.Body); err != nil {
+- output.WriteDebug("Failed when reading HTTP response: %s\n", err)
++ if err != nil {
++ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
++ output.WriteDebug("Failed when making HTTP request: %s\n", err)
+ }
++ return
++ }
++ defer resp.Body.Close()
++
++ if _, err := io.Copy(io.Discard, resp.Body); err != nil {
++ output.WriteDebug("Failed when reading HTTP response: %s\n", err)
++ }
+
+- uploadDone <- struct{}{}
++ // let the main loop start a replacement request, but never block on it
++ // once the test is over, otherwise this goroutine is leaked
++ select {
++ case uploadDone <- struct{}{}:
++ case <-ctx.Done():
+ }
+ }
+
++ spawnUpload := func() {
++ wg.Add(1)
++ go doUpload()
++ }
++
+ counter.Start()
+ if !silent {
+- pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond)
++ pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr))
+ pb.Prefix = "Uploading... "
+ pb.PostUpdate = func(s *spinner.Spinner) {
+ if useBytes {
+@@ -361,18 +402,21 @@ func (s *Server) Upload(noPrealloc, sile
+ }
+
+ pb.Start()
++ // print the rate ourselves instead of via pb.FinalMSG: the spinner only
++ // prints it when it was actually running, which it isn't when stderr is
++ // not a terminal
+ defer func() {
++ pb.Stop()
+ if useBytes {
+- pb.FinalMSG = fmt.Sprintf("Upload rate:\t%s\n", counter.AvgHumanize())
++ output.WriteUI("Upload rate:\t%s\n", counter.AvgHumanize())
+ } else {
+- pb.FinalMSG = fmt.Sprintf("Upload rate:\t%.2f Mbps\n", counter.AvgMbps())
++ output.WriteUI("Upload rate:\t%.2f Mbps\n", counter.AvgMbps())
+ }
+- pb.Stop()
+ }()
+ }
+
+ for i := 0; i < requests; i++ {
+- go doUpload()
++ spawnUpload()
+ time.Sleep(200 * time.Millisecond)
+ }
+ timeout := time.After(duration)
+@@ -383,10 +427,14 @@ Loop:
+ cancel()
+ break Loop
+ case <-uploadDone:
+- go doUpload()
++ spawnUpload()
+ }
+ }
+
++ // let the cancelled requests unwind before reading the counter, so the
++ // result doesn't change under us while it's being reported
++ wg.Wait()
++
+ return counter.AvgMbps(), counter.Total(), nil
+ }
+
+@@ -432,7 +480,7 @@ func (s *Server) GetIPInfo(distanceUnit
+ if len(b) > 0 {
+ if err := json.Unmarshal(b, &ipInfo); err != nil {
+ output.WriteDebug("Failed when parsing get IP result: %s\n", err)
+- output.WriteDebug("Received payload: %s\n", b)
++ output.WriteDebug("Received payload: %s\n", output.Sanitize(string(b)))
+ // try to extract processedString even if full parse fails
+ // (e.g. when rawIspInfo is "" instead of an object)
+ var partial struct {
+@@ -473,7 +521,7 @@ func (s *Server) Sponsor() string {
+ if s.SponsorURL != "" {
+ su, err := url.Parse(s.SponsorURL)
+ if err != nil {
+- output.WriteDebug("Sponsor URL is invalid: %s\n", s.SponsorURL)
++ output.WriteDebug("Sponsor URL is invalid: %s\n", output.Sanitize(s.SponsorURL))
+ } else {
+ if su.Scheme == "" {
+ su.Scheme = "https"
+--- a/output/output.go
++++ b/output/output.go
+@@ -11,6 +11,7 @@ import (
+ "fmt"
+ "io"
+ "os"
++ "strings"
+ )
+
+ // Default is the package-level output writer used by all package functions.
+@@ -127,3 +128,26 @@ func (w *Writer) Fatalf(format string, a
+ fmt.Fprintln(w.ui)
+ os.Exit(1)
+ }
++
++// --- Sanitize: make server-supplied strings safe to display ---
++
++// Sanitize strips control characters from a string so it can be printed
++// without letting a remote party drive the terminal.
++//
++// Server names, sponsor strings and the getIP response all come off the wire
++// (over plain HTTP for schemeless servers), so they are attacker-influenced.
++// Left raw, an embedded ESC sequence can rewrite earlier lines, hide text or
++// recolour the output, and an embedded newline can forge an extra entry in
++// --list output that a script would then parse as real.
++//
++// Dropped: C0 controls (including ESC, CR, LF and TAB), DEL, and C1 controls
++// (0x80-0x9F, where 0x9B doubles as CSI on some terminals). Printable Unicode
++// is left alone.
++func Sanitize(s string) string {
++ return strings.Map(func(r rune) rune {
++ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
++ return -1
++ }
++ return r
++ }, s)
++}
+--- a/speedtest/helper.go
++++ b/speedtest/helper.go
+@@ -46,10 +46,10 @@ func doSpeedTest(c *cli.Context, servers
+ return err
+ }
+
+- output.WriteUI("Selected server: %s [%s]\n", currentServer.Name, u.Hostname())
++ output.WriteUI("Selected server: %s [%s]\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname()))
+
+ if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" {
+- output.WriteUI("Sponsored by: %s\n", sponsorMsg)
++ output.WriteUI("Sponsored by: %s\n", output.Sanitize(sponsorMsg))
+ }
+
+ if currentServer.IsUp() {
+@@ -58,12 +58,12 @@ func doSpeedTest(c *cli.Context, servers
+ output.WriteError("Failed to get IP info: %s\n", err)
+ return err
+ }
+- output.WriteUI("You're testing from: %s\n", ispInfo.ProcessedString)
++ output.WriteUI("You're testing from: %s\n", output.Sanitize(ispInfo.ProcessedString))
+
+ // get ping and jitter value
+ var pb *spinner.Spinner
+ if !silent {
+- pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond)
++ pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr))
+ pb.Prefix = "Pinging server... "
+ pb.Start()
+ }
+@@ -78,8 +78,11 @@ func doSpeedTest(c *cli.Context, servers
+ }
+
+ if pb != nil {
+- pb.FinalMSG = fmt.Sprintf("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter)
++ // print the result ourselves instead of via pb.FinalMSG: the
++ // spinner only prints it when it was actually running, which it
++ // isn't when stderr is not a terminal
+ pb.Stop()
++ output.WriteUI("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter)
+ }
+
+ // get download value
+@@ -182,7 +185,7 @@ func doSpeedTest(c *cli.Context, servers
+ reps_json = append(reps_json, rep)
+ }
+ } else {
+- output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", currentServer.Name, u.Hostname())
++ output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname()))
+ }
+
+ //add a new line after each test if testing multiple servers
+--- a/speedtest/speedtest.go
++++ b/speedtest/speedtest.go
+@@ -283,9 +283,11 @@ func SpeedTest(c *cli.Context) error {
+ for _, svr := range servers {
+ var sponsorMsg string
+ if svr.Sponsor() != "" {
+- sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", svr.Sponsor())
++ sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", output.Sanitize(svr.Sponsor()))
+ }
+- output.WriteOut("%d: %s (%s) %s\n", svr.ID, svr.Name, svr.Server, sponsorMsg)
++ // --list goes to stdout, so a newline smuggled into a server name
++ // would forge an entry for anything parsing it
++ output.WriteOut("%d: %s (%s) %s\n", svr.ID, output.Sanitize(svr.Name), output.Sanitize(svr.Server), sponsorMsg)
+ }
+ return nil
+ }
+@@ -360,7 +362,7 @@ func pingWorker(jobs <-chan PingJob, res
+ // get the URL of the speed test server from the JSON
+ u, err := server.GetURL()
+ if err != nil {
+- output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", server.Name, server.Server)
++ output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(server.Server))
+ wg.Done()
+ continue
+ }
+@@ -373,7 +375,7 @@ func pingWorker(jobs <-chan PingJob, res
+ // if server is up, get ping
+ ping, _, err := server.ICMPPingAndJitter(1, srcIp, network)
+ if err != nil {
+- output.WriteDebug("Can't ping server %s (%s), skipping\n", server.Name, u.Hostname())
++ output.WriteDebug("Can't ping server %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname()))
+ wg.Done()
+ continue
+ }
+@@ -381,7 +383,7 @@ func pingWorker(jobs <-chan PingJob, res
+ results <- PingResult{Index: job.Index, Ping: ping}
+ wg.Done()
+ } else {
+- output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", server.Name, u.Hostname())
++ output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname()))
+ wg.Done()
+ }
+ }
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Date: Fri, 7 Aug 2026 09:50:07 +0200
+Subject: [PATCH] fix: align upload cancellation handling and add tests
+
+- Upload's response-body copy now filters context.Canceled/DeadlineExceeded
+ the way the download path already did, so ending the test no longer logs a
+ spurious debug failure.
+- The two raw-payload debug dumps use %q instead of Sanitize: newlines are
+ legitimate in a response body, and quoting escapes them without losing them.
+- TestSanitize covers C0, DEL, C1 and the printable boundaries, exhaustively
+ rather than by example.
+- TestBytesCounterConcurrentAccess reproduces the spinner-vs-transfer access
+ pattern. Verified: it reports DATA RACE against the pre-fix counter and
+ passes against the fixed one.
+- Download and Upload are exercised against a server that never ends a
+ request, so a hanging wg.Wait() fails the test rather than a release.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/bytes_counter_test.go | 74 +++++++++++++++++++++++
+ defs/server.go | 12 +++-
+ defs/server_test.go | 120 +++++++++++++++++++++++++++++++++++++
+ output/output_test.go | 54 +++++++++++++++++
+ 4 files changed, 257 insertions(+), 3 deletions(-)
+ create mode 100644 defs/bytes_counter_test.go
+ create mode 100644 defs/server_test.go
+ create mode 100644 output/output_test.go
+
+--- /dev/null
++++ b/defs/bytes_counter_test.go
+@@ -0,0 +1,74 @@
++package defs
++
++import (
++ "sync"
++ "testing"
++)
++
++// TestBytesCounterConcurrentAccess exercises the pattern the counter is used
++// in: several transfer goroutines writing while the progress spinner polls the
++// averages. It is the regression test for the mixed atomic/non-atomic access
++// that used to make this racy, so it is only meaningful under -race.
++func TestBytesCounterConcurrentAccess(t *testing.T) {
++ const (
++ writers = 8
++ writes = 2000
++ chunkSize = 16
++ pollers = 4
++ wantTotal = uint64(writers * writes * chunkSize)
++ pollBudget = 1 << 20
++ )
++
++ c := NewCounter()
++ c.Start()
++
++ stop := make(chan struct{})
++
++ var polling sync.WaitGroup
++ for i := 0; i < pollers; i++ {
++ polling.Add(1)
++ go func() {
++ defer polling.Done()
++ for n := 0; n < pollBudget; n++ {
++ select {
++ case <-stop:
++ return
++ default:
++ }
++ _ = c.AvgBytes()
++ _ = c.AvgMbps()
++ _ = c.AvgHumanize()
++ _ = c.CurrentSpeed()
++ _ = c.Total()
++ }
++ }()
++ }
++
++ var writing sync.WaitGroup
++ for i := 0; i < writers; i++ {
++ writing.Add(1)
++ go func() {
++ defer writing.Done()
++ chunk := make([]byte, chunkSize)
++ for j := 0; j < writes; j++ {
++ n, err := c.Write(chunk)
++ if err != nil {
++ t.Errorf("Write returned error: %v", err)
++ return
++ }
++ if n != chunkSize {
++ t.Errorf("Write returned %d, want %d", n, chunkSize)
++ return
++ }
++ }
++ }()
++ }
++
++ writing.Wait()
++ close(stop)
++ polling.Wait()
++
++ if got := c.Total(); got != wantTotal {
++ t.Errorf("Total() = %d, want %d (lost updates indicate a broken counter)", got, wantTotal)
++ }
++}
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -63,7 +63,9 @@ func (s *Server) IsUp() bool {
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil || len(b) > 0 {
+- output.WriteDebug("Failed when parsing get IP result: %s\n", output.Sanitize(string(b)))
++ // %q rather than Sanitize: this is a raw response body where newlines are
++ // legitimate, and quoting escapes control chars without losing them
++ output.WriteDebug("Failed when parsing get IP result: %q\n", b)
+ return false
+ }
+ // only return online if the ping URL returns nothing and 200
+@@ -373,7 +375,10 @@ func (s *Server) Upload(noPrealloc, sile
+ defer resp.Body.Close()
+
+ if _, err := io.Copy(io.Discard, resp.Body); err != nil {
+- output.WriteDebug("Failed when reading HTTP response: %s\n", err)
++ // cancellation is how the test ends, so it is not a failure
++ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
++ output.WriteDebug("Failed when reading HTTP response: %s\n", err)
++ }
+ }
+
+ // let the main loop start a replacement request, but never block on it
+@@ -480,7 +485,8 @@ func (s *Server) GetIPInfo(distanceUnit
+ if len(b) > 0 {
+ if err := json.Unmarshal(b, &ipInfo); err != nil {
+ output.WriteDebug("Failed when parsing get IP result: %s\n", err)
+- output.WriteDebug("Received payload: %s\n", output.Sanitize(string(b)))
++ // %q rather than Sanitize: see IsUp
++ output.WriteDebug("Received payload: %q\n", b)
+ // try to extract processedString even if full parse fails
+ // (e.g. when rawIspInfo is "" instead of an object)
+ var partial struct {
+--- /dev/null
++++ b/defs/server_test.go
+@@ -0,0 +1,120 @@
++package defs
++
++import (
++ "io"
++ "net/http"
++ "net/http/httptest"
++ "testing"
++ "time"
++)
++
++// The transfer tests below cover the timing semantics introduced by wg.Wait():
++// Download and Upload now return only once every in-flight request has
++// unwound. Against a server that never ends a request on its own, the only
++// thing that can end them is the context cancellation, so these fail (by
++// timing out) if wg.Wait() can hang.
++
++const (
++ testRequests = 2
++ // long enough that the requests are still in flight when the test ends
++ testDuration = 500 * time.Millisecond
++ // The spawn loop sleeps 200ms per request before the duration timer even
++ // starts, so a healthy run is ~900ms; measured unwind after cancel() is
++ // 10-40ms, with or without -race. The rest is slack for a loaded runner --
++ // kept tight so a hanging wg.Wait() fails fast instead of stalling CI.
++ returnBudget = testRequests*200*time.Millisecond + testDuration + 2*time.Second
++)
++
++// hangingDownloadServer streams forever until the client goes away.
++func hangingDownloadServer(t *testing.T) *httptest.Server {
++ t.Helper()
++ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ chunk := make([]byte, 32*1024)
++ for {
++ select {
++ case <-r.Context().Done():
++ return
++ default:
++ }
++ if _, err := w.Write(chunk); err != nil {
++ return
++ }
++ if f, ok := w.(http.Flusher); ok {
++ f.Flush()
++ }
++ }
++ }))
++}
++
++// hangingUploadServer drains the body, then holds the request open.
++func hangingUploadServer(t *testing.T) *httptest.Server {
++ t.Helper()
++ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ io.Copy(io.Discard, r.Body)
++ <-r.Context().Done()
++ }))
++}
++
++func runWithinBudget(t *testing.T, name string, fn func() error) {
++ t.Helper()
++
++ done := make(chan error, 1)
++ start := time.Now()
++ go func() { done <- fn() }()
++
++ select {
++ case err := <-done:
++ if err != nil {
++ t.Fatalf("%s returned error: %v", name, err)
++ }
++ if elapsed := time.Since(start); elapsed > returnBudget {
++ t.Errorf("%s took %s, budget was %s", name, elapsed, returnBudget)
++ }
++ case <-time.After(returnBudget):
++ t.Fatalf("%s did not return within %s: wg.Wait() is hanging on cancelled requests", name, returnBudget)
++ }
++}
++
++func TestDownloadReturnsWhenServerNeverEndsTheResponse(t *testing.T) {
++ ts := hangingDownloadServer(t)
++ defer ts.Close()
++
++ s := &Server{Server: ts.URL, DownloadURL: "/"}
++
++ var total uint64
++ runWithinBudget(t, "Download", func() error {
++ _, n, err := s.Download(true, false, false, testRequests, 100, testDuration)
++ total = n
++ return err
++ })
++
++ if total == 0 {
++ t.Error("Download reported 0 bytes, expected the counter to have seen traffic")
++ }
++}
++
++func TestUploadReturnsWhenServerNeverResponds(t *testing.T) {
++ ts := hangingUploadServer(t)
++ defer ts.Close()
++
++ s := &Server{Server: ts.URL, UploadURL: "/"}
++
++ runWithinBudget(t, "Upload", func() error {
++ _, _, err := s.Upload(false, true, false, false, testRequests, 32, testDuration)
++ return err
++ })
++}
++
++// The no-prealloc path streams from crypto/rand, so the request body never
++// ends on its own either; only cancellation can stop it.
++func TestUploadNoPreallocReturnsWhenServerNeverResponds(t *testing.T) {
++ ts := hangingUploadServer(t)
++ defer ts.Close()
++
++ s := &Server{Server: ts.URL, UploadURL: "/"}
++
++ runWithinBudget(t, "Upload(noPrealloc)", func() error {
++ _, _, err := s.Upload(true, true, false, false, testRequests, 32, testDuration)
++ return err
++ })
++}
+--- /dev/null
++++ b/output/output_test.go
+@@ -0,0 +1,54 @@
++package output
++
++import "testing"
++
++func TestSanitize(t *testing.T) {
++ tests := []struct {
++ name string
++ in string
++ want string
++ }{
++ {"empty", "", ""},
++ {"plain text is untouched", "normal text", "normal text"},
++ {"newline is dropped", "hello\nworld", "helloworld"},
++ {"carriage return is dropped", "hello\rworld", "helloworld"},
++ {"tab is dropped", "hello\tworld", "helloworld"},
++ {"nul is dropped", "hello\x00world", "helloworld"},
++ {"ANSI colour sequence loses its ESC", "\x1b[31mred\x1b[0m", "[31mred[0m"},
++ {"OSC window title sequence loses ESC and BEL", "\x1b]0;pwned\x07", "]0;pwned"},
++ {"DEL is dropped", "foo\x7fbar", "foobar"},
++ {"C1 CSI is dropped", "foo\u009b31mbar", "foo31mbar"},
++ {"C1 lower bound U+0080 is dropped", "foo\u0080bar", "foobar"},
++ {"C1 upper bound U+009F is dropped", "foo\u009fbar", "foobar"},
++ {"U+00A0 just past C1 is kept", "foo\u00a0bar", "foo\u00a0bar"},
++ {"space just past C0 is kept", "foo bar", "foo bar"},
++ {"non-ASCII text is kept", "Český server \U0001f1e8\U0001f1ff", "Český server \U0001f1e8\U0001f1ff"},
++ {"forged --list entry is collapsed onto one line", "Server A\n999: Fake", "Server A999: Fake"},
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ if got := Sanitize(tt.in); got != tt.want {
++ t.Errorf("Sanitize(%q) = %q, want %q", tt.in, got, tt.want)
++ }
++ })
++ }
++}
++
++// TestSanitizeDropsEveryControlRune asserts the boundaries exhaustively rather
++// than by example, so a future rewrite cannot silently let one class through.
++func TestSanitizeDropsEveryControlRune(t *testing.T) {
++ for r := rune(0); r <= 0x9f; r++ {
++ if r >= 0x20 && r <= 0x7e {
++ continue // printable ASCII
++ }
++ if got := Sanitize(string(r)); got != "" {
++ t.Errorf("Sanitize(%U) = %q, want it to be dropped", r, got)
++ }
++ }
++ for _, r := range []rune{0x20, 0x7e, 0xa0, 0xa1, 0x2028, 0x1f600} {
++ if got := Sanitize(string(r)); got != string(r) {
++ t.Errorf("Sanitize(%U) = %q, want it kept", r, got)
++ }
++ }
++}
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Sat, 8 Aug 2026 00:54:35 +0200
+Subject: [PATCH] feat: report test progress under --debug
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+--json, --csv and --simple suppress the spinner, so a run prints nothing
+between launch and the final result. --debug did not help: every existing
+debug call sits on an error path, so a run that succeeds stays quiet.
+
+Report each phase instead — server, IP info, ping, download, upload — with
+the timings, byte counts and stream settings the spinner cannot carry.
+
+Rates follow the run's own reporting mode, Mbps normally and MB/s under
+--bytes, so a debug line cannot contradict the result printed beside it.
+
+Nothing changes unless --debug is given.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ speedtest/helper.go | 34 ++++++++++++++++++++++++++++++++++
+ 1 file changed, 34 insertions(+)
+
+--- a/speedtest/helper.go
++++ b/speedtest/helper.go
+@@ -47,18 +47,21 @@ func doSpeedTest(c *cli.Context, servers
+ }
+
+ output.WriteUI("Selected server: %s [%s]\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname()))
++ output.WriteDebug("Testing against %s (%s)\n", output.Sanitize(currentServer.Name), output.Sanitize(u.String()))
+
+ if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" {
+ output.WriteUI("Sponsored by: %s\n", output.Sanitize(sponsorMsg))
+ }
+
+ if currentServer.IsUp() {
++ output.WriteDebug("Fetching IP info\n")
+ ispInfo, err := currentServer.GetIPInfo(c.String(defs.OptionDistance))
+ if err != nil {
+ output.WriteError("Failed to get IP info: %s\n", err)
+ return err
+ }
+ output.WriteUI("You're testing from: %s\n", output.Sanitize(ispInfo.ProcessedString))
++ output.WriteDebug("IP info: %s\n", output.Sanitize(ispInfo.ProcessedString))
+
+ // get ping and jitter value
+ var pb *spinner.Spinner
+@@ -71,12 +74,21 @@ func doSpeedTest(c *cli.Context, servers
+ // skip ICMP if option given
+ currentServer.NoICMP = noICMP
+
++ // The spinner is the only sign of progress, and it is not started
++ // in silent mode, so --json, --csv and --simple runs otherwise show
++ // nothing at all until they finish. Report each phase under --debug
++ // instead, with the timings and counts the spinner cannot carry.
++ output.WriteDebug("Ping test starting: %d pings, ICMP: %t\n", pingCount, !noICMP)
++ pingStart := time.Now()
++
+ p, jitter, err := currentServer.ICMPPingAndJitter(pingCount, c.String(defs.OptionSource), network)
+ if err != nil {
+ output.WriteError("Failed to get ping and jitter: %s\n", err)
+ return err
+ }
+
++ output.WriteDebug("Ping test finished in %s: ping %.2f ms, jitter %.2f ms\n", time.Since(pingStart).Round(time.Millisecond), p, jitter)
++
+ if pb != nil {
+ // print the result ourselves instead of via pb.FinalMSG: the
+ // spinner only prints it when it was actually running, which it
+@@ -90,7 +102,11 @@ func doSpeedTest(c *cli.Context, servers
+ var bytesRead uint64
+ if c.Bool(defs.OptionNoDownload) {
+ output.WriteUI("Download test is disabled\n")
++ output.WriteDebug("Download test skipped\n")
+ } else {
++ output.WriteDebug("Download test starting: %d stream(s), %d chunk(s), up to %ds\n", c.Int(defs.OptionConcurrent), c.Int(defs.OptionChunks), c.Int(defs.OptionDuration))
++ downloadStart := time.Now()
++
+ download, br, err := currentServer.Download(silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionChunks), time.Duration(c.Int(defs.OptionDuration))*time.Second)
+ if err != nil {
+ output.WriteError("Failed to get download speed: %s\n", err)
+@@ -98,6 +114,8 @@ func doSpeedTest(c *cli.Context, servers
+ }
+ downloadValue = download
+ bytesRead = br
++
++ output.WriteDebug("Download test finished in %s: %s, %d byte(s) received\n", time.Since(downloadStart).Round(time.Millisecond), humanizeRate(download, c), br)
+ }
+
+ // get upload value
+@@ -105,7 +123,11 @@ func doSpeedTest(c *cli.Context, servers
+ var bytesWritten uint64
+ if c.Bool(defs.OptionNoUpload) {
+ output.WriteUI("Upload test is disabled\n")
++ output.WriteDebug("Upload test skipped\n")
+ } else {
++ output.WriteDebug("Upload test starting: %d stream(s), %d KiB per request, up to %ds\n", c.Int(defs.OptionConcurrent), c.Int(defs.OptionUploadSize), c.Int(defs.OptionDuration))
++ uploadStart := time.Now()
++
+ upload, bw, err := currentServer.Upload(c.Bool(defs.OptionNoPreAllocate), silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionUploadSize), time.Duration(c.Int(defs.OptionDuration))*time.Second)
+ if err != nil {
+ output.WriteError("Failed to get upload speed: %s\n", err)
+@@ -113,6 +135,8 @@ func doSpeedTest(c *cli.Context, servers
+ }
+ uploadValue = upload
+ bytesWritten = bw
++
++ output.WriteDebug("Upload test finished in %s: %s, %d byte(s) sent\n", time.Since(uploadStart).Round(time.Millisecond), humanizeRate(upload, c), bw)
+ }
+
+ // print result if --simple is given
+@@ -323,6 +347,16 @@ func sendTelemetry(telemetryServer defs.
+ }
+ }
+
++// humanizeRate formats a rate the same way the run's own result will be
++// reported, so a debug line cannot appear to contradict the JSON, CSV or
++// --simple output sitting next to it.
++func humanizeRate(mbps float64, c *cli.Context) string {
++ if c.Bool(defs.OptionBytes) {
++ return humanizeMbps(mbps, c.Bool(defs.OptionMebiBytes))
++ }
++ return fmt.Sprintf("%.2f Mbps", mbps)
++}
++
+ func humanizeMbps(mbps float64, useMebi bool) string {
+ val := mbps / 8
+ var base float64 = 1000
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Sat, 8 Aug 2026 00:57:36 +0200
+Subject: [PATCH] docs: describe what --secure and --insecure actually do
+
+Both said they applied "when communicating with LibreSpeed.org operated
+servers". They apply to every server in the list, and they do not affect
+fetching the list itself.
+
+Help text only.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ main.go | 11 +++++++----
+ 1 file changed, 7 insertions(+), 4 deletions(-)
+
+--- a/main.go
++++ b/main.go
+@@ -150,13 +150,16 @@ func main() {
+ },
+ &cli.BoolFlag{
+ Name: defs.OptionSecure,
+- Usage: "Use HTTPS instead of HTTP when communicating with\n" +
+- "\tLibreSpeed.org operated servers",
++ Usage: "Force HTTPS for every test server, whichever scheme the\n" +
++ "\tserver list gives. Does not affect how the server list\n" +
++ "\titself is fetched",
+ },
+ &cli.BoolFlag{
+ Name: defs.OptionInsecure,
+- Usage: "Use HTTP instead of HTTPS when communicating with\n" +
+- "\tLibreSpeed.org operated servers",
++ Usage: "Force HTTP for every test server, whichever scheme the\n" +
++ "\tserver list gives. Does not affect how the server list\n" +
++ "\titself is fetched; use --" + defs.OptionServerJSON + " with an\n" +
++ "\thttp:// URL for that",
+ },
+ &cli.StringFlag{
+ Name: defs.OptionCACert,
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Sun, 9 Aug 2026 10:11:55 +0200
+Subject: [PATCH] fix: terminate --json and --csv output with a newline
+
+Neither ended with one: --json wrote the marshalled bytes as-is and --csv
+explicitly trimmed the trailing newline. The shell prompt lands on the same
+line as the result, and --csv-header already ends with a newline, so the two
+CSV modes could not be concatenated into a valid file.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ speedtest/helper.go | 3 ++-
+ 1 file changed, 2 insertions(+), 1 deletion(-)
+
+--- a/speedtest/helper.go
++++ b/speedtest/helper.go
+@@ -224,13 +224,14 @@ func doSpeedTest(c *cli.Context, servers
+ if err := gocsv.MarshalWithoutHeaders(&reps_csv, &buf); err != nil {
+ output.WriteError("Error generating CSV report: %s\n", err)
+ } else {
+- os.Stdout.WriteString(strings.TrimRight(buf.String(), "\n\r"))
++ os.Stdout.WriteString(strings.TrimRight(buf.String(), "\n\r") + "\n")
+ }
+ } else if c.Bool(defs.OptionJSON) {
+ if b, err := json.Marshal(&reps_json); err != nil {
+ output.WriteError("Error generating JSON report: %s\n", err)
+ } else {
+ os.Stdout.Write(b[:])
++ os.Stdout.WriteString("\n")
+ }
+ }
+
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Sun, 9 Aug 2026 10:11:55 +0200
+Subject: [PATCH] fix: drop the empty copyright line from --version
+
+"librespeed.org Copyright (C)" has named no holder and no year since the
+first commit. It says nothing that the two lines above it do not.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ speedtest/speedtest.go | 1 -
+ 1 file changed, 1 deletion(-)
+
+--- a/speedtest/speedtest.go
++++ b/speedtest/speedtest.go
+@@ -73,7 +73,6 @@ func SpeedTest(c *cli.Context) error {
+ output.WriteOut("Licensed under GNU Lesser General Public License v3.0\n")
+ output.WriteOut("LibreSpeed\tCopyright (C) 2016-2020 Federico Dossena\n")
+ output.WriteOut("librespeed-cli\tCopyright (C) 2020 Maddie Zhan\n")
+- output.WriteOut("librespeed.org\tCopyright (C)\n")
+ return nil
+ }
+
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Sun, 9 Aug 2026 23:27:01 +0200
+Subject: [PATCH] feat: report the negotiated TLS version and cipher under
+ --debug
+
+On hardware without AES acceleration the cipher, not the link, is what
+bounds an HTTPS result, and the server chooses it. Two runs can differ
+several-fold for a reason the numbers alone do not show.
+
+Measured on a CZ.NIC Turris 1.x, whose e500v2 core has no crypto
+instructions: AES-256-GCM tops out around 150 Mbps while ChaCha20-Poly1305
+reaches about 390 Mbps on the same core. A server that picks the first will
+look like a slow link next to one that picks the second.
+
+Print it once, from the backend check that already runs before every test.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/server.go | 16 ++++++++++++++++
+ 1 file changed, 16 insertions(+)
+
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -4,6 +4,7 @@ import (
+ "bytes"
+ "context"
+ "crypto/rand"
++ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+@@ -61,6 +62,21 @@ func (s *Server) IsUp() bool {
+ return false
+ }
+ defer resp.Body.Close()
++
++ // Report what the connection actually negotiated. On hardware without AES
++ // acceleration the cipher, not the link, is what bounds the result, and
++ // under TLS 1.3 the server picks it: the client offers a set and has no say
++ // in the choice, and Go does not allow that set to be configured at all.
++ // Two runs can therefore differ several-fold for a reason the numbers alone
++ // do not show, which is what this line is for.
++ if resp.TLS != nil {
++ output.WriteDebug("Negotiated %s with %s\n",
++ tls.VersionName(resp.TLS.Version),
++ tls.CipherSuiteName(resp.TLS.CipherSuite))
++ } else {
++ output.WriteDebug("Connection is not encrypted\n")
++ }
++
+ b, err := io.ReadAll(resp.Body)
+ if err != nil || len(b) > 0 {
+ // %q rather than Sanitize: this is a raw response body where newlines are
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Mon, 10 Aug 2026 01:03:30 +0200
+Subject: [PATCH] feat: report which address each ping test used under --debug
+
+IPv4 and IPv6 can take different paths through the network, so a result is
+not fully described by the hostname it was measured against. Nothing in the
+output says which was used: --json carries no client address, and the
+human-readable line only shows it by accident, in the ISP string.
+
+It is not academic. Against speedtest.cesnet.cz the ICMP ping goes over
+IPv4 while the transfers go over IPv6, so the reported latency and the
+reported throughput describe two different paths.
+
+The ICMP path reports the address the pinger resolved and probed; the TCP
+path reports every distinct peer the sockets connected to, taken from
+httptrace. The requests usually share one connection, but a reconnect can
+land on a different address, and reporting only the first would describe a
+connection the later samples did not use.
+
+Both paths are covered because --no-icmp is common enough that doing only
+the ICMP one would leave most users with nothing.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/server.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++
+ 1 file changed, 51 insertions(+)
+
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -10,10 +10,13 @@ import (
+ "fmt"
+ "io"
+ "math"
++ "net"
+ "net/http"
++ "net/http/httptrace"
+ "net/url"
+ "os"
+ "path"
++ "slices"
+ "strconv"
+ "sync"
+ "time"
+@@ -129,6 +132,15 @@ func (s *Server) ICMPPingAndJitter(count
+
+ stats := p.Statistics()
+
++ // Say which address the test actually reached. IPv4 and IPv6 can take
++ // different paths through the network, so a result is not fully described
++ // by the hostname it was measured against, and --json carries no client
++ // address to infer it from.
++ if stats.IPAddr != nil {
++ output.WriteDebug("Pinging %s over ICMP (%s)\n",
++ stats.IPAddr.String(), addressFamily(stats.IPAddr.String()))
++ }
++
+ var lastPing, jitter float64
+ for idx, rtt := range stats.Rtts {
+ if idx != 0 {
+@@ -153,6 +165,24 @@ func (s *Server) ICMPPingAndJitter(count
+ return float64(stats.AvgRtt.Milliseconds()), jitter, nil
+ }
+
++// addressFamily names the IP version of an address, for reporting which path a
++// measurement took. Accepts either a bare address or one with a port.
++func addressFamily(addr string) string {
++ host := addr
++ if h, _, err := net.SplitHostPort(addr); err == nil {
++ host = h
++ }
++ ip := net.ParseIP(host)
++ switch {
++ case ip == nil:
++ return "unknown"
++ case ip.To4() != nil:
++ return "IPv4"
++ default:
++ return "IPv6"
++ }
++}
++
+ // PingAndJitter pings the server via accessing ping URL and calculate the average ping and jitter
+ func (s *Server) PingAndJitter(count int) (float64, float64, error) {
+ t := time.Now()
+@@ -176,6 +206,23 @@ func (s *Server) PingAndJitter(count int
+ }
+ req.Header.Set("User-Agent", UserAgent)
+
++ // Collect every distinct peer, not just the first. The requests usually
++ // share one connection, but a reconnect can land on a different address --
++ // a different family, even -- and reporting only the first would then
++ // describe a connection the later samples did not use.
++ var remotes []string
++ req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
++ GotConn: func(info httptrace.GotConnInfo) {
++ if info.Conn == nil {
++ return
++ }
++ addr := info.Conn.RemoteAddr().String()
++ if !slices.Contains(remotes, addr) {
++ remotes = append(remotes, addr)
++ }
++ },
++ }))
++
+ for i := 0; i < count; i++ {
+ start := time.Now()
+ resp, err := http.DefaultClient.Do(req)
+@@ -190,6 +237,10 @@ func (s *Server) PingAndJitter(count int
+ pings = append(pings, float64(end.Sub(start).Milliseconds()))
+ }
+
++ for _, addr := range remotes {
++ output.WriteDebug("Pinging %s over TCP (%s)\n", addr, addressFamily(addr))
++ }
++
+ // discard first result due to handshake overhead
+ if len(pings) > 1 {
+ pings = pings[1:]
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe@bloodkings.eu>
+Date: Mon, 10 Aug 2026 01:35:20 +0200
+Subject: [PATCH] fix: keep sub-millisecond precision in ping and jitter
+
+Duration.Milliseconds truncates to whole milliseconds, and every round-trip
+sample went through it -- in the ICMP path and in the TCP fallback that
+--no-icmp selects. On a link faster than a millisecond that leaves nothing.
+
+Against a server on loopback, reporting only ping and jitter:
+
+ before ping 0 jitter 0
+ after ping 0.11 jitter 0.01
+
+Over a wider link the reported ping still looks fractional, because it is an
+average of the samples, but its resolution is a whole millisecond and jitter
+is a difference of those samples. Three consecutive ICMP runs against the
+same server:
+
+ before ping 5, 6, 5 jitter 0.59, 4.40, 0.02
+ after ping 5.48, 5.68, 5.45 jitter 0.27, 0.32, 0.12
+
+Divide by time.Millisecond instead, which keeps the fraction.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/server.go | 15 +++++++++++----
+ 1 file changed, 11 insertions(+), 4 deletions(-)
+
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -91,6 +91,13 @@ func (s *Server) IsUp() bool {
+ return resp.StatusCode == http.StatusOK
+ }
+
++// rttMillis converts a round-trip time to milliseconds without discarding the
++// fraction. Duration.Milliseconds truncates to whole milliseconds, which on a
++// link faster than that leaves nothing to report.
++func rttMillis(d time.Duration) float64 {
++ return float64(d) / float64(time.Millisecond)
++}
++
+ // ICMPPingAndJitter pings the server via ICMP echos and calculate the average ping and jitter
+ func (s *Server) ICMPPingAndJitter(count int, srcIp, network string) (float64, float64, error) {
+ t := time.Now()
+@@ -144,7 +151,7 @@ func (s *Server) ICMPPingAndJitter(count
+ var lastPing, jitter float64
+ for idx, rtt := range stats.Rtts {
+ if idx != 0 {
+- instJitter := math.Abs(lastPing - float64(rtt.Milliseconds()))
++ instJitter := math.Abs(lastPing - rttMillis(rtt))
+ if idx > 1 {
+ if jitter > instJitter {
+ jitter = jitter*0.7 + instJitter*0.3
+@@ -153,7 +160,7 @@ func (s *Server) ICMPPingAndJitter(count
+ }
+ }
+ }
+- lastPing = float64(rtt.Milliseconds())
++ lastPing = rttMillis(rtt)
+ }
+
+ if len(stats.Rtts) == 0 {
+@@ -162,7 +169,7 @@ func (s *Server) ICMPPingAndJitter(count
+ return s.PingAndJitter(count + 2)
+ }
+
+- return float64(stats.AvgRtt.Milliseconds()), jitter, nil
++ return rttMillis(stats.AvgRtt), jitter, nil
+ }
+
+ // addressFamily names the IP version of an address, for reporting which path a
+@@ -234,7 +241,7 @@ func (s *Server) PingAndJitter(count int
+ resp.Body.Close()
+ end := time.Now()
+
+- pings = append(pings, float64(end.Sub(start).Milliseconds()))
++ pings = append(pings, rttMillis(end.Sub(start)))
+ }
+
+ for _, addr := range remotes {
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Date: Mon, 10 Aug 2026 02:11:41 +0200
+Subject: [PATCH] feat: report the ping distribution under --debug
+
+The test reports one average, which says nothing about how the samples were
+spread. A link averaging 5 ms because every probe took 5 ms and one averaging
+5 ms because probes ranged from 1 to 30 ms are not the same link, and only the
+second explains a connection that feels unsteady.
+
+pro-bing already works min, max and standard deviation out in Statistics(),
+so this prints what is on hand.
+
+Replies are shown as a raw count rather than a loss percentage. Ten probes are
+too few to express as a rate, and ICMP is frequently policed separately from
+the data path, so a percentage would describe the server's ICMP handling more
+than the network.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/server.go | 13 +++++++++++++
+ 1 file changed, 13 insertions(+)
+
+--- a/defs/server.go
++++ b/defs/server.go
+@@ -148,6 +148,19 @@ func (s *Server) ICMPPingAndJitter(count
+ stats.IPAddr.String(), addressFamily(stats.IPAddr.String()))
+ }
+
++ // A single figure hides how the samples were spread. The pinger already
++ // works these out, and the spread is what says whether a link is steady or
++ // merely fast on average. Raw counts rather than a loss percentage: ten
++ // probes are too few for a rate, and ICMP is often policed independently of
++ // the data path, so a percentage would say more about the server's ICMP
++ // handling than about the network.
++ output.WriteDebug("Ping over ICMP: min %.2f ms, avg %.2f ms, max %.2f ms, stddev %.2f ms, %d/%d replies\n",
++ rttMillis(stats.MinRtt),
++ rttMillis(stats.AvgRtt),
++ rttMillis(stats.MaxRtt),
++ rttMillis(stats.StdDevRtt),
++ stats.PacketsRecv, stats.PacketsSent)
++
+ var lastPing, jitter float64
+ for idx, rtt := range stats.Rtts {
+ if idx != 0 {
--- /dev/null
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Date: Mon, 10 Aug 2026 08:36:47 +0200
+Subject: [PATCH] feat: name the platform in the User-Agent
+
+A server's telemetry stores the User-Agent header already, but the client only
+identifies itself by name and version, so an operator cannot tell which kinds
+of machine are measuring against them. Browsers have always reported this.
+
+Append the operating system and architecture, which runtime.GOOS and
+runtime.GOARCH give as compile-time constants at no cost:
+
+ librespeed-cli/1.0.12 (linux; arm64)
+
+It stays coarse on purpose. The kernel version or the hostname would identify
+the machine rather than describe it, and nothing here needs that.
+Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
+Upstream-Status: Submitted
+---
+ defs/defs.go | 10 +++++++++-
+ 1 file changed, 9 insertions(+), 1 deletion(-)
+
+--- a/defs/defs.go
++++ b/defs/defs.go
+@@ -1,11 +1,19 @@
+ package defs
+
++import "runtime"
++
+ var (
+ // values to be filled in by build script
+ BuildDate string
+ ProgName string
+ ProgVersion string
+- UserAgent = ProgName + "/" + ProgVersion
++ // UserAgent names the platform as well as the program, the way a browser
++ // does. A server's telemetry already stores this header, so reporting the
++ // operating system and architecture here is what lets an operator tell
++ // which kinds of machine are measuring against them without any change to
++ // what the client sends. It stays coarse deliberately: the kernel version
++ // or the hostname would identify the machine rather than describe it.
++ UserAgent = ProgName + "/" + ProgVersion + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")"
+ )
+
+ // GetIPResults represents the returned JSON from backend server's getIP.php endpoint