test(gopher): verify cancel goroutine exits when Download returns
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user