Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,6 @@ allowed only after every route has been removed.
or DNS configuration, `/etc/hosts`, or unrelated `dns-sd` processes.

See [CLI](docs/cli.md), [architecture](docs/architecture.md),
[performance benchmarks](docs/benchmarks.md),
[profiles](docs/proxy-profiles.md), [runner](docs/runner.md),
[sharing](docs/sharing.md), and [PKI/service](docs/pki-service.md).
29 changes: 29 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Performance benchmarks

Portless keeps focused microbenchmarks for the request and persistence paths
where regressions would affect normal operation. They use real loopback TCP and
TLS listeners, real proxy requests, real certificate generation, and real
temporary-file writes, renames, file synchronization, and directory
synchronization. They do not use mocks or stubs.

Run the complete benchmark set with allocation reporting and repeated samples:

```sh
go test -run '^$' -bench '^Benchmark' -benchmem -count=5 \
./internal/proxy ./internal/routes ./internal/daemon ./internal/pki
```

The benchmarks cover:

- HTTP/1.1 and HTTP/2 requests through the reverse proxy to a loopback backend.
- concurrent route resolution with one atomic route-table replacement per 1,024
measured operations;
- durable daemon registry commits containing 1, 100, and 1,024 active routes;
- cached and uncached exact-host certificate issuance, plus issuance that
evicts from a 16-certificate bounded cache.

Use the same machine, filesystem, Go version, power mode, and package selection
when comparing results. Report the five samples rather than only the fastest
run; filesystem synchronization and cryptographic randomness introduce real
run-to-run variation. `go test` prints `ns/op`, `B/op`, and `allocs/op`; the
route concurrency benchmark also prints its measured replacement rate.
54 changes: 54 additions & 0 deletions internal/daemon/state_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package daemon

import (
"fmt"
"path/filepath"
"testing"

"github.com/euforicio/portless/internal/client"
"github.com/euforicio/portless/internal/routes"
)

func BenchmarkRegistryCommit(b *testing.B) {
for _, routeCount := range []int{1, 100, 1024} {
b.Run(fmt.Sprintf("routes_%d", routeCount), func(b *testing.B) {
stateDir := b.TempDir()
table := routes.NewTable()
registry := &registry{
path: filepath.Join(stateDir, stateFileName),
table: table,
records: make(map[string]client.Route),
active: make(map[string]bool),
}
records, active := benchmarkRegistrations(routeCount)
if err := registry.commitLocked(records, active); err != nil {
b.Fatal(err)
}

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
if err := registry.commitLocked(records, active); err != nil {
b.Fatal(err)
}
}
})
}
}

func benchmarkRegistrations(count int) (map[string]client.Route, map[string]bool) {
records := make(map[string]client.Route, count)
active := make(map[string]bool, count)
for index := range count {
name := fmt.Sprintf("app-%04d.localhost", index)
records[name] = client.Route{
Name: name,
Scheme: "http",
Host: "127.0.0.1",
Port: uint16(10000 + index),
Owner: client.Owner{Kind: client.OwnerStatic, Refresh: client.RefreshNever},
}
active[name] = true
}
return records, active
}
62 changes: 62 additions & 0 deletions internal/pki/authority_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package pki

import (
"fmt"
"path/filepath"
"testing"
)

func BenchmarkAuthorityCertificate(b *testing.B) {
b.Run("cached", func(b *testing.B) {
authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{})
if err != nil {
b.Fatal(err)
}
if _, err := authority.Certificate("cached.localhost"); err != nil {
b.Fatal(err)
}

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
if _, err := authority.Certificate("cached.localhost"); err != nil {
b.Fatal(err)
}
}
})

b.Run("uncached", func(b *testing.B) {
authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{})
if err != nil {
b.Fatal(err)
}

b.ReportAllocs()
b.ResetTimer()
for index := 0; b.Loop(); index++ {
if _, err := authority.Certificate(fmt.Sprintf("uncached-%d.localhost", index)); err != nil {
b.Fatal(err)
}
}
})

b.Run("bounded_eviction", func(b *testing.B) {
authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{MaxLeafCertificates: 16})
if err != nil {
b.Fatal(err)
}
for index := range 16 {
if _, err := authority.Certificate(fmt.Sprintf("seed-%d.localhost", index)); err != nil {
b.Fatal(err)
}
}

b.ReportAllocs()
b.ResetTimer()
for index := 0; b.Loop(); index++ {
if _, err := authority.Certificate(fmt.Sprintf("evict-%d.localhost", index)); err != nil {
b.Fatal(err)
}
}
})
}
120 changes: 120 additions & 0 deletions internal/proxy/proxy_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package proxy_test

import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/euforicio/portless/internal/proxy"
"github.com/euforicio/portless/internal/routes"
)

const benchmarkResponse = "portless benchmark response"

func BenchmarkProxyRequests(b *testing.B) {
b.Run("HTTP1.1", benchmarkProxyHTTP11)
b.Run("HTTP2", benchmarkProxyHTTP2)
}

