diff --git a/internal/protocol/gopher/gopher_test.go b/internal/protocol/gopher/gopher_test.go index bf8af24..3d02b5d 100644 --- a/internal/protocol/gopher/gopher_test.go +++ b/internal/protocol/gopher/gopher_test.go @@ -6,9 +6,11 @@ package gopher import ( "bufio" "context" + "fmt" "net" "net/url" "os" + "runtime" "strconv" "strings" "testing" @@ -594,3 +596,78 @@ func TestGopherResumeInformation(t *testing.T) { 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()) +}