Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/sandbox/daemon-go/internal/probe/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions packages/sandbox/daemon-go/internal/probe/probe_test.go
Original file line number Diff line number Diff line change
@@ -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:"):])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: On IPv6-only hosts, this port extraction fails because httptest.NewServer can return a URL such as http://[::1]:<port>. Parse srv.URL with net/url and net.SplitHostPort so the regression test works on every loopback address supported by DialLoopback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox/daemon-go/internal/probe/probe_test.go, line 22:

<comment>On IPv6-only hosts, this port extraction fails because `httptest.NewServer` can return a URL such as `http://[::1]:<port>`. Parse `srv.URL` with `net/url` and `net.SplitHostPort` so the regression test works on every loopback address supported by `DialLoopback`.</comment>

<file context>
@@ -0,0 +1,49 @@
+	}))
+	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)
</file context>

if err != nil {
t.Fatalf("parse port from %q: %v", srv.URL, err)
}

before := runtime.NumGoroutine()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This asserts on the global runtime.NumGoroutine(), so unrelated goroutines (parallel tests, race-detector/GC wakeups, runtime background activity) can transiently push the count above before+5, making the test flaky even when nothing leaks. The threshold and 2s wait reduce but do not remove that risk. Tighten it by asserting the specific readLoop goroutine count (e.g. report via the leak that this guards), or widen the tolerance and note the flakiness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox/daemon-go/internal/probe/probe_test.go, line 27:

<comment>This asserts on the global `runtime.NumGoroutine()`, so unrelated goroutines (parallel tests, race-detector/GC wakeups, runtime background activity) can transiently push the count above `before+5`, making the test flaky even when nothing leaks. The threshold and 2s wait reduce but do not remove that risk. Tighten it by asserting the specific readLoop goroutine count (e.g. report via the leak that this guards), or widen the tolerance and note the flakiness.</comment>

<file context>
@@ -0,0 +1,49 @@
+		t.Fatalf("parse port from %q: %v", srv.URL, err)
+	}
+
+	before := runtime.NumGoroutine()
+
+	for i := 0; i < 20; i++ {
</file context>


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