From aba68e64149977c1d4e7e1fa85e46df48c2d3e0a Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:41 +0100 Subject: [PATCH 1/8] test: benchmarks for the laptop agent's proxy, forwards, box streams and fan-out Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent_test.go | 23 ++-- internal/agent/bench_test.go | 195 +++++++++++++++++++++++++++++++++ internal/forward/bench_test.go | 95 ++++++++++++++++ internal/proxy/bench_test.go | 111 +++++++++++++++++++ internal/wire/bench_test.go | 74 +++++++++++++ internal/wire/wire_test.go | 10 +- 6 files changed, 493 insertions(+), 15 deletions(-) create mode 100644 internal/agent/bench_test.go create mode 100644 internal/forward/bench_test.go create mode 100644 internal/proxy/bench_test.go create mode 100644 internal/wire/bench_test.go diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 560b057f..e0b0c0a6 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -32,7 +32,7 @@ import ( type testBox struct { services []box.Service bus *events.Bus - t *testing.T + t testing.TB dir string address string server *wire.Server @@ -41,6 +41,8 @@ type testBox struct { done chan error // extra mounts more box routes each time the box starts. extra func(*wire.Server) + // servicesCalls counts GET /v1/services. + servicesCalls atomic.Int64 } type countingListener struct { @@ -56,13 +58,13 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func newBox(t *testing.T) *testBox { +func newBox(t testing.TB) *testBox { t.Helper() return newBoxWith(t, nil) } // newBoxWith is newBox with more routes, such as sessions. -func newBoxWith(t *testing.T, extra func(*wire.Server)) *testBox { +func newBoxWith(t testing.TB, extra func(*wire.Server)) *testBox { t.Helper() b := &testBox{t: t, dir: t.TempDir(), extra: extra} b.start("127.0.0.1:0") @@ -86,6 +88,7 @@ func (b *testBox) start(addr string) { b.bus = &events.Bus{} } b.server.Handle("GET /v1/services", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b.servicesCalls.Add(1) json.NewEncoder(w).Encode(b.services) })) b.server.Handle("GET /v1/events", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -185,7 +188,7 @@ func (c *clock) jump(d time.Duration) { // shortSocket returns a socket path under /tmp: test temp dirs on macOS are // longer than the 104-byte limit for Unix socket paths. -func shortSocket(t *testing.T) string { +func shortSocket(t testing.TB) string { t.Helper() dir, err := os.MkdirTemp("/tmp", "cp") if err != nil { @@ -195,19 +198,19 @@ func shortSocket(t *testing.T) string { return filepath.Join(dir, "a.sock") } -func startAgent(t *testing.T, dir string) *runningAgent { +func startAgent(t testing.TB, dir string) *runningAgent { t.Helper() return startAgentWith(t, dir, &fakeNetworks{}) } -func startAgentWith(t *testing.T, dir string, nets Networks) *runningAgent { +func startAgentWith(t testing.TB, dir string, nets Networks) *runningAgent { t.Helper() return startAgentConfig(t, dir, nets, nil) } // startAgentConfig is startAgentWith with changes to the agent's Config, // such as its log or how long a health check waits. -func startAgentConfig(t *testing.T, dir string, nets Networks, change func(*Config)) *runningAgent { +func startAgentConfig(t testing.TB, dir string, nets Networks, change func(*Config)) *runningAgent { t.Helper() proxyLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -266,7 +269,7 @@ func (a *runningAgent) stop() { } } -func eventually(t *testing.T, what string, cond func() bool) { +func eventually(t testing.TB, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !cond() { @@ -277,7 +280,7 @@ func eventually(t *testing.T, what string, cond func() bool) { } } -func stateOf(t *testing.T, a *runningAgent) string { +func stateOf(t testing.TB, a *runningAgent) string { t.Helper() s, err := a.client.Status(context.Background()) if err != nil || len(s.Boxes) != 1 { @@ -305,7 +308,7 @@ func echoServer(t *testing.T) int { return ln.Addr().(*net.TCPAddr).Port } -func freePort(t *testing.T) int { +func freePort(t testing.TB) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go new file mode 100644 index 00000000..2c4cab02 --- /dev/null +++ b/internal/agent/bench_test.go @@ -0,0 +1,195 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/box" + "github.com/cosscom/shipyard/internal/identity" + "github.com/cosscom/shipyard/internal/pairing" + "github.com/cosscom/shipyard/internal/trust" + "github.com/cosscom/shipyard/internal/wire" +) + +// pairAs pairs the laptop in dir with b under name, so one laptop can hold +// several boxes. +func (b *testBox) pairAs(t testing.TB, dir, name string) { + t.Helper() + id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) + if err != nil { + t.Fatal(err) + } + code, err := b.server.Pending.Issue(time.Minute, time.Now()) + if err != nil { + t.Fatal(err) + } + tok := pairing.Token{Address: b.address, Fingerprint: b.server.Identity.Fingerprint(), Code: code} + if _, err := wire.Pair(context.Background(), id, tok, "laptop"); err != nil { + t.Fatal(err) + } + peer := trust.Peer{Name: name, Address: b.address, Fingerprint: tok.Fingerprint, PairedAt: time.Now()} + if err := trust.NewStore(filepath.Join(dir, "boxes.json")).Add(peer); err != nil { + t.Fatal(err) + } +} + +func benchDevServer(b *testing.B) int { + b.Helper() + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + })} + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + go srv.Serve(ln) + b.Cleanup(func() { srv.Close() }) + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkProxyHosts relays small GETs through a running agent's proxy to +// a dev server on a box, for each form of address a page uses. A second +// box, without the worktree, is paired too, and a few routes are saved, +// as on a laptop in use. services/op is how often the boxes were asked +// for their services. +func BenchmarkProxyHosts(b *testing.B) { + port := benchDevServer(b) + one := newBox(b) + one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: port}, {Location: "shop", Worktree: "shop", Port: port, Main: true}} + two := newBox(b) + two.services = []box.Service{{Location: "other", Worktree: "other", Port: port + 1}} + dir := b.TempDir() + one.pairAs(b, dir, "devbox") + two.pairAs(b, dir, "spare") + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != 2 { + return false + } + return s.Boxes[0].State == StateOnline && s.Boxes[1].State == StateOnline + }) + for _, r := range []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: port}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: port}, + {Pattern: "admin.test.localhost", Box: "spare", Port: port}, + } { + if err := a.client.Call(context.Background(), "POST", "/v1/routes", r, nil); err != nil { + b.Fatal(err) + } + } + c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}} + for _, form := range []struct{ name, host string }{ + {"port.box", strconv.Itoa(port) + ".devbox.localhost"}, + {"worktree.location.box", "checkout.shop.devbox.localhost"}, + {"worktree.location", "checkout.shop.localhost"}, + } { + host := form.host + b.Run(form.name, func(b *testing.B) { + before := one.servicesCalls.Load() + two.servicesCalls.Load() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+a.proxy+"/app.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b.Errorf("%s: %d", host, resp.StatusCode) + return + } + } + }) + b.ReportMetric(float64(one.servicesCalls.Load()+two.servicesCalls.Load()-before)/float64(b.N), "services/op") + }) + } +} + +// BenchmarkRouteLookup resolves a request host against the saved routes, +// which the proxy does first for every request it relays. +func BenchmarkRouteLookup(b *testing.B) { + a := &Agent{routes: routeStore{path: filepath.Join(b.TempDir(), "routes.json")}} + all := []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001}, + {Pattern: "admin.test.localhost", Box: "spare", Port: 3002}, + } + if err := a.routes.update(func([]Route) ([]Route, error) { return all, nil }); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, _, ok := a.route("checkout.shop.devbox.localhost"); ok { + b.Fatal("matched") + } + } +} + +// BenchmarkInstalledKits asks every box for its kits, as the app's Kits +// view does, with boxes that each take 50ms to answer. +func BenchmarkInstalledKits(b *testing.B) { + const n, delay = 4, 50 * time.Millisecond + dir := b.TempDir() + for i := range n { + tb := newBoxWith(b, func(s *wire.Server) { + s.Handle("GET /v1/kits", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(delay) + json.NewEncoder(w).Encode([]box.InstalledKitAt{{Location: "shop", Kit: box.InstalledKit{ID: "acme"}}}) + })) + }) + tb.pairAs(b, dir, "box"+strconv.Itoa(i)) + } + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != n { + return false + } + for _, bs := range s.Boxes { + if bs.State != StateOnline { + return false + } + } + return true + }) + b.ResetTimer() + for range b.N { + var out []InstalledKitOn + if err := a.client.Call(context.Background(), "GET", "/v1/kits/installed", nil, &out); err != nil { + b.Fatal(err) + } + if len(out) != n { + b.Fatalf("got %d kits", len(out)) + } + } + b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") +} + +// BenchmarkStatus is GET /v1/status, which the app reads on every box +// event and on its backstop poll. +func BenchmarkStatus(b *testing.B) { + one := newBox(b) + dir := one.pairLaptop() + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := a.client.Status(context.Background()); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/forward/bench_test.go b/internal/forward/bench_test.go new file mode 100644 index 00000000..9b1531f0 --- /dev/null +++ b/internal/forward/bench_test.go @@ -0,0 +1,95 @@ +package forward + +import ( + "context" + "io" + "net" + "os" + "path/filepath" + "testing" +) + +// pairs hands out both ends of Unix socket connections: like loopback TCP +// connections, with half-close, but using no ephemeral ports, which a +// benchmark opening thousands would run out of. +type pairs struct{ ln net.Listener } + +func newPairs(b *testing.B) pairs { + b.Helper() + dir, err := os.MkdirTemp("", "fwd") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { os.RemoveAll(dir) }) + ln, err := net.Listen("unix", filepath.Join(dir, "s")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + return pairs{ln} +} + +func (p pairs) next(b *testing.B) (net.Conn, net.Conn) { + c, err := net.Dial("unix", p.ln.Addr().String()) + if err != nil { + b.Fatal(err) + } + s, err := p.ln.Accept() + if err != nil { + b.Fatal(err) + } + return c, s +} + +// BenchmarkBridgeConn bridges one short connection per op (a request and +// its answer), as a forwarded port's clients make them. +func BenchmarkBridgeConn(b *testing.B) { + p := newPairs(b) + msg := make([]byte, 4<<10) + b.SetBytes(int64(len(msg))) + b.ReportAllocs() + for range b.N { + client, local := p.next(b) + upstream, server := p.next(b) + done := make(chan struct{}) + go func() { Bridge(context.Background(), local, upstream); close(done) }() + go func() { + // The far end answers what it reads, with a buffer of its own. + buf := make([]byte, len(msg)) + for { + n, err := server.Read(buf) + server.Write(buf[:n]) + if err != nil { + break + } + } + server.Close() + }() + client.Write(msg) + client.(*net.UnixConn).CloseWrite() + io.Copy(io.Discard, client) + client.Close() + <-done + } +} + +// BenchmarkBridgeStream pushes data one way through a long bridge. +func BenchmarkBridgeStream(b *testing.B) { + p := newPairs(b) + client, local := p.next(b) + upstream, server := p.next(b) + go Bridge(context.Background(), local, upstream) + go io.Copy(io.Discard, server) + chunk := make([]byte, 64<<10) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := client.Write(chunk); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + client.Close() + server.Close() +} diff --git a/internal/proxy/bench_test.go b/internal/proxy/bench_test.go new file mode 100644 index 00000000..1eaeca9d --- /dev/null +++ b/internal/proxy/bench_test.go @@ -0,0 +1,111 @@ +package proxy + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +// benchUpstream is a dev server on the "box" that answers every request +// with size bytes. +func benchUpstream(b *testing.B, size int) int { + b.Helper() + body := bytes.Repeat([]byte("x"), size) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + b.Cleanup(srv.Close) + return srv.Listener.Addr().(*net.TCPAddr).Port +} + +// benchProxy is a proxy whose one box, devl, is this machine's loopback. +func benchProxy(b *testing.B) string { + b.Helper() + var d net.Dialer + p := &Proxy{Dialer: func(box string) (DialFunc, bool) { + if box != "devl" { + return nil, false + } + return func(ctx context.Context, port int) (net.Conn, error) { + return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + }, true + }} + srv := httptest.NewServer(p) + b.Cleanup(srv.Close) + return srv.Listener.Addr().String() +} + +func benchClient() *http.Client { + return &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64, DisableCompression: true}} +} + +// BenchmarkProxyGet relays GETs of a few sizes through the proxy to a dev +// server, as a page's assets are. +func BenchmarkProxyGet(b *testing.B) { + for _, size := range []int{1 << 10, 64 << 10, 1 << 20} { + b.Run(strconv.Itoa(size>>10)+"KB", func(b *testing.B) { + port := benchUpstream(b, size) + addr := benchProxy(b) + c := benchClient() + host := strconv.Itoa(port) + ".devl.localhost" + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/asset.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }) + }) + } +} + +// BenchmarkRequestLogAdd logs requests for one busy page host, past the +// per-host limit, as a dev server's live page does all day. +func BenchmarkRequestLogAdd(b *testing.B) { + var l requestLog + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200, Type: "script", MIME: "text/javascript"} + for range logLimit { + l.add(req, req.Host, "") + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + l.add(req, req.Host, "shop.devl.localhost") + } +} + +// BenchmarkRequests reads a full host's log after the app's last seq, as +// the Network drawer polls it. +func BenchmarkRequests(b *testing.B) { + p := &Proxy{} + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200} + for range 3 * logLimit { + p.requests.add(req, req.Host) + } + _, last := p.Requests(req.Host, 0) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + list, _ := p.Requests(req.Host, last-10) + if len(list) != 10 { + b.Fatalf("got %d", len(list)) + } + } +} diff --git a/internal/wire/bench_test.go b/internal/wire/bench_test.go new file mode 100644 index 00000000..615278f1 --- /dev/null +++ b/internal/wire/bench_test.go @@ -0,0 +1,74 @@ +package wire + +import ( + "context" + "io" + "net" + "testing" +) + +// benchEcho is a server on the box's loopback that echoes what it reads. +func benchEcho(b *testing.B) int { + b.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { defer c.Close(); io.Copy(c, c) }() + } + }() + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkDialPortRoundTrip opens a stream to a box port, sends a small +// request, reads the answer and closes it: one proxied connection. +func BenchmarkDialPortRoundTrip(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + msg := make([]byte, 1<<10) + buf := make([]byte, len(msg)) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + if _, err := conn.Write(msg); err != nil { + b.Fatal(err) + } + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + conn.Close() + } +} + +// BenchmarkStreamEcho pushes data through one long stream and back. +func BenchmarkStreamEcho(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + chunk := make([]byte, 64<<10) + buf := make([]byte, len(chunk)) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + go conn.Write(chunk) + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go index 5ecc9406..f172018b 100644 --- a/internal/wire/wire_test.go +++ b/internal/wire/wire_test.go @@ -47,9 +47,9 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func startBox(t *testing.T) *box { return startBoxWith(t, nil) } +func startBox(t testing.TB) *box { return startBoxWith(t, nil) } -func startBoxWith(t *testing.T, configure func(*Server)) *box { +func startBoxWith(t testing.TB, configure func(*Server)) *box { t.Helper() dir := t.TempDir() id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) @@ -82,7 +82,7 @@ func startBoxWith(t *testing.T, configure func(*Server)) *box { return &box{server: s, address: ln.Addr().String(), dir: dir, accepted: accepted} } -func (b *box) issue(t *testing.T) pairing.Token { +func (b *box) issue(t testing.TB) pairing.Token { t.Helper() code, err := b.server.Pending.Issue(10*time.Minute, time.Now()) if err != nil { @@ -95,7 +95,7 @@ func (b *box) peer() trust.Peer { return trust.Peer{Name: "dev-test", Address: b.address, Fingerprint: b.server.Identity.Fingerprint()} } -func laptop(t *testing.T) *identity.Identity { +func laptop(t testing.TB) *identity.Identity { t.Helper() id, err := identity.LoadOrCreate(filepath.Join(t.TempDir(), "identity.pem")) if err != nil { @@ -105,7 +105,7 @@ func laptop(t *testing.T) *identity.Identity { } // paired returns a client for a laptop that has completed pairing with b. -func paired(t *testing.T, b *box) *Client { +func paired(t testing.TB, b *box) *Client { t.Helper() me := laptop(t) if _, err := Pair(context.Background(), me, b.issue(t), "alex-mbp"); err != nil { From cdb77dceb32f2be56bcc099fd4f82e42b0782325 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:46 +0100 Subject: [PATCH 2/8] perf(proxy): log each relayed request without copying the host's whole log Past 200 requests a host's log was copied into a new slice on every request (about 64 KB and 8 us each for a page and its API host). It now grows to twice the limit and drops the old half in place. Co-Authored-By: Claude Opus 5.5 --- internal/proxy/requestlog.go | 32 +++++++++++++++++++++++-------- internal/proxy/requestlog_test.go | 27 ++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/proxy/requestlog.go b/internal/proxy/requestlog.go index c8be231b..1b44af3d 100644 --- a/internal/proxy/requestlog.go +++ b/internal/proxy/requestlog.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -60,10 +61,21 @@ type requestLog struct { } type hostLog struct { + // list holds the host's requests, oldest first: the last logLimit of + // them are its log (recent). It grows to twice that before the old half + // is dropped, in place, so logging a request costs no copy of the log. list []Request used time.Time } +// recent is the host's log: its last logLimit requests, oldest first. +func (hl *hostLog) recent() []Request { + if n := len(hl.list); n > logLimit { + return hl.list[n-logLimit:] + } + return hl.list +} + func (l *requestLog) add(req Request, hosts ...string) { l.mu.Lock() defer l.mu.Unlock() @@ -73,12 +85,10 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts = map[string]*hostLog{} } now := time.Now() - seen := map[string]bool{} - for _, h := range hosts { - if h == "" || seen[h] { + for i, h := range hosts { + if h == "" || slices.Contains(hosts[:i], h) { continue } - seen[h] = true hl := l.hosts[h] if hl == nil { if len(l.hosts) >= logHosts { @@ -88,10 +98,16 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts[h] = hl } hl.used = now - hl.list = append(hl.list, req) - if len(hl.list) > logLimit { - hl.list = append([]Request(nil), hl.list[len(hl.list)-logLimit:]...) + switch n := len(hl.list); { + case n == 2*logLimit: + copy(hl.list, hl.list[logLimit:]) + clear(hl.list[logLimit:]) + hl.list = hl.list[:logLimit] + case n == cap(hl.list): + // Grown by hand, to never more than the 2*logLimit it needs. + hl.list = append(make([]Request, 0, min(max(2*n, 16), 2*logLimit)), hl.list...) } + hl.list = append(hl.list, req) } } @@ -115,7 +131,7 @@ func (p *Proxy) Requests(host string, after int64) ([]Request, int64) { defer l.mu.Unlock() out := []Request{} if hl := l.hosts[hostOnly(host)]; hl != nil { - for _, r := range hl.list { + for _, r := range hl.recent() { if r.Seq > after { out = append(out, r) } diff --git a/internal/proxy/requestlog_test.go b/internal/proxy/requestlog_test.go index 99a76963..d8ed2aa5 100644 --- a/internal/proxy/requestlog_test.go +++ b/internal/proxy/requestlog_test.go @@ -137,15 +137,38 @@ func TestRequestLogMarksACanceledRequest(t *testing.T) { } } +func TestRequestLogKeepsTheLatestAcrossManyTrims(t *testing.T) { + p := &Proxy{} + const total = 5*logLimit + 7 + for i := range total { + p.requests.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") + if hl := p.requests.hosts["a.localhost"]; cap(hl.list) > 2*logLimit { + t.Fatalf("log grew to %d", cap(hl.list)) + } + } + list, last := p.Requests("a.localhost", 0) + if len(list) != logLimit || last != total { + t.Fatalf("got %d requests, last %d", len(list), last) + } + for i, r := range list { + if want := "/" + strconv.Itoa(total-logLimit+i); r.Path != want { + t.Fatalf("request %d is %q, want %q", i, r.Path, want) + } + } + if list, _ := p.Requests("a.localhost", total-3); len(list) != 3 || list[2].Path != "/"+strconv.Itoa(total-1) { + t.Fatalf("after: %+v", list) + } +} + func TestRequestLogIsBounded(t *testing.T) { var l requestLog for i := range logLimit + 50 { l.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") } - if got := len(l.hosts["a.localhost"].list); got != logLimit { + if got := len(l.hosts["a.localhost"].recent()); got != logLimit { t.Fatalf("kept %d", got) } - if first := l.hosts["a.localhost"].list[0].Path; first != "/50" { + if first := l.hosts["a.localhost"].recent()[0].Path; first != "/50" { t.Errorf("oldest kept %q", first) } for i := range logHosts + 5 { From f8bcb9ea06d266c05026178c2668f06f10d62072 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:47 +0100 Subject: [PATCH 3/8] perf: reuse copy buffers for proxied requests, forwards and box streams httputil.ReverseProxy and io.Copy between a socket and an HTTP/2 stream each made a fresh 32 KB buffer per request or connection. They now share a pool (internal/copybuf). Co-Authored-By: Claude Opus 5.5 --- internal/copybuf/copybuf.go | 47 +++++++++++++++++++ internal/copybuf/copybuf_test.go | 75 +++++++++++++++++++++++++++++++ internal/copybuf/race_off_test.go | 5 +++ internal/copybuf/race_on_test.go | 7 +++ internal/forward/forward.go | 5 ++- internal/proxy/proxy.go | 8 +++- internal/wire/server.go | 5 ++- 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 internal/copybuf/copybuf.go create mode 100644 internal/copybuf/copybuf_test.go create mode 100644 internal/copybuf/race_off_test.go create mode 100644 internal/copybuf/race_on_test.go diff --git a/internal/copybuf/copybuf.go b/internal/copybuf/copybuf.go new file mode 100644 index 00000000..a4aec3a9 --- /dev/null +++ b/internal/copybuf/copybuf.go @@ -0,0 +1,47 @@ +// Package copybuf copies between connections with pooled buffers. io.Copy +// between a TCP connection and anything but another socket (an HTTP/2 +// stream, a hijacked HTTP connection) makes a fresh 32 KB buffer each time, +// and so does httputil.ReverseProxy for every request it relays: the +// *.localhost proxy, port forwards and box streams do that for every +// connection and request, so the buffers are kept here and reused. +package copybuf + +import ( + "io" + "net" + "sync" +) + +// Size is the buffer size, io.Copy's own. +const Size = 32 << 10 + +var pool = sync.Pool{New: func() any { return new([Size]byte) }} + +// Copy is io.Copy with a pooled buffer. Between two TCP connections it is +// io.Copy itself, which on Linux moves the data in the kernel instead. +func Copy(dst io.Writer, src io.Reader) (int64, error) { + if _, ok := dst.(*net.TCPConn); ok { + if _, ok := src.(*net.TCPConn); ok { + return io.Copy(dst, src) + } + } + buf := pool.Get().(*[Size]byte) + defer pool.Put(buf) + // Hiding ReadFrom and WriteTo makes CopyBuffer use buf: the + // connections' own fallbacks would make a buffer of their own. + return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, buf[:]) +} + +type writerOnly struct{ io.Writer } +type readerOnly struct{ io.Reader } + +// Pool is a httputil.BufferPool over the same buffers. +type Pool struct{} + +func (Pool) Get() []byte { return pool.Get().(*[Size]byte)[:] } + +func (Pool) Put(b []byte) { + if cap(b) >= Size { + pool.Put((*[Size]byte)(b[:Size])) + } +} diff --git a/internal/copybuf/copybuf_test.go b/internal/copybuf/copybuf_test.go new file mode 100644 index 00000000..e83180c9 --- /dev/null +++ b/internal/copybuf/copybuf_test.go @@ -0,0 +1,75 @@ +package copybuf + +import ( + "bytes" + "io" + "net" + "runtime" + "strings" + "testing" +) + +func TestCopyCopiesEverything(t *testing.T) { + src := strings.Repeat("shipyard ", 20000) // several buffers' worth + var dst bytes.Buffer + n, err := Copy(&dst, strings.NewReader(src)) + if err != nil || n != int64(len(src)) || dst.String() != src { + t.Fatalf("copied %d, %v", n, err) + } +} + +func TestCopyBetweenConnectionsReusesItsBuffer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { io.Copy(c, bytes.NewReader(make([]byte, 4096))); c.Close() }() + } + }() + copyOne := func() { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if n, err := Copy(io.Discard, c); err != nil || n != 4096 { + t.Fatalf("copied %d, %v", n, err) + } + } + copyOne() + if raceEnabled { + return + } + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + const runs = 20 + for range runs { + copyOne() + } + runtime.ReadMemStats(&after) + // io.Copy from a TCP connection makes a 32 KB buffer every time; the + // pooled one is reused (the dial itself allocates a little). + if per := (after.TotalAlloc - before.TotalAlloc) / runs; per > 16<<10 { + t.Errorf("%d bytes allocated per copy", per) + } +} + +func TestPoolHandsBackFullBuffers(t *testing.T) { + var p Pool + b := p.Get() + if len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } + p.Put(b) + p.Put(make([]byte, 10)) // too small: dropped, not kept + if b := p.Get(); len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } +} diff --git a/internal/copybuf/race_off_test.go b/internal/copybuf/race_off_test.go new file mode 100644 index 00000000..02a452e6 --- /dev/null +++ b/internal/copybuf/race_off_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package copybuf + +const raceEnabled = false diff --git a/internal/copybuf/race_on_test.go b/internal/copybuf/race_on_test.go new file mode 100644 index 00000000..c8189145 --- /dev/null +++ b/internal/copybuf/race_on_test.go @@ -0,0 +1,7 @@ +//go:build race + +package copybuf + +// The race detector drops pooled items at random, so pooling cannot be +// measured under it. +const raceEnabled = true diff --git a/internal/forward/forward.go b/internal/forward/forward.go index 177a3600..d94d5bcf 100644 --- a/internal/forward/forward.go +++ b/internal/forward/forward.go @@ -5,11 +5,12 @@ import ( "context" "errors" "fmt" - "io" "net" "strconv" "sync" "syscall" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on the box. It is looked up per @@ -79,7 +80,7 @@ func Bridge(ctx context.Context, a, b net.Conn) { wg.Add(2) pipe := func(dst, src net.Conn) { defer wg.Done() - _, err := io.Copy(dst, src) + _, err := copybuf.Copy(dst, src) if cw, ok := dst.(closeWriter); ok && err == nil { cw.CloseWrite() return diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c32df199..27dde12f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on a box. @@ -107,7 +109,8 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { // (devtools.go). devtools := preview == "" && devtoolsPage(r) rp := &httputil.ReverseProxy{ - Transport: p.transport(target.Box), + Transport: p.transport(target.Box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" @@ -165,7 +168,8 @@ func (p *Proxy) passThrough(w http.ResponseWriter, r *http.Request, box string, return } rp := &httputil.ReverseProxy{ - Transport: p.transport(box), + Transport: p.transport(box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" diff --git a/internal/wire/server.go b/internal/wire/server.go index e5de1226..2bceb40a 100644 --- a/internal/wire/server.go +++ b/internal/wire/server.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/cosscom/shipyard/internal/copybuf" "github.com/cosscom/shipyard/internal/identity" "github.com/cosscom/shipyard/internal/pairing" "github.com/cosscom/shipyard/internal/trust" @@ -341,12 +342,12 @@ func (s *Server) handleTCP(w http.ResponseWriter, r *http.Request) { done := make(chan struct{}) go func() { defer close(done) - io.Copy(upstream, r.Body) + copybuf.Copy(upstream, r.Body) if tc, ok := upstream.(*net.TCPConn); ok { tc.CloseWrite() } }() - io.Copy(flushWriter{w: w, rc: rc}, upstream) + copybuf.Copy(flushWriter{w: w, rc: rc}, upstream) upstream.Close() r.Body.Close() <-done From 0933b03c1b333487067058ad18b9343fbd719ac2 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:40:31 +0100 Subject: [PATCH 4/8] perf(agent): look up routes without reading routes.json for every proxied request The proxy read and parsed routes.json first thing for each request it relayed (about 33 us on macOS). The routes are now kept until a stat shows the file changed, so edits from the CLI still apply at once (statefile.Cache). Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent.go | 4 +- internal/agent/bench_test.go | 2 +- internal/agent/routes.go | 26 +++++++++-- internal/agent/routes_test.go | 39 ++++++++++++++++ internal/statefile/cache.go | 70 ++++++++++++++++++++++++++++ internal/statefile/cache_darwin.go | 13 ++++++ internal/statefile/cache_linux.go | 13 ++++++ internal/statefile/cache_test.go | 74 ++++++++++++++++++++++++++++++ 8 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 internal/agent/routes_test.go create mode 100644 internal/statefile/cache.go create mode 100644 internal/statefile/cache_darwin.go create mode 100644 internal/statefile/cache_linux.go create mode 100644 internal/statefile/cache_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bb6d12bf..9439bd1f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -334,7 +334,7 @@ func Run(ctx context.Context, cfg Config) error { id: id, boxes: trust.NewStore(filepath.Join(cfg.Dir, "boxes.json")), forwards: forwardStore{path: filepath.Join(cfg.Dir, "forwards.json")}, - routes: routeStore{path: filepath.Join(cfg.Dir, "routes.json")}, + routes: newRouteStore(filepath.Join(cfg.Dir, "routes.json")), clients: map[string]*boxState{}, seqs: newSeqStore(cfg.Dir), running: map[string]*runningForward{}, @@ -942,7 +942,7 @@ func (a *Agent) status() Status { a.mu.Lock() defer a.mu.Unlock() s := Status{Boxes: []BoxStatus{}, Forwards: []ForwardStatus{}, Routes: []Route{}, Proxy: a.proxySt, TeamSuggestions: suggestions} - if routes, err := a.routes.list(); err == nil && routes != nil { + if routes, err := a.routes.current(); err == nil && routes != nil { s.Routes = routes } s.Proxy.URLPort = s.Proxy.Port diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go index 2c4cab02..96499ceb 100644 --- a/internal/agent/bench_test.go +++ b/internal/agent/bench_test.go @@ -120,7 +120,7 @@ func BenchmarkProxyHosts(b *testing.B) { // BenchmarkRouteLookup resolves a request host against the saved routes, // which the proxy does first for every request it relays. func BenchmarkRouteLookup(b *testing.B) { - a := &Agent{routes: routeStore{path: filepath.Join(b.TempDir(), "routes.json")}} + a := &Agent{routes: newRouteStore(filepath.Join(b.TempDir(), "routes.json"))} all := []Route{ {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000}, {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001}, diff --git a/internal/agent/routes.go b/internal/agent/routes.go index 7ecfb9e9..93dc78c7 100644 --- a/internal/agent/routes.go +++ b/internal/agent/routes.go @@ -21,7 +21,25 @@ type Route struct { var errUnknownRoute = errors.New("no route with that pattern") -type routeStore struct{ path string } +type routeStore struct { + path string + // cache keeps the routes until the file changes: the proxy looks them + // up for every request it relays. Nil reads the file every time. + cache *statefile.Cache[[]Route] +} + +func newRouteStore(path string) routeStore { + return routeStore{path: path, cache: new(statefile.Cache[[]Route])} +} + +// current is list, read again only once the file has changed. Its result +// is shared: callers must not change it. +func (s routeStore) current() ([]Route, error) { + if s.cache == nil { + return s.list() + } + return s.cache.Load(s.path, s.list) +} func (s routeStore) list() ([]Route, error) { b, err := os.ReadFile(s.path) @@ -93,10 +111,10 @@ func (a *Agent) removeRoute(pattern string) error { }) } -// route resolves a request host. Routes are read from disk each time: they -// are few and small, and edits then apply without restarting the agent. +// route resolves a request host. Routes are read from disk whenever the +// file changes (a stat tells), so edits apply without restarting the agent. func (a *Agent) route(host string) (string, int, bool) { - all, err := a.routes.list() + all, err := a.routes.current() if err != nil { return "", 0, false } diff --git a/internal/agent/routes_test.go b/internal/agent/routes_test.go new file mode 100644 index 00000000..ceb7461e --- /dev/null +++ b/internal/agent/routes_test.go @@ -0,0 +1,39 @@ +package agent + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// The proxy keeps the routes between requests, yet a route another +// process saves (the CLI) applies to the very next request. +func TestRoutesEditedOnDiskApplyAtOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + a := &Agent{routes: newRouteStore(path)} + write := func(routes ...Route) { + t.Helper() + b, _ := json.Marshal(routes) + if err := statefile.Write(path, b); err != nil { + t.Fatal(err) + } + } + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("routed with no routes file") + } + write(Route{Pattern: "*.cal.test.localhost", Box: "devl", Port: 3000}) + if box, port, ok := a.route("x.cal.test.localhost"); !ok || box != "devl" || port != 3000 { + t.Fatalf("route = %s %d %v", box, port, ok) + } + // Same length, straight after: another box. + write(Route{Pattern: "*.cal.test.localhost", Box: "devm", Port: 3000}) + if box, _, ok := a.route("x.cal.test.localhost"); !ok || box != "devm" { + t.Fatalf("after an edit: %s %v", box, ok) + } + write() + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("a removed route still routes") + } +} diff --git a/internal/statefile/cache.go b/internal/statefile/cache.go new file mode 100644 index 00000000..b5181bfc --- /dev/null +++ b/internal/statefile/cache.go @@ -0,0 +1,70 @@ +package statefile + +import ( + "errors" + "io/fs" + "os" + "sync" +) + +// Cache keeps what was read from one small state file and reads it again +// only once the file has changed. Some files are read on every request +// (the proxy's routes on each one it relays), so edits made by another +// process (the CLI) still apply at once, without a read of the file each +// time: a stat says whether it changed. +// +// Files here are replaced whole (Write renames a new file over the old), +// so a change shows as a new inode, size, modification or change time. +type Cache[T any] struct { + mu sync.Mutex + key fileKey + ok bool + val T +} + +// fileKey is what tells one version of a file from another. missing is a +// file that isn't there. +type fileKey struct { + missing bool + dev, ino uint64 + size int64 + mtime, ctim int64 // nanoseconds +} + +func keyOf(path string) (fileKey, error) { + fi, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return fileKey{missing: true}, nil + } + if err != nil { + return fileKey{}, err + } + k := fileKey{size: fi.Size(), mtime: fi.ModTime().UnixNano()} + statKey(fi, &k) + return k, nil +} + +// Load returns what read made of path, from the last time it ran if the +// file is as it was then. read's result is shared by every caller until +// the file changes: callers must not change it. Errors are not kept, so a +// file that could not be read is tried again next time. +func (c *Cache[T]) Load(path string, read func() (T, error)) (T, error) { + // The stat comes before the read: a file replaced in between is read + // again next time, rather than its old version kept under its new key. + k, err := keyOf(path) + if err != nil { + return read() + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ok && c.key == k { + return c.val, nil + } + v, err := read() + if err != nil { + c.ok = false + return v, err + } + c.key, c.val, c.ok = k, v, true + return v, nil +} diff --git a/internal/statefile/cache_darwin.go b/internal/statefile/cache_darwin.go new file mode 100644 index 00000000..c0e4c5a7 --- /dev/null +++ b/internal/statefile/cache_darwin.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctimespec.Nano() + } +} diff --git a/internal/statefile/cache_linux.go b/internal/statefile/cache_linux.go new file mode 100644 index 00000000..62b91e7d --- /dev/null +++ b/internal/statefile/cache_linux.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctim.Nano() + } +} diff --git a/internal/statefile/cache_test.go b/internal/statefile/cache_test.go new file mode 100644 index 00000000..7837d3fc --- /dev/null +++ b/internal/statefile/cache_test.go @@ -0,0 +1,74 @@ +package statefile + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCacheReadsAFileAgainOnlyOnceItChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + var c Cache[string] + reads := 0 + load := func() string { + t.Helper() + v, err := c.Load(path, func() (string, error) { + reads++ + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "none", nil + } + return string(b), err + }) + if err != nil { + t.Fatal(err) + } + return v + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("missing file: %q after %d reads", v, reads) + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("still missing: %q after %d reads", v, reads) + } + if err := Write(path, []byte("aaaa")); err != nil { + t.Fatal(err) + } + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("new file: %q after %d reads", v, reads) + } + for range 3 { + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("unchanged file: %q after %d reads", v, reads) + } + } + // The same size, written straight after: still a new version. + if err := Write(path, []byte("bbbb")); err != nil { + t.Fatal(err) + } + if v := load(); v != "bbbb" || reads != 3 { + t.Fatalf("rewritten file: %q after %d reads", v, reads) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if v := load(); v != "none" || reads != 4 { + t.Fatalf("removed file: %q after %d reads", v, reads) + } +} + +func TestCacheKeepsNoErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + if err := Write(path, []byte("x")); err != nil { + t.Fatal(err) + } + var c Cache[int] + broken := errors.New("unreadable") + if _, err := c.Load(path, func() (int, error) { return 0, broken }); err != broken { + t.Fatalf("err = %v", err) + } + if v, err := c.Load(path, func() (int, error) { return 7, nil }); err != nil || v != 7 { + t.Fatalf("after an error: %d, %v", v, err) + } +} From 807815be71d33fa204fb76d51080c94db031dc48 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:40:31 +0100 Subject: [PATCH 5/8] perf(agent): resolve worktree.location.localhost without building the whole status Each request to a worktree name without a box built the agent's full status, which reads three files, just to list the boxes' names. Co-Authored-By: Claude Opus 5.5 --- internal/agent/worktrees.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go index fa65f69e..a60a1481 100644 --- a/internal/agent/worktrees.go +++ b/internal/agent/worktrees.go @@ -2,6 +2,7 @@ package agent import ( "context" + "slices" "time" "github.com/cosscom/shipyard/internal/box" @@ -88,15 +89,29 @@ func (a *Agent) worktree(labels []string) (string, int, bool) { } wt, loc := labels[0], labels[1] found, foundPort := "", 0 - for _, b := range a.status().Boxes { - if port, ok := lowest(b.Name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { + // Only the boxes' names: this runs for every request the proxy + // relays, and the whole status reads files. + for _, name := range a.boxNames() { + if port, ok := lowest(name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { if found != "" { return "", 0, false // ambiguous: name the box } - found, foundPort = b.Name, port + found, foundPort = name, port } } return found, foundPort, found != "" } return "", 0, false } + +// boxNames is every paired box's name, sorted. +func (a *Agent) boxNames() []string { + a.mu.Lock() + names := make([]string, 0, len(a.clients)) + for name := range a.clients { + names = append(names, name) + } + a.mu.Unlock() + slices.Sort(names) + return names +} From 6e7ffd9fc857db1539714c8308dfb73a85a8ad78 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:42:13 +0100 Subject: [PATCH 6/8] perf(agent): ask every box for its kits and projects at once, not in turn The Kits view's installed list and team suggestions asked each online box one after another, so four boxes took four answers' time and one slow box held up the rest by up to its whole timeout. They now ask up to eight boxes at once. Co-Authored-By: Claude Opus 5.5 --- internal/agent/fanout.go | 25 ++++++++++++++++++++++++ internal/agent/fanout_test.go | 34 +++++++++++++++++++++++++++++++++ internal/agent/kits.go | 28 ++++++++++++++++----------- internal/agent/teamsuggest.go | 36 +++++++++++++++++++++-------------- 4 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 internal/agent/fanout.go create mode 100644 internal/agent/fanout_test.go diff --git a/internal/agent/fanout.go b/internal/agent/fanout.go new file mode 100644 index 00000000..e8d22922 --- /dev/null +++ b/internal/agent/fanout.go @@ -0,0 +1,25 @@ +package agent + +import "sync" + +// fanOutLimit is how many boxes one question is put to at a time. +const fanOutLimit = 8 + +// fanOut runs ask for each of n boxes, at most fanOutLimit at a time, and +// waits for them all: asking every box in turn would take as long as all +// their answers together, and a box that is slow or away holds up the +// rest by its whole timeout. +func fanOut(n int, ask func(i int)) { + sem := make(chan struct{}, fanOutLimit) + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + ask(i) + }() + } + wg.Wait() +} diff --git a/internal/agent/fanout_test.go b/internal/agent/fanout_test.go new file mode 100644 index 00000000..0b587ce8 --- /dev/null +++ b/internal/agent/fanout_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestFanOutAsksEveryBoxAFewAtATime(t *testing.T) { + const n = 3*fanOutLimit + 1 + var running, most atomic.Int32 + asked := make([]bool, n) + fanOut(n, func(i int) { + now := running.Add(1) + for { + m := most.Load() + if now <= m || most.CompareAndSwap(m, now) { + break + } + } + time.Sleep(20 * time.Millisecond) + asked[i] = true + running.Add(-1) + }) + for i, ok := range asked { + if !ok { + t.Fatalf("box %d was not asked", i) + } + } + // More than one at once (in turn would be one), never past the limit. + if m := most.Load(); m > fanOutLimit || m < 2 { + t.Errorf("%d asked at once", m) + } +} diff --git a/internal/agent/kits.go b/internal/agent/kits.go index c50105fb..cb36ac36 100644 --- a/internal/agent/kits.go +++ b/internal/agent/kits.go @@ -390,25 +390,31 @@ func (a *Agent) installedKits(ctx context.Context) []InstalledKitOn { for _, k := range a.kits() { kept[k.ID] = k.Hash } - out := []InstalledKitOn{} + var online []string for _, b := range a.status().Boxes { - if b.State != StateOnline { - continue + if b.State == StateOnline { + online = append(online, b.Name) } - c, ok := a.client(b.Name) + } + // Every box at once; the answers keep the boxes' order. + each := make([][]box.InstalledKitAt, len(online)) + fanOut(len(online), func(i int) { + c, ok := a.client(online[i]) if !ok { - continue + return } - var at []box.InstalledKitAt cctx, cancel := context.WithTimeout(ctx, 10*time.Second) - err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at) - cancel() - if err != nil { - continue + defer cancel() + var at []box.InstalledKitAt + if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at) == nil { + each[i] = at } + }) + out := []InstalledKitOn{} + for n, at := range each { for _, i := range at { h, ok := kept[i.Kit.ID] - out = append(out, InstalledKitOn{Box: b.Name, InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash}) + out = append(out, InstalledKitOn{Box: online[n], InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash}) } } return out diff --git a/internal/agent/teamsuggest.go b/internal/agent/teamsuggest.go index 2083d33c..cd873a0f 100644 --- a/internal/agent/teamsuggest.go +++ b/internal/agent/teamsuggest.go @@ -597,21 +597,29 @@ func (a *Agent) suggestLocations(ctx context.Context) map[string][]suggestLoc { a.suggest.mu.Lock() prev := a.suggest.locs a.suggest.mu.Unlock() - out := map[string][]suggestLoc{} - for _, b := range boxes { - if b.online { - if c, ok := a.client(b.name); ok { - var locs []box.Location - cctx, cancel := context.WithTimeout(ctx, 8*time.Second) - err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs) - cancel() - if err == nil { - out[b.name] = suggestLocs(b.name, locs) - continue - } - } + // Every box at once: one that is slow or away holds up no other. + fresh := make([][]suggestLoc, len(boxes)) + fanOut(len(boxes), func(i int) { + b := boxes[i] + if !b.online { + return } - if p, ok := prev[b.name]; ok { + c, ok := a.client(b.name) + if !ok { + return + } + cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + var locs []box.Location + if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs) == nil { + fresh[i] = suggestLocs(b.name, locs) + } + }) + out := map[string][]suggestLoc{} + for i, b := range boxes { + if fresh[i] != nil { + out[b.name] = fresh[i] + } else if p, ok := prev[b.name]; ok { out[b.name] = p } } From ac94eae630c204501278e1452e351e40f2a0362b Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:47:48 +0100 Subject: [PATCH 7/8] perf(agent): relay the app's box requests without a fresh buffer and settings read each Every request the app sends a box through the agent made a 32 KB copy buffer and read box-routes.json while syncing the boxes. The buffer now comes from the shared pool and the route settings are kept until the file changes. Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent.go | 2 ++ internal/agent/bench_ui_test.go | 56 +++++++++++++++++++++++++++++++++ internal/agent/boxroutes.go | 7 +++-- internal/agent/ui.go | 4 ++- 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 internal/agent/bench_ui_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9439bd1f..9460f5de 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -269,6 +269,8 @@ type Agent struct { hostsMu sync.Mutex hostsAt time.Time hosts []string + // boxRoutes keeps box-routes.json between changes (boxroutes.go). + boxRoutes statefile.Cache[map[string]BoxRoutes] mu sync.Mutex svc map[string]serviceCache diff --git a/internal/agent/bench_ui_test.go b/internal/agent/bench_ui_test.go new file mode 100644 index 00000000..d002beff --- /dev/null +++ b/internal/agent/bench_ui_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/box" +) + +// BenchmarkUIBoxAPI is one app request relayed to a box through the +// laptop agent's app API (/v1/boxes/{box}/api/…), the way the app reads +// every box's sessions, worktrees and files. +func BenchmarkUIBoxAPI(b *testing.B) { + one := newBox(b) + one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: 3000}} + a := startAgentConfig(b, one.pairLaptop(), &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline }) + var tok string + eventually(b, "ui token", func() bool { + raw, err := os.ReadFile(filepath.Join(a.dir, UITokenFile)) + tok = strings.TrimSpace(string(raw)) + return err == nil && tok != "" + }) + c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}} + get := func() error { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://"+a.ui+"/v1/boxes/devbox/api/services", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b.Fatalf("status %d", resp.StatusCode) + } + return nil + } + eventually(b, "ui up", func() bool { return get() == nil }) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := get(); err != nil { + b.Error(err) + return + } + } + }) +} diff --git a/internal/agent/boxroutes.go b/internal/agent/boxroutes.go index cc5d837d..3d8c8c97 100644 --- a/internal/agent/boxroutes.go +++ b/internal/agent/boxroutes.go @@ -142,9 +142,12 @@ func RecordSSHRoute(dir, box, fingerprint, host, identity, forward string) error }) } -// routeSettings is the settings file, read at most once per sync. +// routeSettings is the settings file, read again only once it changes: +// every request the app relays to a box syncs the boxes first. The map is +// shared: callers must not change it. func (a *Agent) routeSettings() map[string]BoxRoutes { - all, err := readBoxRoutes(routeStorePath(a.cfg.Dir)) + path := routeStorePath(a.cfg.Dir) + all, err := a.boxRoutes.Load(path, func() (map[string]BoxRoutes, error) { return readBoxRoutes(path) }) if err != nil { a.cfg.Log.Printf("box routes: %v", err) return map[string]BoxRoutes{} diff --git a/internal/agent/ui.go b/internal/agent/ui.go index f703091d..fe2dc9ff 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -23,6 +23,7 @@ import ( "github.com/coder/websocket" "github.com/cosscom/shipyard/internal/box" + "github.com/cosscom/shipyard/internal/copybuf" "github.com/cosscom/shipyard/internal/hooks" "github.com/cosscom/shipyard/internal/statefile" "github.com/cosscom/shipyard/internal/terminal" @@ -272,7 +273,8 @@ func (a *Agent) relayBox(w http.ResponseWriter, r *http.Request, c *wire.Client, } w.WriteHeader(resp.StatusCode) rc := http.NewResponseController(w) - buf := make([]byte, 32<<10) + buf := copybuf.Pool{}.Get() + defer copybuf.Pool{}.Put(buf) for { n, err := resp.Body.Read(buf) if n > 0 { From 2dd0e9135733cc1fb8045ede42bb69781ecd682d Mon Sep 17 00:00:00 2001 From: sean Date: Sat, 10 Oct 2026 09:39:05 +0100 Subject: [PATCH 8/8] docs: the laptop agent's per-request savings in the changelog Co-Authored-By: Claude Opus 5.5 --- docs/changelog.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 9e5609b8..7d01fc3e 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -17,6 +17,9 @@ again. app.** Opening `http://localhost:3000` before its dev server started closed Shipyard half a second later, and again on every launch after, since the tab came back. +- **The laptop agent is lighter per request.** Proxied pages, port forwards + and calls to your boxes allocate a fraction of what they did, and the kits + and team suggestions ask all your boxes at once. ## v0.3.13