// Copyright 2026 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package http import ( "context" "maps" "slices" "sync" "time" ) // clientConnPool is the connection pool state for Transport, // split out into a separate struct for organizational purposes. // // Every Transport has a pool. // // The pool has a set of entries for targets (things we can dial). // Entries are removed from the pool when nothing references them any more // (no conns, no dials, no waiting requests for a conn, etc.) // An entry manages the set of connections for a target. // // The pool also has an LRU of connections, used to maintain the cross-target MaxIdleConn limit. // // Pool entries have a channel-based lock which permits get-connection requests // to wait for available connections and/or dial slots; see clientConnPoolEntry for details. // // Lock ordering is (first to last): clientConnPoolEntry >> clientConnPool >> persistConn. // // Locking and unlocking entries is a channel operation, so we take some care to minimize // the number of lock operations required in the hot path. // // The pool and entry locks should never be held during blocking operations // (such as dials or closing a net.Conn). type clientConnPool struct { mu sync.Mutex ents map[connectMethodKey]*clientConnPoolEntry lru intrusiveList[persistConn] closeIdle bool } type clientConnPoolEntry struct { key connectMethodKey // These three channels (each with a 1-element buffer) act as a lock/condition variable. // // - if the entry is locked, no channel contains a value; otherwise // - if idleConns contains a usable conn, cangetc contains a value; otherwise // - if a new conn may be dialed, candialc contains a value; otherwise // - lockc contains a value. // // Lock ordering: Only lock one entry at a time. Lock entries before the pool mutex. cangetc chan struct{} candialc chan struct{} lockc chan struct{} // Idle (available for use) connections, most recently used last. idleConns []*persistConn // Connection with alt != nil. // // This is historically a pseudo-"connection" backed by an http2.Transport, // which manages its own connection pool. Lack of visibility into this pool // causes a variety of bugs, such as connections-per-host tracking not accounting // for individual HTTP/2 connections. // // This may also be an HTTP/3 connection. // // TODO: Improve management of HTTP/2 and HTTP/3 connections. altConn *persistConn // Total connections, idle and otherwise. numConns int // Requests waiting for a connection. numWaiters int // Dials in progress. dials intrusiveList[poolDial] // Idle timer, fires when we should next check to see if conns // should be closed for idleness. idleTimer *time.Timer idleTimerSet bool // Set when the entry is removed from its pool. removed bool } type poolDial struct { ent *clientConnPoolEntry cm connectMethod cancel context.CancelFunc // call to cancel the dial resultc chan pconnOrError // sends the dial result to the requester donec <-chan struct{} // closed when the requester no longer needs the dial result link intrusiveLink[poolDial] postDial func() } // lock unconditionally locks the entry. func (ent *clientConnPoolEntry) lock() { select { case <-ent.lockc: case <-ent.cangetc: case <-ent.candialc: } } // unlock unlocks the entry. // // If the entry is no longer in use, unlock removes it from the pool. // The pool mutex must not be held when calling unlock. func (ent *clientConnPoolEntry) unlock(tr *Transport) { if !ent.removed && ent.numConns == 0 && ent.numWaiters == 0 && ent.dials.size == 0 && len(ent.idleConns) == 0 { // This entry is no longer in use; remove it from the pool. ent.removed = true if ent.idleTimer != nil { ent.idleTimer.Stop() } tr.pool.mu.Lock() delete(tr.pool.ents, ent.key) tr.pool.mu.Unlock() } switch { case len(ent.idleConns) > 0 || ent.altConn != nil: ent.cangetc <- struct{}{} case tr.MaxConnsPerHost <= 0 || ent.numConns+ent.dials.size < tr.MaxConnsPerHost: ent.candialc <- struct{}{} default: ent.lockc <- struct{}{} } } // registerConnLocked adds pconn to the entry's bookkeeping. func (ent *clientConnPoolEntry) registerConnLocked(pconn *persistConn) { if pconn.isBroken() || pconn.isInPool { return } pconn.isInPool = true ent.numConns++ } // unregisterConnLocked disassociates pconn from the entry. func (ent *clientConnPoolEntry) unregisterConnLocked(pconn *persistConn) { if !pconn.isInPool { // This can happen if a newly-created connection breaks before we finish // registering it with the pool. return } pconn.isInPool = false ent.numConns-- } // deleteIdleLocked deletes every element in idleConns that matches f. func (ent *clientConnPoolEntry) deleteIdleLocked(t *Transport, f func(*persistConn) bool) { ent.idleConns = slices.DeleteFunc(ent.idleConns, func(pconn *persistConn) bool { delete := f(pconn) if delete { ent.unregisterConnLocked(pconn) t.removeFromIdleLRU(pconn) } return delete }) } // getLockedPoolEntry returns the pool entry for key, creating it if necessary. // The entry will be locked. // // If clearCloseIdle is set, pool.closeIdle is set to false. // (This isn't especially principled from an abstraction perspective, // but it lets us get the entry and update closeIdle under one mutex acquisition.) func (t *Transport) getLockedPoolEntry(key connectMethodKey, clearCloseIdle bool) *clientConnPoolEntry { getEnt := func() (*clientConnPoolEntry, bool) { t.pool.mu.Lock() defer t.pool.mu.Unlock() if clearCloseIdle { t.pool.closeIdle = false } if t.pool.ents == nil { t.pool.ents = map[connectMethodKey]*clientConnPoolEntry{} t.pool.lru.link = func(pconn *persistConn) *intrusiveLink[persistConn] { return &pconn.lruLink } } if ent, ok := t.pool.ents[key]; ok { return ent, true } ent := &clientConnPoolEntry{ key: key, lockc: make(chan struct{}, 1), cangetc: make(chan struct{}, 1), candialc: make(chan struct{}, 1), } ent.dials.link = func(dial *poolDial) *intrusiveLink[poolDial] { return &dial.link } t.pool.ents[key] = ent return ent, false } for { ent, needLock := getEnt() if needLock { ent.lock() } if !ent.removed { return ent } // This entry was removed from the pool in the time between the lookup // and us locking it. Try again. ent.unlock(t) } } // poolCopyEntries returns the current set of pool entries as a slice. func (t *Transport) poolCopyEntries() []*clientConnPoolEntry { t.pool.mu.Lock() defer t.pool.mu.Unlock() return slices.Collect(maps.Values(t.pool.ents)) } // poolCloseIdleConnections closes idle connections in the pool. func (t *Transport) poolCloseIdleConnections() { t.pool.mu.Lock() t.pool.closeIdle = true t.pool.mu.Unlock() for _, ent := range t.poolCopyEntries() { var toClose []*persistConn func() { ent.lock() defer ent.unlock(t) if ent.altConn != nil && ent.numWaiters == 0 { ent.unregisterConnLocked(ent.altConn) toClose = append(toClose, ent.altConn) ent.altConn = nil } ent.deleteIdleLocked(t, func(pconn *persistConn) bool { toClose = append(toClose, pconn) return true }) // Cancel dials in progress where the initiating request // is no longer waiting. for dial := range ent.dials.all() { select { case <-dial.donec: dial.cancel() default: } } }() for _, pconn := range toClose { pconn.close(errCloseIdleConns) } } } // poolPutIdleConn indicates that a connection has become idle and may accept new requests. func (t *Transport) poolPutIdleConn(pconn *persistConn) error { ent := pconn.poolEnt if ent == nil { return errNotInPool } if pconn.alt != nil { // HTTP/2 and HTTP/3 connections should never appear here. // Ignore them if they do. return nil } err, needExpire := func() (_ error, needExpire bool) { ent.lock() defer ent.unlock(t) return t.poolPutIdleConnLocked(ent, pconn) }() if needExpire { t.expireIdleLRU() } return err } // poolPutIdleConnLocked places pconn in its entry's idle list. // The entry must be locked. // // It returns an error if pconn should be closed. // // It returns needExpire=true if the caller should call t.expireIdleLRU // after dropping the entry lock. (We check inside poolPutIdleConnLocked to avoid // a needless pool mutex acquisition in the common case when expiry is not needed.) func (t *Transport) poolPutIdleConnLocked(ent *clientConnPoolEntry, pconn *persistConn) (_ error, needExpire bool) { t.pool.mu.Lock() defer t.pool.mu.Unlock() if pconn.isBroken() || !pconn.isInPool { return errConnBroken, false } if t.pool.lru.contains(pconn) { // This conn is already idle. // Move it to the back of the idle list. t.pool.lru.remove(pconn) } else { // This conn is entering the idle list. if t.keepAlivesDisabled() { return errKeepAlivesDisabled, false } if len(ent.idleConns) >= t.maxIdleConnsPerHost() { return errTooManyIdleHost, false } if pconn.alt != nil && ent.altConn != nil { // We already have a reusable, shared connection; // discard this one. return errTooManyIdleHost, false } if t.pool.closeIdle { return errCloseIdle, false } if pconn.alt == nil { ent.idleConns = append(ent.idleConns, pconn) } else { ent.altConn = pconn } } if pconn.alt == nil { t.pool.lru.pushBack(pconn) } // Arm the idle timer (if needed). pconn.idleAt = time.Now() if t.IdleConnTimeout > 0 && !ent.idleTimerSet && pconn.alt == nil { if ent.idleTimer != nil { ent.idleTimer.Reset(t.IdleConnTimeout) } else { ent.idleTimer = time.AfterFunc(t.IdleConnTimeout, func() { t.poolIdleTimeout(ent) }) } ent.idleTimerSet = true } needExpire = t.MaxIdleConns != 0 && t.pool.lru.size > t.MaxIdleConns return nil, needExpire } func (t *Transport) poolIdleTimeout(ent *clientConnPoolEntry) { ent.lock() defer ent.unlock(t) ent.idleTimerSet = false timeout := t.IdleConnTimeout if timeout <= 0 { return } now := time.Now() var next time.Time ent.deleteIdleLocked(t, func(pconn *persistConn) bool { if expiry := pconn.idleAt.Add(timeout); !expiry.After(now) { go pconn.close(errIdleConnTimeout) return true } else if next.IsZero() || expiry.Before(next) { next = expiry } return false }) if !next.IsZero() { ent.idleTimer.Reset(next.Sub(now)) ent.idleTimerSet = true } } // poolRemoveConn entirely removes pconn from the pool. func (t *Transport) poolRemoveConn(pconn *persistConn) { ent := pconn.poolEnt if ent == nil { return } ent.lock() defer ent.unlock(t) if ent.altConn == pconn { ent.altConn = nil } if idx := slices.Index(ent.idleConns, pconn); idx >= 0 { ent.idleConns = slices.Delete(ent.idleConns, idx, idx+1) t.removeFromIdleLRU(pconn) } ent.unregisterConnLocked(pconn) } type pconnOrError struct { pconn *persistConn err error } // poolGetConn gets a usable connection from the pool, // dialing a new one if necessary. func (t *Transport) poolGetConn(ctx context.Context, cm connectMethod) (*persistConn, error) { getConnCancelCause := func(ctx context.Context) error { err := context.Cause(ctx) if err == errRequestCanceled { err = errRequestCanceledConn } return err } if testHookPrePendingDial != nil { testHookPrePendingDial() } postDialHook := testHookPostPendingDial defer func() { if postDialHook != nil { postDialHook() } }() // If the request context is already canceled, don't go looking for a conn. if err := ctx.Err(); err != nil { return nil, getConnCancelCause(ctx) } // Fast path: Check to see if there's a usable conn. const clearCloseIdle = true ent, pconn := func() (*clientConnPoolEntry, *persistConn) { ent := t.getLockedPoolEntry(cm.key(), clearCloseIdle) defer ent.unlock(t) pconn := t.poolTryGetForEntryLocked(ent) if pconn == nil { // Increment the waiters count to keep this pool entry alive // while we're using it. ent.numWaiters++ } return ent, pconn }() if pconn != nil { return pconn, nil } // Remove ourselves from the waiters before returning. // Running this in a defer ensures it happens after any later defers. // In particular, we must close getdonec before trying to lock the entry // to decrement numWaiters, to avoid a deadlock race when the request context // expires at the same time as a dial finishes. waiting := true defer func() { if !waiting { return } ent.lock() defer ent.unlock(t) ent.numWaiters-- }() var ( candialc = ent.candialc dialresultc chan pconnOrError getdonec chan struct{} ) for { select { case <-ent.cangetc: // Reading from cangetc locks the entry, and indicates that there is // an available connection in the idle list. pconn := func() *persistConn { defer ent.unlock(t) pconn := t.poolTryGetForEntryLocked(ent) if pconn != nil { ent.numWaiters-- waiting = false } return pconn }() if pconn != nil { return pconn, nil } // We didn't find a usable connection. Most likely we lost a race against // a closing connection (the conn is now dead, but it hadn't finished // removing itself from the pool). Go back and try again. case <-candialc: // Reading from candialc locks the entry, and indicates that we may // start a new dial. func() { defer ent.unlock(t) // Create a context for the dial which inherits the // request context's values, but not its cancellation // (since the dial may outlive the request). // // Note that we only cancel this context to cancel the dial. // If the dial finishes, we leave it uncanceled since cancelling // it can (for some user-provided DialContext functions) // close the connection. dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) dialresultc = make(chan pconnOrError) getdonec = make(chan struct{}) dial := &poolDial{ ent: ent, cm: cm, cancel: cancel, resultc: dialresultc, donec: getdonec, postDial: postDialHook, } ent.dials.pushBack(dial) postDialHook = nil // will be called by poolDialForEntry go t.poolDialForEntry(dialCtx, dial) candialc = nil // don't dial again }() defer close(getdonec) case res := <-dialresultc: // A dial we started has completed and provided us with // either a connection, or a dial failure. // // poolDialForEntry decrements numWaiters in this case // (avoiding a redundant lock on the entry). waiting = false return res.pconn, res.err case <-ctx.Done(): // The request has timed out. return nil, getConnCancelCause(ctx) } } } // poolTryGetForEntryLocked attempts to fetch an idle connection from ent. // ent must be locked. // It returns nil if no usable connection was found. func (t *Transport) poolTryGetForEntryLocked(ent *clientConnPoolEntry) *persistConn { if ent.altConn != nil { // If we've got an HTTP/2 or HTTP/3 connection, use it. return ent.altConn } // If IdleConnTimeout is set, calculate the oldest // persistConn.idleAt time we're willing to use a cached idle // conn. var oldTime time.Time if t.IdleConnTimeout > 0 { oldTime = time.Now().Add(-t.IdleConnTimeout) } for len(ent.idleConns) > 0 { pconn := ent.idleConns[len(ent.idleConns)-1] // 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) if tooOld { // Async cleanup. Launch in its own goroutine (as if a // time.AfterFunc called it). go pconn.close(errIdleConnTimeout) } if pconn.isBroken() || tooOld { // If either persistConn.readLoop has marked the connection // broken, but Transport.removeIdleConn has not yet removed it // from the idle list, or if this persistConn is too old (it was // idle too long), then ignore it and look for another. In both // cases it's already in the process of being closed. idx := len(ent.idleConns) - 1 ent.idleConns = slices.Delete(ent.idleConns, idx, idx+1) ent.unregisterConnLocked(pconn) t.removeFromIdleLRU(pconn) continue } // HTTP/1: only one client can use pconn. // Remove it from the list. idx := len(ent.idleConns) - 1 ent.idleConns = slices.Delete(ent.idleConns, idx, idx+1) t.removeFromIdleLRU(pconn) return pconn } return nil } // poolDialForEntry dials a new connection. // // Note that dials always use a context derived from a request context, // although a request may use a connection that was dialed with a different request's context. func (t *Transport) poolDialForEntry(ctx context.Context, dial *poolDial) { ent := dial.ent if dial.postDial != nil { defer dial.postDial() } pconn, err := t.dialConn(ctx, dial.cm, false, ent, nil) needClose, needExpire := func() (needClose, needExpire bool) { ent.lock() defer ent.unlock(t) // The dial is complete: Remove the dial from the entry and register // the new connection (if any). ent.dials.remove(dial) if err == nil { ent.registerConnLocked(pconn) } // HTTP/2 and HTTP/3 connections can be shared. // Add to the idle connection pool before trying to deliver to w. deliver := pconnOrError{pconn: pconn, err: err} if err == nil && pconn.alt != nil { err, needExpire = t.poolPutIdleConnLocked(ent, pconn) if err != nil { // This connection should not go into the idle list. // // Note: Setting needClose replicates some odd behaviors // from the earlier pool implementation. If this is an HTTP/2 // connection, calling pconn.close does not actually close // anything: The "connection" is a reference to the HTTP/2 // transport, which manages its own connection pool. // If this is an HTTP/3 connection, pconn.close does close // the connection and may immediately break it while a RoundTrip // is in progress. // // TODO: Fix the above behaviors. ent.unregisterConnLocked(pconn) needClose = true if ent.altConn != nil { // We already have a sharable connection in the pool. // Keep the existing one, and discard this new one. // // Try to deliver the existing conn to poolGetConn. // We must always deliver a result to poolGetConn // to avoid it potentially hanging forever waiting // for a pooled conn. (In this branch, we observe that // there is currently a pooled conn, but it could become // unusable between now and poolGetConn trying to // retrieve it.) deliver = pconnOrError{pconn: ent.altConn} } } } // Try to deliver the result back to poolGetConn. // // Note that we're doing this with the entry still locked, so poolGetConn // needs to take care not to block on locking the entry until after it // closes donec. poolGetConn is free to lock the entry in a non-blocking fashion. delivered := false select { case dial.resultc <- deliver: ent.numWaiters-- delivered = true case <-dial.donec: } if err != nil || delivered || needClose || pconn.alt != nil { // Either an error, or an unsharable HTTP/1 connection which was delivered // to the waiter, or a shared HTTP/2 or /3 connection already in the pool. return needClose, false } err, needExpire = t.poolPutIdleConnLocked(ent, pconn) if err != nil { // This connection should not go into the idle list. ent.unregisterConnLocked(pconn) return true, false } return false, needExpire }() if needClose { pconn.close(err) } if needExpire { t.expireIdleLRU() } } // removeFromIdleLRU removes pconn from the idle-conns LRU. func (t *Transport) removeFromIdleLRU(pconn *persistConn) { t.pool.mu.Lock() defer t.pool.mu.Unlock() t.pool.lru.remove(pconn) } // expireIdleLRU removes idle conns from the pool until the number of idle conns // is less than or equal to MaxIdleConns. func (t *Transport) expireIdleLRU() { t.pool.mu.Lock() defer t.pool.mu.Unlock() if t.MaxIdleConns == 0 { return } maxIdle := max(t.MaxIdleConns, 0) // expireEntry expires conns from the head of the LRU which belong to ent. // It locks the entry and pool. expireEntry := func(ent *clientConnPoolEntry) { ent.lock() defer ent.unlock(t) t.pool.mu.Lock() defer t.pool.mu.Unlock() // Expire conns for the entry we currently have locked. // It's possible the head of the LRU changed while we were juggling locks; // that's okay. Worst case, we loop back and try again with the new head. for t.pool.lru.size > maxIdle { pconn := t.pool.lru.head if pconn.poolEnt != ent { break } if idx := slices.Index(ent.idleConns, pconn); idx >= 0 { ent.idleConns = slices.Delete(ent.idleConns, idx, idx+1) ent.unregisterConnLocked(pconn) go pconn.close(errTooManyIdle) } t.pool.lru.remove(pconn) } } for t.pool.lru.size > maxIdle { func() { // Lock ordering is ent >> pool, so we need to drop the pool mutex // before locking the entry. ent := t.pool.lru.head.poolEnt t.pool.mu.Unlock() defer t.pool.mu.Lock() expireEntry(ent) }() } }