// Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "container/list" "errors" "fmt" "runtime/debug" "slices" "sync" "time" pkgerr "github.com/fatedier/frp/pkg/errors" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/proxy" ) const workConnPoolCapacityOffset = 10 const ( workConnReserveWindowMax = 8 workConnDemandWindow = 64 workConnLegacyWindow = 64 // Canceled waiters remain as lazy tombstones so cancellation stays O(1). // Compact only after enough tombstones have accumulated and comprise at // least half the queue, making the occasional O(n) copy O(1) amortized. workConnWaiterCompactMinTombstones = 64 ) var ( errWorkConnPoolFull = errors.New("work connection pool is full, discarding") errWorkConnControlUnavailable = errors.New("work connection control unavailable") ) type workConnRequestKind string const ( workConnRequestDemand workConnRequestKind = msg.WorkConnTypeDemand workConnRequestReserve workConnRequestKind = msg.WorkConnTypeReserve workConnRequestLegacy workConnRequestKind = "legacy" ) type workConnWaiter struct { resultCh chan workConnResult active bool handoffConn *proxy.WorkConn handoffClosed bool handoffElement *list.Element } type workConnResult struct { conn *proxy.WorkConn err error } type workConnLease struct { kind workConnRequestKind timer *time.Timer active bool dispatched bool late bool } // workConnScheduler owns all work-connection supply state. It deliberately // communicates with Control through callbacks for request dispatch and // dispatcher shutdown; it never acquires Control lifecycle or manager locks. type workConnScheduler struct { workConnCh chan *proxy.WorkConn workConnMu sync.Mutex workConnWaiters []*workConnWaiter workConnActiveWaiters int workConnWaiterTombstones int // Elements retain registration order for cleanup; each waiter owns its // element so consumption removes it in O(1), even during large bursts. workConnHandoffs list.List workConnPending map[workConnRequestKind][]*workConnLease workConnLate map[workConnRequestKind][]*workConnLease workConnPoolClosed bool workConnReconcileCh chan struct{} workConnReconcileStop chan struct{} workConnReconcileDone chan struct{} workConnReconcileOnce sync.Once workConnReconcileStopOnce sync.Once workConnCleanupMu sync.Mutex workConnCleanupPending []*proxy.WorkConn workConnCleanupReady bool workConnCleanupStarted bool workConnCleanupDone chan struct{} workConnLegacyMode bool workConnReserveWindow int workConnLeaseTimeout time.Duration poolCount int // sendRequest is called only after workConnMu has been released. The // callback captures the Control's immutable generation metadata and sends // the wire message without exposing lifecycle state to the scheduler. sendRequest func(workConnRequestKind) error dispatcherDone func() <-chan struct{} logger *xlog.Logger doneCh <-chan struct{} } func newWorkConnScheduler(poolCount int, leaseTimeout time.Duration, logger *xlog.Logger, doneCh <-chan struct{}) workConnScheduler { return workConnScheduler{ workConnCh: make(chan *proxy.WorkConn, poolCount+workConnPoolCapacityOffset), poolCount: poolCount, workConnPending: make(map[workConnRequestKind][]*workConnLease), workConnLate: make(map[workConnRequestKind][]*workConnLease), workConnReconcileCh: make(chan struct{}, 1), workConnReconcileStop: make(chan struct{}), workConnReconcileDone: make(chan struct{}), workConnCleanupDone: make(chan struct{}), workConnLegacyMode: true, workConnReserveWindow: min(poolCount, workConnReserveWindowMax), workConnLeaseTimeout: leaseTimeout, logger: logger, doneCh: doneCh, } } func normalizeWorkConnRequestKind(kind workConnRequestKind) workConnRequestKind { if kind == workConnRequestDemand || kind == workConnRequestReserve { return kind } return workConnRequestLegacy } func (s *workConnScheduler) workConnLeaseTimeoutValue() time.Duration { if s.workConnLeaseTimeout > 0 { return s.workConnLeaseTimeout } return 10 * time.Second } func (s *workConnScheduler) startWorkConnReconciler() { s.workConnReconcileOnce.Do(func() { go func() { defer close(s.workConnReconcileDone) for { select { case <-s.workConnReconcileCh: s.reconcileWorkConnRequests() case <-s.workConnReconcileStop: return } } }() }) } // requestWorkConnReconcile coalesces refill requests into one bounded trigger. // The reconciler may block on dispatcher backpressure, but it owns no control // lifecycle or generation locks and is stopped with the control pool. func (s *workConnScheduler) requestWorkConnReconcile() { s.workConnMu.Lock() closed := s.workConnPoolClosed s.workConnMu.Unlock() if closed { return } s.startWorkConnReconciler() select { case <-s.workConnReconcileStop: return case s.workConnReconcileCh <- struct{}{}: default: } } func (s *workConnScheduler) activeWaiterCountLocked() int { return s.workConnActiveWaiters } func (s *workConnScheduler) appendWorkConnWaiterLocked(waiter *workConnWaiter) { s.workConnWaiters = append(s.workConnWaiters, waiter) if waiter.active { s.workConnActiveWaiters++ } } func (s *workConnScheduler) popWorkConnWaiterLocked() *workConnWaiter { for len(s.workConnWaiters) > 0 { waiter := s.workConnWaiters[0] s.workConnWaiters[0] = nil s.workConnWaiters = s.workConnWaiters[1:] if waiter.active { waiter.active = false s.workConnActiveWaiters-- if len(s.workConnWaiters) == 0 { s.workConnWaiters = nil } return waiter } if s.workConnWaiterTombstones > 0 { s.workConnWaiterTombstones-- } } s.workConnWaiters = nil s.workConnWaiterTombstones = 0 return nil } func (s *workConnScheduler) compactWorkConnWaitersLocked() { if s.workConnWaiterTombstones == 0 { return } activeWaiters := make([]*workConnWaiter, 0, s.workConnActiveWaiters) for _, waiter := range s.workConnWaiters { if waiter.active { activeWaiters = append(activeWaiters, waiter) } } s.workConnWaiters = activeWaiters s.workConnWaiterTombstones = 0 } func (s *workConnScheduler) shouldCompactWorkConnWaitersLocked() bool { tombstones := s.workConnWaiterTombstones if tombstones < workConnWaiterCompactMinTombstones { return false } return tombstones*2 >= len(s.workConnWaiters) } func (s *workConnScheduler) removeWorkConnHandoffLocked(target *workConnWaiter) { if target.handoffElement != nil { s.workConnHandoffs.Remove(target.handoffElement) target.handoffElement = nil } } func (s *workConnScheduler) removeLeaseLocked(leases map[workConnRequestKind][]*workConnLease, target *workConnLease) { items := leases[target.kind] for i, lease := range items { if lease == target { items = append(items[:i], items[i+1:]...) if len(items) == 0 { delete(leases, target.kind) } else { leases[target.kind] = items } return } } } func (s *workConnScheduler) hasLeaseLocked(leases map[workConnRequestKind][]*workConnLease, target *workConnLease) bool { return slices.Contains(leases[target.kind], target) } func (s *workConnScheduler) popLeaseLocked(leases map[workConnRequestKind][]*workConnLease, kind workConnRequestKind) *workConnLease { items := leases[kind] for len(items) > 0 { lease := items[0] items = items[1:] if len(items) == 0 { delete(leases, kind) } else { leases[kind] = items } if lease.active { return lease } } return nil } func (s *workConnScheduler) leaseCountLocked(leases map[workConnRequestKind][]*workConnLease, kind workConnRequestKind) int { count := 0 for _, lease := range leases[kind] { if lease.active { count++ } } return count } func (s *workConnScheduler) workConnSupplyGapLocked() bool { target := s.activeWaiterCountLocked() + s.poolCount pending := s.leaseCountLocked(s.workConnPending, workConnRequestDemand) + s.leaseCountLocked(s.workConnPending, workConnRequestReserve) return len(s.workConnCh)+pending < target } func (s *workConnScheduler) consumeWorkConnLeaseLocked(kind workConnRequestKind) *workConnLease { kind = normalizeWorkConnRequestKind(kind) s.workConnLegacyMode = kind == workConnRequestLegacy kinds := []workConnRequestKind{kind} if s.workConnLegacyMode { // Untyped responses satisfy pending demand before pending reserve. kinds = []workConnRequestKind{workConnRequestDemand, workConnRequestReserve} } // There is no request ID: same-kind replies are interchangeable. A timely // retry must retire pending supply before late leases, otherwise its reply // is discarded while the retry remains pending and the pool never recovers. // For legacy clients, all pending kinds take precedence over late leases. for _, leases := range []map[workConnRequestKind][]*workConnLease{s.workConnPending, s.workConnLate} { for _, candidate := range kinds { if lease := s.popLeaseLocked(leases, candidate); lease != nil { lease.active = false if lease.timer != nil { lease.timer.Stop() } return lease } } } return nil } func (s *workConnScheduler) expireLateWorkConnLease(lease *workConnLease) { s.workConnMu.Lock() if !lease.active || !lease.late { s.workConnMu.Unlock() return } lease.active = false s.removeLeaseLocked(s.workConnLate, lease) s.workConnMu.Unlock() s.requestWorkConnReconcile() } func (s *workConnScheduler) expireWorkConnLease(lease *workConnLease) { s.workConnMu.Lock() if !lease.active || lease.late { s.workConnMu.Unlock() return } s.removeLeaseLocked(s.workConnPending, lease) lease.late = true s.workConnLate[lease.kind] = append(s.workConnLate[lease.kind], lease) lease.timer = time.AfterFunc(s.workConnLeaseTimeoutValue(), func() { s.expireLateWorkConnLease(lease) }) s.workConnMu.Unlock() s.requestWorkConnReconcile() } func (s *workConnScheduler) newWorkConnLeaseLocked(kind workConnRequestKind) *workConnLease { lease := &workConnLease{ kind: kind, active: true, } s.workConnPending[kind] = append(s.workConnPending[kind], lease) return lease } // startWorkConnLeaseTimer starts the response timeout only after the request // has been accepted by Dispatcher.Send. A response may race this call; the // active/pending check under workConnMu prevents installing a timer on a lease // that was already consumed, failed, or closed. // Send acknowledges queue admission, not WriteMsg completion. The dispatcher // has no per-message write acknowledgement, so this timeout includes queue time. func (s *workConnScheduler) startWorkConnLeaseTimer(lease *workConnLease) { s.workConnMu.Lock() if lease.active && !lease.dispatched && !lease.late && lease.timer == nil && s.hasLeaseLocked(s.workConnPending, lease) { lease.dispatched = true lease.timer = time.AfterFunc(s.workConnLeaseTimeoutValue(), func() { s.expireWorkConnLease(lease) }) } s.workConnMu.Unlock() } func (s *workConnScheduler) registerWorkConn(conn *proxy.WorkConn, kind workConnRequestKind) (bool, error) { accepted, err := s.registerWorkConnState(conn, kind) s.reconcileWorkConnRequests() return accepted, err } // registerWorkConnState commits work connection ownership and lease accounting // without sending refill requests. Callers that hold lifecycle or generation // locks must release them before invoking reconcileWorkConnRequests. func (s *workConnScheduler) registerWorkConnState(conn *proxy.WorkConn, kind workConnRequestKind) (bool, error) { s.workConnMu.Lock() if s.workConnPoolClosed { s.workConnMu.Unlock() return false, pkgerr.ErrCtlClosed } s.consumeWorkConnLeaseLocked(kind) if waiter := s.popWorkConnWaiterLocked(); waiter != nil { // A reserve response may satisfy a demand waiter, but any other // pending lease is still an in-flight source of supply until it really // times out. Keep it accounted for instead of creating a replacement. waiter.handoffConn = conn waiter.handoffElement = s.workConnHandoffs.PushBack(waiter) waiter.resultCh <- workConnResult{conn: conn} s.workConnMu.Unlock() return true, nil } // Any response that did not satisfy a waiter must still prove a real supply // gap before entering idle. This covers unmatched responses, demand leases // whose waiter timed out, and late leases whose replacement is already // pending. A matching lease has been consumed above, so pending includes // only the remaining supply credits; replies cannot identify their own lease. if !s.workConnSupplyGapLocked() { s.workConnMu.Unlock() return false, errWorkConnPoolFull } select { case s.workConnCh <- conn: s.workConnMu.Unlock() return true, nil default: s.workConnMu.Unlock() return false, errWorkConnPoolFull } } // consumeWorkConnWaiterResult transfers a direct waiter handoff to its // consumer only while the control pool is still open. A close/replacement may // have already detached the connection for cleanup even though the buffered // result notification remains readable; in that case the caller must never // receive the stale connection. func (s *workConnScheduler) consumeWorkConnWaiterResult(waiter *workConnWaiter, result workConnResult) (*proxy.WorkConn, error) { s.workConnMu.Lock() if s.workConnPoolClosed || waiter.handoffClosed { s.workConnMu.Unlock() return nil, pkgerr.ErrCtlClosed } conn := waiter.handoffConn if conn != nil { waiter.handoffConn = nil s.removeWorkConnHandoffLocked(waiter) } s.workConnMu.Unlock() if result.err != nil { return nil, result.err } if conn == nil { conn = result.conn } return conn, nil } func (s *workConnScheduler) cancelWorkConnWaiter(waiter *workConnWaiter) bool { canceled := false s.workConnMu.Lock() if waiter.active { waiter.active = false s.workConnActiveWaiters-- s.workConnWaiterTombstones++ if s.shouldCompactWorkConnWaitersLocked() { s.compactWorkConnWaitersLocked() } canceled = true } s.workConnMu.Unlock() if canceled { s.requestWorkConnReconcile() } return canceled } func (s *workConnScheduler) startWorkConnCleanup() { s.workConnCleanupMu.Lock() if s.workConnCleanupStarted || !s.workConnCleanupReady { s.workConnCleanupMu.Unlock() return } conns := s.workConnCleanupPending s.workConnCleanupPending = nil s.workConnCleanupStarted = true s.workConnCleanupMu.Unlock() if len(conns) == 0 { close(s.workConnCleanupDone) return } go func() { for _, conn := range conns { _ = conn.Close() } close(s.workConnCleanupDone) }() } func (s *workConnScheduler) waitWorkConnCleanup() { s.workConnCleanupMu.Lock() started := s.workConnCleanupStarted s.workConnCleanupMu.Unlock() if started { <-s.workConnCleanupDone } } func (s *workConnScheduler) takeWorkConnPool() { var conns []*proxy.WorkConn // Serialize pool extraction with cleanup startup. A concurrent worker may // observe the pool as closed before this caller has queued its streams; // keeping the cleanup gate held makes that ordering explicit and prevents // a premature workConnCleanupDone close from losing the extracted conns. s.workConnCleanupMu.Lock() s.workConnMu.Lock() if s.workConnPoolClosed { s.workConnMu.Unlock() s.workConnCleanupMu.Unlock() return } s.workConnPoolClosed = true s.workConnReconcileStopOnce.Do(func() { close(s.workConnReconcileStop) }) for _, waiter := range s.workConnWaiters { if waiter.active { waiter.active = false s.workConnActiveWaiters-- waiter.resultCh <- workConnResult{err: pkgerr.ErrCtlClosed} } } s.workConnWaiters = nil s.workConnActiveWaiters = 0 s.workConnWaiterTombstones = 0 for _, leases := range s.workConnPending { for _, lease := range leases { lease.active = false if lease.timer != nil { lease.timer.Stop() } } } for _, leases := range s.workConnLate { for _, lease := range leases { lease.active = false if lease.timer != nil { lease.timer.Stop() } } } for element := s.workConnHandoffs.Front(); element != nil; element = element.Next() { waiter := element.Value.(*workConnWaiter) if waiter.handoffConn != nil { conns = append(conns, waiter.handoffConn) waiter.handoffConn = nil } waiter.handoffClosed = true waiter.handoffElement = nil } s.workConnHandoffs.Init() for { select { case conn := <-s.workConnCh: conns = append(conns, conn) default: close(s.workConnCh) s.workConnMu.Unlock() s.workConnCleanupPending = append(s.workConnCleanupPending, conns...) s.workConnCleanupReady = true s.workConnCleanupMu.Unlock() return } } } func (s *workConnScheduler) closeWorkConnPool() { s.takeWorkConnPool() s.startWorkConnCleanup() } func (s *workConnScheduler) reconcileWorkConnRequests() { type request struct { lease *workConnLease kind workConnRequestKind } s.workConnMu.Lock() if s.workConnPoolClosed { s.workConnMu.Unlock() return } var requests []request demandPending := s.leaseCountLocked(s.workConnPending, workConnRequestDemand) reservePending := s.leaseCountLocked(s.workConnPending, workConnRequestReserve) activeWaiters := s.activeWaiterCountLocked() demandNeed := activeWaiters - demandPending if demandNeed > 0 { budget := workConnDemandWindow - demandPending if s.workConnLegacyMode { legacyBudget := workConnLegacyWindow - demandPending - reservePending if budget > legacyBudget { budget = legacyBudget } } if budget < 0 { budget = 0 } if demandNeed > budget { demandNeed = budget } for i := 0; i < demandNeed; i++ { requests = append(requests, request{lease: s.newWorkConnLeaseLocked(workConnRequestDemand), kind: workConnRequestDemand}) } demandPending += demandNeed } // Keep the invariant available + pending >= waiters + poolCount, while // limiting only reserve requests to the sliding window. Late leases do not // consume retry budget; their responses are admitted separately above only // when this projected supply still has a real gap. reserveNeed := max(activeWaiters+s.poolCount-len(s.workConnCh)-demandPending-reservePending, 0) reserveBudget := s.workConnReserveWindow - reservePending if s.workConnLegacyMode { legacyBudget := workConnLegacyWindow - demandPending - reservePending if reserveBudget > legacyBudget { reserveBudget = legacyBudget } } if reserveBudget < 0 { reserveBudget = 0 } if reserveNeed > reserveBudget { reserveNeed = reserveBudget } for i := 0; i < reserveNeed; i++ { requests = append(requests, request{lease: s.newWorkConnLeaseLocked(workConnRequestReserve), kind: workConnRequestReserve}) } s.workConnMu.Unlock() for _, req := range requests { if err := s.sendRequest(req.kind); err != nil { s.failWorkConnLease(req.lease) if s.dispatcherDone != nil { select { case <-s.dispatcherDone(): // Wake waiters immediately when the control transport is already // closed; otherwise they would wait until UserConnTimeout. s.closeWorkConnPool() default: } } } else { s.startWorkConnLeaseTimer(req.lease) } } } func (s *workConnScheduler) failWorkConnLease(lease *workConnLease) { s.workConnMu.Lock() if lease.active && !lease.late { lease.active = false s.removeLeaseLocked(s.workConnPending, lease) if lease.timer != nil { lease.timer.Stop() } } s.workConnMu.Unlock() } // When frps get one user connection, we get one work connection from the pool and return it. // If no workConn available in the pool, send message to frpc to get one or more // and wait until it is available. // return an error if wait timeout func (s *workConnScheduler) GetWorkConn() (workConn *proxy.WorkConn, err error) { xl := s.logger started := time.Now() defer func() { if recovered := recover(); recovered != nil { xl.Errorf("panic error: %v", recovered) xl.Errorf(string(debug.Stack())) workConn = nil err = pkgerr.ErrCtlClosed } }() s.workConnMu.Lock() if s.workConnPoolClosed { s.workConnMu.Unlock() return nil, pkgerr.ErrCtlClosed } // Get an idle connection first. select { case workConn = <-s.workConnCh: s.workConnMu.Unlock() xl.Debugf("get work connection from pool") s.requestWorkConnReconcile() return workConn, nil default: } waiter := &workConnWaiter{ resultCh: make(chan workConnResult, 1), active: true, } s.appendWorkConnWaiterLocked(waiter) s.workConnMu.Unlock() // Pool hits need no timer. On a miss, include time already spent acquiring // the pool lock and install the timer before requesting a refill. timer := time.NewTimer(s.workConnLeaseTimeoutValue() - time.Since(started)) defer timer.Stop() s.requestWorkConnReconcile() select { case result := <-waiter.resultCh: return s.consumeWorkConnWaiterResult(waiter, result) case <-timer.C: if !s.cancelWorkConnWaiter(waiter) { select { case result := <-waiter.resultCh: return s.consumeWorkConnWaiterResult(waiter, result) default: } } s.workConnMu.Lock() closed := s.workConnPoolClosed s.workConnMu.Unlock() if closed { return nil, pkgerr.ErrCtlClosed } err = fmt.Errorf("timeout trying to get work connection") xl.Warnf("%v", err) return nil, err case <-s.doneCh: // doneCh is terminal for this control generation. Do not consume a // buffered successful handoff here: takeWorkConnPool detaches that // connection for cleanup, and returning it would leak it past shutdown. s.cancelWorkConnWaiter(waiter) return nil, pkgerr.ErrCtlClosed } }