mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-06 10:27:12 +00:00
1aa81428b8
The Speed column showed "--" for AmneziaWG (and MTProto, which has the identical gap) even while cumulative traffic totals were correct. XrayTrafficJob drives live speed by querying xray-core's own stats API and broadcasting the delta over websocket -- but AmneziaWG/MTProto never run inside xray-core's own runtime inbounds, so they're invisible to that API. Their own jobs already compute the same per-poll delta shape (that's what keeps cumulative totals correct) but never broadcast it. Reusing the existing "traffics"/"clientTraffics" broadcast would have two real bugs: the frontend's existing scope/replace logic would let each side clobber the other's speed on its next unrelated tick, and the websocket hub's per-message-type throttle is keyed only by message type, not caller -- since both sidecar jobs run on identical "@every 10s" grids registered milliseconds apart, one would silently lose almost every broadcast if both protocols were ever configured together. Fixed with a small unthrottled broadcast path (both sidecar jobs are already self-rate-limited by their own cron cadence) and protocol- namespaced wire keys, tracked in their own frontend state and merged into the existing inboundSpeed/clientSpeed only at read time -- so every existing consumer needs zero changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
330 lines
7.4 KiB
Go
330 lines
7.4 KiB
Go
package websocket
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/op/go-logging"
|
|
|
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
_ = os.Setenv("XUI_LOG_FOLDER", os.TempDir())
|
|
xuilogger.InitLogger(logging.ERROR)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestNewClient_HasBufferedSendChannel(t *testing.T) {
|
|
c := NewClient("client-1")
|
|
if c.ID != "client-1" {
|
|
t.Fatalf("ID = %q, want client-1", c.ID)
|
|
}
|
|
if cap(c.Send) != clientSendQueue {
|
|
t.Fatalf("Send cap = %d, want %d", cap(c.Send), clientSendQueue)
|
|
}
|
|
}
|
|
|
|
func TestHub_NilReceiver_DoesNotPanic(t *testing.T) {
|
|
var h *Hub
|
|
if h.GetClientCount() != 0 {
|
|
t.Fatal("nil hub GetClientCount should return 0")
|
|
}
|
|
h.Broadcast(MessageTypeStatus, "anything")
|
|
h.Register(NewClient("x"))
|
|
h.Unregister(NewClient("x"))
|
|
h.Stop()
|
|
}
|
|
|
|
func TestHub_BroadcastDropsWhenNoClients(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
h.Broadcast(MessageTypeStatus, "payload")
|
|
|
|
select {
|
|
case <-h.broadcast:
|
|
t.Fatal("Broadcast should drop when client count is zero")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastDropsNilPayload(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
c := NewClient("c1")
|
|
h.Register(c)
|
|
waitClientCount(t, h, 1)
|
|
|
|
h.Broadcast(MessageTypeStatus, nil)
|
|
|
|
select {
|
|
case <-c.Send:
|
|
t.Fatal("nil payload should be dropped, not delivered")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastDeliversToClient(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
c := NewClient("c1")
|
|
h.Register(c)
|
|
waitClientCount(t, h, 1)
|
|
|
|
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
|
|
|
|
select {
|
|
case raw := <-c.Send:
|
|
var m Message
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
if m.Type != MessageTypeStatus {
|
|
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus)
|
|
}
|
|
if m.Time == 0 {
|
|
t.Fatal("Time should be set to a non-zero unix-millis value")
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("timed out waiting for broadcast to reach client")
|
|
}
|
|
}
|
|
|
|
func TestHub_UnregisterClosesSendAndDecrementsCount(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
c := NewClient("c1")
|
|
h.Register(c)
|
|
waitClientCount(t, h, 1)
|
|
|
|
h.Unregister(c)
|
|
waitClientCount(t, h, 0)
|
|
|
|
select {
|
|
case _, ok := <-c.Send:
|
|
if ok {
|
|
t.Fatal("expected Send channel to be closed after Unregister")
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Send channel was not closed after Unregister")
|
|
}
|
|
}
|
|
|
|
func TestHub_StopClosesAllClients(t *testing.T) {
|
|
h := NewHub()
|
|
go h.Run()
|
|
|
|
c1 := NewClient("c1")
|
|
c2 := NewClient("c2")
|
|
h.Register(c1)
|
|
h.Register(c2)
|
|
waitClientCount(t, h, 2)
|
|
|
|
h.Stop()
|
|
|
|
for _, c := range []*Client{c1, c2} {
|
|
select {
|
|
case _, ok := <-c.Send:
|
|
if ok {
|
|
t.Fatalf("client %s Send should be closed after Stop", c.ID)
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatalf("client %s Send not closed after Stop", c.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHub_ShouldThrottle(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
|
|
if h.shouldThrottle(MessageTypeStatus) {
|
|
t.Fatal("non-gated message type should never throttle")
|
|
}
|
|
if h.shouldThrottle(MessageTypeStatus) {
|
|
t.Fatal("non-gated message type should never throttle on second call")
|
|
}
|
|
|
|
if h.shouldThrottle(MessageTypeTraffic) {
|
|
t.Fatal("first call for gated type should not throttle")
|
|
}
|
|
if !h.shouldThrottle(MessageTypeTraffic) {
|
|
t.Fatal("immediate second call for gated type should throttle")
|
|
}
|
|
}
|
|
|
|
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
|
|
if h.shouldThrottle(MessageTypeTraffic) {
|
|
t.Fatal("first Traffic call should not throttle")
|
|
}
|
|
if h.shouldThrottle(MessageTypeInbounds) {
|
|
t.Fatal("first Inbounds call should not throttle even after Traffic")
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastUnthrottled_DeliversDespiteHotThrottleWindow(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
c := NewClient("c1")
|
|
h.Register(c)
|
|
waitClientCount(t, h, 1)
|
|
|
|
// Consume the throttle window for MessageTypeTraffic, as
|
|
// TestHub_ShouldThrottle already does directly.
|
|
if h.shouldThrottle(MessageTypeTraffic) {
|
|
t.Fatal("first call should not throttle")
|
|
}
|
|
if !h.shouldThrottle(MessageTypeTraffic) {
|
|
t.Fatal("second immediate call should throttle -- window not hot as expected")
|
|
}
|
|
|
|
h.BroadcastUnthrottled(MessageTypeTraffic, map[string]string{"k": "v"})
|
|
|
|
select {
|
|
case raw := <-c.Send:
|
|
var m Message
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
if m.Type != MessageTypeTraffic {
|
|
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeTraffic)
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("BroadcastUnthrottled should deliver even with a hot throttle window")
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastUnthrottled_DropsWhenNoClients(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
h.BroadcastUnthrottled(MessageTypeTraffic, "payload")
|
|
|
|
select {
|
|
case <-h.broadcast:
|
|
t.Fatal("BroadcastUnthrottled should drop when client count is zero")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastUnthrottled_DropsNilPayload(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
c := NewClient("c1")
|
|
h.Register(c)
|
|
waitClientCount(t, h, 1)
|
|
|
|
h.BroadcastUnthrottled(MessageTypeTraffic, nil)
|
|
|
|
select {
|
|
case <-c.Send:
|
|
t.Fatal("nil payload should be dropped, not delivered")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestHub_BroadcastUnthrottled_NilReceiverDoesNotPanic(t *testing.T) {
|
|
var h *Hub
|
|
h.BroadcastUnthrottled(MessageTypeTraffic, "anything")
|
|
}
|
|
|
|
func TestTrySend_SucceedsWithRoom(t *testing.T) {
|
|
c := &Client{ID: "c", Send: make(chan []byte, 1)}
|
|
if !trySend(c, []byte("hi")) {
|
|
t.Fatal("trySend should succeed when buffer has room")
|
|
}
|
|
}
|
|
|
|
func TestTrySend_FailsWhenFull(t *testing.T) {
|
|
c := &Client{ID: "c", Send: make(chan []byte, 1)}
|
|
c.Send <- []byte("first")
|
|
if trySend(c, []byte("second")) {
|
|
t.Fatal("trySend should fail when buffer is full")
|
|
}
|
|
}
|
|
|
|
func TestTrySend_FailsOnClosedChannel(t *testing.T) {
|
|
c := &Client{ID: "c", Send: make(chan []byte, 1)}
|
|
close(c.Send)
|
|
if trySend(c, []byte("after-close")) {
|
|
t.Fatal("trySend should fail (not panic) when channel is closed")
|
|
}
|
|
}
|
|
|
|
func TestHub_FanoutEvictsSlowClient(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
slow := &Client{ID: "slow", Send: make(chan []byte, 1)}
|
|
slow.Send <- []byte("buffer-already-full")
|
|
h.Register(slow)
|
|
waitClientCount(t, h, 1)
|
|
|
|
h.Broadcast(MessageTypeStatus, "payload")
|
|
waitClientCount(t, h, 0)
|
|
|
|
select {
|
|
case _, ok := <-slow.Send:
|
|
if ok {
|
|
_, ok = <-slow.Send
|
|
if ok {
|
|
t.Fatal("slow client Send should eventually be closed by fanout eviction")
|
|
}
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("slow client Send channel was not closed")
|
|
}
|
|
}
|
|
|
|
func TestHub_ConcurrentRegisterUnregister(t *testing.T) {
|
|
h := NewHub()
|
|
defer h.Stop()
|
|
go h.Run()
|
|
|
|
const n = 50
|
|
var wg sync.WaitGroup
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
c := NewClient("c")
|
|
h.Register(c)
|
|
h.Unregister(c)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
waitClientCount(t, h, 0)
|
|
}
|
|
|
|
func waitClientCount(t *testing.T, h *Hub, want int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if h.GetClientCount() == want {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("client count never reached %d (last seen %d)", want, h.GetClientCount())
|
|
}
|