Files
nyoung ed51efd8bc fix: 网关与控制台代码审查修复批次(含回归测试)
- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误
- Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐
- TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配
- metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理
- GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示,
  不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚
- set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、
  限流或求解开关按零值清掉
- 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限
- main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部
  挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray
- proxypool / turnstile / upstream / openai / config / httpx / logbuf 等
  逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
2026-10-08 15:13:23 +08:00

116 lines
2.7 KiB
Go
Raw Permalink 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.
package proxypool
import (
"context"
"os"
"os/exec"
"sync"
"testing"
"time"
"github.com/nyoungo/dsfree2api/internal/config"
)
// 回归:Status() 必须在持锁状态读取 endpoint 字段。检查循环/Report 会在
// m.mu 下写 ep.healthy/lastErr/latencyMs,锁外读它们就是数据竞争
// (go test -race 会直接报 DATA RACE,线上是控制台刷新时的随机脏数据)。
func TestStatusDoesNotRaceWithEndpointUpdates(t *testing.T) {
cfg, err := config.Load(examplePath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.ProxyPool.Enabled = true
cfg.ProxyPool.Entries = map[string]*config.ProxyEntry{
"a": {Link: "http://127.0.0.1:9", Enabled: true},
"b": {Link: "socks5://127.0.0.1:10", Enabled: true},
}
m := New(cfg, t.TempDir(), nil)
m.Reload()
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
// 模拟检查循环:只在锁内写 endpoint 字段
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
m.mu.Lock()
for _, ep := range m.endpoints {
ep.checked = true
ep.healthy = !ep.healthy
ep.latencyMs++
ep.lastErr = "probe failed"
ep.checkedAt = time.Now()
}
m.mu.Unlock()
}
}()
for i := 0; i < 300; i++ {
st := m.Status()
if len(st.Entries) != 2 {
close(stop)
wg.Wait()
t.Fatalf("entries = %d, want 2", len(st.Entries))
}
}
close(stop)
wg.Wait()
}
// 供 TestCloseKillsXrayChild 拉起的子进程桩:当成 xray 起着,等着被杀。
func TestXrayChildStub(t *testing.T) {
if os.Getenv("DSFREE_XRAY_CHILD") != "1" {
t.Skip("只给 Close 回归测试当子进程用")
}
time.Sleep(time.Hour)
}
// 回归:Close 必须同步杀掉 xray 子进程并等后台循环退出。进程退出时
// Windows/Linux 都不会替父进程带走子进程,漏杀就是孤儿 xray。
func TestCloseKillsXrayChild(t *testing.T) {
cfg, err := config.Load(examplePath)
if err != nil {
t.Fatalf("load config: %v", err)
}
m := New(cfg, t.TempDir(), nil)
m.Start(context.Background())
cmd := exec.Command(os.Args[0], "-test.run=^TestXrayChildStub$")
cmd.Env = append(os.Environ(), "DSFREE_XRAY_CHILD=1")
if err := cmd.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
exited := make(chan struct{})
go func() {
_ = cmd.Wait()
close(exited)
}()
m.xray.mu.Lock()
m.xray.proc = cmd
m.xray.exited = exited
m.xray.running = true
m.xray.pid = cmd.Process.Pid
m.xray.mu.Unlock()
m.Close()
select {
case <-exited:
case <-time.After(5 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("Close 没有回收 xray 子进程")
}
if st := m.xray.status(); st.Running || st.PID != 0 {
t.Fatalf("xray 状态没清干净: %+v", st)
}
// 幂等:重复 Close 不能 panic 也不能卡死
m.Close()
}