pax_global_header00006660000000000000000000000064152454716730014530gustar00rootroot0000000000000052 comment=df849caf7af00477d70f8b2978308474b7f68093 showwin-speedtest-go-df849ca/000077500000000000000000000000001524547167300163335ustar00rootroot00000000000000showwin-speedtest-go-df849ca/.github/000077500000000000000000000000001524547167300176735ustar00rootroot00000000000000showwin-speedtest-go-df849ca/.github/workflows/000077500000000000000000000000001524547167300217305ustar00rootroot00000000000000showwin-speedtest-go-df849ca/.github/workflows/ci.yaml000066400000000000000000000022721524547167300232120ustar00rootroot00000000000000name: go-ci on: [push] jobs: setup: runs-on: ubuntu-latest steps: - name: set up uses: actions/setup-go@v7 with: go-version: 1.27.0 id: go - name: check out uses: actions/checkout@v7 - name: Cache uses: actions/cache@v6 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- build: needs: setup runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: 1.27.0 - name: build run: go build ./... test: needs: setup runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: 1.27.0 - name: test run: go test ./speedtest -v lint: needs: setup runs-on: ubuntu-latest steps: - uses: actions/setup-go@v7 with: go-version: 1.26 - uses: actions/checkout@v7 - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.12.2 showwin-speedtest-go-df849ca/.github/workflows/release.yml000066400000000000000000000011101524547167300240640ustar00rootroot00000000000000name: goreleaser on: push: tags: - "v*.*.*" jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v7 with: go-version: 1.27.0 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} showwin-speedtest-go-df849ca/.gitignore000066400000000000000000000005061524547167300203240ustar00rootroot00000000000000# Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so # Folders _obj _test # Architecture specific extensions/prefixes *.[568vq] [568vq].out *.cgo1.go *.cgo2.c _cgo_defun.c _cgo_gotypes.go _cgo_export.* _testmain.go *.exe *.test *.prof .goxc.local.json dist/* speedtest-go .idea/ .run/ .gocacheshowwin-speedtest-go-df849ca/.go-version000066400000000000000000000000071524547167300204210ustar00rootroot000000000000001.26.0 showwin-speedtest-go-df849ca/.goreleaser.yml000066400000000000000000000025341524547167300212700ustar00rootroot00000000000000# This is an example goreleaser.yaml file with some sane defaults. # Make sure to check the documentation at http://goreleaser.com version: 2 before: hooks: # You may remove this if you don't use go modules. - go mod download # you may remove this if you don't need go generate - go generate ./... builds: - env: - CGO_ENABLED=0 goos: - linux - windows - darwin - openbsd - freebsd goarch: - amd64 - arm - arm64 - 386 - s390x - ppc64 - ppc64le - riscv64 - mips - mips64 - mipsle - mips64le - loong64 goarm: - 5 - 6 - 7 gomips: - hardfloat - softfloat ldflags: - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{ .CommitDate }} archives: - name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ if eq .Os "openbsd" }}OpenBSD{{ else }}{{ title .Os }}{{ end }}_{{ if eq .Arch "386" }}i386{{ else if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }} checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' showwin-speedtest-go-df849ca/Dockerfile000066400000000000000000000020261524547167300203250ustar00rootroot00000000000000# syntax=docker/dockerfile:1 # Build stage FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder WORKDIR /app # Install build dependencies RUN apk add --no-cache git # Copy go mod files COPY go.mod go.sum ./ RUN go mod download # Copy source code COPY . . # Build the application ARG TARGETPLATFORM ARG BUILDPLATFORM RUN GOOS=$(echo $TARGETPLATFORM | cut -d/ -f1) \ GOARCH=$(echo $TARGETPLATFORM | cut -d/ -f2) \ go build -o speedtest-go # Final stage FROM alpine:latest # Install bash RUN apk add --no-cache bash # Create non-root user RUN adduser -D -h /home/speedtest speedtest WORKDIR /home/speedtest # Copy the binary from builder COPY --from=builder /app/speedtest-go /usr/local/bin/ # Switch to non-root user USER speedtest # Set default shell SHELL ["/bin/bash", "-c"] # Set the entrypoint to bash, we do this rather than using the speedtest command directly # such that you can also use this container in an interactive way to run speedtests. # see the README for more info and examples. CMD ["/bin/bash"] showwin-speedtest-go-df849ca/LICENSE000066400000000000000000000020651524547167300173430ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2015 ITO Shogo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. showwin-speedtest-go-df849ca/README.md000066400000000000000000000350031524547167300176130ustar00rootroot00000000000000# speedtest-go **Full-featured Command Line Interface and pure [Go API](#go-api) to Test Internet Speed using [speedtest.net](http://www.speedtest.net/)**. You can speedtest 2x faster than [speedtest.net](http://www.speedtest.net/) with almost the same result. [See the experimental results](https://github.com/showwin/speedtest-go#summary-of-experimental-results). Inspired by [sivel/speedtest-cli](https://github.com/sivel/speedtest-cli) ## CLI ### Installation #### macOS (homebrew) ```bash $ brew tap showwin/speedtest $ brew install speedtest ### How to Update ### $ brew update $ brew upgrade speedtest ``` #### [Nix](https://nixos.org) (package manager) ```bash # Enter the latest speedtest-go environment $ nix-shell -p speedtest-go ``` #### Other Platforms (Linux, Windows, etc.) Please download the compatible package from [Releases](https://github.com/showwin/speedtest-go/releases). If there are no compatible packages you want, please let me know on [Issue Tracker](https://github.com/showwin/speedtest-go/issues). #### Docker Build To build a multi-architecture Docker image: ```bash # Check if you already have a builder instance docker buildx ls # Only create a new builder if you don't have one # If the above command shows no builders or none are in use, run: docker buildx create --name mybuilder --use # Build and push for multiple platforms docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t yourusername/speedtest-go:latest --push . ``` #### Running the Container ##### Docker Run the container with default settings (interactive shell): ```bash docker run -it yourusername/speedtest-go:latest ``` Run a speedtest with specific arguments: ```bash # Run a basic speedtest docker run yourusername/speedtest-go:latest speedtest-go # Run with specific server docker run yourusername/speedtest-go:latest speedtest-go --server 6691 # Run with multiple servers and JSON output docker run yourusername/speedtest-go:latest speedtest-go --server 6691 --server 6087 --json # Run with custom location docker run yourusername/speedtest-go:latest speedtest-go --location=60,-110 ``` ##### Kubernetes Here's an example Kubernetes pod specification that runs a speedtest: ```yaml apiVersion: v1 kind: Pod metadata: name: speedtest spec: containers: - name: speedtest image: yourusername/speedtest-go:latest # Base command to run bash command: ["speedtest-go"] # Or run with specific arguments # args: ["--server", "6691", "--json"] restartPolicy: Never ``` For a more complete deployment, you might want to use a CronJob to run periodic speedtests: ```yaml apiVersion: batch/v1 kind: CronJob metadata: name: speedtest spec: schedule: "0 */6 * * *" # Run every 6 hours jobTemplate: spec: template: spec: containers: - name: speedtest image: yourusername/speedtest-go:latest command: ["speedtest-go"] args: ["--json"] restartPolicy: OnFailure ``` ### Usage ```bash $ speedtest --help usage: speedtest-go [] Flags: --help Show context-sensitive help (also try --help-long and --help-man). -l, --list Show available speedtest.net servers. -s, --server=SERVER ... Select server id to speedtest. --custom-url=CUSTOM-URL Specify the url of the server instead of fetching from speedtest.net. --saving-mode Test with few resources, though low accuracy (especially > 30Mbps). --json Output results in json format. --jsonl Output results in jsonl format (one json object per line). --unix Output results in unix like format. --location=LOCATION Change the location with a precise coordinate (format: lat,lon). --city=CITY Change the location with a predefined city label. --city-list List all predefined city labels. --proxy=PROXY Set a proxy(http[s] or socks) for the speedtest. eg: --proxy=socks://10.20.0.101:7890 eg: --proxy=http://10.20.0.101:7890 --source=SOURCE Bind a source interface for the speedtest. --dns-bind-source DNS request binding source (experimental). eg: --source=10.20.0.101 eg: --source=eth0 (Linux only) -m --multi Enable multi-server mode. -t --thread=THREAD Set the maximum concurrent connections (upload adapts automatically; default cap 8). --search=SEARCH Fuzzy search servers by a keyword. --ua Set the user-agent header for the speedtest. --no-download Disable download test. --no-upload Disable upload test. --ping-mode Select a method for Ping (support icmp/tcp/http). -p, --protocol Select the download/upload protocol [http (default) or tcp (experimental)]. -u --unit Set human-readable and auto-scaled rate units for output (options: decimal-bits/decimal-bytes/binary-bits/binary-bytes). --filter-cc=FILTER-CC Filter servers by Country Code(s). (options: CC/auto). eg: --filter-cc=JP eg: --filter-cc=auto --mask-isp Mask sensitive info (IP address and coordinates) in ISP output. -d --debug Enable debug mode. --version Show application version. ``` #### Test Internet Speed Simply use `speedtest` command. The closest server is selected by default. Use the `-m` flag to enable multi-measurement mode (recommended) ```bash ## unix like format output # speedtest --unix $ speedtest speedtest-go v1.8.3 @showwin ✓ ISP: 124.27.199.165 (Fujitsu) [34.9769, 138.3831] ✓ Found 20 Public Servers ✓ Test Server: [6691] 9.03km Shizuoka (Japan) by sudosan ✓ Latency: 4.452963ms Jitter: 41.271µs Min: 4.395179ms Max: 4.517576ms ✓ Packet Loss Analyzer: Running in background (<= 30 Secs) ✓ Download: 115.52 Mbps (Used: 135.75MB) (Latency: 4ms Jitter: 0ms Min: 4ms Max: 4ms) ✓ Upload: 4.02 Mbps (Used: 6.85MB Confirmed: 96.0%) (Latency: 4ms Jitter: 1ms Min: 3ms Max: 8ms) ✓ Packet Loss: 8.82% (Sent: 217/Dup: 0/Max: 237) ``` For upload results, `Confirmed` indicates the proportion of uploaded bytes confirmed by a successful upload response. #### Test with Other Servers If you want to select other servers to test, you can see the available server list. ```bash $ speedtest --list Testing From IP: 124.27.199.165 (Fujitsu) [34.9769, 138.3831] [6691] 9.03km 32.3365ms Shizuoka (Japan) by sudosan [6087] 120.55km 51.7453ms Fussa-shi (Japan) by Allied Telesis Capital Corporation [6508] 125.44km 54.6683ms Yokohama (Japan) by at2wn [6424] 148.23km 61.4724ms Tokyo (Japan) by Cordeos Corp. ... ``` and select them by id. ```bash $ speedtest --server 6691 --server 6087 speedtest-go v1.8.3 @showwin ✓ ISP: 124.27.199.165 (Fujitsu) [34.9769, 138.3831] ✓ Found 2 Specified Public Server(s) ✓ Test Server: [6691] 9.03km Shizuoka (Japan) by sudosan ✓ Latency: 21.424ms Jitter: 1.644ms Min: 19.142ms Max: 23.926ms ✓ Packet Loss Analyzer: Running in background (<= 30 Sec) ✓ Download: 65.82Mbps (Used: 75.48MB) (Latency: 22ms Jitter: 2ms Min: 17ms Max: 24ms) ✓ Upload: 27.00Mbps (Used: 36.33MB) (Latency: 23ms Jitter: 2ms Min: 18ms Max: 25ms) ✓ Packet Loss: 0.00% (Sent: 321/Dup: 0/Max: 320) ✓ Test Server: [6087] 120.55km Fussa-shi (Japan) by Allied Telesis Capital Corporation ✓ Latency: 38.694699ms Jitter: 2.724ms Min: 36.443ms Max: 39.953ms ✓ Packet Loss Analyzer: Running in background (<= 30 Sec) ✓ Download: 72.24Mbps (Used: 83.72MB) (Latency: 37ms Jitter: 3ms Min: 36ms Max: 40ms) ✓ Upload: 29.56Mbps (Used: 47.64MB) (Latency: 38ms Jitter: 3ms Min: 37ms Max: 41ms) ✓ Packet Loss: 0.00% (Sent: 343/Dup: 0/Max: 342) ``` #### Test with a virtual location With `--city` or `--location` option, the closest servers of the location will be picked. You can measure the speed between your location and the target location. ```bash $ speedtest --city-list Available city labels (case insensitive): CC CityLabel Location (za) capetown [-33.9391993, 18.4316716] (pl) warsaw [52.2396659, 21.0129345] (sg) yishun [1.4230218, 103.8404728] ... $ speedtest --city=capetown $ speedtest --location=60,-110 ``` #### Memory Saving Mode With `--saving-mode` option, it can be executed even in an insufficient memory environment like IoT devices. The memory usage can be reduced to 1/10, about 10MB of memory is used. However, please be careful that the accuracy is particularly low, especially in an environment of 30 Mbps or higher. To get more accurate results, run multiple times and average. For more details, please see [saving mode experimental result](https://github.com/showwin/speedtest-go/blob/master/docs/saving_mode_experimental_result.md). ⚠️This feature has been deprecated > v1.4.0, because speedtest-go can always run with less than 10MBytes of memory now. Even so, `--saving-mode` is still a good way to reduce computation. ## Go API ```bash go get github.com/showwin/speedtest-go ``` ### API Usage The [code](https://github.com/showwin/speedtest-go/blob/master/example/naive/main.go) below finds the closest available speedtest server and tests the latency, download, and upload speeds. ```go package main import ( "fmt" "github.com/showwin/speedtest-go/speedtest" ) func main() { var speedtestClient = speedtest.New() // Use a proxy for the speedtest. eg: socks://127.0.0.1:7890 // speedtest.WithUserConfig(&speedtest.UserConfig{Proxy: "socks://127.0.0.1:7890"})(speedtestClient) // Select a network card as the data interface. // speedtest.WithUserConfig(&speedtest.UserConfig{Source: "192.168.1.101"})(speedtestClient) // On Linux, a network interface name also works (binds via SO_BINDTODEVICE): // speedtest.WithUserConfig(&speedtest.UserConfig{Source: "eth0"})(speedtestClient) // Get user's network information // user, _ := speedtestClient.FetchUserInfo() // Get a list of servers near a specified location // user.SetLocationByCity("Tokyo") // user.SetLocation("Osaka", 34.6952, 135.5006) // Search server using serverID. // eg: fetch server with ID 28910. // speedtest.ErrServerNotFound will be returned if the server cannot be found. // server, err := speedtest.FetchServerByID("28910") serverList, _ := speedtestClient.FetchServers() targets, _ := serverList.FindServer([]int{}) for _, s := range targets { // Please make sure your host can access this test server, // otherwise you will get an error. // It is recommended to replace a server at this time s.PingTest(nil) s.DownloadTest() s.UploadTest() // Note: The unit of s.DLSpeed, s.ULSpeed is bytes per second, this is a float64. fmt.Printf("Latency: %s, Download: %s, Upload: %s\n", s.Latency, s.DLSpeed, s.ULSpeed) s.Context.Reset() // reset counter } } ``` The [code](https://github.com/showwin/speedtest-go/blob/master/example/packet_loss/main.go) will find the closest available speedtest server and analyze packet loss. ```go package main import ( "fmt" "github.com/showwin/speedtest-go/speedtest" "github.com/showwin/speedtest-go/speedtest/transport" "log" ) func checkError(err error) { if err != nil { log.Fatal(err) } } // Note: The current packet loss analyzer does not support udp over http. // This means we cannot get packet loss through a proxy. func main() { // Retrieve available servers var speedtestClient = speedtest.New() serverList, _ := speedtestClient.FetchServers() targets, _ := serverList.FindServer([]int{}) // Create a packet loss analyzer, use default options analyzer := speedtest.NewPacketLossAnalyzer(nil) // Perform packet loss analysis on all available servers for _, server := range targets { err := analyzer.Run(server.Host, func(packetLoss *transport.PLoss) { fmt.Println(packetLoss, server.Host, server.Name) // fmt.Println(packetLoss.Loss()) }) checkError(err) } // or test all at the same time. packetLoss, err := analyzer.RunMulti(targets.Hosts()) checkError(err) fmt.Println(packetLoss) } ``` ## Summary of Experimental Results Speedtest-go is a great tool because of the following five reasons: * Cross-platform available. * Low memory environment. * We are the first **FULL-FEATURED** open source speed testing project based on speedtest.net, including down/up rates, jitter and packet loss, etc. * Testing time is the **SHORTEST** compare to [speedtest.net](http://www.speedtest.net/) and [sivel/speedtest-cli](https://github.com/sivel/speedtest-cli), especially about 2x faster than [speedtest.net](http://www.speedtest.net/). * Result is **MORE CLOSE** to [speedtest.net](http://www.speedtest.net/) than [speedtest-cli](https://github.com/sivel/speedtest-cli). The following data is summarized. If you got interested, please see [more details](https://github.com/showwin/speedtest-go/blob/master/docs/experimental_result.md). ### Download (Mbps) distance = distance to testing server * 0 - 1000(km) ≒ domestic * 1000 - 8000(km) ≒ same region * 8000 - 20000(km) ≒ really far! * 20000km is half of the circumference of our planet. | distance (km) | speedtest.net | speedtest-go | speedtest-cli | |:-------------:|:-------------:|:------------:|:-------------:| | 0 - 1000 | 92.12 | **91.21** | 70.27 | | 1000 - 8000 | 66.45 | **65.51** | 56.56 | | 8000 - 20000 | 11.84 | 9.43 | **11.87** | ### Upload (Mbps) | distance (km) | speedtest.net | speedtest-go | speedtest-cli | |:-------------:|:-------------:|:------------:|:-------------:| | 0 - 1000 | 65.56 | **47.58** | 36.16 | | 1000 - 8000 | 58.02 | **54.74** | 26.78 | | 8000 - 20000 | 5.20 | **8.32** | 2.58 | ### Testing Time (sec) | distance (km) | speedtest.net | speedtest-go | speedtest-cli | |:-------------:|:-------------:|:------------:|:-------------:| | 0 - 1000 | 45.03 | **22.84** | 24.46 | | 1000 - 8000 | 44.89 | **24.45** | 28.52 | | 8000 - 20000 | 49.64 | **34.08** | 41.26 | ## Contributors See [Contributors](https://github.com/showwin/speedtest-go/graphs/contributors), PRs are welcome! ## Issues You can find or report issues in the [Issue Tracker](https://github.com/showwin/speedtest-go/issues). ## LICENSE [MIT](https://github.com/showwin/speedtest-go/blob/master/LICENSE) showwin-speedtest-go-df849ca/docs/000077500000000000000000000000001524547167300172635ustar00rootroot00000000000000showwin-speedtest-go-df849ca/docs/experimental_result.md000066400000000000000000000062711524547167300237060ustar00rootroot00000000000000# Experimental Result I randomly select 9 servers to speedtest considering distance from my house. Try speedtesting twice to each server. Testing order is like [speedtest.net] -> [speedtest-go] -> [speedtest-cli] -> [speedtest-cli] -> [speedtest-go] -> [speedtest.net]. To the next server, starting from [speedtest-go], like [speedtest-go] -> [speedtest-cli] -> [speedtest.net] -> [speedtest.net] -> [speedtest-cli] -> [speedtest-go]. ## Downlaod (Mbps) | distance(km) | server id | speediest.net (test1) | speediest.net (test2) | speedtest-go (test1) | speedtest-go (test2) | speedtest-cli (test1) | speedtest-cli (test2) | | :-- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | | 0 - 1000 (9km) | 6691 | 93.34 | 93.51 | 89.13 | 88.40 | 88.21 | 83.34 | | 0 - 1000 (194km) | 6368 | 93.45 | 94.20 | 92.93 | 92.26 | 88.45 | 77.07 | | 0 - 1000 (865km) | 6842 | 88.31 | 89.91 | 91.26 | 93.30 | 67.33 | 87.52 | | 1000 - 8000 (1982km) | 2589 | 90.24 | 94.05 | 79.61 | 79.52 | 79.16 | 70.36 | | 1000 - 8000 (4801km) | 3296 | 75.61 | 52.83 | 69.89 | 69.86 | 58.19 | 55.93 | | 1000 - 8000 (6627km) | 1718 | 42.04 | 43.92 | 39.81 | 54.35 | 44.47 | 31.27 | | 8000 - 20000 (8972km) | 3409 | 22.11 | 22.74 | 28.21 | 21.82 | 24.78 | 30.59 | | 8000 - 20000 (13781km) | 3162 | 15.78 | 7.73 | 1.94 | 1.02 | 3.32 | 2.40 | | 8000 - 20000 (17805km) | 4256 | 1.15 | 1.50 | 1.79 | 1.79 | 6.41 | 3.69 | ## Upload (Mbps) | distance(km) | server id | speediest.net (test1) | speediest.net (test2) | speedtest-go (test1) | speedtest-go (test2) | speedtest-cli (test1) | speedtest-cli (test2) | | :-- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | | 0 - 1000 (9km) | 6691 | 52.03 | 48.82 | 40.72 | 43.84 | 36.03 | 36.21 | 0 - 1000 (194km) | 6368 | 78.30 | 66.92 | 53.98 | 44.07 | 37.26 | 46.98 | 0 - 1000 (865km) | 6842 | 65.34 | 81.96 | 50.27 | 52.57 | 29.42 | 31.08 | 1000 - 8000 (1982km) | 2589 | 89.29 | 58.69 | 80.68 | 56.59 | 42.66 | 44.34 | 1000 - 8000 (4801km) | 3296 | 50.16 | 50.78 | 54.94 | 54.18 | 20.59 | 20.23 | 1000 - 8000 (6627km) | 1718 | 48.48 | 50.71 | 39.81 | 42.21 | 16.16 | 16.72 | 8000 - 20000 (8972km) | 3409 | 18.07 | 20.30 | 20.35 | 24.92 | 5.87 | 3.37 | 8000 - 20000 (13781km) | 3162 | 1.45 | 0.33 | 1.78 | 0.77 | 1.08 | 1.34 | 8000 - 20000 (17805km) | 4256 | 1.16 | 0.30 | 1.00 | 1.11 | 2.37 | 1.42 ## Testing Time (sec) | distance(km) | server id | speediest.net (test1) | speediest.net (test2) | speedtest-go (test1) | speedtest-go (test2) | speedtest-cli (test1) | speedtest-cli (test2) | | :-- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | | 0 - 1000 (9km) | 6691 | 44.87 | 45.76 | 24.45 | 22.40 | 20.82 | 16.51 | 0 - 1000 (194km) | 6368 | 43.85 | 42.97 | 19.60 | 22.36 | 22.25 | 24.01 | 0 - 1000 (865km) | 6842 | 46.62 | 46.12 | 21.83 | 26.40 | 39.07 | 24.10 | 1000 - 8000 (1982km) | 2589 | 45.38 | 44.18 | 18.71 | 21.35 | 26.15 | 25.71 | 1000 - 8000 (4801km) | 3296 | 47.92 | 50.52 | 24.71 | 23.47 | 32.07 | 28.53 | 1000 - 8000 (6627km) | 1718 | 40.15 | 41.16 | 28.05 | 30.42 | 31.01 | 27.65 | 8000 - 20000 (8972km) | 3409 | 51.53 | 56.89 | 38.13 | 41.55 | 36.87 | 34.47 | 8000 - 20000 (13781km) | 3162 | 52.75 | 58.10 | 28.78 | 29.91 | 40.75 | 46.88 | 8000 - 20000 (17805km) | 4256 | 37.73 | 40.82 | 33.33 | 32.79 | 37.82 | 50.78 showwin-speedtest-go-df849ca/docs/release.md000066400000000000000000000007261524547167300212320ustar00rootroot00000000000000# Release flow This is a note for repo owner. ```bach $ git checkout -b release/vX.Y.Z # edit `var version` at speedtest/speedtest.go $ git commit -am 'Release vX.Y.Z' $ git push origin release/vX.Y.Z ``` merge PR ```bach $ git checkout master $ git pull origin master $ git tag vX.Y.Z $ git push origin vX.Y.Z # run GitHub Action to build packages and make release. ``` update brew formula https://github.com/showwin/homebrew-speedtest/blob/master/speedtest.rb#L3 showwin-speedtest-go-df849ca/docs/saving_mode_experimental_result.md000066400000000000000000000013451524547167300262560ustar00rootroot00000000000000# --saving-mode Experimental Result `--saving-mode` vs normal mode. `N - Download` means normal mode download speed results. `S - RAM` means `--saving-mode` memory usage. | exp No. | N - Download | N - Upload | N - RAM | S - Download | S - Upload | S- RAM | | :-- | :--: | :--: | :--: | :--: | :--: | :--: | | 1 | 54.13 Mbit/s | 61.07 Mbit/s | 85.82MB | 50.65 Mbit/s | 29.25 Mbit/s | 5.39MB | | 2 | 47.65 Mbit/s | 57.94 Mbit/s | 93.84MB | 48.39 Mbit/s | 44.04 Mbit/s | 9.39MB | | 3 | 52.58 Mbit/s | 56.43 Mbit/s | 109.86MB | 24.34 Mbit/s | 41.68 Mbit/s | 9.39MB | | 4 | 43.73 Mbit/s | 49.46 Mbit/s | 109.86MB | 32.10 Mbit/s | 36.51 Mbit/s | 9.39MB | | 5 | 54.66 Mbit/s | 42.81 Mbit/s | 101.84MB | 40.08 Mbit/s | 10.37 Mbit/s | 5.39MB | showwin-speedtest-go-df849ca/docs/tcp.md000066400000000000000000000026021524547167300203730ustar00rootroot00000000000000# TCP Speed Test speedtest-go supports the plaintext TCP protocol used by speedtest.net test servers. HTTP remains the default, so existing commands do not change behavior. ## Usage Run a TCP download and upload test with: ```bash speedtest --protocol=tcp ``` The server must expose its speedtest TCP service, normally on port `8080`. The TCP endpoint is taken from the server `Host` field. HTTP proxies are not used for TCP connections. `--ping-mode` is independent from `--protocol`: it controls latency measurement, while `--protocol` controls download and upload traffic. ## Wire protocol Each test connection starts with a handshake: ```text HI HELLO ``` Download requests have the following form: ```text DOWNLOAD \n ``` The server then returns exactly `` bytes. The client streams these bytes directly into the existing speed and byte counters. Upload requests have the following form: ```text UPLOAD 0\n \n ``` The declared total includes the command line, its newline, the payload, and the payload's final newline. The server confirms the request with: ```text OK ``` The acknowledged byte count is used for the upload confirmation ratio. ## Packet loss The packet-loss analyzer is independent of the download/upload protocol. TCP mode still runs the existing TCP-control plus UDP-data packet-loss analysis. showwin-speedtest-go-df849ca/docs/tui.md000066400000000000000000000110301524547167300204010ustar00rootroot00000000000000# Terminal UI This document describes the terminal UI used by the `speedtest-go` command. ## Goals - Keep progress output stable during latency, download, upload, and packet-loss measurements. - Ensure that only one goroutine writes interactive output to the terminal. - Keep task callbacks independent from terminal cursor management. - Preserve machine-readable output for `--json` and `--jsonl`. - Preserve line-oriented output for `--unix` and redirected output. ## Output Modes | Mode | Renderer | Output contract | | --- | --- | --- | | Default TTY | Interactive | Animated task progress and committed final results | | Default non-TTY | Plain | One final line per task, without ANSI sequences | | `--unix` | Plain | Line-oriented text without ANSI sequences | | `--json` | Silent | One JSON document without task output | | `--jsonl` | Silent | One JSON object per line without task output | Errors in a silent mode are written to standard error so standard output stays machine-readable. ## Architecture The task layer and terminal layer communicate through two small interfaces: ```go type taskRenderer interface { NewTask(message string) taskHandle Println(message string) BlankLine() Stop() } type taskHandle interface { Update(message string) Complete() Error() } ``` `TaskManager` creates handles and never performs cursor movement itself. Task callbacks only update in-memory task state. Three renderer implementations provide the output modes: - `interactiveRenderer` owns all ANSI output and runs one render goroutine. - `plainRenderer` writes one final line for each completed or failed task. - `silentRenderer` discards task output for JSON and JSONL modes. All terminal output owned by the TUI, including separator blank lines, goes through the renderer. A direct stdout write while the interactive renderer is active invalidates its cursor position and can leave a stale spinner frame on screen. ## Interactive Rendering The interactive renderer maintains only active tasks in its dynamic region. When a task completes, its final line is committed to the terminal and removed from that region. This avoids redrawing the full command history on every animation frame. The render loop performs these operations: 1. Snapshot task state under a mutex. 2. Clear only the lines drawn by the previous frame. 3. Print newly completed task lines once. 4. Draw the current active task lines. 5. Wait for the next frame or shutdown request. The frame cadence has a lower bound of 120 milliseconds. Measurement callbacks may update task state more frequently, but only the newest value present at the next frame is rendered. Messages are converted to one line and truncated to the current terminal width so wrapping cannot corrupt cursor positioning. The running and completed symbols use ysmrr's default bright green. Error symbols use bright red. Messages remain in the terminal's default color. Width calculation and truncation happen before ANSI styles are applied. Spinner frames come from `ysmrr v0.6.0`. The project does not use ysmrr's spinner-manager lifecycle because its `Stop()` method does not provide a render goroutine completion barrier. ## Lifecycle - One interactive renderer is created for the command. - `Stop()` is idempotent. - Normal shutdown waits for asynchronous tasks before stopping the renderer. - Interactive `Stop()` waits for the final frame and cursor restoration. - No new renderer is created between discovery, measurement, or server phases. - The server list stops the renderer before switching to direct text output. ## Tests `tui_test.go` covers: - Coalescing high-frequency task updates into the latest rendered value. - Serializing separator blank lines with task state changes. - Applying status colors without affecting terminal-width calculations. - Idempotent renderer shutdown. - Message sanitization and terminal-width truncation. - Single-emission behavior for the plain renderer. - The silent renderer contract. - Shutdown waiting for asynchronous task completion. Runtime verification should cover Windows Terminal, PowerShell, a Unix terminal, redirected standard output, every output mode, and multi-server measurements. ## Maintenance Rules - Do not write ANSI sequences outside `tui.go`. - Do not write directly to stdout while the interactive renderer is active. - Do not let measurement callbacks write directly to the terminal. - Do not start more than one interactive renderer. - Keep JSON and JSONL diagnostics on standard error. - Add a renderer test when changing task state or output lifecycle behavior. showwin-speedtest-go-df849ca/example/000077500000000000000000000000001524547167300177665ustar00rootroot00000000000000showwin-speedtest-go-df849ca/example/multi/000077500000000000000000000000001524547167300211205ustar00rootroot00000000000000showwin-speedtest-go-df849ca/example/multi/main.go000066400000000000000000000012221524547167300223700ustar00rootroot00000000000000package main import ( "context" "fmt" "log" "github.com/showwin/speedtest-go/speedtest" ) func main() { serverList, _ := speedtest.FetchServers() targets, _ := serverList.FindServer([]int{}) if len(targets) > 0 { // Use s as main server and use targets as auxiliary servers. // The main server is loaded at a greater proportion than the auxiliary servers. s := targets[0] checkError(s.MultiDownloadTestContext(context.TODO(), targets)) checkError(s.MultiUploadTestContext(context.TODO(), targets)) fmt.Printf("Download: %s, Upload: %s\n", s.DLSpeed, s.ULSpeed) } } func checkError(err error) { if err != nil { log.Fatal(err) } } showwin-speedtest-go-df849ca/example/naive/000077500000000000000000000000001524547167300210705ustar00rootroot00000000000000showwin-speedtest-go-df849ca/example/naive/main.go000066400000000000000000000023461524547167300223500ustar00rootroot00000000000000package main import ( "fmt" "log" "github.com/showwin/speedtest-go/speedtest" ) func main() { // _, _ = speedtest.FetchUserInfo() // Get a list of servers near a specified location // user.SetLocationByCity("Tokyo") // user.SetLocation("Osaka", 34.6952, 135.5006) // Select a network card as the data interface. // speedtest.WithUserConfig(&speedtest.UserConfig{Source: "192.168.1.101"})(speedtestClient) // Search server using serverID. // eg: fetch server with ID 28910. // speedtest.ErrEmptyServers will be returned if the server cannot be found. // server, err := speedtest.FetchServerByID("28910") serverList, _ := speedtest.FetchServers() targets, _ := serverList.FindServer([]int{}) for _, s := range targets { // Please make sure your host can access this test server, // otherwise you will get an error. // It is recommended to replace a server at this time checkError(s.PingTest(nil)) checkError(s.DownloadTest()) checkError(s.UploadTest()) // Note: The unit of s.DLSpeed, s.ULSpeed is bytes per second, this is a float64. fmt.Printf("Latency: %s, Download: %s, Upload: %s\n", s.Latency, s.DLSpeed, s.ULSpeed) s.Context.Reset() } } func checkError(err error) { if err != nil { log.Fatal(err) } } showwin-speedtest-go-df849ca/example/packet_loss/000077500000000000000000000000001524547167300222755ustar00rootroot00000000000000showwin-speedtest-go-df849ca/example/packet_loss/main.go000066400000000000000000000037221524547167300235540ustar00rootroot00000000000000package main import ( "fmt" "log" "sync" "time" "github.com/showwin/speedtest-go/speedtest" "github.com/showwin/speedtest-go/speedtest/transport" ) // Note: The current packet loss analyzer does not support udp over http. // This means we cannot get packet loss through a proxy. func main() { // 0. Fetching servers serverList, err := speedtest.FetchServers() checkError(err) // 1. Retrieve available servers targets := serverList.Available() // 2. Create a packet loss analyzer, use default options analyzer := speedtest.NewPacketLossAnalyzer(&speedtest.PacketLossAnalyzerOptions{ PacketSendingInterval: time.Millisecond * 100, }) wg := &sync.WaitGroup{} // 3. Perform packet loss analysis on all available servers for _, server := range *targets { wg.Add(1) //ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) //go func(server *speedtest.Server, analyzer *speedtest.PacketLossAnalyzer, ctx context.Context, cancel context.CancelFunc) { go func(server *speedtest.Server, analyzer *speedtest.PacketLossAnalyzer) { //defer cancel() defer wg.Done() // Note: Please call ctx.cancel at the appropriate time to release resources if you use analyzer.RunWithContext // we use analyzer.Run() here. err = analyzer.Run(server.Host, func(packetLoss *transport.PLoss) { fmt.Println(packetLoss, server.Host, server.Name) }) //err = analyzer.RunWithContext(ctx, server.Host, func(packetLoss *transport.PLoss) { // fmt.Println(packetLoss, server.Host, server.Name) //}) if err != nil { fmt.Println(err) } //}(server, analyzer, ctx, cancel) }(server, analyzer) } wg.Wait() // use mixed PacketLoss mixed, err := analyzer.RunMulti(serverList.Hosts()) checkError(err) fmt.Printf("Mixed packets lossed: %.2f%%\n", mixed.LossPercent()) fmt.Printf("Mixed packets lossed: %.2f\n", mixed.Loss()) fmt.Printf("Mixed packets lossed: %s\n", mixed) } func checkError(err error) { if err != nil { log.Fatal(err) } } showwin-speedtest-go-df849ca/go.mod000066400000000000000000000007771524547167300174540ustar00rootroot00000000000000module github.com/showwin/speedtest-go go 1.26 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/chelnak/ysmrr v0.6.0 github.com/mattn/go-colorable v0.1.15 github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-runewidth v0.0.28 golang.org/x/term v0.45.0 ) require ( github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/sys v0.47.0 // indirect ) showwin-speedtest-go-df849ca/go.sum000066400000000000000000000055431524547167300174750ustar00rootroot00000000000000github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/chelnak/ysmrr v0.6.0 h1:kMhO0oI02tl/9szvxrOE0yeImtrK4KQhER0oXu1K/iM= github.com/chelnak/ysmrr v0.6.0/go.mod h1:56JSrmQgb7/7xoMvuD87h3PE/qW6K1+BQcrgWtVLTUo= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= showwin-speedtest-go-df849ca/speedtest.go000066400000000000000000000300561524547167300206660ustar00rootroot00000000000000package main import ( "context" "errors" "fmt" "io" "log" "os" "slices" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/alecthomas/kingpin/v2" "github.com/showwin/speedtest-go/speedtest/transport" "github.com/showwin/speedtest-go/speedtest" ) var ( showList = kingpin.Flag("list", "Show available speedtest.net servers.").Short('l').Bool() serverIds = kingpin.Flag("server", "Select server id to run speedtest.").Short('s').Ints() customURL = kingpin.Flag("custom-url", "Specify the url of the server instead of fetching from speedtest.net.").String() savingMode = kingpin.Flag("saving-mode", "Test with few resources, though low accuracy (especially > 30Mbps).").Bool() jsonOutput = kingpin.Flag("json", "Output results in json format.").Bool() jsonlOutput = kingpin.Flag("jsonl", "Output results in jsonl format (one json object per line).").Bool() unixOutput = kingpin.Flag("unix", "Output results in unix like format.").Bool() location = kingpin.Flag("location", "Change the location with a precise coordinate (format: lat,lon).").String() city = kingpin.Flag("city", "Change the location with a predefined city label.").String() showCityList = kingpin.Flag("city-list", "List all predefined city labels.").Bool() proxy = kingpin.Flag("proxy", "Set a proxy(http[s] or socks) for the speedtest.").String() source = kingpin.Flag("source", "Bind a source interface for the speedtest.").String() dnsBindSource = kingpin.Flag("dns-bind-source", "DNS request binding source (experimental).").Bool() multi = kingpin.Flag("multi", "Enable multi-server mode.").Short('m').Bool() thread = kingpin.Flag("thread", "Set the maximum number of concurrent connections (upload adapts automatically; default cap 8).").Short('t').Int() search = kingpin.Flag("search", "Fuzzy search servers by a keyword.").String() userAgent = kingpin.Flag("ua", "Set the user-agent header for the speedtest.").String() noDownload = kingpin.Flag("no-download", "Disable download test.").Bool() noUpload = kingpin.Flag("no-upload", "Disable upload test.").Bool() pingMode = kingpin.Flag("ping-mode", "Select a method for Ping (support icmp/tcp/http).").Default("http").String() protocol = kingpin.Flag("protocol", "Select the download/upload protocol [http (default) or tcp].").Short('p').Default("http").String() unit = kingpin.Flag("unit", "Set human-readable and auto-scaled rate units for output (options: decimal-bits/decimal-bytes/binary-bits/binary-bytes).").Short('u').String() countryCode = kingpin.Flag("filter-cc", "Filter servers by Country Code(s).").Strings() maskISP = kingpin.Flag("mask-isp", "Mask sensitive info (IP address and coordinates) in ISP output.").Bool() debug = kingpin.Flag("debug", "Enable debug mode.").Short('d').Bool() ) var ( commit = "dev" date = "unknown" ) func main() { kingpin.Version(fmt.Sprintf("speedtest-go v%s git-%s built at %s", speedtest.Version(), commit, date)) kingpin.Parse() AppInfo() speedtest.SetUnit(parseUnit(*unit)) // discard standard log. log.SetOutput(io.Discard) // start unix output for saving mode by default. if *savingMode && !*jsonOutput && !*jsonlOutput && !*unixOutput { *unixOutput = true } // 0. speed test setting var speedtestClient = speedtest.New(speedtest.WithUserConfig( &speedtest.UserConfig{ UserAgent: *userAgent, Proxy: *proxy, Source: *source, DnsBindSource: *dnsBindSource, Debug: *debug, PingMode: parseProto(*pingMode), // TCP as default TestMode: parseTestMode(*protocol), SavingMode: *savingMode, MaxConnections: *thread, CityFlag: *city, LocationFlag: *location, Keyword: *search, })) if *showCityList { speedtest.PrintCityList() return } // 1. retrieving user information taskManager := InitTaskManager(*jsonOutput || *jsonlOutput, *unixOutput) taskManager.AsyncRun("Retrieving User Information", func(task *Task) { u, err := speedtestClient.FetchUserInfo() task.CheckError(err) task.Printf("ISP: %s", formatUserInfo(u, *maskISP)) task.Complete() }) // 2. retrieving servers var err error var servers speedtest.Servers var targets speedtest.Servers taskManager.Run("Retrieving Servers", func(task *Task) { if len(*customURL) > 0 { var target *speedtest.Server target, err = speedtestClient.CustomServer(*customURL) task.CheckError(err) targets = []*speedtest.Server{target} task.Println("Skip: Using Custom Server") } else if len(*serverIds) > 0 { // TODO: need async fetch to speedup for _, id := range *serverIds { serverPtr, errFetch := speedtestClient.FetchServerByID(strconv.Itoa(id)) if errFetch != nil { continue // Silently Skip all ids that actually don't exist. } targets = append(targets, serverPtr) } task.CheckError(err) task.Printf("Found %d Specified Public Server(s)", len(targets)) } else { servers, err = speedtestClient.FetchServers() task.CheckError(err) // cc filter auto attach if containsString(*countryCode, "auto") { taskManager.Wait() *countryCode = append(*countryCode, speedtestClient.User.Country) } if len(*countryCode) > 0 { servers = servers.CC(*countryCode) task.Printf("Found %d Public Servers with Country Code[%v]", len(servers), strings.Join(*countryCode, ",")) } else { task.Printf("Found %d Public Servers", len(servers)) } if *showList { task.Complete() task.manager.Stop() showServerList(servers) os.Exit(0) } targets, err = servers.FindServer(*serverIds) task.CheckError(err) } task.Complete() }) // 3. test each selected server with ping, download and upload. for _, server := range targets { if !*jsonOutput && !*jsonlOutput { taskManager.BlankLine() } taskManager.Println("Test Server: " + server.String()) taskManager.Run("Latency: --", func(task *Task) { task.CheckError(server.PingTest(func(latency time.Duration) { task.Updatef("Latency: %v", latency) })) task.Printf("Latency: %v Jitter: %v Min: %v Max: %v", server.Latency, server.Jitter, server.MinLatency, server.MaxLatency) task.Complete() }) blocker := sync.WaitGroup{} analyzer := speedtestClient.NewPacketLossAnalyzer() packetLossAnalyzerCtx, packetLossAnalyzerCancel := context.WithTimeout(context.Background(), time.Second*40) taskManager.Run("Packet Loss Analyzer", func(task *Task) { blocker.Add(1) go func() { defer blocker.Done() err = analyzer.RunWithContext(packetLossAnalyzerCtx, server.Host, func(packetLoss *transport.PLoss) { server.PacketLoss = *packetLoss }) if errors.Is(err, transport.ErrUnsupported) { packetLossAnalyzerCancel() } }() task.Println("Packet Loss Analyzer: Running in background (<= 30 Secs)") task.Complete() }) // 3.1 create accompany Echo accEcho := newAccompanyEcho(server, time.Millisecond*500) taskManager.RunWithTrigger(!*noDownload, "Download", func(task *Task) { accEcho.Run() speedtestClient.SetCallbackDownload(func(downRate speedtest.ByteRate) { lc := accEcho.CurrentLatency() if lc == 0 { task.Updatef("Download: %s (Latency: --)", downRate) } else { task.Updatef("Download: %s (Latency: %dms)", downRate, lc/1000000) } }) if *multi { task.CheckError(server.MultiDownloadTestContext(context.Background(), servers)) } else { task.CheckError(server.DownloadTest()) } accEcho.Stop() mean, _, std, minL, maxL := speedtest.StandardDeviation(accEcho.Latencies()) task.Printf("Download: %s (Used: %.2fMB) (Latency: %dms Jitter: %dms Min: %dms Max: %dms)", server.DLSpeed, float64(server.Context.GetTotalDownload())/1000/1000, mean/1000000, std/1000000, minL/1000000, maxL/1000000) task.Complete() }) taskManager.RunWithTrigger(!*noUpload, "Upload", func(task *Task) { accEcho.Run() speedtestClient.SetCallbackUpload(func(upRate speedtest.ByteRate) { lc := accEcho.CurrentLatency() if lc == 0 { task.Updatef("Upload: %s (Latency: --)", upRate) } else { task.Updatef("Upload: %s (Latency: %dms)", upRate, lc/1000000) } }) if *multi { task.CheckError(server.MultiUploadTestContext(context.Background(), servers)) } else { task.CheckError(server.UploadTest()) } accEcho.Stop() mean, _, std, minL, maxL := speedtest.StandardDeviation(accEcho.Latencies()) task.Printf("Upload: %s (Used: %.2fMB Confirmed: %.1f%%) (Latency: %dms Jitter: %dms Min: %dms Max: %dms)", server.ULSpeed, float64(server.Context.GetTotalUpload())/1000/1000, server.Context.GetUploadConfirmationRatio()*100, mean/1000000, std/1000000, minL/1000000, maxL/1000000) task.Complete() }) if *noUpload && *noDownload { time.Sleep(time.Second * 30) } if packetLossAnalyzerCancel != nil { packetLossAnalyzerCancel() } blocker.Wait() if !*jsonOutput && !*jsonlOutput { taskManager.Println(server.PacketLoss.String()) } speedtestClient.Reset() } taskManager.Stop() if *jsonOutput { json, errMarshal := speedtestClient.JSON(targets) if errMarshal != nil { panic(errMarshal) } fmt.Print(string(json)) } else if *jsonlOutput { for _, server := range targets { json, errMarshal := speedtestClient.JSONL(server) if errMarshal != nil { panic(errMarshal) } fmt.Println(string(json)) } } } func formatUserInfo(user *speedtest.User, maskSensitiveInfo bool) string { if !maskSensitiveInfo { return user.String() } return fmt.Sprintf("%s (%s) [%s, %s]", maskSensitiveValue(user.IP), user.Isp, maskSensitiveValue(user.Lat), maskSensitiveValue(user.Lon)) } func maskSensitiveValue(value string) string { return strings.Map(func(r rune) rune { if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { return '*' } return r }, value) } type AccompanyEcho struct { stopEcho chan bool server *speedtest.Server currentLatency int64 interval time.Duration latencies []int64 } func newAccompanyEcho(server *speedtest.Server, interval time.Duration) *AccompanyEcho { return &AccompanyEcho{ server: server, interval: interval, stopEcho: make(chan bool), } } func (ae *AccompanyEcho) Run() { ae.latencies = make([]int64, 0) ctx, cancel := context.WithCancel(context.Background()) go func() { for { select { case <-ae.stopEcho: cancel() return default: latency, _ := ae.server.HTTPPing(ctx, 1, ae.interval, nil) if len(latency) > 0 { atomic.StoreInt64(&ae.currentLatency, latency[0]) ae.latencies = append(ae.latencies, latency[0]) } } } }() } func (ae *AccompanyEcho) Stop() { ae.stopEcho <- false } func (ae *AccompanyEcho) CurrentLatency() int64 { return atomic.LoadInt64(&ae.currentLatency) } func (ae *AccompanyEcho) Latencies() []int64 { return ae.latencies } func showServerList(servers speedtest.Servers) { for _, s := range servers { fmt.Printf("[%5s] %9.2fkm ", s.ID, s.Distance) if s.Latency == -1 { fmt.Printf("%v", "Timeout ") } else { fmt.Printf("%-dms ", s.Latency/time.Millisecond) } fmt.Printf("\t%s (%s) by %s \n", s.Name, s.Country, s.Sponsor) } } func parseUnit(str string) speedtest.UnitType { str = strings.ToLower(str) switch str { case "decimal-bits": return speedtest.UnitTypeDecimalBits case "decimal-bytes": return speedtest.UnitTypeDecimalBytes case "binary-bits": return speedtest.UnitTypeBinaryBits case "binary-bytes": return speedtest.UnitTypeBinaryBytes default: return speedtest.UnitTypeDefaultMbps } } func parseProto(str string) speedtest.Proto { str = strings.ToLower(str) switch str { case "icmp": return speedtest.ICMP case "tcp": return speedtest.TCP default: return speedtest.HTTP } } func parseTestMode(str string) speedtest.TestMode { if strings.EqualFold(str, "tcp") { return speedtest.TCPTest } return speedtest.HTTPTest } func containsString(values []string, target string) bool { return slices.Contains(values, target) } func AppInfo() { if !*jsonOutput && !*jsonlOutput { fmt.Println() fmt.Printf(" speedtest-go v%s (git-%s) @showwin\n", speedtest.Version(), commit) fmt.Println() } } showwin-speedtest-go-df849ca/speedtest/000077500000000000000000000000001524547167300203335ustar00rootroot00000000000000showwin-speedtest-go-df849ca/speedtest/data_manager.go000066400000000000000000000437551524547167300233030ustar00rootroot00000000000000package speedtest import ( "bytes" "context" "errors" "github.com/showwin/speedtest-go/speedtest/internal" "io" "math" "runtime" "sync" "sync/atomic" "time" ) type Manager interface { SetRateCaptureFrequency(duration time.Duration) Manager SetCaptureTime(duration time.Duration) Manager NewChunk() Chunk GetTotalDownload() int64 GetTotalUpload() int64 GetUploadBacklog() int64 GetUploadConfirmationRatio() float64 AddTotalDownload(value int64) AddTotalUpload(value int64) GetAvgDownloadRate() float64 GetAvgUploadRate() float64 GetEWMADownloadRate() float64 GetEWMAUploadRate() float64 SetCallbackDownload(callback func(downRate ByteRate)) SetCallbackUpload(callback func(upRate ByteRate)) RegisterDownloadHandler(fn func()) *TestDirection RegisterUploadHandler(fn func()) *TestDirection // Wait for the upload or download task to end to avoid errors caused by core occupation Wait() Reset() Snapshots() *Snapshots SetNThread(n int) Manager } type Chunk interface { UploadHandler(size int64) Chunk DownloadHandler(r io.Reader) error GetRate() float64 GetDuration() time.Duration GetParent() Manager Read(b []byte) (n int, err error) } const readChunkSize = 1024 // 1 KBytes with higher frequency rate feedback type DataType int32 const ( typeEmptyChunk = iota typeDownload typeUpload ) var ( ErrorUninitializedManager = errors.New("uninitialized manager") ) type funcGroup struct { fns []func() } func (f *funcGroup) Add(fn func()) { f.fns = append(f.fns, fn) } type DataManager struct { SnapshotStore *Snapshots Snapshot *Snapshot sync.Mutex repeatByte *[]byte captureTime time.Duration rateCaptureFrequency time.Duration nThread int uploadMaxWorkers int running bool runningRW sync.RWMutex download *TestDirection upload *TestDirection } type TestDirection struct { TestType int // test type manager *DataManager // manager totalDataVolume int64 // total send/receive data volume totalReadVolume int64 // upload bytes read into the transport activeWorkers int32 // current upload worker limit maxWorkers int32 // configured upload worker limit RateSequence []int64 // rate history sequence welford *internal.Welford // std/EWMA/mean captureCallback func(realTimeRate ByteRate) // user callback closeFunc func() // close func *funcGroup // actually exec function } func (dm *DataManager) NewDataDirection(testType int) *TestDirection { return &TestDirection{ TestType: testType, manager: dm, funcGroup: &funcGroup{}, } } func NewDataManager() *DataManager { r := bytes.Repeat([]byte{0xAA}, readChunkSize) // uniformly distributed sequence of bits ret := &DataManager{ nThread: runtime.NumCPU(), captureTime: time.Second * 15, rateCaptureFrequency: time.Millisecond * 50, Snapshot: &Snapshot{}, repeatByte: &r, uploadMaxWorkers: 8, } ret.download = ret.NewDataDirection(typeDownload) ret.upload = ret.NewDataDirection(typeUpload) ret.SnapshotStore = newHistorySnapshots(maxSnapshotSize) return ret } func (dm *DataManager) SetCallbackDownload(callback func(downRate ByteRate)) { if dm.download != nil { dm.download.captureCallback = callback } } func (dm *DataManager) SetCallbackUpload(callback func(upRate ByteRate)) { if dm.upload != nil { dm.upload.captureCallback = callback } } func (dm *DataManager) Wait() { oldDownTotal := dm.GetTotalDownload() oldUpTotal := dm.GetTotalUpload() for { time.Sleep(dm.rateCaptureFrequency) newDownTotal := dm.GetTotalDownload() newUpTotal := dm.GetTotalUpload() deltaDown := newDownTotal - oldDownTotal deltaUp := newUpTotal - oldUpTotal oldDownTotal = newDownTotal oldUpTotal = newUpTotal if deltaDown == 0 && deltaUp == 0 { return } } } func (dm *DataManager) RegisterUploadHandler(fn func()) *TestDirection { if len(dm.upload.fns) < dm.nThread { dm.upload.Add(fn) } return dm.upload } func (dm *DataManager) RegisterDownloadHandler(fn func()) *TestDirection { if len(dm.download.fns) < dm.nThread { dm.download.Add(fn) } return dm.download } func (td *TestDirection) GetTotalDataVolume() int64 { return atomic.LoadInt64(&td.totalDataVolume) } func (td *TestDirection) AddTotalDataVolume(delta int64) int64 { return atomic.AddInt64(&td.totalDataVolume, delta) } func (td *TestDirection) GetTotalReadVolume() int64 { return atomic.LoadInt64(&td.totalReadVolume) } func (td *TestDirection) AddTotalReadVolume(delta int64) int64 { return atomic.AddInt64(&td.totalReadVolume, delta) } func (td *TestDirection) Start(cancel context.CancelFunc, mainRequestHandlerIndex int) { if len(td.fns) == 0 { panic("empty task stack") } if mainRequestHandlerIndex > len(td.fns)-1 { mainRequestHandlerIndex = 0 } mainLoadFactor := 0.1 workerLimit := td.manager.nThread if td.TestType == typeUpload && td.manager.uploadMaxWorkers < workerLimit { workerLimit = td.manager.uploadMaxWorkers } // When the number of processor cores is equivalent to the processing program, // the processing efficiency reaches the highest level (VT is not considered). mainN := int(mainLoadFactor * float64(len(td.fns))) if mainN == 0 { mainN = 1 } if len(td.fns) == 1 { mainN = workerLimit } auxN := workerLimit - mainN dbg.Printf("Available fns: %d\n", len(td.fns)) dbg.Printf("mainN: %d\n", mainN) dbg.Printf("auxN: %d\n", auxN) wg := sync.WaitGroup{} td.manager.running = true stopCapture := td.rateCapture() td.maxWorkers = int32(workerLimit) initialWorkers := td.maxWorkers if td.TestType == typeUpload { if td.manager.uploadMaxWorkers <= 8 { initialWorkers = int32(runtime.NumCPU()) if initialWorkers > td.maxWorkers { initialWorkers = td.maxWorkers } } } atomic.StoreInt32(&td.activeWorkers, initialWorkers) if td.TestType == typeUpload { dbg.Printf("Upload workers: %d/%d\n", initialWorkers, td.maxWorkers) } // refresh once function once := sync.Once{} td.closeFunc = func() { once.Do(func() { stopCapture <- true close(stopCapture) td.manager.runningRW.Lock() td.manager.running = false td.manager.runningRW.Unlock() cancel() dbg.Println("FuncGroup: Stop") }) } if td.TestType == typeUpload && td.maxWorkers > 1 { go td.adaptUploadWorkers() } time.AfterFunc(td.manager.captureTime, td.closeFunc) for i := 0; i < mainN; i++ { wg.Add(1) workerID := i go td.runWorker(&wg, workerID, td.fns[mainRequestHandlerIndex]) } for j := 0; j < auxN; { for i := range td.fns { if j == auxN { break } if i == mainRequestHandlerIndex { continue } wg.Add(1) t := i workerID := mainN + j go td.runWorker(&wg, workerID, td.fns[t]) j++ } } wg.Wait() } func (td *TestDirection) runWorker(wg *sync.WaitGroup, workerID int, fn func()) { defer wg.Done() for { td.manager.runningRW.RLock() running := td.manager.running td.manager.runningRW.RUnlock() if !running { return } if !td.workerEnabled(workerID) { time.Sleep(time.Millisecond) continue } fn() } } func (td *TestDirection) workerEnabled(workerID int) bool { if td.TestType != typeUpload { return true } return int32(workerID) < atomic.LoadInt32(&td.activeWorkers) } func (td *TestDirection) adaptUploadWorkers() { ticker := time.NewTicker(time.Second) defer ticker.Stop() previousConfirmed := td.GetTotalDataVolume() previousAt := time.Now() bestRate := 0.0 for range ticker.C { td.manager.runningRW.RLock() running := td.manager.running td.manager.runningRW.RUnlock() if !running { return } now := time.Now() confirmed := td.GetTotalDataVolume() delta := confirmed - previousConfirmed elapsed := now.Sub(previousAt).Seconds() previousConfirmed = confirmed previousAt = now if delta <= 0 || elapsed <= 0 { continue } rate := float64(delta) / elapsed active := atomic.LoadInt32(&td.activeWorkers) read := td.GetTotalReadVolume() backlog := read - confirmed confirmation := td.manager.GetUploadConfirmationRatio() if bestRate == 0 || rate > bestRate*1.05 { bestRate = rate if active < td.maxWorkers { active++ atomic.StoreInt32(&td.activeWorkers, active) dbg.Printf("Upload workers: %d/%d (confirmed: %.2f MB/s, confirmation: %.1f%%, backlog: %.2f MB)\n", active, td.maxWorkers, rate/1000/1000, confirmation*100, float64(backlog)/1000/1000) } continue } if active > 1 && (rate < bestRate*0.85 || backlog > int64(active)*2*1024*1024) { active = reducedUploadWorkerCount(active, rate, bestRate, backlog) atomic.StoreInt32(&td.activeWorkers, active) dbg.Printf("Upload workers: %d/%d (confirmed: %.2f MB/s, confirmation: %.1f%%, backlog: %.2f MB)\n", active, td.maxWorkers, rate/1000/1000, confirmation*100, float64(backlog)/1000/1000) bestRate = rate } } } func reducedUploadWorkerCount(active int32, rate, bestRate float64, backlog int64) int32 { if active <= 1 { return 1 } factor := 1.0 if bestRate > 0 && rate < bestRate*0.85 { factor = math.Min(factor, rate/bestRate) } backlogLimit := int64(active) * 2 * 1024 * 1024 if backlog > backlogLimit { factor = math.Min(factor, float64(backlogLimit)/float64(backlog)) } if factor >= 1 { return active - 1 } target := int32(math.Ceil(float64(active) * factor)) if target >= active { target = active - 1 } if target < 1 { return 1 } return target } func (td *TestDirection) rateCapture() chan bool { ticker := time.NewTicker(td.manager.rateCaptureFrequency) var prevTotalDataVolume int64 = 0 stopCapture := make(chan bool) td.welford = internal.NewWelford(5*time.Second, td.manager.rateCaptureFrequency) sTime := time.Now() go func(t *time.Ticker) { defer t.Stop() for { select { case <-t.C: newTotalDataVolume := td.GetTotalDataVolume() deltaDataVolume := newTotalDataVolume - prevTotalDataVolume prevTotalDataVolume = newTotalDataVolume if deltaDataVolume != 0 { td.RateSequence = append(td.RateSequence, deltaDataVolume) } // anyway we update the measuring instrument globalAvg := (float64(td.GetTotalDataVolume())) / float64(time.Since(sTime).Milliseconds()) * 1000 rateSample := float64(deltaDataVolume) if td.TestType == typeUpload { // Upload bytes are confirmed per completed request, so use the // cumulative rate to avoid reporting a zero/spike sawtooth. rateSample = globalAvg * td.manager.rateCaptureFrequency.Seconds() } if td.welford.Update(globalAvg, rateSample) { go td.closeFunc() } // reports the current rate at the given rate if td.captureCallback != nil { td.captureCallback(ByteRate(td.welford.EWMA())) } case stop := <-stopCapture: if stop { return } } } }(ticker) return stopCapture } func (dm *DataManager) NewChunk() Chunk { var dc DataChunk dc.manager = dm dm.Lock() *dm.Snapshot = append(*dm.Snapshot, &dc) dm.Unlock() return &dc } func (dm *DataManager) AddTotalDownload(value int64) { dm.download.AddTotalDataVolume(value) } func (dm *DataManager) AddTotalUpload(value int64) { dm.upload.AddTotalDataVolume(value) } func (dm *DataManager) GetTotalDownload() int64 { return dm.download.GetTotalDataVolume() } func (dm *DataManager) GetTotalUpload() int64 { return dm.upload.GetTotalDataVolume() } // GetUploadBacklog returns upload bytes read by the transport but not yet // confirmed by a successful upload response. func (dm *DataManager) GetUploadBacklog() int64 { backlog := dm.upload.GetTotalReadVolume() - dm.GetTotalUpload() if backlog < 0 { return 0 } return backlog } // GetUploadConfirmationRatio returns server-confirmed upload bytes divided by // bytes read from upload request bodies. It is an application-level estimate, // not a TCP ACK counter. func (dm *DataManager) GetUploadConfirmationRatio() float64 { read := dm.upload.GetTotalReadVolume() if read <= 0 { return 0 } confirmed := dm.GetTotalUpload() ratio := float64(confirmed) / float64(read) if ratio > 1 { return 1 } if ratio < 0 { return 0 } return ratio } func (dm *DataManager) SetRateCaptureFrequency(duration time.Duration) Manager { dm.rateCaptureFrequency = duration return dm } func (dm *DataManager) SetCaptureTime(duration time.Duration) Manager { dm.captureTime = duration return dm } func (dm *DataManager) SetNThread(n int) Manager { if n < 1 { dm.nThread = runtime.NumCPU() dm.uploadMaxWorkers = 8 } else { dm.nThread = n dm.uploadMaxWorkers = n } return dm } func (dm *DataManager) Snapshots() *Snapshots { return dm.SnapshotStore } func (dm *DataManager) Reset() { dm.SnapshotStore.push(dm.Snapshot) dm.Snapshot = &Snapshot{} dm.download = dm.NewDataDirection(typeDownload) dm.upload = dm.NewDataDirection(typeUpload) } func (dm *DataManager) GetAvgDownloadRate() float64 { unit := float64(dm.captureTime / time.Millisecond) return float64(dm.download.GetTotalDataVolume()*8/1000) / unit } func (dm *DataManager) GetEWMADownloadRate() float64 { if dm.download.welford != nil { return dm.download.welford.EWMA() } return 0 } func (dm *DataManager) GetAvgUploadRate() float64 { unit := float64(dm.captureTime / time.Millisecond) return float64(dm.upload.GetTotalDataVolume()*8/1000) / unit } func (dm *DataManager) GetEWMAUploadRate() float64 { if dm.upload.welford != nil { return dm.upload.welford.EWMA() } return 0 } type DataChunk struct { manager *DataManager dateType DataType startTime time.Time endTime time.Time err error ContentLength int64 remainOrDiscardSize int64 } var blackHolePool = sync.Pool{ New: func() any { b := make([]byte, 8192) return &b }, } func (dc *DataChunk) GetDuration() time.Duration { return dc.endTime.Sub(dc.startTime) } func (dc *DataChunk) GetRate() float64 { switch dc.dateType { case typeDownload: return float64(dc.remainOrDiscardSize) / dc.GetDuration().Seconds() case typeUpload: return float64(dc.ContentLength-dc.remainOrDiscardSize) * 8 / 1000 / 1000 / dc.GetDuration().Seconds() default: return 0 } } // DownloadHandler No value will be returned here, because the error will interrupt the test. // The error chunk is generally caused by the remote server actively closing the connection. func (dc *DataChunk) DownloadHandler(r io.Reader) error { if dc.dateType != typeEmptyChunk { dc.err = errors.New("multiple calls to the same chunk handler are not allowed") return dc.err } dc.dateType = typeDownload dc.startTime = time.Now() defer func() { dc.endTime = time.Now() }() bufP := blackHolePool.Get().(*[]byte) defer blackHolePool.Put(bufP) readSize := 0 for { dc.manager.runningRW.RLock() running := dc.manager.running dc.manager.runningRW.RUnlock() if !running { return nil } readSize, dc.err = r.Read(*bufP) rs := int64(readSize) dc.remainOrDiscardSize += rs dc.manager.download.AddTotalDataVolume(rs) if dc.err != nil { if dc.err == io.EOF { return nil } return dc.err } } } func (dc *DataChunk) UploadHandler(size int64) Chunk { if dc.dateType != typeEmptyChunk { dc.err = errors.New("multiple calls to the same chunk handler are not allowed") } if size <= 0 { panic("the size of repeated bytes should be > 0") } dc.ContentLength = size dc.remainOrDiscardSize = size dc.dateType = typeUpload dc.startTime = time.Now() return dc } func (dc *DataChunk) GetParent() Manager { return dc.manager } func (dc *DataChunk) Read(b []byte) (n int, err error) { if dc.remainOrDiscardSize < readChunkSize { if dc.remainOrDiscardSize <= 0 { dc.endTime = time.Now() return n, io.EOF } n = copy(b, (*dc.manager.repeatByte)[:dc.remainOrDiscardSize]) } else { n = copy(b, *dc.manager.repeatByte) } n64 := int64(n) dc.remainOrDiscardSize -= n64 if dc.dateType == typeUpload { dc.manager.upload.AddTotalReadVolume(n64) } return } // calcMAFilter Median-Averaging Filter func _(list []int64) float64 { if len(list) == 0 { return 0 } var sum int64 = 0 n := len(list) if n == 0 { return 0 } length := len(list) for i := 0; i < length-1; i++ { for j := i + 1; j < length; j++ { if list[i] > list[j] { list[i], list[j] = list[j], list[i] } } } for i := 1; i < n-1; i++ { sum += list[i] } return float64(sum) / float64(n-2) } func pautaFilter(vector []int64) []int64 { dbg.Println("Per capture unit") dbg.Printf("Raw Sequence len: %d\n", len(vector)) dbg.Printf("Raw Sequence: %v\n", vector) if len(vector) == 0 { return vector } mean, _, std, _, _ := sampleVariance(vector) var retVec []int64 for _, value := range vector { if math.Abs(float64(value-mean)) < float64(3*std) { retVec = append(retVec, value) } } dbg.Printf("Raw average: %dByte\n", mean) dbg.Printf("Pauta Sequence len: %d\n", len(retVec)) dbg.Printf("Pauta Sequence: %v\n", retVec) return retVec } // sampleVariance sample Variance func sampleVariance(vector []int64) (mean, variance, stdDev, min, max int64) { if len(vector) == 0 { return 0, 0, 0, 0, 0 } var sumNum, accumulate int64 min = math.MaxInt64 max = math.MinInt64 for _, value := range vector { sumNum += value if min > value { min = value } if max < value { max = value } } mean = sumNum / int64(len(vector)) for _, value := range vector { accumulate += (value - mean) * (value - mean) } variance = accumulate / int64(len(vector)-1) // Bessel's correction stdDev = int64(math.Sqrt(float64(variance))) return } const maxSnapshotSize = 10 type Snapshot []*DataChunk type Snapshots struct { sp []*Snapshot maxSize int } func newHistorySnapshots(size int) *Snapshots { return &Snapshots{ sp: make([]*Snapshot, 0, size), maxSize: size, } } func (rs *Snapshots) push(value *Snapshot) { if len(rs.sp) == rs.maxSize { rs.sp = rs.sp[1:] } rs.sp = append(rs.sp, value) } func (rs *Snapshots) Latest() *Snapshot { if len(rs.sp) > 0 { return rs.sp[len(rs.sp)-1] } return nil } func (rs *Snapshots) All() []*Snapshot { return rs.sp } func (rs *Snapshots) Clean() { rs.sp = make([]*Snapshot, 0, rs.maxSize) } showwin-speedtest-go-df849ca/speedtest/data_manager_test.go000066400000000000000000000142641524547167300243330ustar00rootroot00000000000000package speedtest import ( "context" "fmt" "io" "net/http" "net/http/httptest" "sync" "testing" "time" ) func BenchmarkDataManager_NewDataChunk(b *testing.B) { dmp := NewDataManager() dmp.Reset() for i := 0; i < b.N; i++ { dmp.NewChunk() } } func BenchmarkDataManager_AddTotalDownload(b *testing.B) { dmp := NewDataManager() for i := 0; i < b.N; i++ { dmp.AddTotalDownload(43521) } } func TestDataManager_AddTotalDownload(t *testing.T) { dmp := NewDataManager() wg := sync.WaitGroup{} for i := 0; i < 1000; i++ { wg.Add(1) go func() { for j := 0; j < 1000; j++ { dmp.AddTotalDownload(43521) } wg.Done() }() } wg.Wait() if dmp.download.GetTotalDataVolume() != 43521000000 { t.Fatal() } } func TestDataManager_GetAvgDownloadRate(t *testing.T) { dm := NewDataManager() dm.download.totalDataVolume = 3000000 dm.captureTime = time.Second * 10 result := dm.GetAvgDownloadRate() if result != 2.4 { t.Fatal() } } func TestDataManager_GetUploadConfirmationRatio(t *testing.T) { dm := NewDataManager() dm.upload.AddTotalReadVolume(2 * 1000 * 1000) dm.AddTotalUpload(500 * 1000) if got := dm.GetUploadBacklog(); got != 1500*1000 { t.Fatalf("got upload backlog %d, want %d", got, 1500*1000) } if got := dm.GetUploadConfirmationRatio(); got != 0.25 { t.Fatalf("got confirmation ratio %v, want 0.25", got) } dm.AddTotalUpload(3 * 1000 * 1000) if got := dm.GetUploadConfirmationRatio(); got != 1 { t.Fatalf("got confirmation ratio %v, want 1 after clamping", got) } } func TestDataManager_UploadWorkerLimit(t *testing.T) { dm := NewDataManager() dm.SetNThread(12) if dm.uploadMaxWorkers != 12 { t.Fatalf("explicit worker limit was capped at %d", dm.uploadMaxWorkers) } dm.SetNThread(0) if dm.uploadMaxWorkers != 8 { t.Fatalf("default worker limit is %d, want 8", dm.uploadMaxWorkers) } } func TestReducedUploadWorkerCount(t *testing.T) { if got := reducedUploadWorkerCount(10, 0.5, 1, 0); got != 5 { t.Fatalf("got %d workers after a 50%% rate drop, want 5", got) } if got := reducedUploadWorkerCount(10, 0.9, 1, 0); got != 9 { t.Fatalf("got %d workers after a mild rate drop, want 9", got) } if got := reducedUploadWorkerCount(2, 0.5, 1, 0); got != 1 { t.Fatalf("got %d workers at the minimum, want 1", got) } } func TestUploadRequestCountsOnlyConfirmedUploads(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := io.Copy(io.Discard, r.Body); err != nil { t.Errorf("failed to read upload: %v", err) return } w.WriteHeader(http.StatusOK) })) defer server.Close() client := New() target := &Server{URL: server.URL, Context: client} if err := uploadRequest(context.Background(), target, 0); err != nil { t.Fatalf("upload request failed: %v", err) } want := int64(ulSizes[0]*100-51) * 10 if got := client.GetTotalUpload(); got != want { t.Fatalf("counted %d bytes, want %d", got, want) } } func TestResolveUploadURLFollowsTemporaryRedirect(t *testing.T) { var received int64 targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { t.Errorf("request method is %s, want POST", r.Method) } var err error received, err = io.Copy(io.Discard, r.Body) if err != nil { t.Errorf("failed to read redirected upload: %v", err) return } w.WriteHeader(http.StatusOK) })) defer targetServer.Close() redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, targetServer.URL, http.StatusTemporaryRedirect) })) defer redirectServer.Close() client := New() target := &Server{URL: redirectServer.URL, Context: client} target.resolveUploadURL(context.Background()) if target.URL != targetServer.URL { t.Fatalf("resolved upload URL is %q, want %q", target.URL, targetServer.URL) } if err := uploadRequest(context.Background(), target, 0); err != nil { t.Fatalf("upload request failed: %v", err) } want := int64(ulSizes[0]*100-51) * 10 if received != want { t.Fatalf("resolved endpoint received %d bytes, want %d", received, want) } if got := client.GetTotalUpload(); got != want { t.Fatalf("counted %d upload bytes, want %d", got, want) } } func TestUploadRequestDoesNotCountRejectedUploads(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() client := New() target := &Server{URL: server.URL, Context: client} if err := uploadRequest(context.Background(), target, 0); err == nil { t.Fatal("expected upload request to fail") } if got := client.GetTotalUpload(); got != 0 { t.Fatalf("counted %d bytes for a rejected upload", got) } } func TestDynamicRate(t *testing.T) { server, _ := CustomServer("http://shenzhen.cmcc.speedtest.shunshiidc.com:8080/speedtest/upload.php") //server, _ := CustomServer("http://192.168.5.237:8080/speedtest/upload.php") oldDownTotal := server.Context.GetTotalDownload() oldUpTotal := server.Context.GetTotalUpload() server.Context.SetRateCaptureFrequency(time.Millisecond * 100) server.Context.SetCaptureTime(time.Second) go func() { for i := 0; i < 2; i++ { time.Sleep(time.Second) newDownTotal := server.Context.GetTotalDownload() newUpTotal := server.Context.GetTotalUpload() downRate := float64(newDownTotal-oldDownTotal) * 8 / 1000 / 1000 upRate := float64(newUpTotal-oldUpTotal) * 8 / 1000 / 1000 oldDownTotal = newDownTotal oldUpTotal = newUpTotal fmt.Printf("downRate: %.2fMbps | upRate: %.2fMbps\n", downRate, upRate) } }() err := server.DownloadTest() if err != nil { fmt.Println("Warning: not found server") //t.Error(err) } server.Context.Wait() err = server.UploadTest() if err != nil { fmt.Println("Warning: not found server") //t.Error(err) } fmt.Printf(" \n") fmt.Printf("Download: %5.2f Mbit/s\n", server.DLSpeed) fmt.Printf("Upload: %5.2f Mbit/s\n\n", server.ULSpeed) valid := server.CheckResultValid() if !valid { fmt.Println("Warning: result seems to be wrong. Please test again.") } } showwin-speedtest-go-df849ca/speedtest/debug.go000066400000000000000000000006631524547167300217550ustar00rootroot00000000000000package speedtest import ( "log" "os" ) type Debug struct { dbg *log.Logger flag bool } func NewDebug() *Debug { return &Debug{dbg: log.New(os.Stdout, "[DBG]", log.Ldate|log.Ltime)} } func (d *Debug) Enable() { d.flag = true } func (d *Debug) Println(v ...any) { if d.flag { d.dbg.Println(v...) } } func (d *Debug) Printf(format string, v ...any) { if d.flag { d.dbg.Printf(format, v...) } } var dbg = NewDebug() showwin-speedtest-go-df849ca/speedtest/internal/000077500000000000000000000000001524547167300221475ustar00rootroot00000000000000showwin-speedtest-go-df849ca/speedtest/internal/README.md000066400000000000000000000017571524547167300234400ustar00rootroot00000000000000# Issue #192 SpeedTest-Go (1) 1. Use welford alg to quickly calculate standard deviation and mean. 2. The welford alg integrated moving window feature, This allows us to ignore early data with excessive volatility. 3. Use the coefficient of variation(c.v) to reflect the confidence of the test result datasets. 4. When the data becomes stable(converge), the c.v value will become smaller. When the c.v < 0.05, we terminate this test. We set the tolerance condition as the window buffer being more than half filled and triggering more than five times with c.v < 0.05. 5. Perform EWMA operation on real-time global average, and use c.v as part of the EWMA feedback parameter. 6. The ewma value calculated is the result value of our test. 7. When the test data converge quickly, we can stop early and speed up the testing process. Of course this depends on network/device conditions. showwin-speedtest-go-df849ca/speedtest/internal/welford.go000066400000000000000000000073041524547167300241440ustar00rootroot00000000000000package internal import ( "fmt" "math" "time" ) // Welford Fast standard deviation calculation with moving window // ref Welford, B. P. (1962). Note on a Method for Calculating Corrected Sums of Squares and Products. Technometrics, 4(3), 419–420. https://doi.org/10.1080/00401706.1962.10490022 type Welford struct { n int // data size cap int // queue capacity vector []float64 // data set mean float64 // mean sum float64 // sum eraseIndex int // the value will be erased next time currentStdDev float64 consecutiveStableIterations int consecutiveStableIterationsThreshold int cv float64 ewmaMean float64 steps int minSteps int beta float64 scale float64 movingVector []float64 // data set movingAvg float64 } // NewWelford recommended windowSize = cycle / sampling frequency func NewWelford(cycle, frequency time.Duration) *Welford { windowSize := int(cycle / frequency) return &Welford{ vector: make([]float64, windowSize), movingVector: make([]float64, windowSize), cap: windowSize, consecutiveStableIterationsThreshold: windowSize / 3, // 33% minSteps: windowSize * 2, // set minimum steps with 2x windowSize. beta: 2 / (float64(windowSize) + 1), // ewma beta ratio scale: float64(time.Second / frequency), } } // Update Enter the given value into the measuring system. // return bool stability evaluation func (w *Welford) Update(globalAvg, value float64) bool { value = value * w.scale if w.n == w.cap { delta := w.vector[w.eraseIndex] - w.mean w.mean -= delta / float64(w.n-1) w.sum -= delta * (w.vector[w.eraseIndex] - w.mean) // the calc error is approximated to zero if w.sum < 0 { w.sum = 0 } w.vector[w.eraseIndex] = globalAvg w.movingAvg -= w.movingVector[w.eraseIndex] w.movingVector[w.eraseIndex] = value w.movingAvg += value w.eraseIndex++ if w.eraseIndex == w.cap { w.eraseIndex = 0 } } else { w.vector[w.n] = globalAvg w.movingVector[w.n] = value w.movingAvg += value w.n++ } delta := globalAvg - w.mean w.mean += delta / float64(w.n) w.sum += delta * (globalAvg - w.mean) w.currentStdDev = math.Sqrt(w.Variance()) // update C.V if w.mean != 0 { w.cv = w.currentStdDev / w.mean } w.ewmaMean = value*w.beta + w.ewmaMean*(1-w.beta) // acc consecutiveStableIterations if w.n == w.cap && w.cv < 0.03 { w.consecutiveStableIterations++ } else if w.consecutiveStableIterations > 0 { w.consecutiveStableIterations-- } w.steps++ return w.consecutiveStableIterations >= w.consecutiveStableIterationsThreshold && w.steps > w.minSteps } func (w *Welford) Mean() float64 { return w.mean } func (w *Welford) CV() float64 { return w.cv } func (w *Welford) Variance() float64 { if w.n < 2 { return 0 } return w.sum / float64(w.n-1) } func (w *Welford) StandardDeviation() float64 { return w.currentStdDev } func (w *Welford) EWMA() float64 { return w.ewmaMean*0.5 + w.movingAvg/float64(w.n)*0.5 } func (w *Welford) String() string { return fmt.Sprintf("Mean: %.2f, Standard Deviation: %.2f, C.V: %.2f, EWMA: %.2f", w.Mean(), w.StandardDeviation(), w.CV(), w.EWMA()) } showwin-speedtest-go-df849ca/speedtest/internal/welford_test.go000066400000000000000000000177551524547167300252160ustar00rootroot00000000000000package internal import ( "fmt" "math/rand" "testing" "time" ) func BenchmarkWOM(b *testing.B) { w := NewWelford(time.Second*5, time.Millisecond*50) rd := rand.New(rand.NewSource(0)) var arr []float64 for i := 0; i < 100; i++ { arr = append(arr, rd.Float64()) } for i := 0; i < b.N; i++ { w.Update(arr[i%100], arr[i%100]) } } func TestWOM(t *testing.T) { data := []float64{0, 6.91552, 18.721692307692308, 23.04116556291391, 28.059485148514852, 31.118470119521916, 34.21727152317881, 35.15127065527066, 36.74378054862843, 38.05981374722838, 39.122272, 38.76271506352087, 39.60149084858569, 40.23165538461539, 40.72287589158345, 41.3182940397351, 41.36879800498754, 41.848093896713614, 42.29560044395117, 42.45441684210526, 42.27466666666667, 42.67902476190476, 42.68457142857143, 42.80731190269331, 42.75244, 42.6367104, 42.65657801691007, 42.68948038490007, 42.81048394004282, 42.64527084769124, 42.802427145708585, 42.92410838709678, 43.15598501872659, 43.32482909090909, 43.46838800705467, 43.5166464877213, 43.523846751804555, 43.73983171521036, 43.870409258285115, 43.84913230769231, 43.72752023988006, 43.85322926829268, 43.964127436994765, 44.027410506741056, 43.99742169768498, 44.093007552199026, 44.241321164710996, 44.287108464483204, 44.079746772178254, 44.180878367346935, 44.1128768, 44.17403607843137, 44.0556123076923, 44.20335873255375, 44.19607703703704, 44.2603155216285, 44.202986438258385, 44.2371857042747, 44.17902344827586, 44.31277559322034, 44.30876912840985, 44.378359108781126, 44.3999845410628, 44.37440913415794, 44.4760624609619, 44.471889264841586, 44.454861296184134, 44.37803098927294, 44.37345251396648, 44.47449043478261, 44.43488914285714, 44.50349070422535, 44.45223882254929, 44.433060821917806, 44.46777081081081, 44.4554752, 44.43440842105263, 44.55939356178609, 44.59433376057421, 44.633178734177214, 44.5508462884279, 44.58072493827161, 44.67904608632041, 44.662959537572256, 44.66403237324446, 44.59772449459332, 44.53588837209303, 44.586143382352944, 44.66949738933031, 44.66949618320611, 44.69251965356429, 44.628363956043955, 44.63443946968051, 44.607556129032254, 44.66488851063829, 44.66553062513155, 44.72077, 44.75405813234384, 44.821391020408164, 44.88321874368814, 44.9364192, 44.94112373787369, 44.958842352941176, 44.97548797517455, 44.94797846153846, 44.9455939047619, 44.910117647058826, 44.88617040358744, 44.86545696835092, 44.86558503026968, 44.90363345454546, 44.87501062871554, 44.93104802713802, 44.93590804597701, 44.90068409051044, 44.87467130434782, 44.891225650749874, 44.9156841025641, 44.92164610169491, 44.89092118971601, 44.9027482086319, 44.86017701453105, 44.87692428711898, 44.8651103526735, 44.8483070967742, 44.79754023356263, 44.787605776860815, 44.7891175822446, 44.778797499999996, 44.778773283743995, 44.79586406274028, 44.77517923664122, 44.62143350499849, 44.48119933904161, 44.38141850746269, 44.425831753554505, 44.390321423320096, 44.31049810218978, 44.124642318840586, 44.01107798561151, 44.02091885714285, 44.02881950942861, 44.019682253521125, 44.071147412587415, 44.08087753401833, 44.09411310344827, 44.12980821917809, 44.07751870493811, 44.09083445854713, 44.09876506104924, 44.08840634582056, 44.13032264900663, 44.09793658729115, 44.106114509803916, 44.06832567199065, 44.096165139981935, 44.116, 44.10581480066234, 44.09257851448817, 44.075851106639846, 44.074916, 44.091910073282826, 44.09682567901235, 44.07930895705522, 44.053896892138944, 44.06240019391589, 44.06116150788872, 44.1180531736527, 44.15990285714285, 44.134685126020585, 44.18049829431832, 44.158170252572496, 44.21358883720931, 44.16577849710982, 44.1291108045977, 44.16022671694664, 44.1493200045439, 44.1491597740113, 44.15741842696629, 44.177169162011175, 44.21226797022553, 44.166602585349686, 44.17985497692815, 44.168419672131144, 44.19028366481904, 44.173513455095645, 44.162090322580646, 44.123481608212145, 44.155531914893615, 44.18765714285714, 44.224294736842104, 44.21350366492147, 44.168821324448146, 44.168, 44.17472356215214, 44.187647999999996, 44.18084571428571, 44.18716962744899, 44.19440137359862, 44.1429404079992, 44.15704} // data := []float64{0, 1550.14016, 1102.92032, 759.1852799999999, 601.88672, 502.06617600000004, 435.71381333333335, 385.54944, 348.24736, 316.40547555555554, 289.261344, 267.38202181818184, 252.83786666666668, 245.42818461538462, 245.35842285714287, 235.63543466666667, 227.58704000000003, 220.20446117647057, 213.48643555555554, 207.48786526315791, 201.919424, 197.19868952380952, 192.2912581818182, 188.60752695652172, 185.09968, 181.7553664, 178.6282953846154, 175.99228444444446, 173.34433142857142, 170.91121655172415, 168.496128, 166.37638193548386, 163.99491999999998, 161.9206012121212, 160.24624, 158.76464457142856, 157.2420711111111, 155.45751351351353, 154.00374736842107, 152.63651282051282, 151.112704, 149.88837463414634, 149.02825142857142, 147.3117618604651, 144.63512, 145.58946844444446, 144.72223304347824, 143.76531744680852, 143.01374, 142.06400653061226, 141.0796992, 140.27383843137252, 139.51694153846157, 138.54825660377358, 138.22770370370372, 137.21800727272725, 136.98238857142854, 135.9439045614035, 135.45676137931034, 135.40977898305084, 134.70067622540847, 134.12748048540504, 133.63318709677418, 133.16014222222222, 132.703765, 132.20392861538463, 131.7192387878788, 131.27342328358208, 130.71865882352944, 130.23932753623188, 129.99216914285716, 129.28652169014086, 129.16500888888888, 128.67712438356165, 128.25776864864866, 127.85582506666665, 127.45831157894739, 127.03613922077922, 126.90191179487178, 126.59213772151901, 126.425936, 126.0965688888889, 125.77238634146343, 125.49601735357918, 125.1707276190476, 124.89910964705884, 124.60520186046512, 124.29531218390805, 124.02556363636364, 123.47192808988764, 123.56910222222223, 123.33042637362638, 123.09693913043478, 122.809328172043, 122.58635234042553, 122.41840168421052, 122.15547666666666, 121.92927999999999, 121.73630367346938, 121.48462222222223, 121.29761599999999, 121.06910415841584, 120.87151686274511, 120.68683805825242, 120.51469538461538, 120.2012220952381, 120.15477433962265, 119.2894474766355, 119.77253037037036, 119.57481394495413, 119.31698327272728, 119.10490234234233, 118.99255142857143, 118.76022088495574, 118.8147452631579, 118.6306587826087, 118.51604413793103, 118.38956854700855, 118.1484366101695, 117.98398924369748, 117.95854933333334, 117.80418115702479, 117.68285901639344, 117.32433170731707, 117.36720000000001, 117.21502720000001, 117.10605714285714, 117.01992566929134, 116.88315749999998, 116.77652093023256, 116.65271384615386, 116.51296488549619, 116.39656969696969, 116.29855518796992, 116.18375402985075, 116.00662992592594, 115.96002117647059, 115.85702540145985, 115.77244985507247, 115.67459145200748, 115.48589942857143, 115.44551716312057, 115.35635154929577, 115.21038993006992, 115.11760666666666, 115.01394537931034, 114.97817863013698, 114.80941278911564, 114.72184648648648, 114.64405261744967, 114.55408426666666, 114.41580291390729, 114.3308, 114.27059869281045, 114.18505260423433, 114.14871122580645, 113.99949128205128, 113.94442191082803, 113.83386734177216, 113.59835371069182, 113.515388, 113.6068849689441, 113.52565728395062, 113.47399852760736, 113.35184, 113.3104446060606, 113.19637204819277, 113.12842730538924, 113.09605333333333, 113.08127621301774, 112.81183811764706, 112.9294203508772, 112.81810418604651, 112.69997317919075, 112.63358712643677, 112.5452672, 112.50175090909092, 112.52005423728812, 112.43650516853933, 112.34864983240223, 112.20527644444445, 112.20484950276244, 112.19415912087912, 112.13252546448088, 112.04997913043476, 112.02885535135137, 111.93645075268819, 111.89874994652408, 111.80677787234042, 111.73926264550266, 111.75360336842105, 111.62827225130889, 111.64060833333332, 111.58019481865286, 111.51077113402062, 111.32106666666667, 111.43125714285713, 111.37313757741902, 111.2588913131313, 111.27499738693469, 111.22497689768976} w := NewWelford(time.Second*5, time.Millisecond*50) ok := false for i, x := range data { if w.Update(x, x) { ok = true break } fmt.Printf("[%d] %s\n", i, w) } if !ok { t.Fatal("TestWOM failed") } } showwin-speedtest-go-df849ca/speedtest/location.go000066400000000000000000000070341524547167300224760ustar00rootroot00000000000000package speedtest import ( "errors" "fmt" "strconv" "strings" ) type Location struct { Name string CC string Lat float64 Lon float64 } // Locations TODO more location need to added var Locations = map[string]*Location{ "brasilia": {"brasilia", "br", -15.793876, -47.8835327}, "hongkong": {"hongkong", "hk", 22.3106806, 114.1700546}, "tokyo": {"tokyo", "jp", 35.680938, 139.7674114}, "london": {"london", "uk", 51.5072493, -0.1288861}, "moscow": {"moscow", "ru", 55.7497248, 37.615989}, "beijing": {"beijing", "cn", 39.8721243, 116.4077473}, "paris": {"paris", "fr", 48.8626026, 2.3477229}, "sanfrancisco": {"sanfrancisco", "us", 37.7540028, -122.4429967}, "newyork": {"newyork", "us", 40.7200876, -74.0220945}, "yishun": {"yishun", "sg", 1.4230218, 103.8404728}, "delhi": {"delhi", "in", 28.6251287, 77.1960896}, "monterrey": {"monterrey", "mx", 25.6881435, -100.3073485}, "berlin": {"berlin", "de", 52.5168128, 13.4009469}, "maputo": {"maputo", "mz", -25.9579267, 32.5760444}, "honolulu": {"honolulu", "us", 20.8247065, -156.918706}, "seoul": {"seoul", "kr", 37.6086268, 126.7179721}, "osaka": {"osaka", "jp", 34.6952743, 135.5006967}, "shanghai": {"shanghai", "cn", 31.2292105, 121.4661666}, "urumqi": {"urumqi", "cn", 43.8256624, 87.6058564}, "ottawa": {"ottawa", "ca", 45.4161836, -75.7035467}, "capetown": {"capetown", "za", -33.9391993, 18.4316716}, "sydney": {"sydney", "au", -33.8966622, 151.1731861}, "perth": {"perth", "au", -31.9551812, 115.8591904}, "warsaw": {"warsaw", "pl", 52.2396659, 21.0129345}, "kampala": {"kampala", "ug", 0.3070027, 32.5675581}, "bangkok": {"bangkok", "th", 13.7248936, 100.493026}, } func PrintCityList() { fmt.Println("Available city labels (case insensitive): ") fmt.Println(" CC\t\tCityLabel\tLocation") for k, v := range Locations { fmt.Printf("(%v)\t%20s\t[%v, %v]\n", v.CC, k, v.Lat, v.Lon) } } func GetLocation(locationName string) (*Location, error) { loc, ok := Locations[strings.ToLower(locationName)] if ok { return loc, nil } return nil, errors.New("not found location") } // NewLocation new a Location func NewLocation(locationName string, latitude float64, longitude float64) *Location { var loc Location loc.Lat = latitude loc.Lon = longitude loc.Name = locationName Locations[locationName] = &loc return &loc } // ParseLocation parse latitude and longitude string func ParseLocation(locationName string, coordinateStr string) (*Location, error) { ll := strings.Split(coordinateStr, ",") if len(ll) == 2 { // parameters check lat, err := betweenRange(ll[0], 90) if err != nil { return nil, err } lon, err := betweenRange(ll[1], 180) if err != nil { return nil, err } name := "Custom-%s" if len(locationName) == 0 { name = "Custom-Default" } return NewLocation(fmt.Sprintf(name, locationName), lat, lon), nil } return nil, fmt.Errorf("invalid location input: %s", coordinateStr) } func (l *Location) String() string { return fmt.Sprintf("(%s) [%v, %v]", l.Name, l.Lat, l.Lon) } // betweenRange latitude and longitude range check func betweenRange(inputStrValue string, interval float64) (float64, error) { value, err := strconv.ParseFloat(inputStrValue, 64) if err != nil { return 0, fmt.Errorf("invalid input: %v", inputStrValue) } if value < -interval || interval < value { return 0, fmt.Errorf("invalid input. got: %v, expected between -%v and %v", inputStrValue, interval, interval) } return value, nil } showwin-speedtest-go-df849ca/speedtest/loss.go000066400000000000000000000124061524547167300216450ustar00rootroot00000000000000package speedtest import ( "context" "github.com/showwin/speedtest-go/speedtest/transport" "net" "sync" "syscall" "time" ) type PacketLossAnalyzerOptions struct { RemoteSamplingInterval time.Duration SamplingDuration time.Duration PacketSendingInterval time.Duration PacketSendingTimeout time.Duration SourceInterface string // source interface TCPDialer *net.Dialer // tcp dialer for sampling UDPDialer *net.Dialer // udp dialer for sending packet } type PacketLossAnalyzer struct { options *PacketLossAnalyzerOptions } func NewPacketLossAnalyzer(options *PacketLossAnalyzerOptions) *PacketLossAnalyzer { if options == nil { options = &PacketLossAnalyzerOptions{} } if options.SamplingDuration == 0 { options.SamplingDuration = time.Second * 30 } if options.RemoteSamplingInterval == 0 { options.RemoteSamplingInterval = 1 * time.Second } if options.PacketSendingInterval == 0 { options.PacketSendingInterval = 67 * time.Millisecond } if options.PacketSendingTimeout == 0 { options.PacketSendingTimeout = 5 * time.Second } if options.TCPDialer == nil { var addr net.Addr var control func(string, string, syscall.RawConn) error if len(options.SourceInterface) > 0 { address := options.SourceInterface if ip, ctrl, ok := resolveInterface(address); ok { addr = &net.TCPAddr{IP: ip} control = ctrl } else if ip := net.ParseIP(address); ip != nil { addr = &net.TCPAddr{IP: ip} } } options.TCPDialer = &net.Dialer{ LocalAddr: addr, Timeout: options.PacketSendingTimeout, Control: control, } } if options.UDPDialer == nil { var addr net.Addr var control func(string, string, syscall.RawConn) error if len(options.SourceInterface) > 0 { address := options.SourceInterface if ip, ctrl, ok := resolveInterface(address); ok { addr = &net.UDPAddr{IP: ip} control = ctrl } else if ip := net.ParseIP(address); ip != nil { addr = &net.UDPAddr{IP: ip} } } options.UDPDialer = &net.Dialer{ Timeout: options.PacketSendingTimeout, LocalAddr: addr, Control: control, } } return &PacketLossAnalyzer{ options: options, } } // NewPacketLossAnalyzer creates an analyzer using the same network source as // the speedtest client, including interface binding and source addresses. func (s *Speedtest) NewPacketLossAnalyzer() *PacketLossAnalyzer { return NewPacketLossAnalyzer(&PacketLossAnalyzerOptions{ TCPDialer: s.tcpDialer, UDPDialer: s.udpDialer, }) } // RunMulti Mix all servers to get the average packet loss. func (pla *PacketLossAnalyzer) RunMulti(hosts []string) (*transport.PLoss, error) { ctx, cancel := context.WithTimeout(context.Background(), pla.options.SamplingDuration) defer cancel() return pla.RunMultiWithContext(ctx, hosts) } func (pla *PacketLossAnalyzer) RunMultiWithContext(ctx context.Context, hosts []string) (*transport.PLoss, error) { results := make(map[string]*transport.PLoss) mutex := &sync.Mutex{} wg := &sync.WaitGroup{} for _, host := range hosts { wg.Add(1) go func(h string) { defer wg.Done() _ = pla.RunWithContext(ctx, h, func(packetLoss *transport.PLoss) { if packetLoss.Sent != 0 { mutex.Lock() results[h] = packetLoss mutex.Unlock() } }) }(host) } wg.Wait() if len(results) == 0 { return nil, transport.ErrUnsupported } var pLoss transport.PLoss for _, hostPacketLoss := range results { pLoss.Sent += hostPacketLoss.Sent pLoss.Dup += hostPacketLoss.Dup pLoss.Max += hostPacketLoss.Max } return &pLoss, nil } func (pla *PacketLossAnalyzer) Run(host string, callback func(packetLoss *transport.PLoss)) error { ctx, cancel := context.WithTimeout(context.Background(), pla.options.SamplingDuration) defer cancel() return pla.RunWithContext(ctx, host, callback) } func (pla *PacketLossAnalyzer) RunWithContext(ctx context.Context, host string, callback func(packetLoss *transport.PLoss)) error { samplerClient, err := transport.NewClient(pla.options.TCPDialer) if err != nil { return transport.ErrUnsupported } senderClient, err := transport.NewPacketLossSender(samplerClient.ID(), pla.options.UDPDialer) if err != nil { return transport.ErrUnsupported } if err = samplerClient.Connect(ctx, host); err != nil { return transport.ErrUnsupported } if err = senderClient.Connect(ctx, host); err != nil { return transport.ErrUnsupported } if err = samplerClient.InitPacketLoss(); err != nil { return transport.ErrUnsupported } go pla.loopSender(ctx, senderClient) return pla.loopSampler(ctx, samplerClient, callback) } func (pla *PacketLossAnalyzer) loopSampler(ctx context.Context, client *transport.Client, callback func(packetLoss *transport.PLoss)) error { ticker := time.NewTicker(pla.options.RemoteSamplingInterval) defer ticker.Stop() for { select { case <-ticker.C: if pl, err1 := client.PacketLoss(); err1 == nil { if pl != nil { callback(pl) } } else { return err1 } case <-ctx.Done(): return nil } } } func (pla *PacketLossAnalyzer) loopSender(ctx context.Context, senderClient *transport.PacketLossSender) { order := 0 sendTick := time.NewTicker(pla.options.PacketSendingInterval) defer sendTick.Stop() for { select { case <-sendTick.C: _ = senderClient.Send(order) order++ case <-ctx.Done(): return } } } showwin-speedtest-go-df849ca/speedtest/output.go000066400000000000000000000017461524547167300222320ustar00rootroot00000000000000package speedtest import ( "encoding/json" "fmt" "time" ) type fullOutput struct { Timestamp outputTime `json:"timestamp"` UserInfo *User `json:"user_info"` Servers Servers `json:"servers"` } type singleServerOutput struct { Timestamp outputTime `json:"timestamp"` UserInfo *User `json:"user_info"` Server *Server `json:"server"` } type outputTime time.Time func (t outputTime) MarshalJSON() ([]byte, error) { stamp := fmt.Sprintf("\"%s\"", time.Time(t).Format("2006-01-02 15:04:05.000")) return []byte(stamp), nil } func (s *Speedtest) JSON(servers Servers) ([]byte, error) { return json.Marshal( fullOutput{ Timestamp: outputTime(time.Now()), UserInfo: s.User, Servers: servers, }, ) } // JSONL outputs a single server result in JSON format func (s *Speedtest) JSONL(server *Server) ([]byte, error) { return json.Marshal( singleServerOutput{ Timestamp: outputTime(time.Now()), UserInfo: s.User, Server: server, }, ) } showwin-speedtest-go-df849ca/speedtest/request.go000066400000000000000000000362451524547167300223640ustar00rootroot00000000000000package speedtest import ( "context" "errors" "fmt" "io" "math" "net" "net/http" "net/url" "path" "strings" "sync/atomic" "time" "github.com/showwin/speedtest-go/speedtest/transport" ) type ( downloadFunc func(context.Context, *Server, int) error uploadFunc func(context.Context, *Server, int) error ) var ( dlSizes = [...]int{350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000} ulSizes = [...]int{100, 300, 500, 800, 1000, 1500, 2500, 3000, 3500, 4000} // kB ) var ( ErrConnectTimeout = errors.New("server connect timeout") ) func (s *Server) MultiDownloadTestContext(ctx context.Context, servers Servers) error { ss := servers.Available() if ss.Len() == 0 { return errors.New("not found available servers") } mainIDIndex := 0 var td *TestDirection _context, cancel := context.WithCancel(ctx) defer cancel() var errorTimes int64 = 0 var requestTimes int64 = 0 for i, server := range *ss { if server.ID == s.ID { mainIDIndex = i } sp := server dbg.Printf("Register Download Handler: %s\n", sp.URL) td = server.Context.RegisterDownloadHandler(func() { atomic.AddInt64(&requestTimes, 1) if err := sp.downloadRequest()(_context, sp, 3); err != nil { atomic.AddInt64(&errorTimes, 1) } }) } if td == nil { return ErrorUninitializedManager } td.Start(cancel, mainIDIndex) // block here s.DLSpeed = ByteRate(td.manager.GetEWMADownloadRate()) if s.DLSpeed == 0 && float64(errorTimes)/float64(requestTimes) > 0.1 { s.DLSpeed = -1 // N/A } return nil } func (s *Server) MultiUploadTestContext(ctx context.Context, servers Servers) error { ss := servers.Available() if ss.Len() == 0 { return errors.New("not found available servers") } mainIDIndex := 0 var td *TestDirection _context, cancel := context.WithCancel(ctx) defer cancel() var errorTimes int64 = 0 var requestTimes int64 = 0 for i, server := range *ss { if server.ID == s.ID { mainIDIndex = i } server.resolveUploadURL(ctx) sp := server dbg.Printf("Register Upload Handler: %s\n", sp.URL) td = server.Context.RegisterUploadHandler(func() { atomic.AddInt64(&requestTimes, 1) if err := sp.uploadRequest()(_context, sp, 3); err != nil { atomic.AddInt64(&errorTimes, 1) } }) } if td == nil { return ErrorUninitializedManager } td.Start(cancel, mainIDIndex) // block here s.ULSpeed = ByteRate(td.manager.GetEWMAUploadRate()) if s.ULSpeed == 0 && float64(errorTimes)/float64(requestTimes) > 0.1 { s.ULSpeed = -1 // N/A } return nil } // DownloadTest executes the test to measure download speed func (s *Server) DownloadTest() error { return s.downloadTestContext(context.Background(), s.downloadRequest()) } // DownloadTestContext executes the test to measure download speed, observing the given context. func (s *Server) DownloadTestContext(ctx context.Context) error { return s.downloadTestContext(ctx, s.downloadRequest()) } func (s *Server) downloadTestContext(ctx context.Context, downloadRequest downloadFunc) error { var errorTimes int64 = 0 var requestTimes int64 = 0 start := time.Now() _context, cancel := context.WithCancel(ctx) s.Context.RegisterDownloadHandler(func() { atomic.AddInt64(&requestTimes, 1) if err := downloadRequest(_context, s, 3); err != nil { atomic.AddInt64(&errorTimes, 1) } }).Start(cancel, 0) duration := time.Since(start) s.DLSpeed = ByteRate(s.Context.GetEWMADownloadRate()) if s.DLSpeed == 0 && float64(errorTimes)/float64(requestTimes) > 0.1 { s.DLSpeed = -1 // N/A } s.TestDuration.Download = &duration s.testDurationTotalCount() return nil } // UploadTest executes the test to measure upload speed func (s *Server) UploadTest() error { return s.UploadTestContext(context.Background()) } // UploadTestContext executes the test to measure upload speed, observing the given context. func (s *Server) UploadTestContext(ctx context.Context) error { s.resolveUploadURL(ctx) return s.uploadTestContext(ctx, s.uploadRequest()) } func (s *Server) resolveUploadURL(ctx context.Context) { if s.Context.config.TestMode == TCPTest { return } req, err := http.NewRequestWithContext(ctx, http.MethodHead, s.URL, nil) if err != nil { return } resp, err := s.Context.doer.Do(req) if err != nil { return } defer func() { _ = resp.Body.Close() }() if resp.Request == nil || resp.Request.URL == nil { return } resolvedURL := resp.Request.URL.String() if resolvedURL != s.URL { dbg.Printf("Resolved upload URL: %s -> %s\n", s.URL, resolvedURL) s.URL = resolvedURL } } func (s *Server) downloadRequest() downloadFunc { if s.Context.config.TestMode == TCPTest { return tcpDownloadRequest } return downloadRequest } func (s *Server) uploadRequest() uploadFunc { if s.Context.config.TestMode == TCPTest { return tcpUploadRequest } return uploadRequest } func (s *Server) uploadTestContext(ctx context.Context, uploadRequest uploadFunc) error { var errorTimes int64 = 0 var requestTimes int64 = 0 start := time.Now() _context, cancel := context.WithCancel(ctx) s.Context.RegisterUploadHandler(func() { atomic.AddInt64(&requestTimes, 1) if err := uploadRequest(_context, s, 4); err != nil { atomic.AddInt64(&errorTimes, 1) } }).Start(cancel, 0) duration := time.Since(start) s.ULSpeed = ByteRate(s.Context.GetEWMAUploadRate()) if s.ULSpeed == 0 && float64(errorTimes)/float64(requestTimes) > 0.1 { s.ULSpeed = -1 // N/A } s.TestDuration.Upload = &duration s.testDurationTotalCount() return nil } func downloadRequest(ctx context.Context, s *Server, w int) error { size := dlSizes[w] u, err := url.Parse(s.URL) if err != nil { return err } u.Path = path.Dir(u.Path) xdlURL := u.JoinPath(fmt.Sprintf("random%dx%d.jpg", size, size)).String() dbg.Printf("XdlURL: %s\n", xdlURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, xdlURL, nil) if err != nil { return err } resp, err := s.Context.doer.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() return s.Context.NewChunk().DownloadHandler(resp.Body) } func uploadRequest(ctx context.Context, s *Server, w int) error { size := ulSizes[w] chunkSize := int64(size*100-51) * 10 dc := s.Context.NewChunk().UploadHandler(chunkSize) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.URL, io.NopCloser(dc)) if err != nil { return err } req.ContentLength = chunkSize dbg.Printf("Len=%d, XulURL: %s\n", req.ContentLength, s.URL) req.Header.Set("Content-Type", "application/octet-stream") resp, err := s.Context.doer.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if _, err := io.Copy(io.Discard, resp.Body); err != nil { return err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("upload request failed: %s", resp.Status) } // Treat a successful upload.php response as application-level upload confirmation. s.Context.AddTotalUpload(chunkSize) return nil } func tcpHost(s *Server) (string, error) { host := s.Host if host == "" { u, err := url.Parse(s.URL) if err != nil { return "", err } if u.Hostname() == "" { return "", errors.New("tcp server host is empty") } host = u.Host } if _, _, err := net.SplitHostPort(host); err == nil { return host, nil } if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") } return net.JoinHostPort(host, "8080"), nil } func tcpDownloadRequest(ctx context.Context, s *Server, w int) error { host, err := tcpHost(s) if err != nil { return err } client, err := transport.NewClient(s.Context.tcpDialer) if err != nil { return err } if err = client.Connect(ctx, host); err != nil { return err } defer func() { _ = client.Disconnect() }() if _, err = client.VersionContext(ctx); err != nil { return err } size := int64(dlSizes[w]) * 1000 return client.Download(ctx, size, s.Context.NewChunk().DownloadHandler) } func tcpUploadRequest(ctx context.Context, s *Server, w int) error { host, err := tcpHost(s) if err != nil { return err } client, err := transport.NewClient(s.Context.tcpDialer) if err != nil { return err } if err = client.Connect(ctx, host); err != nil { return err } defer func() { _ = client.Disconnect() }() size := int64(ulSizes[w]) * 1000 payloadSize, err := transport.UploadPayloadSize(size) if err != nil { return err } dc := s.Context.NewChunk().UploadHandler(payloadSize) acknowledged, err := client.Upload(ctx, size, dc) if err != nil { return err } overhead := size - payloadSize if acknowledged < overhead { return transport.ErrInvalidResponse } s.Context.AddTotalUpload(acknowledged - overhead) return nil } // PingTest executes test to measure latency func (s *Server) PingTest(callback func(latency time.Duration)) error { return s.PingTestContext(context.Background(), callback) } // PingTestContext executes test to measure latency, observing the given context. func (s *Server) PingTestContext(ctx context.Context, callback func(latency time.Duration)) (err error) { start := time.Now() var vectorPingResult []int64 switch s.Context.config.PingMode { case TCP: vectorPingResult, err = s.TCPPing(ctx, 10, time.Millisecond*200, callback) case ICMP: vectorPingResult, err = s.ICMPPing(ctx, time.Second*4, 10, time.Millisecond*200, callback) default: vectorPingResult, err = s.HTTPPing(ctx, 10, time.Millisecond*200, callback) } if err != nil || len(vectorPingResult) == 0 { return err } dbg.Printf("Before StandardDeviation: %v\n", vectorPingResult) mean, _, std, minLatency, maxLatency := StandardDeviation(vectorPingResult) duration := time.Since(start) s.Latency = time.Duration(mean) * time.Nanosecond s.Jitter = time.Duration(std) * time.Nanosecond s.MinLatency = time.Duration(minLatency) * time.Nanosecond s.MaxLatency = time.Duration(maxLatency) * time.Nanosecond s.TestDuration.Ping = &duration s.testDurationTotalCount() return nil } // TestAll executes ping, download and upload tests one by one func (s *Server) TestAll() error { err := s.PingTest(nil) if err != nil { return err } err = s.DownloadTest() if err != nil { return err } return s.UploadTest() } func (s *Server) TCPPing( ctx context.Context, echoTimes int, echoFreq time.Duration, callback func(latency time.Duration), ) (latencies []int64, err error) { var pingDst string if len(s.Host) == 0 { u, err := url.Parse(s.URL) if err != nil || len(u.Host) == 0 { return nil, err } pingDst = u.Host } else { pingDst = s.Host } failTimes := 0 client, err := transport.NewClient(s.Context.tcpDialer) if err != nil { return nil, err } defer func() { _ = client.Disconnect() }() err = client.Connect(ctx, pingDst) if err != nil { return nil, err } for i := 0; i < echoTimes; i++ { latency, err := client.PingContext(ctx) if err != nil { failTimes++ continue } latencies = append(latencies, latency) if callback != nil { callback(time.Duration(latency)) } time.Sleep(echoFreq) } if failTimes == echoTimes { return nil, ErrConnectTimeout } return } func (s *Server) HTTPPing( ctx context.Context, echoTimes int, echoFreq time.Duration, callback func(latency time.Duration), ) (latencies []int64, err error) { var contextErr error u, err := url.Parse(s.URL) if err != nil || len(u.Host) == 0 { return nil, err } u.Path = path.Dir(u.Path) pingDst := u.JoinPath("latency.txt").String() dbg.Printf("Echo: %s\n", pingDst) failTimes := 0 req, err := http.NewRequestWithContext(ctx, http.MethodGet, pingDst, nil) if err != nil { return nil, err } // carry out an extra request to warm up the connection and ensure the first request is not going to affect the // overall estimation echoTimes++ for i := 0; i < echoTimes; i++ { sTime := time.Now() resp, err := s.Context.doer.Do(req) endTime := time.Since(sTime) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { contextErr = err break } failTimes++ continue } _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() if i > 0 { latency := endTime.Nanoseconds() latencies = append(latencies, latency) dbg.Printf("RTT: %d\n", latency) if callback != nil { callback(endTime) } } time.Sleep(echoFreq) } if contextErr != nil { return latencies, contextErr } if failTimes == echoTimes { return nil, ErrConnectTimeout } return } const PingTimeout = -1 const echoOptionDataSize = 32 // `echoMessage` need to change at same time // ICMPPing privileged method func (s *Server) ICMPPing( ctx context.Context, readTimeout time.Duration, echoTimes int, echoFreq time.Duration, callback func(latency time.Duration), ) (latencies []int64, err error) { u, err := url.ParseRequestURI(s.URL) if err != nil || len(u.Host) == 0 { return nil, err } dbg.Printf("Echo: %s\n", strings.Split(u.Host, ":")[0]) dialContext, err := s.Context.ipDialer.DialContext(ctx, "ip:icmp", strings.Split(u.Host, ":")[0]) if err != nil { return nil, err } defer func() { _ = dialContext.Close() }() ICMPData := make([]byte, 8+echoOptionDataSize) // header + data ICMPData[0] = 8 // echo ICMPData[1] = 0 // code ICMPData[2] = 0 // checksum ICMPData[3] = 0 // checksum ICMPData[4] = 0 // id ICMPData[5] = 1 // id ICMPData[6] = 0 // seq ICMPData[7] = 1 // seq var echoMessage = "Hi! SpeedTest-Go \\(●'◡'●)/" for i := 0; i < len(echoMessage); i++ { ICMPData[7+i] = echoMessage[i] } failTimes := 0 for i := 0; i < echoTimes; i++ { ICMPData[2] = byte(0) ICMPData[3] = byte(0) ICMPData[6] = byte(1 >> 8) ICMPData[7] = byte(1) ICMPData[8+echoOptionDataSize-1] = 6 cs := checkSum(ICMPData) ICMPData[2] = byte(cs >> 8) ICMPData[3] = byte(cs) sTime := time.Now() _ = dialContext.SetDeadline(sTime.Add(readTimeout)) _, err = dialContext.Write(ICMPData) if err != nil { failTimes += echoTimes - i break } buf := make([]byte, 20+echoOptionDataSize+8) _, err = dialContext.Read(buf) if err != nil || buf[20] != 0x00 { failTimes++ continue } endTime := time.Since(sTime) latencies = append(latencies, endTime.Nanoseconds()) dbg.Printf("1RTT: %s\n", endTime) if callback != nil { callback(endTime) } time.Sleep(echoFreq) } if failTimes == echoTimes { return nil, ErrConnectTimeout } return } func checkSum(data []byte) uint16 { var sum uint32 var length = len(data) var index int for length > 1 { sum += uint32(data[index])<<8 + uint32(data[index+1]) index += 2 length -= 2 } if length > 0 { sum += uint32(data[index]) } sum += sum >> 16 return uint16(^sum) } func StandardDeviation(vector []int64) (mean, variance, stdDev, min, max int64) { if len(vector) == 0 { return } var sumNum, accumulate int64 min = math.MaxInt64 max = math.MinInt64 for _, value := range vector { sumNum += value if min > value { min = value } if max < value { max = value } } mean = sumNum / int64(len(vector)) for _, value := range vector { accumulate += (value - mean) * (value - mean) } variance = accumulate / int64(len(vector)) stdDev = int64(math.Sqrt(float64(variance))) return } showwin-speedtest-go-df849ca/speedtest/request_test.go000066400000000000000000000056711524547167300234220ustar00rootroot00000000000000package speedtest import ( "context" "runtime" "testing" "time" ) func TestDownloadTestContext(t *testing.T) { idealSpeed := 0.1 * 8 * float64(runtime.NumCPU()) * 10 / 0.1 // one mockRequest per second with all CPU cores delta := 0.15 latency, _ := time.ParseDuration("5ms") server := Server{ URL: "https://dummy.com/upload.php", Latency: latency, Context: defaultClient, } server.Context.Reset() server.Context.SetRateCaptureFrequency(time.Millisecond) server.Context.SetCaptureTime(time.Second) err := server.downloadTestContext( context.Background(), mockRequest, ) if err != nil { t.Error(err) } value := server.Context.GetAvgDownloadRate() if value < idealSpeed*(1-delta) || idealSpeed*(1+delta) < value { t.Errorf("got unexpected server.DLSpeed '%v', expected between %v and %v", value, idealSpeed*(1-delta), idealSpeed*(1+delta)) } if server.TestDuration.Download == nil || *server.TestDuration.Download != *server.TestDuration.Total { t.Errorf("can't count test duration, server.TestDuration.Download=%v, server.TestDuration.Total=%v", server.TestDuration.Download, server.TestDuration.Total) } } func TestUploadTestContext(t *testing.T) { idealSpeed := 0.1 * 8 * 1 * 10 / 0.1 // upload test is explicitly single-threaded below delta := 0.15 // tolerance scope (-0.05, +0.05) latency, _ := time.ParseDuration("5ms") server := Server{ URL: "https://dummy.com/upload.php", Latency: latency, Context: defaultClient, } server.Context.Reset() server.Context.SetRateCaptureFrequency(time.Millisecond) server.Context.SetCaptureTime(time.Second) server.Context.SetNThread(1) err := server.uploadTestContext( context.Background(), mockRequest, ) if err != nil { t.Error(err) } value := server.Context.GetAvgUploadRate() if value < idealSpeed*(1-delta) || idealSpeed*(1+delta) < value { t.Errorf("got unexpected server.ULSpeed '%v', expected between %v and %v", value, idealSpeed*(1-delta), idealSpeed*(1+delta)) } if server.TestDuration.Upload == nil || *server.TestDuration.Upload != *server.TestDuration.Total { t.Errorf("can't count test duration, server.TestDuration.Upload=%v, server.TestDuration.Total=%v", server.TestDuration.Upload, server.TestDuration.Total) } } func mockRequest(ctx context.Context, s *Server, w int) error { dc := s.Context.NewChunk() // (0.1MegaByte * 8bit * nConn * 10loop) / 0.1s = n*80Megabit // sleep has bad deviation on windows // ref https://github.com/golang/go/issues/44343 dc.GetParent().AddTotalDownload(1 * 1000 * 1000) dc.GetParent().AddTotalUpload(1 * 1000 * 1000) time.Sleep(time.Millisecond * 100) return nil } func TestPautaFilter(t *testing.T) { //vector := []float64{6, 6, 6, 6, 6, 6, 6, 6, 6, 6} vector0 := []int64{26, 23, 32} vector1 := []int64{3, 4, 5, 6, 6, 6, 1, 7, 9, 5, 200} _, _, std, _, _ := StandardDeviation(vector0) if std != 3 { t.Fail() } result := pautaFilter(vector1) if len(result) != 10 { t.Fail() } } showwin-speedtest-go-df849ca/speedtest/server.go000066400000000000000000000307401524547167300221740ustar00rootroot00000000000000package speedtest import ( "context" "encoding/json" "encoding/xml" "errors" "fmt" "math" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time" "github.com/showwin/speedtest-go/speedtest/transport" ) const ( speedTestServersUrl = "https://www.speedtest.net/api/js/servers" speedTestServersAlternativeUrl = "https://www.speedtest.net/speedtest-servers-static.php" speedTestServersAdvanced = "https://www.speedtest.net/api/ios-config.php" ) type payloadType int const ( typeJSONPayload payloadType = iota typeXMLPayload ) var ( ErrServerNotFound = errors.New("no server available or found") ) // Server information type Server struct { URL string `xml:"url,attr" json:"url"` Lat string `xml:"lat,attr" json:"lat"` Lon string `xml:"lon,attr" json:"lon"` Name string `xml:"name,attr" json:"name"` Country string `xml:"country,attr" json:"country"` Sponsor string `xml:"sponsor,attr" json:"sponsor"` ID string `xml:"id,attr" json:"id"` Host string `xml:"host,attr" json:"host"` Distance float64 `json:"distance"` Latency time.Duration `json:"latency"` MaxLatency time.Duration `json:"max_latency"` MinLatency time.Duration `json:"min_latency"` Jitter time.Duration `json:"jitter"` DLSpeed ByteRate `json:"dl_speed"` ULSpeed ByteRate `json:"ul_speed"` TestDuration TestDuration `json:"test_duration"` CC string `json:"cc"` PacketLoss transport.PLoss `json:"packet_loss"` Context *Speedtest `json:"-"` } type TestDuration struct { Ping *time.Duration `json:"ping"` Download *time.Duration `json:"download"` Upload *time.Duration `json:"upload"` Total *time.Duration `json:"total"` } // CustomServer use defaultClient, given a URL string, return a new Server object, with as much // filled in as we can func CustomServer(host string) (*Server, error) { return defaultClient.CustomServer(host) } // CustomServer given a URL string, return a new Server object, with as much // filled in as we can func (s *Speedtest) CustomServer(host string) (*Server, error) { u, err := url.Parse(host) if err != nil { return nil, err } u.Path = "/speedtest/upload.php" parseHost := u.String() return &Server{ ID: "Custom", Lat: "?", Lon: "?", Country: "?", URL: parseHost, Name: u.Host, Host: u.Host, Sponsor: "?", Context: s, }, nil } // ServerList list of Server // Users(Client) also exists with @param speedTestServersAdvanced type ServerList struct { Servers []*Server `xml:"servers>server"` Users []User `xml:"client"` } // Servers for sorting servers. type Servers []*Server // ByDistance for sorting servers. type ByDistance struct { Servers } func (servers Servers) Available() *Servers { retServer := Servers{} for _, server := range servers { if server.Latency != PingTimeout { retServer = append(retServer, server) } } for i := 0; i < len(retServer)-1; i++ { for j := 0; j < len(retServer)-i-1; j++ { if retServer[j].Latency > retServer[j+1].Latency { retServer[j], retServer[j+1] = retServer[j+1], retServer[j] } } } return &retServer } // Len finds length of servers. For sorting servers. func (servers Servers) Len() int { return len(servers) } // Swap swaps i-th and j-th. For sorting servers. func (servers Servers) Swap(i, j int) { servers[i], servers[j] = servers[j], servers[i] } // Filter filter by filterFunc func (servers Servers) Filter(filterFunc func(server *Server) bool) Servers { var retServers Servers for i := range servers { if filterFunc(servers[i]) { retServers = append(retServers, servers[i]) } } return retServers } // CC filter by Country Code func (servers Servers) CC(cc []string) Servers { var upperCC []string for i := range cc { upperCC = append(upperCC, strings.ToUpper(cc[i])) } return servers.Filter(func(server *Server) bool { for _, code := range upperCC { if code == server.CC { return true } } return false }) } // Hosts return hosts of servers func (servers Servers) Hosts() []string { var retServer []string for _, server := range servers { retServer = append(retServer, server.Host) } return retServer } // Less compares the distance. For sorting servers. func (b ByDistance) Less(i, j int) bool { return b.Servers[i].Distance < b.Servers[j].Distance } // FetchServerByID retrieves a server by given serverID. func (s *Speedtest) FetchServerByID(serverID string) (*Server, error) { return s.FetchServerByIDContext(context.Background(), serverID) } // FetchServerByID retrieves a server by given serverID. func FetchServerByID(serverID string) (*Server, error) { return defaultClient.FetchServerByID(serverID) } // FetchServerByIDContext retrieves a server by given serverID, observing the given context. func (s *Speedtest) FetchServerByIDContext(ctx context.Context, serverID string) (*Server, error) { u, err := url.Parse(speedTestServersAdvanced) if err != nil { return nil, err } query := u.Query() query.Set(strings.ToLower("serverID"), serverID) u.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } resp, err := s.doer.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() var list ServerList decoder := xml.NewDecoder(resp.Body) if err = decoder.Decode(&list); err != nil { return nil, err } for i := range list.Servers { if list.Servers[i].ID == serverID { list.Servers[i].Context = s if len(list.Users) > 0 { sLat, _ := strconv.ParseFloat(list.Servers[i].Lat, 64) sLon, _ := strconv.ParseFloat(list.Servers[i].Lon, 64) uLat, _ := strconv.ParseFloat(list.Users[0].Lat, 64) uLon, _ := strconv.ParseFloat(list.Users[0].Lon, 64) list.Servers[i].Distance = distance(sLat, sLon, uLat, uLon) } return list.Servers[i], err } } return nil, ErrServerNotFound } // FetchServers retrieves a list of available servers func (s *Speedtest) FetchServers() (Servers, error) { return s.FetchServerListContext(context.Background()) } // FetchServers retrieves a list of available servers func FetchServers() (Servers, error) { return defaultClient.FetchServers() } // FetchServerListContext retrieves a list of available servers, observing the given context. func (s *Speedtest) FetchServerListContext(ctx context.Context) (Servers, error) { u, err := url.Parse(speedTestServersUrl) if err != nil { return Servers{}, err } query := u.Query() if len(s.config.Keyword) > 0 { query.Set("search", s.config.Keyword) } if s.config.Location != nil { query.Set("lat", strconv.FormatFloat(s.config.Location.Lat, 'f', -1, 64)) query.Set("lon", strconv.FormatFloat(s.config.Location.Lon, 'f', -1, 64)) } u.RawQuery = query.Encode() dbg.Printf("Retrieving servers: %s\n", u.String()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return Servers{}, err } resp, err := s.doer.Do(req) if err != nil { return Servers{}, err } _payloadType := typeJSONPayload if resp.ContentLength == 0 { _ = resp.Body.Close() req, err = http.NewRequestWithContext(ctx, http.MethodGet, speedTestServersAlternativeUrl, nil) if err != nil { return Servers{}, err } resp, err = s.doer.Do(req) if err != nil { return Servers{}, err } _payloadType = typeXMLPayload } defer func() { _ = resp.Body.Close() }() var servers Servers switch _payloadType { case typeJSONPayload: // Decode xml decoder := json.NewDecoder(resp.Body) if err = decoder.Decode(&servers); err != nil { return servers, err } case typeXMLPayload: var list ServerList // Decode xml decoder := xml.NewDecoder(resp.Body) if err = decoder.Decode(&list); err != nil { return servers, err } servers = list.Servers default: return servers, errors.New("response payload decoding not implemented") } dbg.Printf("Servers Num: %d\n", len(servers)) // set doer of server for _, server := range servers { server.Context = s } // ping once var wg sync.WaitGroup pCtx, fc := context.WithTimeout(context.Background(), time.Second*4) dbg.Println("Echo each server...") for _, server := range servers { wg.Add(1) go func(gs *Server) { var latency []int64 var errPing error switch s.config.PingMode { case TCP: latency, errPing = gs.TCPPing(pCtx, 1, time.Millisecond, nil) case ICMP: latency, errPing = gs.ICMPPing(pCtx, 4*time.Second, 1, time.Millisecond, nil) default: latency, errPing = gs.HTTPPing(pCtx, 1, time.Millisecond, nil) } if errPing != nil || len(latency) < 1 { gs.Latency = PingTimeout } else { gs.Latency = time.Duration(latency[0]) * time.Nanosecond } wg.Done() }(server) } wg.Wait() fc() // Calculate distance // If we don't call FetchUserInfo() before FetchServers(), // we don't calculate the distance, instead we use the // remote computing distance provided by Ookla as default. if s.User != nil { for _, server := range servers { sLat, _ := strconv.ParseFloat(server.Lat, 64) sLon, _ := strconv.ParseFloat(server.Lon, 64) uLat, _ := strconv.ParseFloat(s.User.Lat, 64) uLon, _ := strconv.ParseFloat(s.User.Lon, 64) server.Distance = distance(sLat, sLon, uLat, uLon) } } // Sort by distance sort.Sort(ByDistance{servers}) if len(servers) <= 0 { return servers, ErrServerNotFound } return servers, nil } // FetchServerListContext retrieves a list of available servers, observing the given context. func FetchServerListContext(ctx context.Context) (Servers, error) { return defaultClient.FetchServerListContext(ctx) } func distance(lat1 float64, lon1 float64, lat2 float64, lon2 float64) float64 { radius := 6378.137 phi1 := lat1 * math.Pi / 180.0 phi2 := lat2 * math.Pi / 180.0 deltaPhiHalf := (lat1 - lat2) * math.Pi / 360.0 deltaLambdaHalf := (lon1 - lon2) * math.Pi / 360.0 sinePhiHalf2 := math.Sin(deltaPhiHalf)*math.Sin(deltaPhiHalf) + math.Cos(phi1)*math.Cos(phi2)*math.Sin(deltaLambdaHalf)*math.Sin(deltaLambdaHalf) // phi half-angle sine ^ 2 delta := 2 * math.Atan2(math.Sqrt(sinePhiHalf2), math.Sqrt(1-sinePhiHalf2)) // 2 arc sine return radius * delta // r * delta } // FindServer finds server by serverID in given server list. // If the id is not found in the given list, return the server with the lowest latency. func (servers Servers) FindServer(serverID []int) (Servers, error) { retServer := Servers{} if len(servers) <= 0 { return retServer, ErrServerNotFound } for _, sid := range serverID { for _, s := range servers { id, _ := strconv.Atoi(s.ID) if sid == id { retServer = append(retServer, s) break } } } if len(retServer) == 0 { // choose the lowest latency server var minLatency int64 = math.MaxInt64 var minServerIndex int for index, server := range servers { if server.Latency <= 0 { continue } if minLatency > server.Latency.Milliseconds() { minLatency = server.Latency.Milliseconds() minServerIndex = index } } retServer = append(retServer, servers[minServerIndex]) } return retServer, nil } // String representation of ServerList func (servers ServerList) String() string { slr := "" for _, server := range servers.Servers { slr += server.String() } return slr } // String representation of Servers func (servers Servers) String() string { slr := "" for _, server := range servers { slr += server.String() } return slr } // String representation of Server func (s *Server) String() string { if s.Sponsor == "?" { return fmt.Sprintf("[%4s] %s", s.ID, s.Name) } if len(s.Country) == 0 { return fmt.Sprintf("[%4s] %.2fkm %s by %s", s.ID, s.Distance, s.Name, s.Sponsor) } return fmt.Sprintf("[%4s] %.2fkm %s (%s) by %s", s.ID, s.Distance, s.Name, s.Country, s.Sponsor) } // CheckResultValid checks that results are logical given UL and DL speeds func (s *Server) CheckResultValid() bool { return !(s.DLSpeed*100 < s.ULSpeed) || !(s.DLSpeed > s.ULSpeed*100) } func (s *Server) testDurationTotalCount() { total := s.getNotNullValue(s.TestDuration.Ping) + s.getNotNullValue(s.TestDuration.Download) + s.getNotNullValue(s.TestDuration.Upload) s.TestDuration.Total = &total } func (s *Server) getNotNullValue(time *time.Duration) time.Duration { if time == nil { return 0 } return *time } showwin-speedtest-go-df849ca/speedtest/server_test.go000066400000000000000000000116601524547167300232330ustar00rootroot00000000000000package speedtest import ( "errors" "math" "math/rand" "testing" "time" ) func TestFetchServerList(t *testing.T) { client := New() client.User = &User{ IP: "111.111.111.111", Lat: "35.22", Lon: "138.44", Isp: "Hello", } servers, err := client.FetchServers() if err != nil { t.Error(err) } if len(servers) == 0 { t.Errorf("Failed to fetch server list.") return } if len(servers[0].Country) == 0 { t.Errorf("got unexpected country name '%v'", servers[0].Country) } } func TestDistanceSame(t *testing.T) { for i := 0; i < 10000000; i++ { v1 := rand.Float64() * 90 v2 := rand.Float64() * 180 v3 := rand.Float64() * 90 v4 := rand.Float64() * 180 k1 := distance(v1, v2, v1, v2) k2 := distance(v1, v2, v3, v4) if math.IsNaN(k1) || math.IsNaN(k2) { t.Fatalf("NaN distance: %f, %f, %f, %f", v1, v2, v3, v4) } } testdata := [][]float64{ {32.0803, 34.7805, 32.0803, 34.7805}, {0, 0, 0, 0}, {1, 1, 1, 1}, {2, 2, 2, 2}, {-123.23, 123.33, -123.23, 123.33}, {90, 180, 90, 180}, } for i := range testdata { k := distance(testdata[i][0], testdata[i][1], testdata[i][2], testdata[i][3]) if math.IsNaN(k) { t.Fatalf("NaN distance: %f, %f, %f, %f", testdata[i][0], testdata[i][1], testdata[i][2], testdata[i][3]) } } } func TestDistance(t *testing.T) { d := distance(0.0, 0.0, 1.0, 1.0) if d < 157 || 158 < d { t.Errorf("got: %v, expected between 157 and 158", d) } d = distance(0.0, 180.0, 0.0, -180.0) if d < 0 && d > 1 { t.Errorf("got: %v, expected 0", d) } d1 := distance(100.0, 100.0, 100.0, 101.0) d2 := distance(100.0, 100.0, 100.0, 99.0) if d1 != d2 { t.Errorf("%v and %v should be same value", d1, d2) } d = distance(35.0, 140.0, -40.0, -140.0) if d < 11000 || 12000 < d { t.Errorf("got: %v, expected ~11694.5122", d) } } func TestFindServer(t *testing.T) { servers := Servers{ &Server{ ID: "1", }, &Server{ ID: "2", }, &Server{ ID: "3", }, } var serverID []int s, err := servers.FindServer(serverID) if err != nil { t.Error(err) } if len(s) != 1 { t.Errorf("unexpected server length. got: %v, expected: 1", len(s)) } if s[0].ID != "1" { t.Errorf("unexpected server ID. got: %v, expected: '1'", s[0].ID) } serverID = []int{2} s, err = servers.FindServer(serverID) if err != nil { t.Error(err) } if len(s) != 1 { t.Errorf("unexpected server length. got: %v, expected: 1", len(s)) } if s[0].ID != "2" { t.Errorf("unexpected server ID. got: %v, expected: '2'", s[0].ID) } serverID = []int{3, 1} s, err = servers.FindServer(serverID) if err != nil { t.Error(err) } if len(s) != 2 { t.Errorf("unexpected server length. got: %v, expected: 2", len(s)) } if s[0].ID != "3" { t.Errorf("unexpected server ID. got: %v, expected: '3'", s[0].ID) } if s[1].ID != "1" { t.Errorf("unexpected server ID. got: %v, expected: '1'", s[0].ID) } } func TestCustomServer(t *testing.T) { // Good server got, err := CustomServer("https://example.com/upload.php") if err != nil { t.Error(err) } if got == nil { t.Error("empty server") return } if got.Host != "example.com" { t.Error("did not properly set the Host field on a custom server") } } func TestFetchServerByID(t *testing.T) { remoteList, err := FetchServers() if err != nil { t.Fatal(err) } if remoteList.Len() < 1 { t.Fatal(errors.New("server not found")) } testData := map[string]bool{ remoteList[0].ID: true, "-99999999": false, "どうも": false, "hello": false, "你好": false, } for id, b := range testData { server, err := FetchServerByID(id) if err != nil && b { t.Error(err) } if server != nil && (server.ID == id) != b { t.Errorf("id %s == %s is not %v", id, server.ID, b) } } } func TestTotalDurationCount(t *testing.T) { server, _ := CustomServer("https://example.com/upload.php") uploadTime := time.Duration(10000805542) server.TestDuration.Upload = &uploadTime server.testDurationTotalCount() if server.TestDuration.Total.Nanoseconds() != 10000805542 { t.Error("addition in testDurationTotalCount didn't work") } downloadTime := time.Duration(10000403875) server.TestDuration.Download = &downloadTime server.testDurationTotalCount() if server.TestDuration.Total.Nanoseconds() != 20001209417 { t.Error("addition in testDurationTotalCount didn't work") } pingTime := time.Duration(2183156458) server.TestDuration.Ping = &pingTime server.testDurationTotalCount() if server.TestDuration.Total.Nanoseconds() != 22184365875 { t.Error("addition in testDurationTotalCount didn't work") } } func TestCityFlag(t *testing.T) { testCC := "YISHUN" testData := Servers{ {CC: "YISHUN"}, {CC: "TOKYO"}, {CC: "YISHUN"}, {CC: "TEST"}, } tmpServers := testData.CC([]string{testCC}) if tmpServers.Len() != 2 { t.Fatalf("got %d servers, want 2", tmpServers.Len()) } for _, server := range tmpServers { if server.CC != testCC { t.Fatalf("got country code %q, want %q", server.CC, testCC) } } } showwin-speedtest-go-df849ca/speedtest/source_linux.go000066400000000000000000000020051524547167300233760ustar00rootroot00000000000000//go:build linux package speedtest import ( "net" "syscall" ) // resolveInterface checks if source is a network interface name. // If so, it returns the interface's first IPv4 address and a // DialerControl function that binds sockets to the interface // via SO_BINDTODEVICE. func resolveInterface(source string) (ip net.IP, control func(string, string, syscall.RawConn) error, ok bool) { iface, err := net.InterfaceByName(source) if err != nil { return nil, nil, false } addrs, err := iface.Addrs() if err != nil { return nil, nil, false } for _, a := range addrs { ipnet, isIPNet := a.(*net.IPNet) if isIPNet && ipnet.IP.To4() != nil { devName := iface.Name ctrl := func(_, _ string, c syscall.RawConn) error { var serr error if err := c.Control(func(fd uintptr) { serr = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, devName) }); err != nil { return err } return serr } return ipnet.IP, ctrl, true } } return nil, nil, false } showwin-speedtest-go-df849ca/speedtest/source_other.go000066400000000000000000000002741524547167300233660ustar00rootroot00000000000000//go:build !linux package speedtest import ( "net" "syscall" ) func resolveInterface(_ string) (net.IP, func(string, string, syscall.RawConn) error, bool) { return nil, nil, false } showwin-speedtest-go-df849ca/speedtest/source_test.go000066400000000000000000000014411524547167300232210ustar00rootroot00000000000000package speedtest import ( "net" "runtime" "testing" ) func TestResolveInterface(t *testing.T) { t.Run("InvalidName", func(t *testing.T) { _, _, ok := resolveInterface("nonexistent0") if ok { t.Error("expected ok=false for nonexistent interface") } }) t.Run("IPAddress", func(t *testing.T) { _, _, ok := resolveInterface("127.0.0.1") if ok { t.Error("expected ok=false for IP address") } }) t.Run("Loopback", func(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("SO_BINDTODEVICE requires Linux") } ip, ctrl, ok := resolveInterface("lo") if !ok { t.Fatal("expected ok=true for lo") } if !ip.Equal(net.IPv4(127, 0, 0, 1)) { t.Errorf("expected 127.0.0.1, got %s", ip) } if ctrl == nil { t.Error("expected non-nil control function") } }) } showwin-speedtest-go-df849ca/speedtest/speedtest.go000066400000000000000000000135561524547167300226740ustar00rootroot00000000000000package speedtest import ( "context" "fmt" "net" "net/http" "net/url" "runtime" "strings" "syscall" "time" ) var ( version = "1.8.3" DefaultUserAgent = fmt.Sprintf("showwin/speedtest-go %s", version) ) type Proto int const ( HTTP Proto = iota TCP ICMP ) type TestMode int const ( HTTPTest TestMode = iota TCPTest ) // Speedtest is a speedtest client. type Speedtest struct { User *User Manager doer *http.Client config *UserConfig tcpDialer *net.Dialer udpDialer *net.Dialer ipDialer *net.Dialer } type UserConfig struct { T *http.Transport UserAgent string Proxy string Source string DnsBindSource bool DialerControl func(network, address string, c syscall.RawConn) error Debug bool PingMode Proto TestMode TestMode SavingMode bool MaxConnections int CityFlag string LocationFlag string Location *Location Keyword string // Fuzzy search } func parseAddr(addr string) (string, string) { prefixIndex := strings.Index(addr, "://") if prefixIndex != -1 { return addr[:prefixIndex], addr[prefixIndex+3:] } return "", addr // ignore address network prefix } func (s *Speedtest) NewUserConfig(uc *UserConfig) { if uc.Debug { dbg.Enable() } if uc.SavingMode { uc.MaxConnections = 1 // Set the number of concurrent connections to 1 } s.SetNThread(uc.MaxConnections) if len(uc.CityFlag) > 0 { var err error uc.Location, err = GetLocation(uc.CityFlag) if err != nil { dbg.Printf("Warning: skipping command line arguments: --city. err: %v\n", err.Error()) } } if len(uc.LocationFlag) > 0 { var err error uc.Location, err = ParseLocation(uc.CityFlag, uc.LocationFlag) if err != nil { dbg.Printf("Warning: skipping command line arguments: --location. err: %v\n", err.Error()) } } var tcpSource net.Addr // If nil, a local address is automatically chosen. var udpSource net.Addr var icmpSource net.Addr var proxy = http.ProxyFromEnvironment s.config = uc if len(s.config.UserAgent) == 0 { s.config.UserAgent = DefaultUserAgent } if len(uc.Source) > 0 { _, address := parseAddr(uc.Source) // Try as interface name first (Linux: SO_BINDTODEVICE) if ifIP, ctrl, ok := resolveInterface(address); ok { address = ifIP.String() if uc.DialerControl == nil { uc.DialerControl = ctrl } } addr0, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("[%s]:0", address)) // dynamic tcp port if err == nil { tcpSource = addr0 } else { dbg.Printf("Warning: skipping parse the source address. err: %s\n", err.Error()) } if sourceIP := net.ParseIP(address); sourceIP != nil { udpSource = &net.UDPAddr{IP: sourceIP} } addr1, err := net.ResolveIPAddr("ip", address) // dynamic tcp port if err == nil { icmpSource = addr1 } else { dbg.Printf("Warning: skipping parse the source address. err: %s\n", err.Error()) } if uc.DnsBindSource { net.DefaultResolver.Dial = func(ctx context.Context, network, dnsServer string) (net.Conn, error) { dialer := &net.Dialer{ Timeout: 5 * time.Second, LocalAddr: func(network string) net.Addr { switch network { case "udp", "udp4", "udp6": return &net.UDPAddr{IP: net.ParseIP(address)} case "tcp", "tcp4", "tcp6": return &net.TCPAddr{IP: net.ParseIP(address)} default: return nil } }(network), } return dialer.DialContext(ctx, network, dnsServer) } } } if len(uc.Proxy) > 0 { if parse, err := url.Parse(uc.Proxy); err != nil { dbg.Printf("Warning: skipping parse the proxy host. err: %s\n", err.Error()) } else { proxy = func(_ *http.Request) (*url.URL, error) { return parse, err } } } s.tcpDialer = &net.Dialer{ LocalAddr: tcpSource, Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Control: uc.DialerControl, } s.udpDialer = &net.Dialer{ LocalAddr: udpSource, Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Control: uc.DialerControl, } s.ipDialer = &net.Dialer{ LocalAddr: icmpSource, Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Control: uc.DialerControl, } s.config.T = &http.Transport{ Proxy: proxy, DialContext: s.tcpDialer.DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } s.doer.Transport = s } func (s *Speedtest) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Add("User-Agent", s.config.UserAgent) return s.config.T.RoundTrip(req) } // Option is a function that can be passed to New to modify the Client. type Option func(*Speedtest) // WithDoer sets the http.Client used to make requests. func WithDoer(doer *http.Client) Option { return func(s *Speedtest) { s.doer = doer } } // WithUserConfig adds a custom user config for speedtest. // This configuration may be overwritten again by WithDoer, // because client and transport are parent-child relationship: // `New(WithDoer(myDoer), WithUserAgent(myUserAgent), WithDoer(myDoer))` func WithUserConfig(userConfig *UserConfig) Option { return func(s *Speedtest) { s.NewUserConfig(userConfig) dbg.Printf("Source: %s\n", s.config.Source) dbg.Printf("Proxy: %s\n", s.config.Proxy) dbg.Printf("SavingMode: %v\n", s.config.SavingMode) dbg.Printf("Keyword: %v\n", s.config.Keyword) dbg.Printf("PingType: %v\n", s.config.PingMode) dbg.Printf("OS: %s, ARCH: %s, NumCPU: %d\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) } } // New creates a new speedtest client. func New(opts ...Option) *Speedtest { s := &Speedtest{ doer: http.DefaultClient, Manager: NewDataManager(), } // load default config s.NewUserConfig(&UserConfig{UserAgent: DefaultUserAgent}) for _, opt := range opts { opt(s) } return s } func Version() string { return version } var defaultClient = New() showwin-speedtest-go-df849ca/speedtest/speedtest_test.go000066400000000000000000000035231524547167300237240ustar00rootroot00000000000000package speedtest import ( "net/http" "net/http/httptest" "testing" ) func BenchmarkLogSpeed(b *testing.B) { s := New() config := &UserConfig{ UserAgent: DefaultUserAgent, Debug: false, } WithUserConfig(config)(s) for i := 0; i < b.N; i++ { dbg.Printf("hello %s\n", "s20080123") // ~1ns/op } } func TestNew(t *testing.T) { t.Run("DefaultDoer", func(t *testing.T) { c := New() if c.doer == nil { t.Error("doer is nil by") } }) t.Run("CustomDoer", func(t *testing.T) { doer := &http.Client{} c := New(WithDoer(doer)) if c.doer != doer { t.Error("doer is not the same") } }) } func TestUserAgent(t *testing.T) { testServer := func(expectedUserAgent string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.UserAgent() == "" { t.Error("did not receive User-Agent header") } else if r.UserAgent() != expectedUserAgent { t.Errorf("incorrect User-Agent header: %s, expected: %s", r.UserAgent(), expectedUserAgent) } })) } t.Run("DefaultUserAgent", func(t *testing.T) { c := New(WithUserConfig(&UserConfig{UserAgent: DefaultUserAgent})) s := testServer(DefaultUserAgent) _, err := c.doer.Get(s.URL) if err != nil { t.Error(err) } }) t.Run("CustomUserAgent", func(t *testing.T) { testAgent := "1234" s := testServer(testAgent) c := New(WithUserConfig(&UserConfig{UserAgent: testAgent})) _, err := c.doer.Get(s.URL) if err != nil { t.Error(err) } }) // Test that With t.Run("CustomUserAgentAndDoer", func(t *testing.T) { testAgent := "4321" doer := &http.Client{} s := testServer(testAgent) c := New(WithDoer(doer), WithUserConfig(&UserConfig{UserAgent: testAgent})) if c.doer != doer { t.Error("doer is not the same") } _, err := c.doer.Get(s.URL) if err != nil { t.Error(err) } }) } showwin-speedtest-go-df849ca/speedtest/transport/000077500000000000000000000000001524547167300223675ustar00rootroot00000000000000showwin-speedtest-go-df849ca/speedtest/transport/tcp.go000066400000000000000000000236721524547167300235160ustar00rootroot00000000000000package transport import ( "bufio" "bytes" "context" "errors" "fmt" "io" "net" "strconv" "strings" "time" ) var ( pingPrefix = []byte{0x50, 0x49, 0x4e, 0x47, 0x20} downloadPrefix = []byte{0x44, 0x4F, 0x57, 0x4E, 0x4C, 0x4F, 0x41, 0x44, 0x20} uploadPrefix = []byte{0x55, 0x50, 0x4C, 0x4F, 0x41, 0x44, 0x20} initPacket = []byte{0x49, 0x4e, 0x49, 0x54, 0x50, 0x4c, 0x4f, 0x53, 0x53} packetLoss = []byte{0x50, 0x4c, 0x4f, 0x53, 0x53} hiFormat = []byte{0x48, 0x49} quitFormat = []byte{0x51, 0x55, 0x49, 0x54} ) var ( ErrEchoData = errors.New("incorrect echo data") ErrEmptyConn = errors.New("empty conn") ErrUnsupported = errors.New("unsupported protocol") // Some servers have disabled ip:8080, we return this error. ErrUninitializedPacketLossInst = errors.New("uninitialized packet loss inst") ErrInvalidResponse = errors.New("invalid tcp speedtest response") ) func pingFormat(locTime int64) []byte { command := append([]byte{}, pingPrefix...) return strconv.AppendInt(command, locTime, 10) } func downloadFormat(size int64) []byte { command := append([]byte{}, downloadPrefix...) return strconv.AppendInt(command, size, 10) } func uploadFormat(size int64) []byte { command := append([]byte{}, uploadPrefix...) command = strconv.AppendInt(command, size, 10) return append(command, ' ', '0') } type Client struct { id string conn net.Conn host string version string dialer *net.Dialer reader *bufio.Reader } func NewClient(dialer *net.Dialer) (*Client, error) { uuid, err := generateUUID() if err != nil { return nil, err } return &Client{ id: uuid, dialer: dialer, }, nil } func (client *Client) ID() string { return client.id } func (client *Client) Connect(ctx context.Context, host string) (err error) { if client.dialer == nil { return ErrEmptyConn } client.host = host client.conn, err = client.dialer.DialContext(ctx, "tcp", client.host) if err != nil { return err } client.reader = bufio.NewReader(client.conn) return nil } func (client *Client) Disconnect() (err error) { if client.conn == nil { return nil } _ = client.writeAll(append(append([]byte{}, quitFormat...), '\n')) _ = client.conn.Close() client.conn = nil client.reader = nil client.version = "" return } func (client *Client) Write(data []byte) (err error) { if client.conn == nil { return ErrEmptyConn } return client.writeAll(append(append([]byte{}, data...), '\n')) } func (client *Client) writeAll(data []byte) error { for len(data) > 0 { n, err := client.conn.Write(data) if err != nil { return err } if n == 0 { return io.ErrShortWrite } data = data[n:] } return nil } func (client *Client) Read() ([]byte, error) { if client.conn == nil { return nil, ErrEmptyConn } return client.reader.ReadBytes('\n') } func (client *Client) Version() string { version, err := client.VersionContext(context.Background()) if err != nil { return "unknown" } return version } // VersionContext performs the HI handshake while observing ctx. func (client *Client) VersionContext(ctx context.Context) (string, error) { if client.conn == nil { return "", ErrEmptyConn } if client.version != "" { return client.version, nil } stop := client.watchContext(ctx) defer stop() if err := client.setDeadline(ctx); err != nil { return "", err } if err := client.Write(hiFormat); err != nil { return "", err } message, err := client.Read() if err != nil { return "", err } if len(message) < 8 || !bytes.HasPrefix(message, []byte("HELLO ")) { return "", ErrInvalidResponse } client.version = string(message[6 : len(message)-1]) return client.version, nil } // PingContext Measure latency(RTT) between client and server. // We use the 2RTT method to obtain three RTT result in // order to get more data in less time (t2-t0, t4-t2, t3-t1). // And give lower weight to the delay measured by the server. // local factor = 0.4 * 2 and remote factor = 0.2 // latency = 0.4 * (t2 - t0) + 0.4 * (t4 - t2) + 0.2 * (t3 - t1) // @return cumulative delay in nanoseconds func (client *Client) PingContext(ctx context.Context) (int64, error) { if err := client.setDeadline(ctx); err != nil { return 0, err } stop := client.watchContext(ctx) defer stop() resultChan := make(chan error, 1) var accumulatedLatency int64 = 0 var firstReceivedByServer int64 // t1 go func() { for i := 0; i < 2; i++ { t0 := time.Now().UnixNano() if err := client.Write(pingFormat(t0)); err != nil { resultChan <- err return } data, err := client.Read() t2 := time.Now().UnixNano() if err != nil { resultChan <- err return } if len(data) != 19 { resultChan <- ErrEchoData return } tx, err := strconv.ParseInt(string(data[5:18]), 10, 64) if err != nil { resultChan <- err return } accumulatedLatency += (t2 - t0) * 4 / 10 // 0.4 if i == 0 { firstReceivedByServer = tx } else { // append server-side latency result accumulatedLatency += (tx - firstReceivedByServer) * 1000 * 1000 * 2 / 10 // 0.2 } } resultChan <- nil close(resultChan) }() select { case err := <-resultChan: return accumulatedLatency, err case <-ctx.Done(): return 0, ctx.Err() } } func (client *Client) InitPacketLoss() error { id := client.id payload := append(hiFormat, 0x20) payload = append(payload, []byte(id)...) err := client.Write(payload) if err != nil { return err } return client.Write(initPacket) } // PLoss Packet loss statistics // The packet loss here generally refers to uplink packet loss. // We use the following formula to calculate the packet loss: // packetLoss = [1 - (Sent - Dup) / (Max + 1)] * 100% type PLoss struct { Sent int `json:"sent"` // Number of sent packets acknowledged by the remote. Dup int `json:"dup"` // Number of duplicate packets acknowledged by the remote. Max int `json:"max"` // The maximum index value received by the remote. } func (p PLoss) String() string { if p.Sent == 0 { // if p.Sent == 0, maybe all data is dropped by the upper gateway. // we believe this feature is not applicable on this server now. return "Packet Loss: N/A" } return fmt.Sprintf("Packet Loss: %.2f%% (Sent: %d/Dup: %d/Max: %d)", p.Loss()*100, p.Sent, p.Dup, p.Max) } func (p PLoss) Loss() float64 { if p.Sent == 0 { return -1 } return 1 - (float64(p.Sent-p.Dup))/float64(p.Max+1) } func (p PLoss) LossPercent() float64 { if p.Sent == 0 { return -1 } return p.Loss() * 100 } func (client *Client) PacketLoss() (*PLoss, error) { err := client.Write(packetLoss) if err != nil { return nil, err } result, err := client.Read() if err != nil { return nil, err } splitResult := bytes.Split(result, []byte{0x20}) if len(splitResult) < 4 || !bytes.Equal(splitResult[0], packetLoss) { return nil, nil } x0, err := strconv.Atoi(string(splitResult[1])) if err != nil { return nil, err } x1, err := strconv.Atoi(string(splitResult[2])) if err != nil { return nil, err } x2, err := strconv.Atoi(string(bytes.TrimRight(splitResult[3], "\n"))) if err != nil { return nil, err } return &PLoss{ Sent: x0, Dup: x1, Max: x2, }, nil } // Download requests exactly size bytes and passes the response stream to handler. func (client *Client) Download(ctx context.Context, size int64, handler func(io.Reader) error) error { if client.conn == nil { return ErrEmptyConn } if size <= 0 || handler == nil { return ErrInvalidResponse } stop := client.watchContext(ctx) defer stop() if err := client.setDeadline(ctx); err != nil { return err } if err := client.Write(downloadFormat(size)); err != nil { return err } reader := &countingReader{Reader: io.LimitReader(client.reader, size)} if err := handler(reader); err != nil { return err } if reader.n != size { return io.ErrUnexpectedEOF } return nil } // Upload sends exactly size bytes including the protocol command and final newline. // The returned value is the byte count acknowledged by the server. func (client *Client) Upload(ctx context.Context, size int64, src io.Reader) (int64, error) { if client.conn == nil { return 0, ErrEmptyConn } if size <= 0 || src == nil { return 0, ErrInvalidResponse } stop := client.watchContext(ctx) defer stop() if err := client.setDeadline(ctx); err != nil { return 0, err } payloadSize, err := UploadPayloadSize(size) if err != nil { return 0, err } command := append(uploadFormat(size), '\n') if err := client.writeAll(command); err != nil { return 0, err } if _, err := io.CopyN(client.conn, src, payloadSize); err != nil { return 0, err } if err := client.writeAll([]byte{'\n'}); err != nil { return 0, err } response, err := client.Read() if err != nil { return 0, err } fields := strings.Fields(string(response)) if len(fields) < 2 || fields[0] != "OK" { return 0, ErrInvalidResponse } acknowledged, err := strconv.ParseInt(fields[1], 10, 64) if err != nil || acknowledged < 0 || acknowledged > size { return 0, ErrInvalidResponse } return acknowledged, nil } // UploadPayloadSize returns the number of data bytes sent after the upload command. func UploadPayloadSize(size int64) (int64, error) { if size <= 0 { return 0, ErrInvalidResponse } commandSize := int64(len(uploadFormat(size)) + 1) payloadSize := size - commandSize - 1 if payloadSize < 0 { return 0, ErrInvalidResponse } return payloadSize, nil } type countingReader struct { io.Reader n int64 } func (r *countingReader) Read(p []byte) (int, error) { n, err := r.Reader.Read(p) r.n += int64(n) return n, err } func (client *Client) setDeadline(ctx context.Context) error { if client.conn == nil { return ErrEmptyConn } if deadline, ok := ctx.Deadline(); ok { return client.conn.SetDeadline(deadline) } return client.conn.SetDeadline(time.Time{}) } func (client *Client) watchContext(ctx context.Context) func() { done := make(chan struct{}) go func() { select { case <-ctx.Done(): _ = client.conn.Close() case <-done: } }() return func() { close(done) } } showwin-speedtest-go-df849ca/speedtest/transport/tcp_test.go000066400000000000000000000064411524547167300245500ustar00rootroot00000000000000package transport import ( "bufio" "bytes" "context" "io" "net" "strings" "testing" "time" ) func TestClientDownload(t *testing.T) { const size int64 = 32 want := bytes.Repeat([]byte("JABCDEFGHI"), 4)[:size] addr, stop := startTestTCPServer(t, func(conn net.Conn) { reader := bufio.NewReader(conn) if line, _ := reader.ReadString('\n'); strings.TrimSpace(line) != "HI" { t.Errorf("unexpected handshake: %q", line) } _, _ = conn.Write([]byte("HELLO 2.1 test\n")) line, _ := reader.ReadString('\n') if strings.TrimSpace(line) != "DOWNLOAD 32" { t.Errorf("unexpected download command: %q", line) } _, _ = conn.Write(want[:7]) _, _ = conn.Write(want[7:]) }) defer stop() client, err := NewClient(&net.Dialer{Timeout: time.Second}) if err != nil { t.Fatal(err) } if err = client.Connect(context.Background(), addr); err != nil { t.Fatal(err) } defer func() { _ = client.Disconnect() }() if _, err := client.VersionContext(context.Background()); err != nil { t.Fatal("handshake failed") } var got bytes.Buffer err = client.Download(context.Background(), size, func(r io.Reader) error { _, err := io.Copy(&got, r) return err }) if err != nil { t.Fatal(err) } if !bytes.Equal(got.Bytes(), want) { t.Fatalf("download data mismatch: got %q want %q", got.Bytes(), want) } } func TestClientUploadUsesDeclaredTotal(t *testing.T) { const size int64 = 20 addr, stop := startTestTCPServer(t, func(conn net.Conn) { reader := bufio.NewReader(conn) _, _ = reader.ReadString('\n') _, _ = conn.Write([]byte("HELLO 2.1 test\n")) line, _ := reader.ReadString('\n') if strings.TrimSpace(line) != "UPLOAD 20 0" { t.Errorf("unexpected upload command: %q", line) } payloadSize := size - int64(len(line)) - 1 payload := make([]byte, payloadSize+1) if _, err := io.ReadFull(reader, payload); err != nil { t.Errorf("read upload payload: %v", err) } if payload[len(payload)-1] != '\n' { t.Errorf("upload payload is missing final newline") } _, _ = conn.Write([]byte("OK 20 0\n")) }) defer stop() client, err := NewClient(&net.Dialer{Timeout: time.Second}) if err != nil { t.Fatal(err) } if err = client.Connect(context.Background(), addr); err != nil { t.Fatal(err) } defer func() { _ = client.Disconnect() }() if _, err := client.VersionContext(context.Background()); err != nil { t.Fatal("handshake failed") } payloadSize, err := UploadPayloadSize(size) if err != nil || payloadSize != 7 { t.Fatalf("payload size = %d, want 7 (err=%v)", payloadSize, err) } acknowledged, err := client.Upload(context.Background(), size, bytes.NewReader(bytes.Repeat([]byte("x"), int(size)))) if err != nil { t.Fatal(err) } if acknowledged != size { t.Fatalf("acknowledged %d bytes, want %d", acknowledged, size) } } func startTestTCPServer(t *testing.T, handler func(net.Conn)) (string, func()) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } stop := make(chan struct{}) go func() { for { conn, err := listener.Accept() if err != nil { select { case <-stop: return default: t.Error(err) return } } go func() { defer func() { _ = conn.Close() }() handler(conn) }() } }() return listener.Addr().String(), func() { close(stop) _ = listener.Close() } } showwin-speedtest-go-df849ca/speedtest/transport/udp.go000066400000000000000000000032251524547167300235100ustar00rootroot00000000000000package transport import ( "bytes" "context" "crypto/rand" "fmt" mrand "math/rand" "net" "strconv" "strings" "time" ) var ( loss = []byte{0x4c, 0x4f, 0x53, 0x53} ) type PacketLossSender struct { ID string // UUID nounce int64 // Random int (maybe) [0,10000000000) withTimestamp bool // With timestamp (ten seconds level) conn net.Conn // UDP Conn raw []byte host string dialer *net.Dialer } func NewPacketLossSender(uuid string, dialer *net.Dialer) (*PacketLossSender, error) { rd := mrand.New(mrand.NewSource(time.Now().UnixNano())) nounce := rd.Int63n(10000000000) p := &PacketLossSender{ ID: strings.ToUpper(uuid), nounce: nounce, withTimestamp: false, // we close it as default, we won't be able to use it right now. dialer: dialer, } p.raw = []byte(fmt.Sprintf("%s %d %s %s", loss, nounce, "#", uuid)) return p, nil } func (ps *PacketLossSender) Connect(ctx context.Context, host string) (err error) { ps.host = host ps.conn, err = ps.dialer.DialContext(ctx, "udp", ps.host) return err } // Send // @param order the value will be sent func (ps *PacketLossSender) Send(order int) error { payload := bytes.Replace(ps.raw, []byte{0x23}, []byte(strconv.Itoa(order)), 1) _, err := ps.conn.Write(payload) return err } func generateUUID() (string, error) { randUUID := make([]byte, 16) _, err := rand.Read(randUUID) if err != nil { return "", err } randUUID[8] = randUUID[8]&^0xc0 | 0x80 randUUID[6] = randUUID[6]&^0xf0 | 0x40 return fmt.Sprintf("%x-%x-%x-%x-%x", randUUID[0:4], randUUID[4:6], randUUID[6:8], randUUID[8:10], randUUID[10:]), nil } showwin-speedtest-go-df849ca/speedtest/unit.go000066400000000000000000000047101524547167300216430ustar00rootroot00000000000000package speedtest import ( "strconv" ) type UnitType int // IEC and SI const ( UnitTypeDecimalBits = UnitType(iota) // auto scaled UnitTypeDecimalBytes // auto scaled UnitTypeBinaryBits // auto scaled UnitTypeBinaryBytes // auto scaled UnitTypeDefaultMbps // fixed ) var ( DecimalBitsUnits = []string{"bps", "Kbps", "Mbps", "Gbps"} DecimalBytesUnits = []string{"B/s", "KB/s", "MB/s", "GB/s"} BinaryBitsUnits = []string{"Kibps", "KiMbps", "KiGbps"} BinaryBytesUnits = []string{"KiB/s", "MiB/s", "GiB/s"} ) var unitMaps = map[UnitType][]string{ UnitTypeDecimalBits: DecimalBitsUnits, UnitTypeDecimalBytes: DecimalBytesUnits, UnitTypeBinaryBits: BinaryBitsUnits, UnitTypeBinaryBytes: BinaryBytesUnits, } const ( B = 1.0 KB = 1000 * B MB = 1000 * KB GB = 1000 * MB IB = 1 KiB = 1024 * IB MiB = 1024 * KiB GiB = 1024 * MiB ) type ByteRate float64 var globalByteRateUnit UnitType func (r ByteRate) String() string { if r == 0 { return "0.00 Mbps" } if r == -1 { return "N/A" } if globalByteRateUnit != UnitTypeDefaultMbps { return r.Byte(globalByteRateUnit) } return strconv.FormatFloat(float64(r/125000.0), 'f', 2, 64) + " Mbps" } // SetUnit Set global output units func SetUnit(unit UnitType) { globalByteRateUnit = unit } func (r ByteRate) Mbps() float64 { return float64(r) / 125000.0 } func (r ByteRate) Gbps() float64 { return float64(r) / 125000000.0 } // Byte Specifies the format output byte rate func (r ByteRate) Byte(formatType UnitType) string { if r == 0 { return "0.00 Mbps" } if r == -1 { return "N/A" } return format(float64(r), formatType) } func format(byteRate float64, i UnitType) string { val := byteRate if i%2 == 0 { val *= 8 } if i < UnitTypeBinaryBits { switch { case byteRate >= GB: return strconv.FormatFloat(val/GB, 'f', 2, 64) + " " + unitMaps[i][3] case byteRate >= MB: return strconv.FormatFloat(val/MB, 'f', 2, 64) + " " + unitMaps[i][2] case byteRate >= KB: return strconv.FormatFloat(val/KB, 'f', 2, 64) + " " + unitMaps[i][1] default: return strconv.FormatFloat(val/B, 'f', 2, 64) + " " + unitMaps[i][0] } } switch { case byteRate >= GiB: return strconv.FormatFloat(val/GiB, 'f', 2, 64) + " " + unitMaps[i][2] case byteRate >= MiB: return strconv.FormatFloat(val/MiB, 'f', 2, 64) + " " + unitMaps[i][1] default: return strconv.FormatFloat(val/KiB, 'f', 2, 64) + " " + unitMaps[i][0] } } showwin-speedtest-go-df849ca/speedtest/unit_test.go000066400000000000000000000030721524547167300227020ustar00rootroot00000000000000package speedtest import ( "testing" ) func BenchmarkFmt(b *testing.B) { bt := ByteRate(1002031.0) for i := 0; i < b.N; i++ { _ = bt.Byte(UnitTypeDecimalBits) } } func BenchmarkDefaultFmt(b *testing.B) { bt := ByteRate(1002031.0) for i := 0; i < b.N; i++ { _ = bt.String() } } func TestFmt(t *testing.T) { testData := []struct { rate ByteRate format string t UnitType }{ {123123123.123, "984.98 Mbps", UnitTypeDecimalBits}, {1231231231.123, "9.85 Gbps", UnitTypeDecimalBits}, {123123.123, "984.98 Kbps", UnitTypeDecimalBits}, {123.1, "984.80 bps", UnitTypeDecimalBits}, {123123123.123, "123.12 MB/s", UnitTypeDecimalBytes}, {1231231231.123, "1.23 GB/s", UnitTypeDecimalBytes}, {123123.123, "123.12 KB/s", UnitTypeDecimalBytes}, {123.1, "123.10 B/s", UnitTypeDecimalBytes}, {123123123.123, "939.35 KiMbps", UnitTypeBinaryBits}, {1231231231.123, "9.17 KiGbps", UnitTypeBinaryBits}, {123123.123, "961.90 Kibps", UnitTypeBinaryBits}, {123.1, "0.96 Kibps", UnitTypeBinaryBits}, {123123123.123, "117.42 MiB/s", UnitTypeBinaryBytes}, {1231231231.123, "1.15 GiB/s", UnitTypeBinaryBytes}, {123123.123, "120.24 KiB/s", UnitTypeBinaryBytes}, {123.1, "0.12 KiB/s", UnitTypeBinaryBytes}, {-1, "N/A", UnitTypeBinaryBytes}, {0, "0.00 Mbps", UnitTypeDecimalBits}, } if testData[0].rate.String() != testData[0].format { t.Errorf("got: %s, want: %s", testData[0].rate.String(), testData[0].format) } for _, v := range testData { if got := v.rate.Byte(v.t); got != v.format { t.Errorf("got: %s, want: %s", got, v.format) } } } showwin-speedtest-go-df849ca/speedtest/user.go000066400000000000000000000047251524547167300216500ustar00rootroot00000000000000package speedtest import ( "context" "encoding/xml" "errors" "fmt" "math/rand" "net/http" "net/url" "strconv" ) const speedTestConfigUrl = "https://www.speedtest.net/speedtest-config.php" // User represents information determined about the caller by speedtest.net type User struct { IP string `xml:"ip,attr"` Lat string `xml:"lat,attr"` Lon string `xml:"lon,attr"` Isp string `xml:"isp,attr"` Country string `xml:"country,attr"` } // Users for decode xml type Users struct { Users []User `xml:"client"` } // FetchUserInfo returns information about caller determined by speedtest.net func (s *Speedtest) FetchUserInfo() (*User, error) { return s.FetchUserInfoContext(context.Background()) } // FetchUserInfo returns information about caller determined by speedtest.net func FetchUserInfo() (*User, error) { return defaultClient.FetchUserInfo() } // FetchUserInfoContext returns information about caller determined by speedtest.net, observing the given context. func (s *Speedtest) FetchUserInfoContext(ctx context.Context) (*User, error) { // speedtest-config.php contains request-specific client data. Add a unique // query parameter so a shared CDN cannot serve another user's response. configURL, err := url.Parse(speedTestConfigUrl) if err != nil { return nil, err } query := configURL.Query() query.Set("r", strconv.FormatUint(rand.Uint64(), 16)) configURL.RawQuery = query.Encode() dbg.Printf("Retrieving user info: %s\n", configURL.String()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL.String(), nil) if err != nil { return nil, err } resp, err := s.doer.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() // Decode xml decoder := xml.NewDecoder(resp.Body) var users Users if err = decoder.Decode(&users); err != nil { return nil, err } if len(users.Users) == 0 { return nil, errors.New("failed to fetch user information") } s.User = &users.Users[0] if s.config.Location != nil && len(s.config.Location.CC) > 0 { s.User.Country = s.config.Location.CC } return s.User, nil } // FetchUserInfoContext returns information about caller determined by speedtest.net, observing the given context. func FetchUserInfoContext(ctx context.Context) (*User, error) { return defaultClient.FetchUserInfoContext(ctx) } // String representation of User func (u *User) String() string { extInfo := "" return fmt.Sprintf("%s (%s) [%s, %s] %s", u.IP, u.Isp, u.Lat, u.Lon, extInfo) } showwin-speedtest-go-df849ca/speedtest/user_test.go000066400000000000000000000016531524547167300227040ustar00rootroot00000000000000package speedtest import ( "strconv" "strings" "testing" ) func TestFetchUserInfo(t *testing.T) { client := New() user, err := client.FetchUserInfo() if err != nil { t.Error(err) } if user == nil { t.Error("empty user info") return } // IP if len(user.IP) < 7 || len(user.IP) > 15 { t.Errorf("invalid IP length. got: %v;", user.IP) } if strings.Count(user.IP, ".") != 3 { t.Errorf("invalid IP format. got: %v", user.IP) } // Lat lat, err := strconv.ParseFloat(user.Lat, 64) if err != nil { t.Error(err) } if lat < -90 || 90 < lat { t.Errorf("invalid Latitude. got: %v, expected between -90 and 90", user.Lat) } // Lon lon, err := strconv.ParseFloat(user.Lon, 64) if err != nil { t.Error(err) } if lon < -180 || 180 < lon { t.Errorf("invalid Longitude. got: %v, expected between -180 and 180", user.Lon) } // Isp if len(user.Isp) == 0 { t.Errorf("invalid Iso. got: %v;", user.Isp) } } showwin-speedtest-go-df849ca/speedtest_test.go000066400000000000000000000012121524547167300217150ustar00rootroot00000000000000package main import ( "testing" "github.com/showwin/speedtest-go/speedtest" ) func TestFormatUserInfo(t *testing.T) { user := &speedtest.User{ IP: "219.77.208.183", Isp: "Netvigator Home Broadband", Lat: "22.2908", Lon: "114.1501", } got := formatUserInfo(user, true) want := "***.**.***.*** (Netvigator Home Broadband) [**.****, ***.****]" if got != want { t.Fatalf("formatUserInfo() = %q, want %q", got, want) } } func TestMaskSensitiveValue(t *testing.T) { got := maskSensitiveValue("2001:db8::1/-22.2908") want := "****:***::*/-**.****" if got != want { t.Fatalf("maskSensitiveValue() = %q, want %q", got, want) } } showwin-speedtest-go-df849ca/task.go000066400000000000000000000045041524547167300176270ustar00rootroot00000000000000package main import ( "fmt" "os" "strings" "sync" ) type TaskManager struct { renderer taskRenderer silent bool async sync.WaitGroup } type Task struct { handle taskHandle manager *TaskManager title string } func InitTaskManager(jsonOutput, unixOutput bool) *TaskManager { return &TaskManager{ renderer: newTaskRenderer(jsonOutput, unixOutput), silent: jsonOutput && !unixOutput, } } func (tm *TaskManager) Stop() { if tm == nil { return } tm.async.Wait() tm.stopRenderer() } func (tm *TaskManager) Wait() { if tm == nil { return } tm.async.Wait() } func (tm *TaskManager) stopRenderer() { if tm.renderer == nil { return } tm.renderer.Stop() } func (tm *TaskManager) Println(message string) { if tm == nil || tm.renderer == nil { return } tm.renderer.Println(message) } func (tm *TaskManager) BlankLine() { if tm == nil || tm.renderer == nil { return } tm.renderer.BlankLine() } func (tm *TaskManager) RunWithTrigger(enable bool, title string, callback func(task *Task)) { if enable { tm.Run(title, callback) } } func (tm *TaskManager) Run(title string, callback func(task *Task)) { task := tm.newTask(title) callback(task) } func (tm *TaskManager) AsyncRun(title string, callback func(task *Task)) { task := tm.newTask(title) tm.async.Add(1) go func() { defer tm.async.Done() callback(task) }() } func (tm *TaskManager) newTask(title string) *Task { return &Task{ handle: tm.renderer.NewTask(title), manager: tm, title: title, } } func (t *Task) Complete() { if t == nil || t.handle == nil { return } t.handle.Complete() } func (t *Task) Updatef(format string, a ...interface{}) { if t == nil || t.handle == nil { return } t.handle.Update(fmt.Sprintf(format, a...)) } func (t *Task) Update(message string) { if t == nil || t.handle == nil { return } t.handle.Update(message) } func (t *Task) Println(message string) { t.Update(message) } func (t *Task) Printf(format string, a ...interface{}) { t.Update(fmt.Sprintf(format, a...)) } func (t *Task) CheckError(err error) { if err == nil { return } message := fmt.Sprintf("Fatal: %s, err: %v", strings.ToLower(t.title), err) if t.handle != nil { t.handle.Update(message) t.handle.Error() } if t.manager.silent { _, _ = fmt.Fprintln(os.Stderr, message) } t.manager.stopRenderer() os.Exit(1) } showwin-speedtest-go-df849ca/tui.go000066400000000000000000000214471524547167300174730ustar00rootroot00000000000000package main import ( "bytes" "fmt" "io" "os" "runtime" "strings" "sync" "time" "github.com/chelnak/ysmrr/pkg/animations" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" "golang.org/x/term" ) const ( defaultTerminalWidth = 80 minFrameDuration = 120 * time.Millisecond ansiBrightGreen = "\x1b[92m" ansiBrightRed = "\x1b[91m" ansiReset = "\x1b[0m" ansiSynchronizedOpen = "\x1b[?2026h" ansiSynchronizedClose = "\x1b[?2026l" ) type taskRenderer interface { NewTask(message string) taskHandle Println(message string) BlankLine() Stop() } type taskHandle interface { Update(message string) Complete() Error() } var ( _ taskRenderer = (*interactiveRenderer)(nil) _ taskRenderer = (*plainRenderer)(nil) _ taskRenderer = silentRenderer{} _ taskHandle = (*interactiveTask)(nil) _ taskHandle = (*plainTask)(nil) _ taskHandle = silentTask{} ) func newTaskRenderer(jsonOutput, unixOutput bool) taskRenderer { if jsonOutput && !unixOutput { return silentRenderer{} } if unixOutput || !stdoutIsTerminal() { return newPlainRenderer(os.Stdout) } frameDuration, frames := animations.GetAnimation(animations.Dots) if frameDuration < minFrameDuration { frameDuration = minFrameDuration } return newInteractiveRenderer(defaultTerminalWriter(), frames, frameDuration, terminalWidth) } func stdoutIsTerminal() bool { fd := os.Stdout.Fd() return os.Getenv("YSMRR_FORCE_TTY") == "true" || isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) } func defaultTerminalWriter() io.Writer { if runtime.GOOS == "windows" { return colorable.NewColorableStdout() } return os.Stdout } func terminalWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { return defaultTerminalWidth } return width } type taskStatus uint8 const ( taskRunning taskStatus = iota taskComplete taskError ) type interactiveTask struct { renderer *interactiveRenderer message string status taskStatus raw bool } type interactiveRenderer struct { mu sync.Mutex writer io.Writer tasks []*interactiveTask frames []string frameDuration time.Duration width func() int stop chan struct{} done chan struct{} stopOnce sync.Once renderedLines int frame int } func newInteractiveRenderer( writer io.Writer, frames []string, frameDuration time.Duration, width func() int, ) *interactiveRenderer { if len(frames) == 0 { frames = []string{"-"} } if frameDuration <= 0 { frameDuration = minFrameDuration } if width == nil { width = func() int { return defaultTerminalWidth } } renderer := &interactiveRenderer{ writer: writer, frames: frames, frameDuration: frameDuration, width: width, stop: make(chan struct{}), done: make(chan struct{}), } go renderer.run() return renderer } func (r *interactiveRenderer) NewTask(message string) taskHandle { task := &interactiveTask{renderer: r, message: message, status: taskRunning} r.mu.Lock() r.tasks = append(r.tasks, task) r.mu.Unlock() return task } func (r *interactiveRenderer) Println(message string) { task := &interactiveTask{renderer: r, message: message, status: taskComplete} r.mu.Lock() r.tasks = append(r.tasks, task) r.mu.Unlock() } func (r *interactiveRenderer) BlankLine() { task := &interactiveTask{renderer: r, status: taskComplete, raw: true} r.mu.Lock() r.tasks = append(r.tasks, task) r.mu.Unlock() } func (r *interactiveRenderer) Stop() { r.stopOnce.Do(func() { close(r.stop) <-r.done }) } func (r *interactiveRenderer) run() { ticker := time.NewTicker(r.frameDuration) defer ticker.Stop() defer close(r.done) _, _ = fmt.Fprint(r.writer, "\x1b[?25l") defer func() { r.clearRenderedLines() _, _ = fmt.Fprint(r.writer, "\x1b[?25h") }() for { select { case <-ticker.C: r.render(false) case <-r.stop: r.render(true) return } } } func (r *interactiveRenderer) render(final bool) { finalLines, activeLines := r.collectLines(final) // Build the complete frame before writing it. This avoids exposing the // intermediate cleared state while the terminal redraws the task region. var frame bytes.Buffer frame.WriteString(ansiSynchronizedOpen) r.appendClearRenderedLines(&frame) for _, line := range finalLines { _, _ = fmt.Fprintln(&frame, line) } for _, line := range activeLines { _, _ = fmt.Fprintln(&frame, line) } frame.WriteString(ansiSynchronizedClose) _, _ = r.writer.Write(frame.Bytes()) r.renderedLines = len(activeLines) } func (r *interactiveRenderer) collectLines(final bool) ([]string, []string) { r.mu.Lock() defer r.mu.Unlock() frame := r.frames[r.frame] r.frame = (r.frame + 1) % len(r.frames) width := r.width() finalLines := make([]string, 0) activeLines := make([]string, 0, len(r.tasks)) activeTasks := make([]*interactiveTask, 0, len(r.tasks)) for _, task := range r.tasks { if final || task.status != taskRunning { if task.raw { finalLines = append(finalLines, "") } else { line := formatTaskLine(task.message, task.status, frame, width) finalLines = append(finalLines, colorizeTaskLine(line, task.status)) } continue } line := formatTaskLine(task.message, task.status, frame, width) activeLines = append(activeLines, colorizeTaskLine(line, task.status)) activeTasks = append(activeTasks, task) } if final { r.tasks = nil } else { r.tasks = activeTasks } return finalLines, activeLines } func (r *interactiveRenderer) clearRenderedLines() { if r.renderedLines == 0 { return } var frame bytes.Buffer r.appendClearRenderedLines(&frame) _, _ = r.writer.Write(frame.Bytes()) r.renderedLines = 0 } func (r *interactiveRenderer) appendClearRenderedLines(frame *bytes.Buffer) { if r.renderedLines == 0 { return } fmt.Fprintf(frame, "\x1b[%dA", r.renderedLines) for i := 0; i < r.renderedLines; i++ { frame.WriteString("\r\x1b[2K") if i < r.renderedLines-1 { frame.WriteString("\n") } } if r.renderedLines > 1 { fmt.Fprintf(frame, "\x1b[%dA", r.renderedLines-1) } } func (t *interactiveTask) Update(message string) { t.renderer.mu.Lock() if t.status == taskRunning { t.message = message } t.renderer.mu.Unlock() } func (t *interactiveTask) Complete() { t.renderer.mu.Lock() if t.status == taskRunning { t.status = taskComplete } t.renderer.mu.Unlock() } func (t *interactiveTask) Error() { t.renderer.mu.Lock() if t.status == taskRunning { t.status = taskError } t.renderer.mu.Unlock() } func formatTaskLine(message string, status taskStatus, frame string, width int) string { prefix := frame + " " switch status { case taskComplete: prefix = "\u2713 " case taskError: prefix = "\u2717 " } message = strings.NewReplacer("\r", " ", "\n", " ").Replace(message) prefixWidth := runewidth.StringWidth(prefix) if width <= prefixWidth { return runewidth.Truncate(prefix, width, "") } maxMessageWidth := width - prefixWidth if maxMessageWidth < 0 { maxMessageWidth = 0 } return prefix + runewidth.Truncate(message, maxMessageWidth, "") } func colorizeTaskLine(line string, status taskStatus) string { if line == "" { return line } color := ansiBrightGreen if status == taskError { color = ansiBrightRed } prefixEnd := strings.IndexByte(line, ' ') if prefixEnd < 0 { prefixEnd = len(line) } return color + line[:prefixEnd] + ansiReset + line[prefixEnd:] } type plainRenderer struct { mu sync.Mutex writer io.Writer } type plainTask struct { renderer *plainRenderer mu sync.Mutex message string emitted bool } func newPlainRenderer(writer io.Writer) *plainRenderer { return &plainRenderer{writer: writer} } func (r *plainRenderer) NewTask(message string) taskHandle { return &plainTask{renderer: r, message: message} } func (r *plainRenderer) Println(message string) { r.mu.Lock() _, _ = fmt.Fprintln(r.writer, message) r.mu.Unlock() } func (r *plainRenderer) BlankLine() { r.Println("") } func (r *plainRenderer) Stop() {} func (t *plainTask) Update(message string) { t.mu.Lock() if !t.emitted { t.message = message } t.mu.Unlock() } func (t *plainTask) Complete() { t.emit() } func (t *plainTask) Error() { t.emit() } func (t *plainTask) emit() { t.mu.Lock() defer t.mu.Unlock() if t.emitted { return } t.renderer.mu.Lock() _, _ = fmt.Fprintln(t.renderer.writer, t.message) t.renderer.mu.Unlock() t.emitted = true } type silentRenderer struct{} type silentTask struct{} func (silentRenderer) NewTask(string) taskHandle { return silentTask{} } func (silentRenderer) Println(string) {} func (silentRenderer) BlankLine() {} func (silentRenderer) Stop() {} func (silentTask) Update(string) {} func (silentTask) Complete() {} func (silentTask) Error() {} showwin-speedtest-go-df849ca/tui_test.go000066400000000000000000000103161524547167300205230ustar00rootroot00000000000000package main import ( "bytes" "strings" "testing" "time" ) func TestInteractiveRendererCoalescesUpdates(t *testing.T) { var output bytes.Buffer renderer := newInteractiveRenderer( &output, []string{"."}, time.Hour, func() int { return defaultTerminalWidth }, ) task := renderer.NewTask("starting") task.Update("first update") task.Update("final update") task.Complete() renderer.Stop() renderer.Stop() rendered := output.String() if strings.Contains(rendered, "first update") { t.Fatalf("intermediate update was rendered: %q", rendered) } if count := strings.Count(rendered, "final update"); count != 1 { t.Fatalf("final update rendered %d times: %q", count, rendered) } } func TestInteractiveRendererSerializesBlankLineAfterCompletedTask(t *testing.T) { var output bytes.Buffer renderer := &interactiveRenderer{ writer: &output, frames: []string{"."}, width: func() int { return defaultTerminalWidth }, } task := renderer.NewTask("Retrieving Servers") renderer.render(false) task.Update("Found 30 Public Servers") task.Complete() renderer.BlankLine() renderer.render(false) rendered := output.String() want := "\x1b[1A\r\x1b[2K\x1b[92m\u2713\x1b[0m Found 30 Public Servers\n\n" if !strings.Contains(rendered, want) { t.Fatalf("completed task and blank line were not serialized together: %q", rendered) } if strings.Contains(rendered, "\u2713 \n") { t.Fatalf("blank line was rendered as a completed task: %q", rendered) } } func TestInteractiveRendererColorsStatusSymbol(t *testing.T) { if got, want := colorizeTaskLine(". running", taskRunning), "\x1b[92m.\x1b[0m running"; got != want { t.Fatalf("unexpected running style: got %q, want %q", got, want) } if got, want := colorizeTaskLine("\u2713 done", taskComplete), "\x1b[92m\u2713\x1b[0m done"; got != want { t.Fatalf("unexpected complete style: got %q, want %q", got, want) } if got, want := colorizeTaskLine("\u2717 failed", taskError), "\x1b[91m\u2717\x1b[0m failed"; got != want { t.Fatalf("unexpected error style: got %q, want %q", got, want) } } func TestInteractiveRendererSanitizesAndTruncatesMessages(t *testing.T) { line := formatTaskLine("hello\nworld", taskComplete, ".", 9) if line != "\u2713 hello w" { t.Fatalf("unexpected task line: %q", line) } wideLine := formatTaskLine("\u6d4b\u8bd5\u7f51\u7edc", taskComplete, ".", 6) if wideLine != "\u2713 \u6d4b\u8bd5" { t.Fatalf("unexpected wide task line: %q", wideLine) } if narrowLine := formatTaskLine("ignored", taskComplete, ".", 1); narrowLine != "\u2713" { t.Fatalf("unexpected narrow task line: %q", narrowLine) } } func TestPlainRendererEmitsFinalMessageOnce(t *testing.T) { var output bytes.Buffer renderer := newPlainRenderer(&output) task := renderer.NewTask("starting") task.Update("done") task.Complete() task.Complete() renderer.BlankLine() renderer.Stop() if got, want := output.String(), "done\n\n"; got != want { t.Fatalf("unexpected plain output: got %q, want %q", got, want) } } func TestSilentRendererProducesNoTaskOutput(t *testing.T) { renderer := silentRenderer{} task := renderer.NewTask("starting") task.Update("done") task.Complete() renderer.Println("ignored") renderer.BlankLine() renderer.Stop() } func TestTaskManagerStopWaitsForAsyncTasks(t *testing.T) { renderer := &testTaskRenderer{stopped: make(chan struct{})} manager := &TaskManager{renderer: renderer} started := make(chan struct{}) release := make(chan struct{}) manager.AsyncRun("async", func(task *Task) { close(started) <-release task.Complete() }) <-started stopped := make(chan struct{}) go func() { manager.Stop() close(stopped) }() select { case <-stopped: t.Fatal("manager stopped before its asynchronous task completed") case <-time.After(20 * time.Millisecond): } close(release) select { case <-stopped: case <-time.After(time.Second): t.Fatal("manager did not stop after its asynchronous task completed") } } type testTaskRenderer struct { stopped chan struct{} } func (r *testTaskRenderer) NewTask(string) taskHandle { return silentTask{} } func (r *testTaskRenderer) Println(string) {} func (r *testTaskRenderer) BlankLine() {} func (r *testTaskRenderer) Stop() { close(r.stopped) }