Skip to content

Commit 106efc7

Browse files
committed
fix(http): close connections of non-pooled clients after each request
Port of Danny-Dasilva/CycleTLS#426. With enable_connection_reuse=false, getOrCreateClient builds a fresh client outside the pool and Do() drops it without closing what it opened - dialTLS caches the live conn into rt.cachedConnections and nothing ever frees it. On HTTP/2 (the common ALPN outcome) the ClientConn read/write loops pin the socket open, so every request leaked a socket + 2 goroutines until EMFILE. HTTP/1 was already covered by DisableKeepAlives; h2 has no such knob. - CycleTLS.Do(): close all connections on the roundTripper when the client came from outside the pool. Safe: nothing shares it and the body is fully read by then. Covers every FFI entry point. - dispatcherAsync: non-pooled clients now close all conns instead of keeping the just-served hostPort conn cached on a dropped client. - roundTripper.CloseIdleConnections: also drain idle conns inside cached transports (graceful h2 GOAWAY) and drop transport entries that have no cached conn (http-scheme path was never cleaned). Regression test drives 8 reuse-off requests against a local h2 server and asserts goroutine/fd counts stay flat; fails on the unfixed code (+24 goroutines, +16 fds).
1 parent 4c0b215 commit 106efc7

3 files changed

Lines changed: 218 additions & 26 deletions

File tree

