diff --git a/src/net/http/h2_bundle.go b/src/net/http/h2_bundle.go index a948ff3eed4af6..9cdbfbb37ac013 100644 --- a/src/net/http/h2_bundle.go +++ b/src/net/http/h2_bundle.go @@ -6862,6 +6862,7 @@ type http2ClientConn struct { bw *bufio.Writer br *bufio.Reader fr *http2Framer + reuseDeadline time.Time lastActive time.Time lastIdle time.Time // time last idle // Settings from peer: (also guarded by mu) @@ -7249,9 +7250,20 @@ func (t *http2Transport) newClientConn(c net.Conn, singleUse bool) (*http2Client wantSettingsAck: true, pings: make(map[[8]byte]chan struct{}), } - if d := t.idleConnTimeout(); d != 0 { - cc.idleTimeout = d - cc.idleTimer = time.AfterFunc(d, cc.onIdleTimeout) + + if t.t1 != nil && t.t1.MaxConnLifespan > 0 { + cc.reuseDeadline = time.Now().Add(t.t1.MaxConnLifespan) + } + + cc.idleTimeout = t.idleConnTimeout() + ttl, hasTtl := cc.timeToLive() + + if hasTtl || cc.idleTimeout > 0 { + timeout := cc.idleTimeout + if hasTtl && (timeout <= 0 || ttl < timeout) { + timeout = ttl + } + cc.idleTimer = time.AfterFunc(timeout, cc.onIdleTimeout) } if http2VerboseLogs { t.vlogf("http2: Transport creating client conn %p to %v", cc, c.RemoteAddr()) @@ -7303,6 +7315,30 @@ func (t *http2Transport) newClientConn(c net.Conn, singleUse bool) (*http2Client return cc, nil } +// timeToLive checks if a http2ClientConn has been initialized +// from a transport with MaxConnLifespan > 0 and returns the time +// remaining for this connection to be reusable. The second response +// would be true in this case. +// +// If the connection has a zero-value reuseDeadline set then +// it returns (0, false) +// +// The returned duration will never be less than zero and the connection's +// idle time is NOT taken into account. +func (cc *http2ClientConn) timeToLive() (time.Duration, bool) { + + if cc.reuseDeadline.IsZero() { + return 0, false + } + + ttl := time.Until(cc.reuseDeadline) + if ttl < 0 { + return 0, true + } + + return ttl, true +} + func (cc *http2ClientConn) healthCheck() { pingTimeout := cc.t.pingTimeout() // We don't need to periodically ping in the health check, because the readLoop of ClientConn will @@ -8325,7 +8361,11 @@ func (cc *http2ClientConn) streamByID(id uint32, andRemove bool) *http2clientStr cc.lastActive = time.Now() delete(cc.streams, id) if len(cc.streams) == 0 && cc.idleTimer != nil { - cc.idleTimer.Reset(cc.idleTimeout) + timeout := cc.idleTimeout + if ttl, ok := cc.timeToLive(); ok && (timeout <= 0 || ttl < timeout) { + timeout = ttl + } + cc.idleTimer.Reset(timeout) cc.lastIdle = time.Now() } close(cs.done) diff --git a/src/net/http/transport.go b/src/net/http/transport.go index 47cb992a50215b..4f8ed07db12bbb 100644 --- a/src/net/http/transport.go +++ b/src/net/http/transport.go @@ -189,6 +189,10 @@ type Transport struct { // uncompressed. DisableCompression bool + // MaxConnLifespan controls how long a connection is allowed + // to be reused before it must be closed. Zero means no limit. + MaxConnLifespan time.Duration + // MaxIdleConns controls the maximum number of idle (keep-alive) // connections across all hosts. Zero means no limit. MaxIdleConns int @@ -983,14 +987,22 @@ func (t *Transport) tryPutIdleConn(pconn *persistConn) error { t.removeIdleConnLocked(oldest) } + ttl, hasTtl := pconn.timeToLive() + // Set idle timer, but only for HTTP/1 (pconn.alt == nil). // The HTTP/2 implementation manages the idle timer itself // (see idleConnTimeout in h2_bundle.go). - if t.IdleConnTimeout > 0 && pconn.alt == nil { + if (hasTtl || t.IdleConnTimeout > 0) && pconn.alt == nil { + + timeout := t.IdleConnTimeout + if hasTtl && (timeout <= 0 || ttl < timeout) { + timeout = ttl + } + if pconn.idleTimer != nil { - pconn.idleTimer.Reset(t.IdleConnTimeout) + pconn.idleTimer.Reset(timeout) } else { - pconn.idleTimer = time.AfterFunc(t.IdleConnTimeout, pconn.closeConnIfStillIdle) + pconn.idleTimer = time.AfterFunc(timeout, pconn.closeConnIfStillIdle) } } pconn.idleAt = time.Now() @@ -1020,9 +1032,10 @@ func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) { // If IdleConnTimeout is set, calculate the oldest // persistConn.idleAt time we're willing to use a cached idle // conn. + now := time.Now() var oldTime time.Time if t.IdleConnTimeout > 0 { - oldTime = time.Now().Add(-t.IdleConnTimeout) + oldTime = now.Add(-t.IdleConnTimeout) } // Look for most recently-used idle connection. @@ -1035,7 +1048,8 @@ func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) { // See whether this connection has been idle too long, considering // only the wall time (the Round(0)), in case this is a laptop or VM // coming out of suspend with previously cached idle connections. - tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime) + tooOld := (!oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)) || (!pconn.reuseDeadline.IsZero() && pconn.reuseDeadline.Round(0).Before(now)) + if tooOld { // Async cleanup. Launch in its own goroutine (as if a // time.AfterFunc called it); it acquires idleMu, which we're @@ -1616,6 +1630,11 @@ func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *pers } } + var reuseDeadline time.Time + if t.MaxConnLifespan > 0 { + reuseDeadline = time.Now().Add(t.MaxConnLifespan) + } + // Proxy setup. switch { case cm.proxyURL == nil: @@ -1736,10 +1755,11 @@ func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *pers // pconn.conn was closed by next (http2configureTransports.upgradeFn). return nil, e.RoundTripErr() } - return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: alt}, nil + return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: alt, reuseDeadline: reuseDeadline}, nil } } + pconn.reuseDeadline = reuseDeadline pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize()) pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize()) @@ -1892,6 +1912,8 @@ type persistConn struct { writeLoopDone chan struct{} // closed when write loop ends + reuseDeadline time.Time // time when this connection can no longer be reused + // Both guarded by Transport.idleMu: idleAt time.Time // time it last become idle idleTimer *time.Timer // holding an AfterFunc to close it @@ -1908,6 +1930,30 @@ type persistConn struct { mutateHeaderFunc func(Header) } +// timeToLive checks if a persistent connection has been initialized +// from a transport with MaxConnLifespan > 0 and returns the time +// remaining for this connection to be reusable. The second response +// would be true in this case. +// +// If the connection has a zero-value reuseDeadline set then +// it returns (0, false) +// +// The returned duration will never be less than zero and the connection's +// idle time is NOT taken into account. +func (pc *persistConn) timeToLive() (time.Duration, bool) { + + if pc.reuseDeadline.IsZero() { + return 0, false + } + + ttl := time.Until(pc.reuseDeadline) + if ttl < 0 { + return 0, true + } + + return ttl, true +} + func (pc *persistConn) maxHeaderResponseSize() int64 { if v := pc.t.MaxResponseHeaderBytes; v != 0 { return v diff --git a/src/net/http/transport_test.go b/src/net/http/transport_test.go index 690e0c299d2b35..46c69d54d5ed63 100644 --- a/src/net/http/transport_test.go +++ b/src/net/http/transport_test.go @@ -4895,6 +4895,70 @@ func TestTransportMaxIdleConns(t *testing.T) { } } +func TestTransportMaxConnLifespan_h1(t *testing.T) { testTransportMaxConnLifespan(t, h1Mode) } +func TestTransportMaxConnLifespan_h2(t *testing.T) { testTransportMaxConnLifespan(t, h2Mode) } +func testTransportMaxConnLifespan(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + defer afterTest(t) + + const timeout = 1 * time.Second + + cst := newClientServerTest(t, h2, HandlerFunc(func(w ResponseWriter, r *Request) { + // No body for convenience. + })) + defer cst.close() + tr := cst.tr + tr.MaxConnLifespan = timeout + tr.IdleConnTimeout = timeout * 3 + defer tr.CloseIdleConnections() + c := &Client{Transport: tr} + + idleConns := func() []string { + if h2 { + return tr.IdleConnStrsForTesting_h2() + } else { + return tr.IdleConnStrsForTesting() + } + } + + var conn string + doReq := func(n int) { + req, _ := NewRequest("GET", cst.ts.URL, nil) + req = req.WithContext(httptrace.WithClientTrace(context.Background(), &httptrace.ClientTrace{ + PutIdleConn: func(err error) { + if err != nil { + t.Errorf("failed to keep idle conn: %v", err) + } + }, + })) + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + conns := idleConns() + if len(conns) != 1 { + t.Fatalf("req %v: unexpected number of idle conns: %q", n, conns) + } + if conn == "" { + conn = conns[0] + } + if conn != conns[0] { + t.Fatalf("req %v: cached connection changed; expected the same one throughout the test", n) + } + } + for i := 0; i < 3; i++ { + doReq(i) + time.Sleep(timeout / 4) + } + time.Sleep(timeout / 2) + if got := idleConns(); len(got) != 0 { + t.Errorf("idle conns = %q; want none", got) + } +} + func TestTransportIdleConnTimeout_h1(t *testing.T) { testTransportIdleConnTimeout(t, h1Mode) } func TestTransportIdleConnTimeout_h2(t *testing.T) { testTransportIdleConnTimeout(t, h2Mode) } func testTransportIdleConnTimeout(t *testing.T, h2 bool) {