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) + } +}