‎golang/conn_leak_test.go‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package main
2+
3+
import (
4+
"net"
5+
nhttp "net/http"
6+
"net/http/httptest"
7+
"os"
8+
"runtime"
9+
"testing"
10+
"time"
11+
12+
http "github.com/Danny-Dasilva/fhttp"
13+
)
14+
15+
// countingTransport records CloseIdleConnections calls so tests can verify
16+
// that roundTripper.CloseIdleConnections propagates into cached transports.
17+
type countingTransport struct {
18+
calls int
19+
}
20+
21+
func (c *countingTransport) RoundTrip(*http.Request) (*http.Response, error) {
22+
return nil, nil
23+
}
24+
25+
func (c *countingTransport) CloseIdleConnections() { c.calls++ }
26+
27+
// TestCloseIdleConnectionsDrainsTransports asserts that CloseIdleConnections
28+
// also asks each cached transport to drop its idle connections - not just the
29+
// raw conns in cachedConnections. Without propagation an HTTP/2 ClientConn's
30+
// read/write loops would keep running on the dropped conn.
31+
func TestCloseIdleConnectionsDrainsTransports(t *testing.T) {
32+
rt := newRoundTripper(Browser{}).(*roundTripper)
33+
34+
serverConn, clientConn := net.Pipe()
35+
defer serverConn.Close()
36+
rt.cachedConnections["example.test:443"] = clientConn
37+
38+
ct := &countingTransport{}
39+
rt.cachedTransports["example.test:443"] = ct
40+
41+
rt.CloseIdleConnections()
42+
43+
if ct.calls == 0 {
44+
t.Errorf("expected CloseIdleConnections to be called on the cached transport")
45+
}
46+
if len(rt.cachedConnections) != 0 || len(rt.cachedTransports) != 0 {
47+
t.Errorf("expected both caches empty; got %d conns, %d transports",
48+
len(rt.cachedConnections), len(rt.cachedTransports))
49+
}
50+
if _, err := clientConn.Write([]byte("x")); err == nil {
51+
t.Errorf("expected cached conn to be closed, write succeeded")
52+
}
53+
}
54+
55+
// TestCloseIdleConnectionsKeepsSelectedAddr pins the keep-one-address path:
56+
// only non-matching conns/transports are drained.
57+
func TestCloseIdleConnectionsKeepsSelectedAddr(t *testing.T) {
58+
rt := newRoundTripper(Browser{}).(*roundTripper)
59+
60+
s1, c1 := net.Pipe()
61+
s2, c2 := net.Pipe()
62+
defer s1.Close()
63+
defer s2.Close()
64+
65+
rt.cachedConnections["keep.test:443"] = c1
66+
rt.cachedConnections["drop.test:443"] = c2
67+
keepTr := &countingTransport{}
68+
dropTr := &countingTransport{}
69+
rt.cachedTransports["keep.test:443"] = keepTr
70+
rt.cachedTransports["drop.test:443"] = dropTr
71+
72+
rt.CloseIdleConnections("keep.test:443")
73+
74+
if keepTr.calls != 0 {
75+
t.Errorf("kept transport should not be drained")
76+
}
77+
if dropTr.calls == 0 {
78+
t.Errorf("dropped transport should be drained")
79+
}
80+
if rt.cachedConnections["keep.test:443"] == nil || rt.cachedTransports["keep.test:443"] == nil {
81+
t.Errorf("kept addr lost its conn/transport")
82+
}
83+
if _, ok := rt.cachedConnections["drop.test:443"]; ok {
84+
t.Errorf("dropped conn still cached")
85+
}
86+
if _, ok := rt.cachedTransports["drop.test:443"]; ok {
87+
t.Errorf("dropped transport still cached")
88+
}
89+
}
90+
91+
// countOpenFDs returns the number of open file descriptors for this process.
92+
// Linux-only (/proc/self/fd); callers should skip elsewhere.
93+
func countOpenFDs(t *testing.T) int {
94+
t.Helper()
95+
entries, err := os.ReadDir("/proc/self/fd")
96+
if err != nil {
97+
t.Fatalf("cannot read /proc/self/fd: %v", err)
98+
}
99+
return len(entries)
100+
}
101+
102+
// TestDoClosesNonPooledClientConnections is the end-to-end regression for
103+
// upstream CycleTLS PR #426: with enable_connection_reuse=false the client
104+
// is built outside the pool and dropped after a single request - nothing
105+
// used to close its connections. Against an HTTP/2 server every request
106+
// leaked a socket plus the h2 read/write loop goroutines (EMFILE over time).
107+
// HTTP/1.x was spared because DisableKeepAlives makes the inner transport
108+
// close the conn itself; h2 has no such knob, so Do() must close them.
109+
func TestDoClosesNonPooledClientConnections(t *testing.T) {
110+
if runtime.GOOS != "linux" {
111+
t.Skip("fd counting requires /proc/self/fd")
112+
}
113+
114+
srv := httptest.NewUnstartedServer(nhttp.HandlerFunc(func(w nhttp.ResponseWriter, r *nhttp.Request) {
115+
w.WriteHeader(200)
116+
_, _ = w.Write([]byte("ok"))
117+
}))
118+
srv.EnableHTTP2 = true
119+
srv.StartTLS()
120+
defer srv.Close()
121+
122+
client := CycleTLS{}
123+
opts := Options{
124+
EnableConnectionReuse: false,
125+
InsecureSkipVerify: true,
126+
Timeout: 10,
127+
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
128+
}
129+
130+
do := func() {
131+
resp, err := client.Do(srv.URL, opts, "GET")
132+
if err != nil {
133+
t.Fatalf("request failed: %v", err)
134+
}
135+
// Do() reports request-level failures inside the Response rather
136+
// than via err - require a real 200 so the test can't pass on a
137+
// conn that never came up.
138+
if resp.Status != 200 {
139+
t.Fatalf("expected 200, got status %d body %q", resp.Status, resp.Body)
140+
}
141+
}
142+
143+
// Warmup: one request so steady-state goroutines (timers, GC helpers)
144+
// are spun up before we take the baseline.
145+
do()
146+
runtime.GC()
147+
time.Sleep(200 * time.Millisecond)
148+
baseG := runtime.NumGoroutine()
149+
baseFD := countOpenFDs(t)
150+
151+
const n = 8
152+
for i := 0; i < n; i++ {
153+
do()
154+
}
155+
156+
// Give closed conns a moment to release fds and let h2 loops exit.
157+
time.Sleep(500 * time.Millisecond)
158+
runtime.GC()
159+
time.Sleep(200 * time.Millisecond)
160+
161+
gotG := runtime.NumGoroutine() - baseG
162+
gotFD := countOpenFDs(t) - baseFD
163+
164+
// Each leaked h2 conn costs ~2 goroutines and 1 fd. Allow slack for
165+
// unrelated runtime churn but fail if the per-request leak is present.
166+
if gotG >= n {
167+
t.Errorf("goroutine count grew by %d after %d reuse-off requests - non-pooled client conns not closed", gotG, n)
168+
}
169+
if gotFD >= n/2 {
170+
t.Errorf("fd count grew by %d after %d reuse-off requests - non-pooled client conns not closed", gotFD, n)
171+
}
172+
}

