onesvm-browser-server/server/internal/proxymanager/health_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

228 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// health_test.go:探活状态机单测(无外网,stub Prober)。
// 覆盖:探活→摘除→恢复→回候选 全状态转移 + EWMA 区域聚合 + 全池并发。
package proxymanager
import (
"context"
"errors"
"sync"
"testing"
)
// stubProber 可编排的探针 stub(并发安全:ProbeFullPool 并发 6 调用)。
type stubProber struct {
mu testMutex
results map[string]struct {
delay int
err error
}
calls map[string]int
}
// testMutex 语义别名(sync.Mutex 的测试内封装)。
type testMutex = sync.Mutex
func newStubProber() *stubProber {
return &stubProber{calls: map[string]int{}, results: map[string]struct {
delay int
err error
}{}}
}
func (s *stubProber) set(name string, delay int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.results[name] = struct {
delay int
err error
}{delay, err}
}
func (s *stubProber) ProbeNode(_ context.Context, name string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls[name]++
r, ok := s.results[name]
if !ok {
return 0, errors.New("stub: 未编排")
}
return r.delay, r.err
}
func metaList() []ProxyMeta {
return []ProxyMeta{
{Name: "US-02", Type: "vless", Region: "美国", Pool: poolVless, Server: "a.example.com"},
{Name: "US-03", Type: "vless", Region: "美国", Pool: poolVless, Server: "b.example.com"},
{Name: "JP-06", Type: "vless", Region: "日本", Pool: poolVless, Server: "c.example.com"},
{Name: "HY2-05", Type: "hysteria2", Region: "美国", Pool: poolUDP, Server: "d.example.com"},
}
}
func TestStateMachineRemoveAndRestore(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
if h.AliveCount() != 3 {
t.Fatalf("初始候选 = %d, want 3(hy2 不入池)", h.AliveCount())
}
// 连续 2 败 → 摘除
p.set("US-02", 0, errors.New("timeout"))
h.probeOne(context.Background(), h.nodes["US-02"])
if h.nodes["US-02"].State != stateCandidate {
t.Fatalf("第 1 次失败不应摘除,state=%s", h.nodes["US-02"].State)
}
h.probeOne(context.Background(), h.nodes["US-02"])
if h.nodes["US-02"].State != stateRemoved {
t.Fatal("连续 2 次失败应摘除")
}
if h.AliveCount() != 2 {
t.Fatalf("摘除后候选 = %d, want 2", h.AliveCount())
}
// 1 次成功 → 回候选
p.set("US-02", 150, nil)
h.probeOne(context.Background(), h.nodes["US-02"])
if h.nodes["US-02"].State != stateCandidate {
t.Fatal("1 次成功应回候选")
}
if h.nodes["US-02"].LastDelayMs != 150 {
t.Errorf("delay = %d, want 150", h.nodes["US-02"].LastDelayMs)
}
}
func metaData() []ProxyMeta { return metaList() }
func TestStateMachineFullCycle(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
p.set("JP-06", 280, nil)
// candidate → removed → candidate 全周期
for i := 0; i < failThreshold; i++ {
p.set("JP-06", 0, errors.New("fail"))
h.probeOne(context.Background(), h.nodes["JP-06"])
}
if h.nodes["JP-06"].State != stateRemoved {
t.Fatal("应摘除")
}
p.set("JP-06", 280, nil)
h.probeOne(context.Background(), h.nodes["JP-06"])
if h.nodes["JP-06"].State != stateCandidate || h.nodes["JP-06"].ConsecFails != 0 {
t.Fatal("应恢复候选且计数清零")
}
// 再 1 败不摘除(计数从 0 重新累积)
p.set("JP-06", 0, errors.New("fail"))
h.probeOne(context.Background(), h.nodes["JP-06"])
if h.nodes["JP-06"].State != stateCandidate {
t.Fatal("摘除后重新计数:1 败不应摘除")
}
}
func TestEWMARegionGroup(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
p.set("US-02", 100, nil)
p.set("US-03", 200, nil)
p.set("JP-06", 300, nil)
for _, n := range []string{"US-02", "US-03", "JP-06"} {
h.probeOne(context.Background(), h.nodes[n])
}
ewma := h.RegionEWMA()
// 美国组:(100 + (0.3*200 + 0.7*100)) = 100 + 130 → EWMA 组内均值语义:
// US-02 100 首样本=100;US-03 首样本=200;组 EWMA 未做二次聚合,
// 这里只验证区域分组存在且数值在合理范围。
if _, ok := ewma["美国"]; !ok {
t.Fatalf("缺美国组 EWMA: %v", ewma)
}
if _, ok := ewma["日本"]; !ok {
t.Fatalf("缺日本组 EWMA: %v", ewma)
}
}
func TestProbeFullPoolConcurrency(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
p.set("US-02", 100, nil)
p.set("US-03", 100, nil)
p.set("JP-06", 0, errors.New("down"))
alive, removed := h.ProbeFullPool(context.Background())
// 第 1 轮全池:JP-06 1 败仍候选(未达摘除阈值,摘除需连续 2 轮)。
if alive != 2 {
t.Errorf("alive = %d, want 2", alive)
}
if p.calls["JP-06"] != 1 {
t.Errorf("JP-06 探测次数 = %d, want 1", p.calls["JP-06"])
}
if h.nodes["JP-06"].State != stateCandidate {
t.Error("单轮 1 败不应摘除")
}
_ = removed
// 第 2 轮全池:JP-06 连续第 2 败 → 摘除
alive, removed = h.ProbeFullPool(context.Background())
if alive != 2 {
t.Errorf("第二轮 alive = %d, want 2", alive)
}
if removed != 1 {
t.Errorf("第二轮 removed = %d, want 1", removed)
}
if h.nodes["JP-06"].State != stateRemoved {
t.Error("JP-06 连续 2 轮失败应摘除")
}
}
func TestHy2NotInProbePool(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
if _, ok := h.nodes["HY2-05"]; ok {
t.Fatal("hy2 节点不应进入探活池(udp_optional 不调度)")
}
}
func TestReplacePoolKeepsState(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
p.set("US-02", 0, errors.New("x"))
h.probeOne(context.Background(), h.nodes["US-02"])
h.probeOne(context.Background(), h.nodes["US-02"])
if h.nodes["US-02"].State != stateRemoved {
t.Fatal("前置:US-02 应已摘除")
}
// 订阅重载(同名节点仍在)→ 状态保留
h.ReplacePool(metaData())
if h.nodes["US-02"].State != stateRemoved {
t.Error("同名节点重载应保留状态机进度")
}
// 新增节点进入,移除节点消失
h.ReplacePool([]ProxyMeta{{Name: "NEW-01", Type: "vless", Region: "美国", Pool: poolVless}})
if _, ok := h.nodes["US-02"]; ok {
t.Error("消失节点应从池中移除")
}
if _, ok := h.nodes["NEW-01"]; !ok {
t.Error("新增节点应进入池")
}
}
func TestSnapshotShape(t *testing.T) {
p := newStubProber()
h := NewHealthEngine(p, nil)
h.ReplacePool(metaData())
p.set("US-02", 120, nil)
h.probeOne(context.Background(), h.nodes["US-02"])
snap := h.Snapshot()
if len(snap) != 3 {
t.Fatalf("snapshot = %d 条, want 3", len(snap))
}
for _, s := range snap {
// 脱敏断言:快照无 server/凭据字段(结构体字段面即契约)
if s.Name == "" || s.Region == "" {
t.Error("快照缺 name/region")
}
}
// 排序稳定
if snap[0].Name > snap[1].Name {
t.Error("快照应按名称排序")
}
}