Files
3x-ui/internal/web/service/traffic_writer_test.go
T
25f2002654 9f0f2b3802 fix(service): run inbound deletes and depleted-client purges on the serial traffic writer (#6912)
Inbound and traffic writes are serialised on one goroutine because the traffic
poll locks inbounds then client_traffics while an admin mutation does the
reverse, which Postgres aborts as a deadlock (40P01). Deleting an inbound and
purging depleted clients still opened their own transactions, so they could
deadlock the poll, and a delete committing between a concurrent edit's read
and its Save let GORM's Save fall back to CREATE and resurrect the inbound.
Both now go through runSerializedTx like every other inbound write.
2026-10-24 05:06:03 +02:00

314 lines
7.8 KiB
Go

package service
import (
"sync/atomic"
"testing"
"time"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestTrafficWriterStartStopStartAcceptsWrites(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
var writes atomic.Int32
if err := submitTrafficWrite(func() error {
writes.Add(1)
return nil
}); err != nil {
t.Fatalf("first submitTrafficWrite: %v", err)
}
StopTrafficWriter()
StartTrafficWriter()
if err := submitTrafficWrite(func() error {
writes.Add(1)
return nil
}); err != nil {
t.Fatalf("second submitTrafficWrite: %v", err)
}
if got := writes.Load(); got != 2 {
t.Fatalf("writes = %d, want 2", got)
}
}
func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
StopTrafficWriter()
ran := make(chan struct{})
errCh := make(chan error, 1)
go func() {
errCh <- submitTrafficWrite(func() error {
close(ran)
return nil
})
}()
select {
case <-ran:
case <-time.After(time.Second):
t.Fatal("submitTrafficWrite did not run after traffic writer stopped")
}
if err := waitTrafficWriterErr(t, errCh); err != nil {
t.Fatalf("submitTrafficWrite after stop: %v", err)
}
}
func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
firstErr := make(chan error, 1)
go func() {
firstErr <- submitTrafficWrite(func() error {
close(firstStarted)
<-releaseFirst
return nil
})
}()
waitTrafficWriterSignal(t, firstStarted, "first write did not start")
secondRan := make(chan struct{})
secondErr := make(chan error, 1)
go func() {
secondErr <- submitTrafficWrite(func() error {
close(secondRan)
return nil
})
}()
waitTrafficWriterQueued(t)
stopDone := make(chan struct{})
go func() {
StopTrafficWriter()
close(stopDone)
}()
select {
case <-stopDone:
t.Fatal("StopTrafficWriter returned before in-flight write was released")
case <-time.After(50 * time.Millisecond):
}
close(releaseFirst)
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter did not return")
waitTrafficWriterSignal(t, secondRan, "queued write was not drained")
if err := waitTrafficWriterErr(t, firstErr); err != nil {
t.Fatalf("first submitTrafficWrite: %v", err)
}
if err := waitTrafficWriterErr(t, secondErr); err != nil {
t.Fatalf("second submitTrafficWrite: %v", err)
}
}
func TestTrafficWriterConcurrentStopDuringSubmitDoesNotHang(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
started := make(chan struct{})
release := make(chan struct{})
errCh := make(chan error, 1)
go func() {
errCh <- submitTrafficWrite(func() error {
close(started)
<-release
return nil
})
}()
waitTrafficWriterSignal(t, started, "write did not start")
stopDone := make(chan struct{})
go func() {
StopTrafficWriter()
close(stopDone)
}()
close(release)
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter hung during submit")
if err := waitTrafficWriterErr(t, errCh); err != nil {
t.Fatalf("submitTrafficWrite during stop: %v", err)
}
}
func resetTrafficWriterForTest(t *testing.T) {
t.Helper()
StopTrafficWriter()
twMu.Lock()
clearTrafficWriterState()
twMu.Unlock()
t.Cleanup(func() {
StopTrafficWriter()
twMu.Lock()
clearTrafficWriterState()
twMu.Unlock()
})
}
func waitTrafficWriterQueued(t *testing.T) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
twMu.Lock()
queued := 0
if twQueue != nil {
queued = len(twQueue)
}
twMu.Unlock()
if queued > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("write was not queued")
}
func waitTrafficWriterSignal(t *testing.T, ch <-chan struct{}, msg string) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal(msg)
}
}
func waitTrafficWriterErr(t *testing.T, ch <-chan error) error {
t.Helper()
select {
case err := <-ch:
return err
case <-time.After(time.Second):
t.Fatal("timed out waiting for traffic writer result")
return nil
}
}
// parkSerializedWriter holds the single writer inside an open transaction, so a write handed to it stays queued.
func parkSerializedWriter(t *testing.T) (release func()) {
t.Helper()
entered := make(chan struct{})
releaseCh := make(chan struct{})
go func() {
_ = submitTrafficWrite(func() error {
return database.GetDB().Transaction(func(tx *gorm.DB) error {
close(entered)
<-releaseCh
return nil
})
})
}()
waitTrafficWriterSignal(t, entered, "serial writer did not enter its transaction")
return func() { close(releaseCh) }
}
// waitForQueuedWrite reports whether a write reached the writer queue instead of opening its own transaction.
func waitForQueuedWrite() bool {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
twMu.Lock()
queued := len(twQueue) > 0
twMu.Unlock()
if queued {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
func waitWriterResult(t *testing.T, done <-chan error) error {
t.Helper()
select {
case err := <-done:
return err
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the queued write to finish")
return nil
}
}
// The poll and an admin mutation lock inbounds and client_traffics in opposite order, so a delete in its
// own transaction can deadlock the poll on Postgres (40P01) and resurrect an inbound an edit just saved.
func TestDelInboundRunsOnTheSerialWriter(t *testing.T) {
startSerializedWriter(t)
db := initTrafficTestDB(t)
svc := &InboundService{}
ib := mkInbound(t, 54101, model.VLESS, `{"clients": []}`)
release := parkSerializedWriter(t)
done := make(chan error, 1)
go func() {
_, _, err := svc.delInbound(ib.Id)
done <- err
}()
queued := waitForQueuedWrite()
release()
if err := waitWriterResult(t, done); err != nil {
t.Fatalf("delInbound: %v", err)
}
if !queued {
t.Fatal("the inbound delete bypassed the serial traffic writer and opened its own transaction")
}
var remaining int64
if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Count(&remaining).Error; err != nil {
t.Fatalf("count inbounds: %v", err)
}
if remaining != 0 {
t.Fatalf("inbound %d still present after the delete", ib.Id)
}
}
// The purge writes inbounds and client_traffics in the opposite order to the poll, so off the writer it
// can deadlock the poll on Postgres (40P01).
func TestDelDepletedClientsRunsOnTheSerialWriter(t *testing.T) {
startSerializedWriter(t)
db := initTrafficTestDB(t)
svc := &InboundService{}
clients := []model.Client{{Email: "depleted@x", ID: "aaaaaaaa-0000-0000-0000-000000000301", Enable: true}}
ib := mkInbound(t, 54102, model.VLESS, clientsSettings(t, clients))
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "depleted@x", 60, 60, 100, 0, true)
release := parkSerializedWriter(t)
done := make(chan error, 1)
go func() { done <- svc.DelDepletedClients(-1) }()
queued := waitForQueuedWrite()
release()
if err := waitWriterResult(t, done); err != nil {
t.Fatalf("DelDepletedClients: %v", err)
}
if !queued {
t.Fatal("the depleted-client purge bypassed the serial traffic writer and opened its own transaction")
}
var inbounds int64
if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Count(&inbounds).Error; err != nil {
t.Fatalf("count inbounds: %v", err)
}
if inbounds != 0 {
t.Fatalf("depleted inbound %d still present after the purge", ib.Id)
}
var traffics int64
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", "depleted@x").Count(&traffics).Error; err != nil {
t.Fatalf("count traffic rows: %v", err)
}
if traffics != 0 {
t.Fatalf("traffic rows for the depleted client = %d, want 0", traffics)
}
}