mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 21:54:52 -04:00
GET /api/cluster/peer authenticated with the deployment's shared registration token and took the dialling replica's id from ?id= on trust. Every worker holds that token, so anything holding it could open a peer link as any replica: relay through it to every worker tunnel that replica owns, displace a real replica's inbound link by declaring its id, and point the roughly 31 GiB per-session receive window at one replica. Validating the id against the instances table does not fix this, because the attack declares a real replica's id. So the route now checks two credentials and needs both. The shared token still says the dialler belongs to this deployment; a new per-replica credential says which replica it is. The credential follows the per-node worker credential rather than inventing a second mechanism: crypto/rand.Text, stored only as a hex SHA-256, compared in constant time, with no fallback to the shared token. It differs in the stronger direction. A worker's credential is minted by the frontend and handed over once; a replica writes its own instances row, so it mints its own secret, publishes only the hash in the same statement that publishes its address, and never sends the plaintext anywhere but the peer dial. A peer that presents no credential is refused, not waved through. An old replica and an attacker holding the shared token send the same request, so accepting the first accepts the second; there is no safe downgrade here, only a quiet one. The refusal is made loud instead, on both sides, naming the upgrade rather than the network. On the documented frontend-first order a new replica still dials an old one; an old replica cannot dial a new one, which costs relayed requests that land on a not-yet-restarted replica and surfaces as no route, never as absence. A rejected peer gets its own sentinel, ErrPeerRejected, whose unwrap chain carries ErrPeerUnreachable as well and no absence sentinel at all. Keeping the older sentinel means no existing consumer changes behaviour; the cause stays out of the chain, so absence cannot escape through it and nothing can read an authorization failure as a worker that went away. One consequence beyond the fix: a replica with no advertised address has no instances row, so it now cannot dial out either. It was already unreachable inward. The startup error and the docs say so. Registry.Register, NewMembership, NewPeerPool, PeerHandler and RegisterClusterRoutes all gained required arguments, so the identity cannot be dropped without a compile failure. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
485 lines
20 KiB
Go
485 lines
20 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/libp2p/go-yamux/v5"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// PeerPath is the route a replica dials to open a peer link, and the route the
|
|
// HTTP layer registers the handler on. It lives here, with the dialler and the
|
|
// WebSocket adapter, so that core/services/cluster stays a leaf: the HTTP
|
|
// endpoints package imports this one, never the other way round.
|
|
//
|
|
// The literal is spelled out rather than derived from auth.ClusterPathPrefix
|
|
// because importing core/http/auth is exactly the dependency this package must
|
|
// not have. The two are kept from drifting apart by a spec in the endpoints
|
|
// package, which can see both.
|
|
const PeerPath = "/api/cluster/peer"
|
|
|
|
// ErrPeerUnreachable reports that a peer this deployment knows about could not
|
|
// be reached: the dial failed, the peer refused the credentials, or its
|
|
// multiplexer would not carry a stream.
|
|
//
|
|
// It is deliberately NOT a form of ErrInstanceNotFound, and the two must stay
|
|
// unmixable. A caller that sees absence is entitled to conclude a node is gone
|
|
// and reclaim what it was running; a caller that sees unreachability may only
|
|
// retry. Collapsing the two means a network hiccup between two healthy
|
|
// replicas evicts healthy workers. See unreachableError for how that is
|
|
// enforced rather than merely documented.
|
|
var ErrPeerUnreachable = errors.New("cluster: peer unreachable")
|
|
|
|
// unreachableError reports a peer that could not be reached, keeping the
|
|
// underlying cause in its message and out of its unwrap chain.
|
|
//
|
|
// Withholding the cause from errors.Is is the point. The dial path resolves a
|
|
// peer's address through the registry, so ErrInstanceNotFound is a cause this
|
|
// error can genuinely be built over: a row deleted between two attempts, for
|
|
// one. If the cause were unwrapped, that failure would satisfy both sentinels
|
|
// at once and every caller's absence check would fire on a transport problem.
|
|
// The guarantee therefore belongs to the type: no call site can leak absence
|
|
// through it, because there is no path by which absence gets out.
|
|
type unreachableError struct {
|
|
peerID string
|
|
cause error
|
|
}
|
|
|
|
func (e *unreachableError) Error() string {
|
|
return fmt.Sprintf("cluster: peer %q unreachable: %v", e.peerID, e.cause)
|
|
}
|
|
|
|
// Unwrap reports only ErrPeerUnreachable. The cause reaches a human through
|
|
// Error() and reaches no error-matching caller at all.
|
|
func (e *unreachableError) Unwrap() error { return ErrPeerUnreachable }
|
|
|
|
func unreachablePeer(peerID string, cause error) error {
|
|
return &unreachableError{peerID: peerID, cause: cause}
|
|
}
|
|
|
|
// ErrPeerRejected reports that a peer answered and REFUSED this replica's
|
|
// credentials: either the deployment's shared cluster token or, since
|
|
// per-replica peer identity, this replica's own peer credential.
|
|
//
|
|
// It is its own sentinel because an authorization failure has a different cure
|
|
// from every other way a dial can end. A peer that will not answer is a
|
|
// transport problem an operator waits out; a peer that answers "no" is a
|
|
// configuration problem, and during a rolling upgrade it is the specific,
|
|
// expected one of a replica running a release that predates peer credentials
|
|
// dialling one that requires them. A single sentinel for both would leave that
|
|
// window looking like a network fault.
|
|
//
|
|
// It is emphatically NOT absence. What it says is that a peer is up, listening
|
|
// and talking; concluding from it that a worker has gone away is the mistake
|
|
// this phase is built to make impossible, because acting on absence reaps rows
|
|
// and evicts models.
|
|
var ErrPeerRejected = errors.New("cluster: peer refused this replica's credentials")
|
|
|
|
// rejectedError reports a peer that refused the dial.
|
|
//
|
|
// It unwraps to BOTH ErrPeerRejected and ErrPeerUnreachable, and each half is
|
|
// load bearing. ErrPeerUnreachable is the condition every existing caller
|
|
// already handles correctly for a refused dial (ErrPeerUnreachable's own doc
|
|
// has named "the peer refused the credentials" from the start), so keeping it
|
|
// means adding an identity check cannot change how one single consumer treats a
|
|
// refusal. ErrPeerRejected is what lets a caller that WANTS to tell the two
|
|
// apart do so, without any consumer having to opt in first.
|
|
//
|
|
// What it never unwraps to is any absence sentinel, and the cause is kept out
|
|
// of the chain for exactly the reason unreachableError keeps its own out: the
|
|
// dial path resolves a peer through the registry, so ErrInstanceNotFound is a
|
|
// cause this error could genuinely be built over, and an unwrapped one would
|
|
// satisfy an absence check on an authorization failure.
|
|
type rejectedError struct {
|
|
peerID string
|
|
cause error
|
|
}
|
|
|
|
func (e *rejectedError) Error() string {
|
|
return fmt.Sprintf("cluster: peer %q refused this replica's credentials: %v", e.peerID, e.cause)
|
|
}
|
|
|
|
// Unwrap reports the two sentinels and nothing else. The cause reaches a human
|
|
// through Error() and reaches no error-matching caller at all.
|
|
func (e *rejectedError) Unwrap() []error { return []error{ErrPeerRejected, ErrPeerUnreachable} }
|
|
|
|
func rejectedPeer(peerID string, cause error) error {
|
|
return &rejectedError{peerID: peerID, cause: cause}
|
|
}
|
|
|
|
// ErrPoolClosed reports an Open on a pool that has been shut down. It is a
|
|
// third condition on purpose: the pool being closed is a fact about this
|
|
// process and says nothing about whether the peer exists or answers.
|
|
var ErrPoolClosed = errors.New("cluster: peer pool is closed")
|
|
|
|
const (
|
|
// peerLinkHandshakeTimeout bounds the WebSocket upgrade. It also bounds
|
|
// how long Close can wait behind an in-flight dial, since a dial holds the
|
|
// per-peer lock Close needs to reach the cached session.
|
|
peerLinkHandshakeTimeout = 10 * time.Second
|
|
|
|
// peerLinkInitialWindow is the per-stream receive window every stream on a
|
|
// peer link starts at, raised from yamux's 256 KiB default.
|
|
//
|
|
// yamux already bounds head-of-line blocking with MaxMessageSize (64 KiB
|
|
// by default), so one stream cannot monopolise the connection whatever the
|
|
// window is. What the small default costs is the ramp: a stream carrying a
|
|
// multi-megabyte gRPC message spends its first megabytes window-parked,
|
|
// paying a round trip per doubling (stream.go:229) before it reaches full
|
|
// rate. On a link that is also carrying token streams for other workers,
|
|
// that ramp is pure added latency on the bulk transfer for no benefit.
|
|
peerLinkInitialWindow = 4 * 1024 * 1024
|
|
|
|
// peerLinkMaxWindow is the ceiling the auto-tuner may grow a stream to,
|
|
// raised from yamux's 16 MiB default to cover the bandwidth-delay product
|
|
// of a fast cross-zone link (roughly 31 MiB at 10 Gbps and 25 ms).
|
|
//
|
|
// The window is a cap on data received but not yet read, so the worst case
|
|
// a peer can make this replica buffer is MaxIncomingStreams times this.
|
|
// At yamux's default MaxIncomingStreams of 1000 that ceiling goes from
|
|
// about 15.6 GiB to about 31 GiB per peer session, which is the figure to
|
|
// size a replica against; it is why MaxIncomingStreams is left at the
|
|
// default rather than raised alongside the window. Both are ceilings on
|
|
// unread data and not allocations: yamux grows a stream's receive buffer
|
|
// as data arrives.
|
|
peerLinkMaxWindow = 32 * 1024 * 1024
|
|
)
|
|
|
|
// PeerLinkConfig returns the yamux configuration for a replica-to-replica link.
|
|
//
|
|
// Exported because BOTH ENDS need it and only one of them lives here. A yamux
|
|
// receive window is advertised by the side that RECEIVES, so a link configured
|
|
// on the dialler alone is tuned in exactly one direction: bytes travelling from
|
|
// the accepting replica back to the dialler get these windows, and bytes
|
|
// travelling the other way get yamux's defaults. The other way is the one that
|
|
// carries a relayed model artifact to the replica that owns the worker's
|
|
// tunnel, which is the largest thing this link ever moves.
|
|
//
|
|
// A fresh config per call, never a shared one: yamux keeps the pointer for the
|
|
// life of the session, and two sessions sharing one struct would share whatever
|
|
// a future field on it comes to mean.
|
|
func PeerLinkConfig() *yamux.Config {
|
|
cfg := yamux.DefaultConfig()
|
|
cfg.InitialStreamWindowSize = peerLinkInitialWindow
|
|
cfg.MaxStreamWindowSize = peerLinkMaxWindow
|
|
return cfg
|
|
}
|
|
|
|
// PeerPool dials peer replicas and keeps one multiplexed session per peer.
|
|
//
|
|
// A peer link carries traffic for every worker that peer owns, so it is pooled
|
|
// rather than dialled per request: a dial per relayed request would add a
|
|
// WebSocket handshake to every inference.
|
|
//
|
|
// The pool needs no knowledge of yamux error shapes to keep its cache honest.
|
|
// The two conditions worth reacting to arrive as OpenStream failures and are
|
|
// handled by the same retry: a peer that shut down gracefully hands its
|
|
// session ErrRemoteGoAway and closes it, and a session whose transport died
|
|
// hands out its shutdown error. Conditions scoped to a single stream, such as
|
|
// a peer resetting one request, never reach the pool at all, which is right:
|
|
// dropping the session over one reset request would tear down every other
|
|
// worker's traffic on that link.
|
|
type PeerPool struct {
|
|
selfID string
|
|
token string
|
|
// cred is this replica's OWN peer credential, the plaintext half. It is
|
|
// what makes selfID a claim rather than a label: the peer resolves selfID
|
|
// to an instances row and checks this against the hash that row publishes.
|
|
//
|
|
// Held by value beside selfID on purpose. The two are one identity, and a
|
|
// pool that could be built with one and not the other would dial as a
|
|
// replica it cannot prove it is, which every peer refuses and which looks
|
|
// from the outside like a peer running an older release.
|
|
cred PeerCredential
|
|
reg *Registry
|
|
|
|
dialer *websocket.Dialer
|
|
|
|
mu sync.Mutex
|
|
links map[string]*peerLink
|
|
closed bool
|
|
}
|
|
|
|
// peerLink is the cached session for one peer, plus the lock that serialises
|
|
// dialling it. The lock is per-peer so a slow or hanging dial to one peer does
|
|
// not hold up opens to any other.
|
|
type peerLink struct {
|
|
mu sync.Mutex
|
|
sess *yamux.Session
|
|
}
|
|
|
|
// NewPeerPool returns a pool that dials peers as selfID, authenticating with
|
|
// the deployment's cluster token AND with this replica's own peer credential.
|
|
//
|
|
// Both, never one. The cluster token says the dialler belongs to this
|
|
// deployment and the credential says which replica it is, and the peer checks
|
|
// them separately: the first is what has always been checked and is not
|
|
// weakened by the second existing.
|
|
//
|
|
// cred must be the SAME value the membership loop published the hash of. Pass
|
|
// the one PeerCredential this process minted, which is what makes that
|
|
// unstateable rather than merely required.
|
|
func NewPeerPool(selfID, token string, cred PeerCredential, reg *Registry) *PeerPool {
|
|
return &PeerPool{
|
|
selfID: selfID,
|
|
token: token,
|
|
cred: cred,
|
|
reg: reg,
|
|
dialer: &websocket.Dialer{
|
|
HandshakeTimeout: peerLinkHandshakeTimeout,
|
|
// No Proxy: a peer link is replica-to-replica inside one
|
|
// deployment, and honouring HTTP_PROXY would route it through
|
|
// whatever egress proxy the environment happens to name.
|
|
},
|
|
links: map[string]*peerLink{},
|
|
}
|
|
}
|
|
|
|
// Open returns a stream to peerID, dialling and caching the session on first
|
|
// use.
|
|
//
|
|
// The errors are three distinct conditions and callers act differently on
|
|
// them: ErrInstanceNotFound means the peer is not part of this deployment,
|
|
// ErrPeerUnreachable means it is but will not answer, and ErrPoolClosed means
|
|
// this process is shutting down. Only the first is node absence.
|
|
func (p *PeerPool) Open(ctx context.Context, peerID string) (net.Conn, error) {
|
|
l, err := p.link(peerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
if l.sess != nil {
|
|
st, err := l.sess.OpenStream(ctx)
|
|
if err == nil {
|
|
return st, nil
|
|
}
|
|
// A caller whose own budget expired must not cost every other worker
|
|
// its link: the session is fine, this request is not.
|
|
if ctxErr := callerRanOut(ctx); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
// A session that died between calls is the common case, not an
|
|
// exception, so this is a debug line and not a warning.
|
|
xlog.Debug("cluster peer link session unusable, re-dialling", "peer", peerID, "error", err)
|
|
_ = l.sess.Close()
|
|
l.sess = nil
|
|
}
|
|
|
|
sess, err := p.dial(ctx, peerID)
|
|
if err != nil {
|
|
// Same rule as above, on the path that has no cached session to
|
|
// protect: a dial that ran out of the caller's time says nothing about
|
|
// the peer, which may be listening and perfectly healthy. Blaming it
|
|
// would let one impatient client get a good replica routed around.
|
|
//
|
|
// This also swallows a genuine ErrInstanceNotFound when the budget
|
|
// happened to expire at the same moment, which is the safe direction:
|
|
// a timeout must never be able to manufacture absence.
|
|
if ctxErr := callerRanOut(ctx); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
st, err := sess.OpenStream(ctx)
|
|
if err != nil {
|
|
// The peer answered and completed a handshake but will not carry a
|
|
// stream, which is a transport condition and never absence.
|
|
_ = sess.Close()
|
|
if ctxErr := callerRanOut(ctx); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
return nil, unreachablePeer(peerID, err)
|
|
}
|
|
|
|
l.sess = sess
|
|
return st, nil
|
|
}
|
|
|
|
// callerRanOut reports whether the CALLER's budget is what ended an attempt,
|
|
// and is the one place that question is answered.
|
|
//
|
|
// ctx.Err() alone is not that question, and the difference is a real
|
|
// misclassification rather than a nicety. A dial carries the caller's deadline
|
|
// down to the socket, so when the budget runs out the socket's own timer fires
|
|
// and the error travels back up through the WebSocket handshake and the
|
|
// multiplexer. The context's cancellation is a SEPARATE timer whose func has to
|
|
// be run by the scheduler before ctx.Err() stops returning nil, and nothing
|
|
// orders the two. Under contention the socket's error can be back here first,
|
|
// ctx.Err() reads nil, and a peer that is listening and healthy is reported as
|
|
// ErrPeerUnreachable to a caller that simply ran out of time.
|
|
//
|
|
// That is the exact confusion this package refuses everywhere else: an
|
|
// unreachable peer is a fact about the peer that a caller may act on, and an
|
|
// expired deadline is a fact about the caller that it may not. The wall clock
|
|
// settles it without waiting for a goroutine, because the deadline is the same
|
|
// instant the socket compared itself against: if the socket's timer fired, this
|
|
// comparison is past it too.
|
|
//
|
|
// A context with no deadline falls through to ctx.Err(), which is the whole
|
|
// answer for cancellation: a Canceled context has already had its error set by
|
|
// the caller of cancel, with no timer in between.
|
|
func callerRanOut(ctx context.Context) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
// Not time.Now().After: a failure at exactly the deadline is the caller's
|
|
// too, and the ambiguous instant is resolved towards never blaming a peer.
|
|
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
|
|
return context.DeadlineExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// link returns the per-peer entry, creating it on first use.
|
|
//
|
|
// Entries are never pruned: a peer id opened once keeps its entry, and any
|
|
// session cached on it, until Close. The cost is not the map entry. A peer that
|
|
// has left the deployment but is still listening keeps a live WebSocket and the
|
|
// two yamux loop goroutines behind it for as long as this process runs; a peer
|
|
// that is genuinely gone is reclaimed by the 30s keepalive default, so the real
|
|
// exposure is narrow. There is no Forget: the membership sweep DELETES departed
|
|
// replicas but reports only how many, so which ones they were would have to be
|
|
// surfaced before anything could be plumbed here. Until it is, an entry for a
|
|
// departed peer outlives it and only the keepalive reclaims what it holds.
|
|
func (p *PeerPool) link(peerID string) (*peerLink, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.closed {
|
|
return nil, ErrPoolClosed
|
|
}
|
|
l, ok := p.links[peerID]
|
|
if !ok {
|
|
l = &peerLink{}
|
|
p.links[peerID] = l
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// dial resolves the peer's advertised address and brings up one yamux client
|
|
// session over an authenticated WebSocket.
|
|
//
|
|
// A registry miss is returned unchanged so ErrInstanceNotFound reaches the
|
|
// caller; everything after it is wrapped as unreachable.
|
|
//
|
|
// The address is only read here, so a peer that re-registers on a new address
|
|
// while its current session is still alive keeps being reached over that
|
|
// session until it dies. That is deliberate: an address change without a
|
|
// session break means the peer is still answering on the old one, and dropping
|
|
// a working link to chase a registry write would interrupt live requests for
|
|
// nothing. A replica that actually moved breaks its sessions in the process,
|
|
// and the re-dial above picks the new address up on the next Open.
|
|
func (p *PeerPool) dial(ctx context.Context, peerID string) (*yamux.Session, error) {
|
|
inst, err := p.reg.Get(ctx, peerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if inst.AdvertisedAddr == "" {
|
|
// A registered replica with no address is reachable by nobody. It is
|
|
// present, so this is not absence.
|
|
return nil, unreachablePeer(peerID, errors.New("peer has no advertised address"))
|
|
}
|
|
|
|
endpoint := url.URL{
|
|
// Plain ws: replica-to-replica TLS is not part of this phase, and the
|
|
// link is authenticated by the cluster token rather than by transport.
|
|
Scheme: "ws",
|
|
Host: inst.AdvertisedAddr,
|
|
Path: PeerPath,
|
|
RawQuery: url.Values{"id": []string{p.selfID}}.Encode(),
|
|
}
|
|
header := http.Header{}
|
|
header.Set("Authorization", "Bearer "+p.token)
|
|
// The identity half. Sent unconditionally, including when it is empty: an
|
|
// empty value is refused by the peer, which is the right outcome for a
|
|
// process that never minted a credential, and omitting the header instead
|
|
// would make that indistinguishable from an older release at the only place
|
|
// that can tell an operator which one it is looking at.
|
|
header.Set(PeerIdentityHeader, p.cred.Token())
|
|
|
|
ws, resp, err := p.dialer.DialContext(ctx, endpoint.String(), header)
|
|
status := 0
|
|
if resp != nil {
|
|
status = resp.StatusCode
|
|
if resp.Body != nil {
|
|
// gorilla hands back the failed handshake's response so a caller can
|
|
// read the status; nothing here needs the body, but it has to be
|
|
// drained or the connection is not returned to the transport.
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|
|
if err != nil {
|
|
// A peer that ANSWERED 401 is up and talking, and what it refused is a
|
|
// credential. Reported as its own condition so the log names the cure:
|
|
// during a frontend rollout this is what a replica running a release
|
|
// without peer credentials gets from one that requires them, and it
|
|
// must not be read as, or logged as, a network fault.
|
|
if status == http.StatusUnauthorized {
|
|
xlog.Warn("A peer refused this replica's credentials. Either the deployment's registration token differs between the two, or this replica has no peer credential published in the instances table; during a rolling upgrade this is what an older replica sees until it is upgraded",
|
|
"peer", peerID, "self", p.selfID, "addr", inst.AdvertisedAddr)
|
|
return nil, rejectedPeer(peerID, err)
|
|
}
|
|
return nil, unreachablePeer(peerID, err)
|
|
}
|
|
|
|
// Client side of the mux: the dialling replica owns the odd stream IDs,
|
|
// matching the yamux.Server the peer handler puts on its end.
|
|
sess, err := yamux.Client(WebsocketConn(ws), PeerLinkConfig(), nil)
|
|
if err != nil {
|
|
_ = ws.Close()
|
|
return nil, unreachablePeer(peerID, err)
|
|
}
|
|
|
|
// Close raced this dial. Handing the session back would leak it, since
|
|
// Close has already walked the map.
|
|
p.mu.Lock()
|
|
closed := p.closed
|
|
p.mu.Unlock()
|
|
if closed {
|
|
_ = sess.Close()
|
|
return nil, ErrPoolClosed
|
|
}
|
|
|
|
xlog.Debug("cluster peer link dialled", "peer", peerID, "addr", inst.AdvertisedAddr)
|
|
return sess, nil
|
|
}
|
|
|
|
// Close closes every cached session. It is safe to call twice, and an Open
|
|
// after it reports ErrPoolClosed rather than anything a caller could read as
|
|
// node absence.
|
|
func (p *PeerPool) Close() {
|
|
p.mu.Lock()
|
|
if p.closed {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.closed = true
|
|
links := p.links
|
|
p.links = nil
|
|
p.mu.Unlock()
|
|
|
|
// Each session is closed under its own peer lock rather than under p.mu,
|
|
// so closing the pool cannot deadlock against an Open that is mid-dial and
|
|
// about to take p.mu to re-check p.closed.
|
|
for _, l := range links {
|
|
l.mu.Lock()
|
|
if l.sess != nil {
|
|
_ = l.sess.Close()
|
|
l.sess = nil
|
|
}
|
|
l.mu.Unlock()
|
|
}
|
|
}
|