From 7a5207f3b4ee4907844cec3e25a644cdf9c1c822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Jun 2025 13:51:18 +0300 Subject: [PATCH 1/5] nix: set up tooling --- .envrc | 1 + flake.lock | 27 +++++++++++++++++++++++++++ flake.nix | 25 +++++++++++++++++++++++++ nix/package.nix | 24 ++++++++++++++++++++++++ nix/shell.nix | 14 ++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/package.nix create mode 100644 nix/shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6f3c06e --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4e2187b --- /dev/null +++ b/flake.nix @@ -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; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..a6fc842 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,24 @@ +{ + lib, + buildGoModule, +}: let + fs = lib.fileset; + s = ../.; +in + buildGoModule { + 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"]; + } diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..226ab68 --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,14 @@ +{ + mkShell, + go, + gopls, + delve, +}: +mkShell { + name = "go"; + packages = [ + delve + go + gopls + ]; +} From 1dd971e16b6dffb4de6e7dd9cdd8c4d4330fe3bc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Jun 2025 14:01:26 +0300 Subject: [PATCH 2/5] treewide: refactor; defer cli handling to cobra --- cmd/root.go | 91 +++++++++++++++++++++++++++++++++++++ go.mod | 10 ++-- go.sum | 18 ++++---- internal/tct/client.go | 32 +++++++++++++ internal/tct/client_test.go | 46 +++++++++++++++++++ main.go | 77 ++----------------------------- main_test.go | 62 ------------------------- 7 files changed, 185 insertions(+), 151 deletions(-) create mode 100644 cmd/root.go create mode 100644 internal/tct/client.go create mode 100644 internal/tct/client_test.go delete mode 100644 main_test.go diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..4b4a7f1 --- /dev/null +++ b/cmd/root.go @@ -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) +} diff --git a/go.mod b/go.mod index 8bff24f..ee35e6b 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,10 @@ 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 ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect ) diff --git a/go.sum b/go.sum index 8565daa..ffae55e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,10 @@ -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/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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= diff --git a/internal/tct/client.go b/internal/tct/client.go new file mode 100644 index 0000000..3c198e8 --- /dev/null +++ b/internal/tct/client.go @@ -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 +} diff --git a/internal/tct/client_test.go b/internal/tct/client_test.go new file mode 100644 index 0000000..09efacb --- /dev/null +++ b/internal/tct/client_test.go @@ -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) + }) + } +} diff --git a/main.go b/main.go index 25c69aa..d854c8e 100644 --- a/main.go +++ b/main.go @@ -1,79 +1,10 @@ package main -import ( - "context" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/signal" - "sync" - "syscall" - "time" -) +import "notashelf.dev/tct/cmd" -type HttpClient interface { - Get(url string) (*http.Response, error) -} - -var httpClient HttpClient = &http.Client{} +var version = "dev" // will be set by build process func main() { - urlPtr := flag.String("url", "http://example.com", "URL to fetch") - maxRequestsPtr := flag.Int("max", 100, "Maximum number of parallel requests") - 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 + cmd.Version = version + cmd.Execute() } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index b6186c5..0000000 --- a/main_test.go +++ /dev/null @@ -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) - }) - } -} From cfad0e9b12d74973aedabb22a87bb6db6901ae9a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Jun 2025 14:01:37 +0300 Subject: [PATCH 3/5] nix: inject version string in build --- .gitignore | 23 +++++------------------ nix/package.nix | 10 +++++++--- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 6f6f5e6..381ef6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,9 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Built binary +/tct # Test binary, built with `go test -c` *.test -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum +# Nix +/.direnv/ +/result* diff --git a/nix/package.nix b/nix/package.nix index a6fc842..8bd211c 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -5,7 +5,7 @@ fs = lib.fileset; s = ../.; in - buildGoModule { + buildGoModule (finalAttrs: { pname = "tct"; version = "0.1.0"; @@ -20,5 +20,9 @@ in vendorHash = "sha256-m5mBubfbXXqXKsygF5j7cHEY+bXhAMcXUts5KBKoLzM="; - ldflags = ["-s" "-w"]; - } + ldflags = [ + "-s" + "-w" + "-X main.version=${finalAttrs.version}" + ]; + }) From 4f97aa3d833b3e77e3a174bbe3c2ee366cd9f9d5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Jun 2025 14:01:57 +0300 Subject: [PATCH 4/5] ci: build and test --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 37b660d..5dc9cea 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,10 +15,10 @@ jobs: - name: "Set up Go" uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.24' - name: "Build" run: go build -v ./. - name: "Test" - run: go test -v ./. + run: go test -v ./... From c6413a0e583694f9aa1ef2459b06048d7144948f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Jun 2025 15:45:06 +0300 Subject: [PATCH 5/5] docs: update README for new cli handling options --- README.md | 81 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9c05f96..cee2868 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # tct -**tct** (**t**cp **c**onnection **t**imer) is a quick and minimal program (< 70 -LoC!) that helps determine the "optimal" number of parallel TCP requests for -your network connection. +**t**cp **c**onnection **t**imer (tct) is a miniscule utility to help determinee +the "optimal" number of parallel TCP requests for your network connection, for a +given target. It performs a series of tests (following your desired configuration) by 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 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 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 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. - - - -> [!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`. - - - -## 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 - 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 conditions. - tct will try to always exit gracefully when you, e.g., kill the program with - ctrl+c. + Ctrl+C. ## Contributing