Files
3x-ui/internal/web/service/traffic_writer.go
T
Sanaei 510bee2f30 fix(traffic): serialize inline traffic writes with the writer
submitTrafficWrite falls back to running fn inline when the serial
writer is not running (before StartTrafficWriter, during the shutdown
drain, in CLI paths and tests). That path took no lock, so an inline
write could run beside the draining writer or another inline write,
breaking the single-writer invariant that guards the Postgres deadlock.

One visible effect: ResetTrafficByEmail fans out one reset per inbound.
Run side by side, the second reset read the record still disabled but
the traffic already zeroed, judged the client operator-disabled and
saved enable=false over the first reset's restore
(TestResetTrafficByEmail_ReenablesEveryInboundOfTheClient failed ~25%).

safeApply now holds a package mutex, so every write path is serialized.
2026-11-06 18:53:42 +01:00

271 lines
7.0 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"gorm.io/gorm"
)
const (
trafficWriterQueueSize = 256
trafficWriterSubmitTimeout = 5 * time.Second
)
type trafficWriteRequest struct {
apply func() error
done chan error
}
type serializedTxContextKey struct{}
var (
twMu sync.Mutex
twAdminQueue chan *trafficWriteRequest
twBgQueue chan *trafficWriteRequest
twCtx context.Context
twCancel context.CancelFunc
twDone chan struct{}
twApplyMu sync.Mutex
)
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
// panel restart: Stop killed the consumer goroutine but Once prevented Start
// from spawning a new one, so every later submitTrafficWrite blocked forever
// on <-req.done with no consumer (including the AddTraffic call inside
// XrayService.GetXrayConfig that runs from startTask).
func StartTrafficWriter() {
twMu.Lock()
defer twMu.Unlock()
if twCancel != nil && twDone != nil {
select {
case <-twDone:
clearTrafficWriterState()
default:
return
}
}
adminQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
bgQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
twAdminQueue = adminQueue
twBgQueue = bgQueue
twCtx = ctx
twCancel = cancel
twDone = done
go runTrafficWriter(ctx, adminQueue, bgQueue, done)
}
// StopTrafficWriter cancels the writer context and waits for the goroutine to
// drain any pending requests before returning. Resets the package state so a
// subsequent StartTrafficWriter can spawn a fresh consumer.
func StopTrafficWriter() {
twMu.Lock()
cancel := twCancel
done := twDone
if cancel == nil || done == nil {
twMu.Unlock()
return
}
cancel()
twMu.Unlock()
<-done
twMu.Lock()
if twDone == done {
clearTrafficWriterState()
}
twMu.Unlock()
}
func clearTrafficWriterState() {
twAdminQueue = nil
twBgQueue = nil
twCtx = nil
twCancel = nil
twDone = nil
}
// trafficWriterQueueLen is the test's view of both lanes' pending requests.
func trafficWriterQueueLen() int {
twMu.Lock()
defer twMu.Unlock()
return len(twAdminQueue) + len(twBgQueue)
}
// One goroutine serves both lanes (no two writes side by side — the Postgres
// deadlock guard c5d31de4e; the admin lane is preferred over the 5s poll and node merges, #6035).
func runTrafficWriter(ctx context.Context, adminQueue, bgQueue chan *trafficWriteRequest, done chan struct{}) {
defer close(done)
for {
select {
case req := <-adminQueue:
req.done <- safeApply(req.apply)
case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue)
return
default:
select {
case req := <-adminQueue:
req.done <- safeApply(req.apply)
case req := <-bgQueue:
req.done <- safeApply(req.apply)
case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue)
return
}
}
}
}
// drainTrafficWriter finishes every request already queued on shutdown, as the
// single-queue writer used to: both lanes drain until empty, so no write is dropped.
func drainTrafficWriter(adminQueue, bgQueue chan *trafficWriteRequest) {
for {
drained := false
select {
case req := <-adminQueue:
req.done <- safeApply(req.apply)
drained = true
default:
}
select {
case req := <-bgQueue:
req.done <- safeApply(req.apply)
drained = true
default:
}
if !drained {
return
}
}
}
// runSerializedTx runs fn inside one DB transaction on the shared serial
// traffic-writer goroutine, so it can never execute concurrently with the
// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
// inbounds rows, and they acquire them in opposite order (the poll locks
// inbounds then client_traffics; an admin client/inbound mutation does the
// reverse), which Postgres aborts as a deadlock (SQLSTATE 40P01). Routing every
// such mutation through this single writer removes that contention entirely.
//
// Keep network I/O (node pushes) OUT of fn: holding the single writer across a
// remote node call would stall all traffic accounting for up to the remote
// timeout. Apply runtime changes after this returns.
func runSerializedTx(fn func(tx *gorm.DB) error) error {
return submitTrafficWrite(func() error {
state := &serializedTx{}
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(tx.Statement.Context, serializedTxContextKey{}, state)
return fn(tx.WithContext(ctx))
})
if err == nil {
// Still on the writer, so no node merge runs between the commit and the lift.
withdrawClientTombstones(state.createdClientEmails...)
}
return err
})
}
// serializedTx carries what may only take effect once its transaction commits.
type serializedTx struct {
createdClientEmails []string
}
func serializedTxOf(tx *gorm.DB) *serializedTx {
if tx == nil || tx.Statement == nil || tx.Statement.Context == nil {
return nil
}
state, _ := tx.Statement.Context.Value(serializedTxContextKey{}).(*serializedTx)
return state
}
func isSerializedTx(tx *gorm.DB) bool {
return serializedTxOf(tx) != nil
}
// safeApply holds twApplyMu so an inline fallback (no writer, or shutdown drain)
// never runs beside another write: overlapping resets switched a client back off.
func safeApply(fn func() error) (err error) {
twApplyMu.Lock()
defer twApplyMu.Unlock()
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("traffic writer panic: %v", r)
logger.Error(err.Error())
}
}()
return fn()
}
// submitTrafficWrite runs fn on the serial writer's admin lane, ahead of any
// queued periodic work (the traffic poll, the node sync merges).
func submitTrafficWrite(fn func() error) error {
return submitTrafficWriteLane(fn, false)
}
// submitTrafficWriteBackground runs fn on the periodic lane (the 5s traffic poll
// and node snapshot merges), served only when no admin-lane write is queued.
func submitTrafficWriteBackground(fn func() error) error {
return submitTrafficWriteLane(fn, true)
}
func submitTrafficWriteLane(fn func() error, background bool) error {
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
twMu.Lock()
queue := twAdminQueue
ctx := twCtx
done := twDone
if background {
queue = twBgQueue
}
if queue == nil || ctx == nil || done == nil {
twMu.Unlock()
return safeApply(fn)
}
select {
case <-ctx.Done():
twMu.Unlock()
return safeApply(fn)
default:
}
timer := time.NewTimer(trafficWriterSubmitTimeout)
defer timer.Stop()
select {
case queue <- req:
twMu.Unlock()
case <-timer.C:
twMu.Unlock()
return errors.New("traffic writer queue full")
}
select {
case err := <-req.done:
return err
case <-done:
select {
case err := <-req.done:
return err
default:
return errors.New("traffic writer stopped before write completed")
}
}
}