Compare commits

...

5 commits

Author SHA1 Message Date
c6413a0e58
docs: update README for new cli handling options
Some checks failed
Build & Test CI / build (push) Has been cancelled
2025-06-18 15:45:06 +03:00
4f97aa3d83
ci: build and test 2025-06-18 14:01:57 +03:00
cfad0e9b12
nix: inject version string in build 2025-06-18 14:01:37 +03:00
1dd971e16b
treewide: refactor; defer cli handling to cobra 2025-06-18 14:01:26 +03:00
7a5207f3b4
nix: set up tooling 2025-06-18 13:51:18 +03:00
15 changed files with 333 additions and 206 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -15,10 +15,10 @@ jobs:
- name: "Set up Go" - name: "Set up Go"
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.22' go-version: '1.24'
- name: "Build" - name: "Build"
run: go build -v ./. run: go build -v ./.
- name: "Test" - name: "Test"
run: go test -v ./. run: go test -v ./...

23
.gitignore vendored
View file

@ -1,22 +1,9 @@
# If you prefer the allow list template instead of the deny list, see community template: # Built binary
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore /tct
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# Output of the go coverage tool, specifically when used with LiteIDE # Nix
*.out /.direnv/
/result*
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum

View file

@ -1,8 +1,8 @@
# tct # tct
**tct** (**t**cp **c**onnection **t**imer) is a quick and minimal program (< 70 **t**cp **c**onnection **t**imer (tct) is a miniscule utility to help determinee
LoC!) that helps determine the "optimal" number of parallel TCP requests for the "optimal" number of parallel TCP requests for your network connection, for a
your network connection. given target.
It performs a series of tests (following your desired configuration) by It performs a series of tests (following your desired configuration) by
incrementally increasing the number of parallel requests and measuring the time incrementally increasing the number of parallel requests and measuring the time
@ -11,6 +11,48 @@ taken for each test.
The optimal number is identified as the point where adding more parallel The optimal number is identified as the point where adding more parallel
requests does not significantly reduce the overall time taken. requests does not significantly reduce the overall time taken.
## Usage
```bash
Usage:
tct [flags]
Flags:
-d, --delay duration Delay between requests
-h, --help help for tct
-m, --max int Maximum number of parallel requests (default 100)
-u, --url string URL to fetch (default "http://example.com")
-v, --version version for tct
```
For example:
```bash
tct --max 200 --delay 100ms --url "http://yourtargeturl.com"
```
Replace `"http://yourtargeturl.com"` with the actual URL you wish to test
against. You can also omit the URL and use an IP address instead. For example,
`8.8.8.8` for Google or `1.1.1.1` for Cloudflare. You may notice differences
between target URLs as a result of different distances to different hosts, or
different network setups.
The `--max` parameter specifies the maximum number of parallel requests to test,
and `--delay` sets the interval between each request.
> [!TIP]
> You are strongly advised to use the delay option. I have observed high latency
> while running with the default 0 second delay, which is likely some form of
> throttling by the host. If you test against an URL that you _know_ does not
> throttle connections, then you may consider omitting `-delay`.
### Flags
- `--url`: The URL to fetch.
- `--max`: Maximum number of parallel requests to test. Default is `100`
- `--delay`: Delay between requests. Can be specified as a duration (e.g.,
`500ms`). Default is `0` (i.e. no delay)
## Motivation ## Motivation
The [Nix Package Manager](https://github.com/NixOS/nix) has an option called The [Nix Package Manager](https://github.com/NixOS/nix) has an option called
@ -25,37 +67,6 @@ set `http-connections` in.
Do keep in mind that this is not 100% accurate. There are many factors that may Do keep in mind that this is not 100% accurate. There are many factors that may
affect the results of network related tests. affect the results of network related tests.
## Usage
```bash
tct -url="http://yourtargeturl.com" -max=200 -delay=500ms
```
Replace `"http://yourtargeturl.com"` with the actual URL you wish to test
against. You can also omit the URL and use an IP address instead, for example,
`8.8.8.8` for Google or `1.1.1.1` for Cloudflare. You may notice differences
between target URLs.
The `-max` parameter specifies the maximum number of parallel requests to test,
and `-delay` sets the interval between each request.
<!-- deno-fmt-ignore-start -->
> [!NOTE]
> You are strongly advised to use the delay option. I have observed high
> latency while running with the default 0 second delay, which is likely some
> form of throttling by the host. If you test against an URL that you _know_
> does not throttle connections, then you may consider omitting `-delay`.
<!-- deno-fmt-ignore-end -->
## Flags
- `-url`: The URL to fetch.
- `-max`: Maximum number of parallel requests to test. Default is `100`
- `-delay`: Delay between requests. Can be specified as a duration (e.g.,
`500ms`). Default is `0` (i.e. no delay)
## Notes ## Notes
- Remember to adjust the `-max` and `-delay` parameters based on your network - Remember to adjust the `-max` and `-delay` parameters based on your network
@ -63,7 +74,7 @@ and `-delay` sets the interval between each request.
any assumptions, but the default values will not be suitable for all testing any assumptions, but the default values will not be suitable for all testing
conditions. conditions.
- tct will try to always exit gracefully when you, e.g., kill the program with - tct will try to always exit gracefully when you, e.g., kill the program with
<kbd>ctrl+c</kbd>. <kbd>Ctrl+C</kbd>.
## Contributing ## Contributing

91
cmd/root.go Normal file
View file

@ -0,0 +1,91 @@
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/spf13/cobra"
"notashelf.dev/tct/internal/tct"
)
var (
url string
maxRequests int
delay time.Duration
Version string // will be set by main package
)
var rootCmd = &cobra.Command{
Use: "tct",
Short: "TCP Connection Timer - find optimal parallel request count",
Long: `A tool to measure and find the optimal number of parallel TCP requests for a given URL.`,
Run: run,
}
func Execute() {
if Version != "" {
rootCmd.Version = Version
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.Flags().StringVarP(&url, "url", "u", "http://example.com", "URL to fetch")
rootCmd.Flags().IntVarP(&maxRequests, "max", "m", 100, "Maximum number of parallel requests")
rootCmd.Flags().DurationVarP(&delay, "delay", "d", 0, "Delay between requests")
}
func run(cmd *cobra.Command, args []string) {
client := tct.NewClient()
// Context that listens for the interrupt signal
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
optimal := 0
minTime := time.Duration(0)
for i := 1; i <= maxRequests; i++ {
// Check for cancellation before starting the loop
if ctx.Err() != nil {
break
}
start := time.Now()
wg := &sync.WaitGroup{}
wg.Add(i)
for range i {
go func() {
defer wg.Done()
select {
case <-ctx.Done():
fmt.Println("Operation cancelled.")
return
default:
time.Sleep(delay)
client.MakeRequest(url)
}
}()
}
wg.Wait()
duration := time.Since(start)
fmt.Printf("Parallel Requests: %d, Time Taken: %s\n", i, duration)
if minTime == 0 || duration < minTime {
minTime = duration
optimal = i
}
}
fmt.Printf("\nOptimal Number of Parallel TCP Requests: %d\n", optimal)
}

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1750134718,
"narHash": "sha256-v263g4GbxXv87hMXMCpjkIxd/viIF7p3JpJrwgKdNiI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9e83b64f727c88a7711a2c463a7b16eedb69a84c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

25
flake.nix Normal file
View file

@ -0,0 +1,25 @@
{
description = "Fast and minimal parallel TCP connection testing utility ";
inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
outputs = {
self,
nixpkgs,
...
}: let
systems = ["x86_64-linux" "aarch64-linux"];
forEachSystem = nixpkgs.lib.genAttrs systems;
pkgsForEach = nixpkgs.legacyPackages;
in {
packages = forEachSystem (system: {
default = pkgsForEach.${system}.callPackage ./nix/package.nix {};
});
devShells = forEachSystem (system: {
default = pkgsForEach.${system}.callPackage ./nix/shell.nix {};
});
hydraJobs = self.packages;
};
}

10
go.mod
View file

@ -1,12 +1,10 @@
module notashelf.dev/tct module notashelf.dev/tct
go 1.22.2 go 1.24.3
require github.com/stretchr/testify v1.9.0 require github.com/spf13/cobra v1.9.1
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

18
go.sum
View file

@ -1,12 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

32
internal/tct/client.go Normal file
View file

@ -0,0 +1,32 @@
package tct
import (
"fmt"
"io"
"net/http"
)
type HttpClient interface {
Get(url string) (*http.Response, error)
}
type Client struct {
httpClient HttpClient
}
func NewClient() *Client {
return &Client{
httpClient: &http.Client{},
}
}
func (c *Client) MakeRequest(url string) {
resp, err := c.httpClient.Get(url)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
_ = body
}

View file

@ -0,0 +1,46 @@
package tct
import (
"net/http"
"testing"
)
type MockHttpClient struct {
GetFunc func(url string) (*http.Response, error)
}
func (m *MockHttpClient) Get(url string) (*http.Response, error) {
return m.GetFunc(url)
}
func TestClientMakeRequest(t *testing.T) {
tests := []struct {
name string
url string
}{
{
name: "valid URL",
url: "http://example.com",
},
{
name: "another valid URL",
url: "https://google.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockHttpClient{
GetFunc: func(url string) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: http.NoBody,
}, nil
},
}
client := &Client{httpClient: mockClient}
client.MakeRequest(tt.url)
})
}
}

