Files
frp/server/workconn_scheduler_test.go
T

288 lines
9.9 KiB
Go

// 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 (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
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"
)
func TestWorkConnReplacementRestoresPoolWithoutOriginalResponse(t *testing.T) {
for _, kind := range []workConnRequestKind{workConnRequestReserve, workConnRequestLegacy} {
t.Run(string(kind), func(t *testing.T) {
ctl, serverConn, clientConn := newWorkConnSchedulingControl(t, 1)
ctl.workConnLeaseTimeout = time.Minute
t.Cleanup(func() {
ctl.closeWorkConnPool()
_ = serverConn.Close()
_ = clientConn.Close()
})
ctl.reconcileWorkConnRequests()
readWorkConnRequest(t, clientConn)
// The original request never returns. Trigger its timeout transition
// without sleeping, then deliver only the replacement's response.
moveReserveWorkConnLeaseToLateForTest(t, ctl)
ctl.reconcileWorkConnRequests()
readWorkConnRequest(t, clientConn)
conn := newCountingCloseConn()
workConn := proxy.NewWorkConn(msg.NewConn(conn, msg.NewV1ReadWriter(conn)))
accepted, err := ctl.registerWorkConnState(workConn, kind)
if !accepted {
_ = workConn.Close()
}
require.NoError(t, err)
require.True(t, accepted)
ctl.workConnMu.Lock()
pending := ctl.leaseCountLocked(ctl.workConnPending, workConnRequestReserve)
late := ctl.leaseCountLocked(ctl.workConnLate, workConnRequestReserve)
idle := len(ctl.workConnCh)
ctl.workConnMu.Unlock()
require.Zero(t, pending, "the successful retry must not time out and retry again")
require.Equal(t, 1, late)
require.Equal(t, 1, idle)
got, err := ctl.GetWorkConn()
if got != nil {
t.Cleanup(func() { _ = got.Close() })
}
require.NoError(t, err)
require.Same(t, workConn, got)
})
}
}
func TestWorkConnLeaseMatchingPrefersPendingSupply(t *testing.T) {
for _, tc := range []struct {
name string
responseKind workConnRequestKind
pendingKind workConnRequestKind
lateKind workConnRequestKind
}{
{"typed demand", workConnRequestDemand, workConnRequestDemand, workConnRequestDemand},
{"typed reserve", workConnRequestReserve, workConnRequestReserve, workConnRequestReserve},
{"legacy demand", workConnRequestLegacy, workConnRequestDemand, workConnRequestDemand},
{"legacy reserve before late demand", workConnRequestLegacy, workConnRequestReserve, workConnRequestDemand},
} {
t.Run(tc.name, func(t *testing.T) {
s := newWorkConnScheduler(1, time.Minute, xlog.New(), nil)
t.Cleanup(s.closeWorkConnPool)
s.workConnMu.Lock()
late := &workConnLease{kind: tc.lateKind, active: true, late: true}
s.workConnLate[tc.lateKind] = []*workConnLease{late}
pending := s.newWorkConnLeaseLocked(tc.pendingKind)
first := s.consumeWorkConnLeaseLocked(tc.responseKind)
lateStillActive := late.active
second := s.consumeWorkConnLeaseLocked(tc.responseKind)
s.workConnMu.Unlock()
require.Same(t, pending, first)
require.True(t, lateStillActive)
require.Same(t, late, second)
})
}
}
func TestWorkConnQueuedLeaseTimerUsesAdmissionBoundary(t *testing.T) {
conn := newBlockingWriteConn()
dispatcher := msg.NewDispatcher(msg.NewConn(conn, msg.NewV1ReadWriter(conn)))
s := newWorkConnScheduler(1, time.Minute, xlog.New(), nil)
s.sendRequest = func(kind workConnRequestKind) error {
return dispatcher.Send(&msg.ReqWorkConn{WorkConnType: string(kind)})
}
s.dispatcherDone = func() <-chan struct{} { return dispatcher.Done() }
t.Cleanup(func() {
s.closeWorkConnPool()
_ = conn.Close()
})
dispatcher.Run()
require.NoError(t, dispatcher.Send(&msg.Ping{}))
waitForSignal(t, conn.writeStarted, "initial dispatcher write to block")
// Unlike the full-queue test, Send can enqueue here. This records the
// remaining API boundary: a timer starts while the writer is still blocked
// on Ping, because Dispatcher offers no per-message write acknowledgement.
s.reconcileWorkConnRequests()
s.workConnMu.Lock()
leases := s.workConnPending[workConnRequestReserve]
pending := len(leases)
timerInstalled := pending == 1 && leases[0].timer != nil && leases[0].dispatched
s.workConnMu.Unlock()
require.Equal(t, 1, pending)
require.True(t, timerInstalled)
select {
case <-conn.allowWrite:
t.Fatal("writer was unexpectedly released")
default:
}
_ = conn.Close()
waitForSignal(t, dispatcher.Done(), "dispatcher shutdown")
}
type orderedWorkConnClose struct {
*countingCloseConn
id int
closed chan<- int
}
func (c *orderedWorkConnClose) Close() error {
c.closed <- c.id
return c.countingCloseConn.Close()
}
func TestWorkConnHandoffsPreserveFIFOAndCleanupOrder(t *testing.T) {
s := newWorkConnScheduler(0, time.Minute, xlog.New(), nil)
t.Cleanup(s.closeWorkConnPool)
const count = 6
closed := make(chan int, count)
waiters := make([]*workConnWaiter, count)
conns := make([]*orderedWorkConnClose, count)
results := make([]workConnResult, count)
for i := range waiters {
waiters[i] = &workConnWaiter{resultCh: make(chan workConnResult, 1), active: true}
s.workConnMu.Lock()
s.appendWorkConnWaiterLocked(waiters[i])
s.workConnMu.Unlock()
}
for i := range waiters {
conns[i] = &orderedWorkConnClose{countingCloseConn: newCountingCloseConn(), id: i, closed: closed}
workConn := proxy.NewWorkConn(msg.NewConn(conns[i], msg.NewV1ReadWriter(conns[i])))
accepted, err := s.registerWorkConnState(workConn, workConnRequestDemand)
require.NoError(t, err)
require.True(t, accepted)
results[i] = waitForResult(t, waiters[i].resultCh, "FIFO waiter delivery")
require.Same(t, workConn, results[i].conn)
}
// Consumers may run out of order. Removing middle/head/tail nodes must
// preserve the remaining registration order used by shutdown cleanup.
for _, i := range []int{3, 0, 5} {
conn, err := s.consumeWorkConnWaiterResult(waiters[i], results[i])
require.NoError(t, err)
require.Same(t, results[i].conn, conn)
require.NoError(t, conn.Close())
require.Equal(t, i, waitForResult(t, closed, "consumer close"))
}
s.closeWorkConnPool()
waitForSignal(t, s.workConnCleanupDone, "ordered handoff cleanup")
for _, i := range []int{1, 2, 4} {
require.Equal(t, i, waitForResult(t, closed, "cleanup close"))
conn, err := s.consumeWorkConnWaiterResult(waiters[i], results[i])
require.Nil(t, conn)
require.ErrorIs(t, err, pkgerr.ErrCtlClosed)
}
for _, conn := range conns {
require.Equal(t, int64(1), conn.closeCount.Load())
}
}
func TestWorkConnConcurrentHandoffConsumptionAndClose(t *testing.T) {
s := newWorkConnScheduler(0, time.Minute, xlog.New(), nil)
t.Cleanup(s.closeWorkConnPool)
const count = 512
waiters := make([]*workConnWaiter, count)
conns := make([]*countingCloseConn, count)
results := make([]workConnResult, count)
for i := range waiters {
waiters[i] = &workConnWaiter{resultCh: make(chan workConnResult, 1), active: true}
s.workConnMu.Lock()
s.appendWorkConnWaiterLocked(waiters[i])
s.workConnMu.Unlock()
}
for i := range waiters {
conns[i] = newCountingCloseConn()
accepted, err := s.registerWorkConnState(
proxy.NewWorkConn(msg.NewConn(conns[i], msg.NewV1ReadWriter(conns[i]))), workConnRequestDemand,
)
require.NoError(t, err)
require.True(t, accepted)
results[i] = waitForResult(t, waiters[i].resultCh, "burst waiter delivery")
}
// Guarantee coverage of both ownership outcomes in addition to the race:
// one quarter claims before close, one quarter only attempts after close.
for i := range count / 4 {
conn, err := s.consumeWorkConnWaiterResult(waiters[i], results[i])
require.NoError(t, err)
require.NoError(t, conn.Close())
}
start := make(chan struct{})
closed := make(chan struct{})
outcomes := make(chan workConnResult, count)
var ready sync.WaitGroup
ready.Add(count - count/4)
for i := count / 4; i < count; i++ {
go func() {
ready.Done()
if i >= 3*count/4 {
<-closed
} else {
<-start
}
conn, err := s.consumeWorkConnWaiterResult(waiters[i], results[i])
if conn != nil {
_ = conn.Close()
}
outcomes <- workConnResult{conn: conn, err: err}
}()
}
ready.Wait()
close(start)
s.closeWorkConnPool()
close(closed)
for i := count / 4; i < count; i++ {
outcome := waitForResult(t, outcomes, "handoff consumer to finish")
if outcome.err != nil {
require.ErrorIs(t, outcome.err, pkgerr.ErrCtlClosed)
require.Nil(t, outcome.conn)
} else {
require.NotNil(t, outcome.conn)
}
}
waitForSignal(t, s.workConnCleanupDone, "concurrent handoff cleanup")
s.workConnMu.Lock()
remaining := s.workConnHandoffs.Len()
retained := false
for _, waiter := range waiters {
retained = retained || waiter.handoffConn != nil || waiter.handoffElement != nil
}
s.workConnMu.Unlock()
require.Zero(t, remaining)
require.False(t, retained)
for _, conn := range conns {
require.Equal(t, int64(1), conn.closeCount.Load(), "consumer and cleanup must never both close the same connection")
}
}
func BenchmarkWorkConnPoolHit(b *testing.B) {
s := newWorkConnScheduler(1, time.Minute, xlog.New(), nil)
// Isolate acquisition from background refill work. The coalesced trigger
// remains buffered; every iteration restores the same idle connection.
s.workConnReconcileOnce.Do(func() {})
conn := &proxy.WorkConn{}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
s.workConnCh <- conn
got, err := s.GetWorkConn()
if err != nil || got != conn {
b.Fatalf("unexpected pool result: conn=%p err=%v", got, err)
}
}
}