mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 23:05:01 +03:00
9f0f2b3802
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.
314 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|