‎golang/index.go‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -728,11 +728,18 @@ func dispatcherAsync(res fullRequest, chanWrite *safeChannelWriter) {
728728
}
729729

730730
// Don't close connections when finished - they'll be reused for the same host
731-
// Instead, tell the roundtripper to keep this connection but close others
731+
// Instead, tell the roundtripper to keep this connection but close others.
732+
// A client built outside the pool (connection reuse disabled) is not
733+
// shared with any other request, so close all of its connections instead
734+
// of keeping this one - otherwise each request strands a socket.
732735
defer func() {
733736
// Use type assertion to access the roundTripper
734737
if transport, ok := res.client.Transport.(*roundTripper); ok {
735-
transport.CloseIdleConnections(hostPort)
738+
if res.options.Options.EnableConnectionReuse {
739+
transport.CloseIdleConnections(hostPort)
740+
} else {
741+
transport.CloseIdleConnections()
742+
}
736743
}
737744
}()
738745

@@ -1772,6 +1779,18 @@ func (client CycleTLS) Do(URL string, options Options, Method string) (Response,
17721779
return Response{}, err
17731780
}
17741781

1782+
if !enableConnectionReuse {
1783+
// The client was built outside the pool, so no other request shares
1784+
// it and Do() reads the body to completion before returning. Close
1785+
// all of its connections on the way out - otherwise every request
1786+
// strands a socket (and, on HTTP/2, the conn's read/write loops).
1787+
defer func() {
1788+
if transport, ok := httpClient.Transport.(*roundTripper); ok {
1789+
transport.CloseIdleConnections()
1790+
}
1791+
}()
1792+
}
1793+
17751794
// Create request using fhttp
17761795
var bodyReader io.Reader
17771796
if len(options.BodyBytes) > 0 {

‎golang/roundtripper.go‎

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -658,32 +658,33 @@ func (rt *roundTripper) CloseIdleConnections(selectedAddr ...string) {
658658
rt.Lock()
659659
defer rt.Unlock()
660660

661-
// If we have a specific address to keep, only close other connections
662-
if len(selectedAddr) > 0 && selectedAddr[0] != "" {
663-
addr := selectedAddr[0]
664-
// Keep the connection for the provided address, close others
665-
for connAddr, conn := range rt.cachedConnections {
666-
if connAddr != addr {
667-
if conn != nil {
668-
// Ignore errors from closing - connection might already be closed
669-
_ = conn.Close()
670-
}
671-
delete(rt.cachedConnections, connAddr)
672-
// Also clean up the transport cache for this address
673-
delete(rt.cachedTransports, connAddr)
674-
}
661+
keep := ""
662+
if len(selectedAddr) > 0 {
663+
keep = selectedAddr[0]
664+
}
665+
666+
// Let each cached transport drain its own idle connections first - on
667+
// HTTP/2 this performs a graceful GOAWAY instead of just cutting the
668+
// underlying socket.
669+
for trAddr, tr := range rt.cachedTransports {
670+
if trAddr == keep {
671+
continue
675672
}
676-
} else {
677-
// No address specified, close all connections (original behavior)
678-
for addr, conn := range rt.cachedConnections {
679-
if conn != nil {
680-
// Ignore errors from closing - connection might already be closed
681-
_ = conn.Close()
682-
}
683-
delete(rt.cachedConnections, addr)
684-
// Also clean up the transport cache for this address
685-
delete(rt.cachedTransports, addr)
673+
if idleCloser, ok := tr.(interface{ CloseIdleConnections() }); ok {
674+
idleCloser.CloseIdleConnections()
675+
}
676+
delete(rt.cachedTransports, trAddr)
677+
}
678+
679+
for connAddr, conn := range rt.cachedConnections {
680+
if connAddr == keep {
681+
continue
682+
}
683+
if conn != nil {
684+
// Ignore errors from closing - connection might already be closed
685+
_ = conn.Close()
686686
}
687+
delete(rt.cachedConnections, connAddr)
687688
}
688689
}
689690

0 commit comments

Comments
 (0)