mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
eb11e8c85a
An operation that calls every node has to finish inside the panel's 30s write timeout. Reset all traffic, UpdatePanels and bulk inbound delete walked the nodes one at a time, up to 10s per hanging node, so 15 hanging nodes out of 150 kept each request running for 2m41s while the browser had already been told it failed. All three now fan out through fanoutInboundResults, bounded by nodeFanoutConcurrency (32, the heartbeat's bound), and UpdatePanels keeps its results in request order. Bulk delete still removes the rows one at a time, since each rewrites shared routing references, and only fans out the node pushes that delInbound now hands back.
162 lines
4.5 KiB
Go
162 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// fanoutGate holds every node call open until released, so a test sees how many
|
|
// nodes an operation reaches at once.
|
|
type fanoutGate struct {
|
|
entered atomic.Int32
|
|
release chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func newFanoutGate() *fanoutGate { return &fanoutGate{release: make(chan struct{})} }
|
|
|
|
func (g *fanoutGate) hold(ctx context.Context) error {
|
|
g.entered.Add(1)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-g.release:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (g *fanoutGate) open() { g.once.Do(func() { close(g.release) }) }
|
|
|
|
func (g *fanoutGate) waitAll(t *testing.T, want int32, op string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for g.entered.Load() < want {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("%s reached %d of %d hanging nodes, want all of them at once", op, g.entered.Load(), want)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
type gatedNodeRuntime struct {
|
|
fakeNodeRuntime
|
|
gate *fanoutGate
|
|
}
|
|
|
|
func (r *gatedNodeRuntime) ResetAllTraffics(ctx context.Context) error { return r.gate.hold(ctx) }
|
|
|
|
func (r *gatedNodeRuntime) DelInbound(ctx context.Context, _ *model.Inbound) error {
|
|
return r.gate.hold(ctx)
|
|
}
|
|
|
|
func gatedNodes(t *testing.T, gate *fanoutGate, n int) []int {
|
|
t.Helper()
|
|
mgr := useTestRuntimeManager(t)
|
|
ids := make([]int, 0, n)
|
|
for i := range n {
|
|
node := &model.Node{Name: fmt.Sprintf("fanout-%d", i), Address: "127.0.0.1", Port: 2100 + i, ApiToken: "tok", Enable: true, Status: "online"}
|
|
if err := database.GetDB().Create(node).Error; err != nil {
|
|
t.Fatalf("create node: %v", err)
|
|
}
|
|
mgr.SetRuntimeOverride(node.Id, &gatedNodeRuntime{gate: gate})
|
|
ids = append(ids, node.Id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// Operations that touch every node walked them one at a time, so a few hanging
|
|
// nodes kept the request running for minutes past the panel's write timeout.
|
|
func TestResetAllTrafficsReachesNodesConcurrently(t *testing.T) {
|
|
setupConflictDB(t)
|
|
gate := newFanoutGate()
|
|
gatedNodes(t, gate, 3)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_ = (&InboundService{}).ResetAllTraffics()
|
|
}()
|
|
t.Cleanup(func() { gate.open(); <-done })
|
|
gate.waitAll(t, 3, "ResetAllTraffics")
|
|
}
|
|
|
|
func TestDelInboundsPushesNodeDeletesConcurrently(t *testing.T) {
|
|
setupConflictDB(t)
|
|
gate := newFanoutGate()
|
|
var inboundIDs []int
|
|
for i, nodeID := range gatedNodes(t, gate, 3) {
|
|
inboundIDs = append(inboundIDs, nodeInbound(t, nodeID, 46400+i, nil).Id)
|
|
}
|
|
done := make(chan struct{})
|
|
var result BulkDelInboundResult
|
|
var err error
|
|
go func() {
|
|
defer close(done)
|
|
result, _, err = (&InboundService{}).DelInbounds(inboundIDs)
|
|
}()
|
|
t.Cleanup(func() { gate.open(); <-done })
|
|
gate.waitAll(t, 3, "DelInbounds")
|
|
gate.open()
|
|
<-done
|
|
if err != nil || result.Deleted != 3 || len(result.Skipped) != 0 {
|
|
t.Fatalf("DelInbounds = %+v, %v; want 3 deleted", result, err)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePanelsReachesNodesConcurrently(t *testing.T) {
|
|
setupConflictDB(t)
|
|
useTestRuntimeManager(t)
|
|
gate := newFanoutGate()
|
|
var ids []int
|
|
for i := range 3 {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
if strings.HasSuffix(r.URL.Path, "server/updatePanel") {
|
|
_ = gate.hold(r.Context())
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
|
portNum, _ := strconv.Atoi(port)
|
|
node := &model.Node{
|
|
Name: fmt.Sprintf("panel-%d", i), Scheme: "http", Address: host, Port: portNum, BasePath: "/",
|
|
ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
|
}
|
|
if err := database.GetDB().Create(node).Error; err != nil {
|
|
t.Fatalf("create node: %v", err)
|
|
}
|
|
ids = append(ids, node.Id)
|
|
}
|
|
done := make(chan struct{})
|
|
var results []NodeUpdateResult
|
|
go func() {
|
|
defer close(done)
|
|
results, _ = (&NodeService{}).UpdatePanels(ids, false)
|
|
}()
|
|
t.Cleanup(func() { gate.open(); <-done })
|
|
gate.waitAll(t, 3, "UpdatePanels")
|
|
gate.open()
|
|
<-done
|
|
if len(results) != len(ids) {
|
|
t.Fatalf("UpdatePanels returned %d results for %d nodes", len(results), len(ids))
|
|
}
|
|
for i, res := range results {
|
|
if res.Id != ids[i] || !res.OK {
|
|
t.Fatalf("UpdatePanels result %d = %+v, want node %d updated, in request order", i, res, ids[i])
|
|
}
|
|
}
|
|
}
|