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/.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 ./...
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/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
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/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/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)
- })
- }
-}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 0000000..8bd211c
--- /dev/null
+++ b/nix/package.nix
@@ -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}"
+ ];
+ })
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
+ ];
+}