func benchmarkProxyHTTP11(b *testing.B) {
backend := httptest.NewServer(benchmarkBackend(1))
b.Cleanup(backend.Close)

table := routes.NewTable()
benchmarkSetRoute(b, table, "bench.localhost", backend.URL)
handler := benchmarkNewProxy(b, table, proxy.Options{})
b.Cleanup(handler.CloseIdleConnections)

frontend := httptest.NewServer(handler)
b.Cleanup(frontend.Close)
client := frontend.Client()

benchmarkRequest(b, client, frontend.URL, 1)
b.ReportAllocs()
b.SetBytes(int64(len(benchmarkResponse)))
b.ResetTimer()
for b.Loop() {
benchmarkRequest(b, client, frontend.URL, 1)
}
}

func benchmarkProxyHTTP2(b *testing.B) {
backend := httptest.NewUnstartedServer(benchmarkBackend(2))
backend.EnableHTTP2 = true
backend.StartTLS()
b.Cleanup(backend.Close)

table := routes.NewTable()
benchmarkSetRoute(b, table, "bench.localhost", backend.URL)
backendTransport := backend.Client().Transport.(*http.Transport).Clone()
backendTransport.ForceAttemptHTTP2 = true
b.Cleanup(backendTransport.CloseIdleConnections)
handler := benchmarkNewProxy(b, table, proxy.Options{Transport: backendTransport})

frontend := httptest.NewUnstartedServer(handler)
frontend.EnableHTTP2 = true
frontend.StartTLS()
b.Cleanup(frontend.Close)
client := frontend.Client()

benchmarkRequest(b, client, frontend.URL, 2)
b.ReportAllocs()
b.SetBytes(int64(len(benchmarkResponse)))
b.ResetTimer()
for b.Loop() {
benchmarkRequest(b, client, frontend.URL, 2)
}
}

func benchmarkBackend(protocolMajor int) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.ProtoMajor != protocolMajor {
http.Error(writer, "unexpected upstream protocol", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Length", strconv.Itoa(len(benchmarkResponse)))
_, _ = io.WriteString(writer, benchmarkResponse)
})
}

func benchmarkSetRoute(b *testing.B, table *routes.Table, host, upstream string) {
b.Helper()
if _, err := table.Set(host, upstream); err != nil {
b.Fatal(err)
}
}

func benchmarkNewProxy(b *testing.B, table *routes.Table, options proxy.Options) *proxy.Handler {
b.Helper()
handler, err := proxy.New(table, options)
if err != nil {
b.Fatal(err)
}
return handler
}

func benchmarkRequest(b *testing.B, client *http.Client, target string, protocolMajor int) {
b.Helper()
request, err := http.NewRequest(http.MethodGet, target+"/assets/app.js?benchmark=1", nil)
if err != nil {
b.Fatal(err)
}
request.Host = "bench.localhost"
response, err := client.Do(request)
if err != nil {
b.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
closeErr := response.Body.Close()
if readErr != nil {
b.Fatal(readErr)
}
if closeErr != nil {
b.Fatal(closeErr)
}
if response.StatusCode != http.StatusOK || response.ProtoMajor != protocolMajor || string(body) != benchmarkResponse {
b.Fatalf("response = status %d, protocol %q, body %q", response.StatusCode, response.Proto, body)
}
}
62 changes: 62 additions & 0 deletions internal/routes/routes_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package routes_test

import (
"fmt"
"sync/atomic"
"testing"

"github.com/euforicio/portless/internal/routes"
)

func BenchmarkTableResolveConcurrentReplace(b *testing.B) {
const routeCount = 100

first := benchmarkRoutes(b, routeCount, 8000)
second := benchmarkRoutes(b, routeCount, 9000)
table := routes.NewTable()
if err := table.Replace(first); err != nil {
b.Fatal(err)
}

var operations atomic.Uint64
var replacements atomic.Uint64
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
operation := operations.Add(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the shared counter from the resolve hot path

Under parallel benchmark execution, every measured operation first performs an update to the same atomic cache line, so all resolver goroutines contend on operations before reaching Table.Resolve. At higher CPU counts this contention can dominate the reported latency and make changes to the route table appear neutral or noisy; drive replacements from a separate mutator or use per-worker scheduling so ordinary resolve iterations do not share a global counter.

Useful? React with 👍 / 👎.

if operation%1024 == 0 {
replacement := first
if replacements.Add(1)%2 == 1 {
replacement = second
}
if err := table.Replace(replacement); err != nil {
b.Error(err)
return
}
continue
}
if _, ok := table.Resolve("app-050.localhost:443"); !ok {
b.Error("registered route was not resolved")
return
}
}
})
b.ReportMetric(float64(replacements.Load())/float64(b.N), "replaces/op")
}

func benchmarkRoutes(b *testing.B, count, basePort int) []routes.Route {
b.Helper()
result := make([]routes.Route, count)
for index := range count {
route, err := routes.NewRoute(
fmt.Sprintf("app-%03d.localhost", index),
fmt.Sprintf("http://127.0.0.1:%d", basePort+index),
)
if err != nil {
b.Fatal(err)
}
result[index] = route
}
return result
}