修复二开 agent 流量增量丢失: 基线改上报确认后推进并落盘持久化
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions

- xray 新增快照/确认接口 GetTrafficSnapshot/CommitTrafficSnapshot/
  RestoreTrafficBaseline, 与面板 GetTraffic 的 StatsLastValues 完全隔离
- agent 改走快照: 仅主控 HTTP 200 后才推进基线, 失败轮增量由累计
  计数做差在下轮自动补齐; 已确认基线原子落盘, agent 重启恢复续差
- 核心重启计数归零的从零规则保留; Onlines 仍每轮新鲜采集直报
- 新增 11 个测试覆盖断网恢复/连续失败补齐/重启恢复/计数归零/
  面板语义隔离, xray 与 agent 两包全绿
This commit is contained in:
Muse
2026-10-21 01:24:14 +08:00
parent a654a96c38
commit 17be1ad5ae
5 changed files with 818 additions and 22 deletions
+121
View File
@@ -61,6 +61,19 @@ type XrayAPI struct {
grpcClient *grpc.ClientConn
isConnected bool
StatsLastValues map[string]int64
// committedStats is the agent reporting path's last ACKNOWLEDGED
// baseline: the raw core counters as of the most recent snapshot the
// master confirmed receiving (HTTP 200). It is deliberately separate
// from StatsLastValues — GetTraffic (the panel's local accounting)
// advances its baseline at read time, while the agent path may only
// advance on CommitTrafficSnapshot, so a failed upload never loses
// the deltas it carried. committedReady is false until a baseline has
// been established (first snapshot committed, or one restored from
// disk); while false, snapshots report no deltas, mirroring
// GetTraffic's first-poll rule.
committedStats map[string]int64
committedReady bool
}
// Init connects to the Xray API server.
@@ -650,6 +663,114 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
}
// TrafficSnapshot is one read of the core counters taken for the agent
// reporting path: the deltas accrued since the last COMMITTED baseline,
// plus Values, the raw cumulative counters the deltas were computed from.
//
// Unlike GetTraffic — which advances its baseline as it reads, the right
// contract for the panel's local accounting job — a snapshot changes no
// state. The caller reports the deltas and only then calls
// CommitTrafficSnapshot; if the report fails, the next snapshot recomputes
// against the same committed baseline and the unreported traffic is
// included again, because the core counters are cumulative and are never
// reset by polling.
type TrafficSnapshot struct {
Traffics []*Traffic
ClientTraffics []*ClientTraffic
// Values holds every counter returned by the core at snapshot time,
// keyed by stat name. CommitTrafficSnapshot installs exactly this map
// as the new committed baseline.
Values map[string]int64
}
// BaselineValues returns a copy of the baseline CommitTrafficSnapshot
// would install for this snapshot, so callers can persist it (the agent
// writes it to disk before/while committing).
func (s *TrafficSnapshot) BaselineValues() map[string]int64 {
out := make(map[string]int64, len(s.Values))
for k, v := range s.Values {
out[k] = v
}
return out
}
// GetTrafficSnapshot reads the core counters and computes deltas against
// the committed baseline WITHOUT advancing it. Per-stat rules match
// GetTraffic exactly: while no baseline has been established the snapshot
// reports no deltas (it only proposes one), a counter the baseline has
// never seen counts from zero, and a counter below its baseline (the core
// restarted) counts from zero as well.
func (x *XrayAPI) GetTrafficSnapshot() (*TrafficSnapshot, error) {
if x.grpcClient == nil {
return nil, common.NewError("xray api is not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
stats, err := x.queryStats(ctx)
if err != nil {
logger.Debug("Failed to query Xray stats:", err)
return nil, err
}
snap := &TrafficSnapshot{Values: make(map[string]int64, len(stats))}
tagTrafficMap := make(map[string]*Traffic)
emailTrafficMap := make(map[string]*ClientTraffic)
for _, stat := range stats {
snap.Values[stat.Name] = stat.Value
if !x.committedReady {
continue
}
lastValue, ok := x.committedStats[stat.Name]
if !ok || stat.Value < lastValue {
lastValue = 0
}
value := stat.Value - lastValue
if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 {
processTraffic(matches, value, tagTrafficMap)
} else if matches := clientTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 3 {
processClientTraffic(matches, value, emailTrafficMap)
}
}
snap.Traffics = mapToSlice(tagTrafficMap)
snap.ClientTraffics = mapToSlice(emailTrafficMap)
return snap, nil
}
// CommitTrafficSnapshot marks a snapshot as acknowledged by the master:
// its Values become the committed baseline that later snapshots diff
// against. Installing only the counters the core currently reports also
// retires baselines for deleted inbounds/clients, the same end state
// GetTraffic's pruning converges to.
func (x *XrayAPI) CommitTrafficSnapshot(s *TrafficSnapshot) {
if s == nil {
return
}
x.committedStats = s.BaselineValues()
x.committedReady = true
}
// RestoreTrafficBaseline installs a committed baseline recovered from
// persistent storage (the agent saves one after every acknowledged
// report), so a restarted agent keeps diffing against the last values the
// master confirmed instead of swallowing the unreported tail into a
// fresh baseline. An empty map leaves the baseline unestablished: the
// next snapshot is the baseline-only first pass, exactly like a first
// run with no state file.
func (x *XrayAPI) RestoreTrafficBaseline(values map[string]int64) {
if len(values) == 0 {
return
}
x.committedStats = make(map[string]int64, len(values))
for k, v := range values {
x.committedStats[k] = v
}
x.committedReady = true
}
// OnlineIP is one source address of a live connection, with the unix time (seconds)
// the core last dispatched a link from it.
type OnlineIP struct {
+225
View File
@@ -0,0 +1,225 @@
package xray
import "testing"
// The snapshot/commit contract is what keeps agent traffic reporting from
// losing deltas when the master is unreachable: a snapshot never advances
// the baseline, only CommitTrafficSnapshot does. These tests pin that
// contract against the scripted stats server, plus the recovery paths
// (baseline restore after an agent restart, counter reset after a core
// restart) and the isolation from GetTraffic's read-advances semantics.
func clientUp(t *testing.T, traffics []*ClientTraffic, email string) (int64, bool) {
t.Helper()
for _, ct := range traffics {
if ct.Email == email {
return ct.Up, true
}
}
return 0, false
}
// TestTrafficSnapshotFirstPassIsBaselineOnly mirrors the GetTraffic rule:
// counters may already hold unattributable traffic when reporting starts,
// so the first snapshot proposes a baseline and reports nothing — and
// committing it is what arms delta reporting.
func TestTrafficSnapshotFirstPassIsBaselineOnly(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{stat("user>>>alice>>>traffic>>>uplink", 5000)},
{stat("user>>>alice>>>traffic>>>uplink", 5300)},
})
snap, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot: %v", err)
}
if len(snap.ClientTraffics) != 0 {
t.Fatalf("first snapshot reported %+v, want baseline only", snap.ClientTraffics[0])
}
if got := snap.Values["user>>>alice>>>traffic>>>uplink"]; got != 5000 {
t.Fatalf("snapshot values = %d, want the raw counter 5000", got)
}
api.CommitTrafficSnapshot(snap)
snap, err = api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot after commit: %v", err)
}
up, ok := clientUp(t, snap.ClientTraffics, "alice")
if !ok || up != 300 {
t.Fatalf("after baseline commit, delta = %d (found %v), want 300", up, ok)
}
}
// TestTrafficSnapshotWithoutCommitReReports is the core of the outage fix:
// while reports fail, the baseline stays put and every later snapshot
// re-includes the unreported traffic, so a recovery report carries the
// whole accumulated amount — exactly once.
func TestTrafficSnapshotWithoutCommitReReports(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{stat("user>>>alice>>>traffic>>>uplink", 100)},
{stat("user>>>alice>>>traffic>>>uplink", 260)},
{stat("user>>>alice>>>traffic>>>uplink", 700)},
{stat("user>>>alice>>>traffic>>>uplink", 750)},
})
base, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (baseline): %v", err)
}
api.CommitTrafficSnapshot(base)
// Two polls whose uploads "fail": no commit happens between them.
snap1, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (failed round 1): %v", err)
}
if up, _ := clientUp(t, snap1.ClientTraffics, "alice"); up != 160 {
t.Fatalf("first unreported delta = %d, want 160", up)
}
snap2, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (failed round 2): %v", err)
}
// Not 440+160 and not just 440: the delta is recomputed from the
// committed baseline, covering both failed rounds in one amount.
if up, _ := clientUp(t, snap2.ClientTraffics, "alice"); up != 600 {
t.Fatalf("accumulated delta after two failed rounds = %d, want 600", up)
}
// The master recovers and acknowledges this report.
api.CommitTrafficSnapshot(snap2)
snap3, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (after recovery): %v", err)
}
if up, _ := clientUp(t, snap3.ClientTraffics, "alice"); up != 50 {
t.Fatalf("delta after recovery commit = %d, want only the new 50", up)
}
}
// TestTrafficSnapshotRestoreBaselineSkipsBaselinePass covers an agent
// restart with a persisted baseline: a fresh XrayAPI with the restored
// values must diff against them immediately — the traffic that accrued
// while the agent was down is reported, not folded into a new baseline.
func TestTrafficSnapshotRestoreBaselineSkipsBaselinePass(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{stat("user>>>alice>>>traffic>>>uplink", 1250)},
})
api.RestoreTrafficBaseline(map[string]int64{
"user>>>alice>>>traffic>>>uplink": 1000,
})
snap, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot: %v", err)
}
up, ok := clientUp(t, snap.ClientTraffics, "alice")
if !ok || up != 250 {
t.Fatalf("restored-baseline delta = %d (found %v), want 250 with no baseline-only pass", up, ok)
}
}
// TestTrafficSnapshotCountsAfterCounterReset: the core restarts and its
// counters start over below the committed baseline. The whole new counter
// is new traffic — never a negative delta — and after committing, normal
// diffing resumes in the new epoch.
func TestTrafficSnapshotCountsAfterCounterReset(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{stat("user>>>alice>>>traffic>>>uplink", 250)},
{stat("user>>>alice>>>traffic>>>uplink", 300)},
})
api.RestoreTrafficBaseline(map[string]int64{
"user>>>alice>>>traffic>>>uplink": 900,
})
snap, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (after reset): %v", err)
}
if up, _ := clientUp(t, snap.ClientTraffics, "alice"); up != 250 {
t.Fatalf("delta after counter reset = %d, want 250 counted from zero", up)
}
api.CommitTrafficSnapshot(snap)
snap, err = api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot (new epoch delta): %v", err)
}
if up, _ := clientUp(t, snap.ClientTraffics, "alice"); up != 50 {
t.Fatalf("delta in new epoch = %d, want 50", up)
}
}
// TestTrafficSnapshotNewStatCountsFromZero: a counter that appears after
// the baseline was committed (client's first use) is entirely new traffic.
func TestTrafficSnapshotNewStatCountsFromZero(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{
stat("user>>>alice>>>traffic>>>uplink", 180),
stat("user>>>bob>>>traffic>>>uplink", 4096),
stat("inbound>>>in-443>>>traffic>>>downlink", 7000),
},
})
api.RestoreTrafficBaseline(map[string]int64{
"user>>>alice>>>traffic>>>uplink": 100,
})
snap, err := api.GetTrafficSnapshot()
if err != nil {
t.Fatalf("GetTrafficSnapshot: %v", err)
}
if up, _ := clientUp(t, snap.ClientTraffics, "alice"); up != 80 {
t.Fatalf("alice delta = %d, want 80", up)
}
if up, _ := clientUp(t, snap.ClientTraffics, "bob"); up != 4096 {
t.Fatalf("bob delta = %d, want his full counter 4096", up)
}
if len(snap.Traffics) != 1 || snap.Traffics[0].Tag != "in-443" || snap.Traffics[0].Down != 7000 {
t.Fatalf("inbound deltas = %+v, want in-443 down 7000 from zero", snap.Traffics)
}
}
// TestSnapshotDoesNotDisturbGetTraffic pins the range constraint: the
// panel path must keep its exact read-advances-baseline semantics even
// when snapshot calls interleave on the same client — the two baselines
// are fully independent.
func TestSnapshotDoesNotDisturbGetTraffic(t *testing.T) {
api := startFakeStats(t, [][]statEntry{
{stat("user>>>alice>>>traffic>>>uplink", 100)},
{stat("user>>>alice>>>traffic>>>uplink", 160)},
{stat("user>>>alice>>>traffic>>>uplink", 400)},
{stat("user>>>alice>>>traffic>>>uplink", 460)},
})
// Panel poll 1: baseline only.
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("GetTraffic (baseline): %v", err)
}
// Agent snapshot in between: must not move the panel's baseline.
if _, err := api.GetTrafficSnapshot(); err != nil {
t.Fatalf("GetTrafficSnapshot: %v", err)
}
// Panel poll 2: sees 400-100=300, including what accrued while the
// snapshot was taken — proof the snapshot consumed nothing.
_, clients, err := api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
if up, _ := clientUp(t, clients, "alice"); up != 300 {
t.Fatalf("panel delta after interleaved snapshot = %d, want 300", up)
}
if got := api.StatsLastValues["user>>>alice>>>traffic>>>uplink"]; got != 400 {
t.Fatalf("panel baseline = %d, want 400 (advanced at read, as before)", got)
}
// Panel poll 3 advances per poll, exactly as it always has.
_, clients, err = api.GetTraffic()
if err != nil {
t.Fatalf("GetTraffic: %v", err)
}
if up, _ := clientUp(t, clients, "alice"); up != 60 {
t.Fatalf("panel delta = %d, want 60", up)
}
}