77
main.go
View file

@ -1,79 +1,10 @@
package main package main
import ( import "notashelf.dev/tct/cmd"
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type HttpClient interface { var version = "dev" // will be set by build process
Get(url string) (*http.Response, error)
}
var httpClient HttpClient = &http.Client{}
func main() { func main() {
urlPtr := flag.String("url", "http://example.com", "URL to fetch") cmd.Version = version
maxRequestsPtr := flag.Int("max", 100, "Maximum number of parallel requests") cmd.Execute()
delayPtr := flag.Duration("delay", 0, "Delay between requests")
flag.Parse()
url := *urlPtr
maxRequests := *maxRequestsPtr
delay := *delayPtr
// context that listens for the interrupt signal.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
optimal := 0
minTime := time.Duration(0)
for i := 1; i <= maxRequests; i++ {
// check for cancellation before starting the loop
if ctx.Err() != nil {
break
}
start := time.Now()
wg := &sync.WaitGroup{}
wg.Add(i)
for j := 0; j < i; j++ {
go func() {
defer wg.Done()
select {
case <-ctx.Done():
fmt.Println("Operation cancelled.")
return
default:
time.Sleep(delay)
makeRequest(httpClient, url)
}
}()
}
wg.Wait()
duration := time.Since(start)
fmt.Printf("Parallel Requests: %d, Time Taken: %s\n", i, duration)
if minTime == 0 || duration < minTime {
minTime = duration
optimal = i
}
}
fmt.Printf("\nOptimal Number of Parallel TCP Requests: %d\n", optimal)
}
func makeRequest(client HttpClient, url string) {
resp, err := client.Get(url)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
_ = body
} }

View file

@ -1,62 +0,0 @@
package main
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
)
type MockHttpClient struct {
mock.Mock
}
func (m *MockHttpClient) Get(url string) (*http.Response, error) {
args := m.Called(url)
return args.Get(0).(*http.Response), args.Error(1)
}
func TestMakeRequest(t *testing.T) {
tests := []struct {
name string
statusCode int
responseBody string
expectedError error
}{
{
name: "Successful request",
statusCode: http.StatusOK,
responseBody: "OK",
},
{
name: "Failed request",
statusCode: http.StatusInternalServerError,
responseBody: "",
expectedError: errors.New("request failed"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := new(MockHttpClient)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.responseBody))
}))
defer server.Close()
mockClient.On("Get", server.URL).Return(&http.Response{
StatusCode: tt.statusCode,
Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
}, tt.expectedError)
makeRequest(mockClient, server.URL)
mockClient.AssertExpectations(t)
})
}
}

28
nix/package.nix Normal file
View file

@ -0,0 +1,28 @@
{
lib,
buildGoModule,
}: let
fs = lib.fileset;
s = ../.;
in
buildGoModule (finalAttrs: {
pname = "tct";
version = "0.1.0";
src = fs.toSource {
root = s;
fileset = fs.unions [
(fs.fileFilter (file: builtins.any file.hasExt ["go"]) s)
../go.mod
../go.sum
];
};
vendorHash = "sha256-m5mBubfbXXqXKsygF5j7cHEY+bXhAMcXUts5KBKoLzM=";
ldflags = [
"-s"
"-w"
"-X main.version=${finalAttrs.version}"
];
})

14
nix/shell.nix Normal file
View file

@ -0,0 +1,14 @@
{
mkShell,
go,
gopls,
delve,
}:
mkShell {
name = "go";
packages = [
delve
go
gopls
];
}