From c5e37e69cea3346ac5fdfcf4d596e3401958ff8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Apr 2026 22:30:58 +0800 Subject: [PATCH] Fix goroutine leak in networkquality tool Serialize probe rounds in startProber to eliminate unbounded fan-out of fire-and-forget probe goroutines (up to 100/sec per direction), and close HTTP/3 transports via transport.Close() in addition to CloseIdleConnections. --- common/networkquality/networkquality.go | 52 +++++++++++++++---------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/common/networkquality/networkquality.go b/common/networkquality/networkquality.go index a4c73472c..8373035d8 100644 --- a/common/networkquality/networkquality.go +++ b/common/networkquality/networkquality.go @@ -227,7 +227,7 @@ type loadConnection struct { } func (c *loadConnection) run(ctx context.Context, onError func(error)) { - defer c.client.CloseIdleConnections() + defer closeMeasurementClient(c.client) markActive := func() { c.ready.Store(true) c.active.Store(true) @@ -451,29 +451,31 @@ func (r *directionRunner) startProber(ctx context.Context) { if conn == nil { continue } - go func(selfClient *http.Client) { - foreignClient, err := r.factory(r.plan.connectEndpoint, true, true, nil, nil) - if err != nil { - return - } - round, err := collectProbeRound(ctx, foreignClient, selfClient, r.plan.probeURL.String()) - foreignClient.CloseIdleConnections() - if err != nil { - return - } - r.recordProbeRound(probeRound{ - interval: int(r.currentInterval.Load()), - tcp: round.tcp, - tls: round.tls, - httpFirst: round.httpFirst, - httpLoaded: round.httpLoaded, - }) - }(conn.client) + r.runProbeRound(ctx, conn.client) ticker.Reset(r.probeInterval()) } }() } +func (r *directionRunner) runProbeRound(ctx context.Context, selfClient *http.Client) { + foreignClient, err := r.factory(r.plan.connectEndpoint, true, true, nil, nil) + if err != nil { + return + } + defer closeMeasurementClient(foreignClient) + round, err := collectProbeRound(ctx, foreignClient, selfClient, r.plan.probeURL.String()) + if err != nil { + return + } + r.recordProbeRound(probeRound{ + interval: int(r.currentInterval.Load()), + tcp: round.tcp, + tls: round.tls, + httpFirst: round.httpFirst, + httpLoaded: round.httpLoaded, + }) +} + func (r *directionRunner) probeInterval() time.Duration { interval := time.Second / time.Duration(settings.maxProbesPerSecond) capacity := r.currentCapacity.Load() @@ -945,7 +947,7 @@ func measureIdleLatency(ctx context.Context, factory MeasurementClientFactory, c return 0, 0, err } measurement, err := runProbe(ctx, client, config.smallURL.String(), false) - client.CloseIdleConnections() + closeMeasurementClient(client) if err != nil { return 0, 0, err } @@ -1274,6 +1276,16 @@ func newRequest(ctx context.Context, method string, rawURL string, body io.Reade return req, nil } +func closeMeasurementClient(client *http.Client) { + if client == nil { + return + } + client.CloseIdleConnections() + if closer, ok := client.Transport.(interface{ Close() error }); ok { + _ = closer.Close() + } +} + func validateResponse(resp *http.Response) error { if resp.StatusCode < 200 || resp.StatusCode >= 300 { return E.New("unexpected status: ", resp.Status)