test(gopher): verify cancel goroutine exits when Download returns

This commit is contained in:
2026-06-30 14:54:52 +02:00
parent 88e210daaf
commit 4268fa5d5f
+77
View File
@@ -6,9 +6,11 @@ package gopher
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
"net" "net"
"net/url" "net/url"
"os" "os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -594,3 +596,78 @@ func TestGopherResumeInformation(t *testing.T) {
t.Error("expected .goget.meta to be deleted after successful resume") t.Error("expected .goget.meta to be deleted after successful resume")
} }
} }
// TestGopherCancelGoroutineExits verifies that the ctx-cancellation
// goroutine spawned by Download does not outlive Download itself.
// The goroutine used to wait forever on <-ctx.Done() when the parent
// context was never cancelled; the done-channel bound added with M3
// must let every iteration return to the runtime goroutine pool.
func TestGopherCancelGoroutineExits(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
go func() {
// Drain connections so the listener does not backpressure
// the test loop. We do not read or write — a quick accept
// and close is enough for Download to complete.
for {
conn, lerr := listener.Accept()
if lerr != nil {
return
}
go func(c net.Conn) {
// Reply with the response header so the client
// reaches downloadText and spawns its goroutine.
defer c.Close()
fmt.Fprintf(c, "0hi\t/x\th\t70\r\n.\r\n")
// Block until the peer closes — this lets the
// client-side goroutine sit on ReadString until
// it's raced by done, exercising the bound.
buf := make([]byte, 1)
c.Read(buf)
}(conn)
}
}()
// Warm up: a single download is enough to settle the runtime
// goroutine pool (timer, GC, poll, etc.) before we measure.
proto := NewProtocol()
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/x")
req := core.DownloadRequest{URL: u, Writer: &stringWriter{&strings.Builder{}}}
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
t.Fatalf("warmup: %v", dlErr)
}
// Settle baseline after warmup.
runtime.GC()
time.Sleep(50 * time.Millisecond)
runtime.GC()
baseline := runtime.NumGoroutine()
const iterations = 50
for i := 0; i < iterations; i++ {
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
t.Fatalf("download %d: %v", i, dlErr)
}
}
// After every successful Download returns, its cancellation
// goroutine must have exited. Allow a brief settle window for
// the runtime to reclaim them.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
// One tolerance for any incidental stdlib goroutine that
// the Go runtime occasionally spins up between calls.
if runtime.NumGoroutine() <= baseline+1 {
return
}
}
t.Fatalf("goroutine leak: baseline=%d, after %d downloads = %d",
baseline, iterations, runtime.NumGoroutine())
}