From 3b7b80103a2d8874c6d8191fd429b5d29fee3597 Mon Sep 17 00:00:00 2001 From: pedrofrxncx Date: Tue, 1 Sep 2026 16:06:09 -0300 Subject: [PATCH] fix(sandbox): stop the dev-server probe leaking a keep-alive connection every tick head() builds a fresh http.Transport on every probe tick (every 1s while booting, every 30s once online) and never closes it or drains its idle connection pool. With keep-alive left on, each successful HEAD leaves an idle persistConn + readLoop goroutine open indefinitely, since the Transport itself is discarded but the goroutine keeps it (and the socket) alive. Over a long-running sandbox this accumulates goroutines and file descriptors without bound. Set DisableKeepAlives on the per-tick Transport so the connection closes right after each HEAD instead of idling forever. --- .../sandbox/daemon-go/internal/probe/probe.go | 6 ++- .../daemon-go/internal/probe/probe_test.go | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/sandbox/daemon-go/internal/probe/probe_test.go diff --git a/packages/sandbox/daemon-go/internal/probe/probe.go b/packages/sandbox/daemon-go/internal/probe/probe.go index ad3360a4b6..4c2361bb3b 100644 --- a/packages/sandbox/daemon-go/internal/probe/probe.go +++ b/packages/sandbox/daemon-go/internal/probe/probe.go @@ -168,10 +168,14 @@ func itoa(n int) string { } func head(port int) (status int, isHtml, up bool) { + // A fresh Transport is built (and discarded) on every tick, so keep-alive + // would leak an idle connection + its readLoop goroutine per tick for the + // life of the daemon instead of being reused. client := &http.Client{ Timeout: HeadTimeout, Transport: &http.Transport{ - DialContext: DialLoopback, + DialContext: DialLoopback, + DisableKeepAlives: true, }, } req, err := http.NewRequest("HEAD", "http://loopback:"+itoa(port)+"/", nil) diff --git a/packages/sandbox/daemon-go/internal/probe/probe_test.go b/packages/sandbox/daemon-go/internal/probe/probe_test.go new file mode 100644 index 0000000000..4ec12384da --- /dev/null +++ b/packages/sandbox/daemon-go/internal/probe/probe_test.go @@ -0,0 +1,49 @@ +package probe + +import ( + "net/http" + "net/http/httptest" + "runtime" + "strconv" + "testing" + "time" +) + +// TestHeadDoesNotLeakKeepAliveGoroutine guards against regressing to a +// per-tick Transport that leaves an idle keep-alive connection (and its +// readLoop goroutine) open forever, since head() is called repeatedly for +// the life of the daemon. +func TestHeadDoesNotLeakKeepAliveGoroutine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + port, err := strconv.Atoi(srv.URL[len("http://127.0.0.1:"):]) + if err != nil { + t.Fatalf("parse port from %q: %v", srv.URL, err) + } + + before := runtime.NumGoroutine() + + for i := 0; i < 20; i++ { + status, _, up := head(port) + if !up || status != 200 { + t.Fatalf("head() = status=%d up=%v, want 200/true", status, up) + } + } + + // Idle keep-alive readLoop goroutines don't exit synchronously; give them + // a moment, then assert we haven't accumulated one per call. + deadline := time.Now().Add(2 * time.Second) + for { + if runtime.NumGoroutine() <= before+5 { + return + } + if time.Now().After(deadline) { + t.Fatalf("goroutine count grew from %d to %d after 20 head() calls — keep-alive connections are leaking", + before, runtime.NumGoroutine()) + } + time.Sleep(10 * time.Millisecond) + } +}