复审 p2-2items(P2-1 跨进程去重按裁决只报不修):
(a) **关闭期 ctx 取消 ≠ 没起**:后端关闭时 ctx 被取消,探活返回
`context.Canceled`;原实现把它判成"没起" ⇒ **在关闭过程中拉起 TTS**。
现在按 `errors.Is(err, context.Canceled)` 判为跳过:不记 markAttempt(不消耗
冷却)、不拉、也不 WARN 成故障。探活**自身超时**(DeadlineExceeded)仍是"没起",
照常拉 —— 两者用 errors.Is 区分,不按文案。新增 OutcomeCanceled。
(b) **探活地址必须合法**:写成 `127.0.0.1:9880`(漏 scheme)会让 http.NewRequest
每次都失败 ⇒「没起」永远成立 ⇒ 每次启动都拉一次进程。新增
`ttslaunch.ValidateProbeURL`(url.Parse + scheme ∈ {http,https} + host 非空):
- `New` 里校验 ⇒ 不合法即 WARN + 不启用(兜底闸,覆盖任何调用方);
- 组合根 `validateResolvedProbeURL` 里同一把闸再校验一次,WARN 带
`probe_url_source` 指明该改哪个文件 —— **baseUrl 兜底那条路也被覆盖**。
顺带按复审记录项 1 修正 `Proxy: nil` 的注释:net/http 本来就不给回环 IP 走代理,
这行真正保护的是 probeUrl 被配成主机名的情况(原注释高估了它)。
用例(均变异验证打红):
- TestCheck_ParentCanceled_SkipsLaunch:取消 ⇒ OutcomeCanceled + 0 次拉起 + 不进冷却
(变异:跳过改回"没起" ⇒ Outcome=launched、marker 落地)
- TestCheck_ProbeOwnTimeout_StillLaunches:探活自身超时仍照常拉
- TestNew_InvalidProbeURL_Disables:7 种非法形状 ⇒ 不接单、0 次拉起、有 WARN
- TestStartVoiceTTSLaunch_{ShutdownCancelDoesNotLaunch,InvalidProbeURLStaysOff,
InvalidBaseURLFallbackStaysOff}:组合根三条,均用真 .bat marker 观察"有没有拉起"
262 lines
8.4 KiB
Go
262 lines
8.4 KiB
Go
package ttslaunch
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// errDown 是"探活判定没起"的假错误(只要非 nil,口径就是"没起")。
|
||
var errDown = errors.New("probe: connection refused")
|
||
|
||
// syncBuf 是并发安全的日志缓冲(日志由后台 goroutine 写,用例稍后读)。
|
||
type syncBuf struct {
|
||
mu sync.Mutex
|
||
buf bytes.Buffer
|
||
}
|
||
|
||
func (b *syncBuf) Write(p []byte) (int, error) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.buf.Write(p)
|
||
}
|
||
|
||
func (b *syncBuf) String() string {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.buf.String()
|
||
}
|
||
|
||
// captureLogger 返回一个把 DEBUG 也收下的日志器 + 它的缓冲。
|
||
func captureLogger() (*slog.Logger, *syncBuf) {
|
||
buf := &syncBuf{}
|
||
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||
}
|
||
|
||
// 用例里的"已在跑/没起"都是注入的判据,因此不碰网络也不起进程。
|
||
|
||
// countingSpawn 返回一个"只数次数"的 spawn 实现。
|
||
func countingSpawn(n *int32) spawnFunc {
|
||
return func(context.Context) error {
|
||
atomic.AddInt32(n, 1)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// TestCheck_AlreadyRunning_NeverLaunches 钉住「已在跑 ⇒ 什么都不做」。
|
||
//
|
||
// 变异点:只要探活成功仍去 spawn(例如把探活结果当"警告"继续走),本用例立刻红。
|
||
func TestCheck_AlreadyRunning_NeverLaunches(t *testing.T) {
|
||
var spawned int32
|
||
l := New(Config{Enabled: true},
|
||
WithProbe(func(context.Context) error { return nil }),
|
||
WithSpawn(countingSpawn(&spawned)))
|
||
|
||
if got := l.Check(context.Background()); got != OutcomeAlreadyRunning {
|
||
t.Fatalf("Outcome = %v, want already_running", got)
|
||
}
|
||
if got := atomic.LoadInt32(&spawned); got != 0 {
|
||
t.Fatalf("已在跑仍拉起了 %d 次", got)
|
||
}
|
||
}
|
||
|
||
// TestCheck_NotRunning_LaunchesOnce 钉住「没在跑 ⇒ 拉一次」。
|
||
func TestCheck_NotRunning_LaunchesOnce(t *testing.T) {
|
||
var spawned int32
|
||
l := New(Config{Enabled: true},
|
||
WithProbe(func(context.Context) error { return errDown }),
|
||
WithSpawn(countingSpawn(&spawned)))
|
||
|
||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||
t.Fatalf("Outcome = %v, want launched", got)
|
||
}
|
||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||
t.Fatalf("拉起 %d 次, want 1", got)
|
||
}
|
||
}
|
||
|
||
// TestTrigger_ConcurrentTriggers_LaunchOnce 钉住「同进程内并发触发只拉一个」。
|
||
//
|
||
// 探活被卡住(release 之前不返回)以拉宽竞争窗口:10 个并发 Trigger 里应当
|
||
// 只有**一个**接到单,最终只 spawn 一次。
|
||
//
|
||
// 变异点:去掉 inflight 守卫 ⇒ accepted 变成 10;去掉冷却/checkMu ⇒ spawned 可能 >1。
|
||
func TestTrigger_ConcurrentTriggers_LaunchOnce(t *testing.T) {
|
||
var spawned, accepted int32
|
||
release := make(chan struct{})
|
||
l := New(Config{Enabled: true},
|
||
WithProbe(func(context.Context) error {
|
||
<-release // 探活挂住,让 10 个 Trigger 都碰到 inflight 窗口
|
||
return errDown
|
||
}),
|
||
WithSpawn(countingSpawn(&spawned)))
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 10; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if l.Trigger(context.Background()) {
|
||
atomic.AddInt32(&accepted, 1)
|
||
}
|
||
}()
|
||
}
|
||
// 给并发触发足够时间撞上 inflight;这一步只影响用例的"严格度",不影响结论。
|
||
time.Sleep(100 * time.Millisecond)
|
||
close(release)
|
||
wg.Wait()
|
||
l.Wait()
|
||
|
||
if got := atomic.LoadInt32(&accepted); got != 1 {
|
||
t.Fatalf("接单 %d 次, want 1(并发触发只该有一个派后台任务)", got)
|
||
}
|
||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||
t.Fatalf("拉起 %d 次, want 1", got)
|
||
}
|
||
}
|
||
|
||
// TestCheck_CooldownSuppressesRepeat 钉住「一次尝试之后的冷却」:后端重启风暴里
|
||
// 不会每次都去拉一个正在启动中的 TTS。
|
||
func TestCheck_CooldownSuppressesRepeat(t *testing.T) {
|
||
var spawned int32
|
||
now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
|
||
clock := func() time.Time { return now }
|
||
l := New(Config{Enabled: true, Cooldown: time.Minute},
|
||
WithProbe(func(context.Context) error { return errDown }),
|
||
WithSpawn(countingSpawn(&spawned)),
|
||
WithClock(clock))
|
||
|
||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||
t.Fatalf("第一次: Outcome = %v, want launched", got)
|
||
}
|
||
now = now.Add(30 * time.Second)
|
||
if got := l.Check(context.Background()); got != OutcomeCooling {
|
||
t.Fatalf("冷却期内: Outcome = %v, want cooling", got)
|
||
}
|
||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||
t.Fatalf("冷却期内又拉起: %d 次", got)
|
||
}
|
||
now = now.Add(time.Minute)
|
||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||
t.Fatalf("冷却过后: Outcome = %v, want launched", got)
|
||
}
|
||
if got := atomic.LoadInt32(&spawned); got != 2 {
|
||
t.Fatalf("冷却过后拉起 %d 次, want 2", got)
|
||
}
|
||
}
|
||
|
||
// TestTrigger_MissingScript_WarnsAndNeverPropagates 钉住「拉不起来时后端启动不受
|
||
// 影响」:脚本不存在 ⇒ 一条带 source-ish 字段的 WARN,**没有** error 通道、没有
|
||
// panic,且这次失败同样进入冷却(重启风暴不会变成重试风暴)。
|
||
func TestTrigger_MissingScript_WarnsAndNeverPropagates(t *testing.T) {
|
||
log, buf := captureLogger()
|
||
missing := filepath.Join(t.TempDir(), "不存在的一键启动.bat")
|
||
l := New(Config{Enabled: true, ScriptPath: missing}, WithLogger(log),
|
||
// 探活也注入:本用例要验的是"拉的失败",不能因为本机真跑着 TTS 就走成
|
||
// already_running。
|
||
WithProbe(func(context.Context) error { return errDown }))
|
||
|
||
if !l.Trigger(context.Background()) {
|
||
t.Fatal("启用时 Trigger 应接单")
|
||
}
|
||
l.Wait()
|
||
|
||
out := buf.String()
|
||
if !strings.Contains(out, "level=WARN") {
|
||
t.Fatalf("拉不起来没记 WARN: %s", out)
|
||
}
|
||
if !strings.Contains(out, "backend startup unaffected") {
|
||
t.Fatalf("WARN 没说明后端启动不受影响: %s", out)
|
||
}
|
||
if got := l.Check(context.Background()); got != OutcomeCooling {
|
||
t.Fatalf("失败之后仍不冷却: Outcome = %v", got)
|
||
}
|
||
}
|
||
|
||
// TestTrigger_ReturnsImmediately 钉住「探活/拉起不占调用方时长」:探活要 1 秒,
|
||
// Trigger 必须在毫秒级返回(同步实现会红)。
|
||
func TestTrigger_ReturnsImmediately(t *testing.T) {
|
||
l := New(Config{Enabled: true},
|
||
WithProbe(func(context.Context) error {
|
||
time.Sleep(1 * time.Second)
|
||
return errDown
|
||
}),
|
||
WithSpawn(func(context.Context) error { return nil }))
|
||
|
||
start := time.Now()
|
||
if !l.Trigger(context.Background()) {
|
||
t.Fatal("启用时 Trigger 应接单")
|
||
}
|
||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||
t.Fatalf("Trigger 阻塞了 %v(应在毫秒级返回)", elapsed)
|
||
}
|
||
l.Wait()
|
||
}
|
||
|
||
// TestTrigger_Disabled_DoesNothing 钉住「默认关」:一次探活都不发、一次都不拉。
|
||
func TestTrigger_Disabled_DoesNothing(t *testing.T) {
|
||
var spawned int32
|
||
l := New(Config{}, // 零值 = 默认关
|
||
WithProbe(func(context.Context) error {
|
||
t.Error("未启用时不该探活")
|
||
return nil
|
||
}),
|
||
WithSpawn(countingSpawn(&spawned)))
|
||
|
||
if l.Trigger(context.Background()) {
|
||
t.Fatal("未启用时 Trigger 不该接单")
|
||
}
|
||
l.Wait()
|
||
if got := atomic.LoadInt32(&spawned); got != 0 {
|
||
t.Fatalf("未启用却拉起了 %d 次", got)
|
||
}
|
||
if got := l.Check(context.Background()); got != OutcomeDisabled {
|
||
t.Fatalf("Check = %v, want disabled", got)
|
||
}
|
||
}
|
||
|
||
// TestConfig_Normalized 钉住缺省值的唯一裁决处:零值 ⇒ 主人的脚本 + 回环地址 +
|
||
// 毫秒级探活 + 一段冷却。
|
||
func TestConfig_Normalized(t *testing.T) {
|
||
got := Config{}.Normalized()
|
||
if got.Enabled {
|
||
t.Fatal("零值必须默认关")
|
||
}
|
||
if got.ScriptPath != DefaultScriptPath {
|
||
t.Fatalf("ScriptPath = %q", got.ScriptPath)
|
||
}
|
||
if got.ProbeURL != DefaultProbeURL {
|
||
t.Fatalf("ProbeURL = %q", got.ProbeURL)
|
||
}
|
||
if got.ProbeTimeout != DefaultProbeTimeout || got.Cooldown != DefaultCooldown {
|
||
t.Fatalf("超时/冷却没补默认值: %+v", got)
|
||
}
|
||
if !strings.Contains(DefaultScriptPath, "一键启动.bat") {
|
||
t.Fatalf("默认脚本路径不对: %q", DefaultScriptPath)
|
||
}
|
||
}
|
||
|
||
// TestOutcome_String 让日志读得懂(写错就是"日志说 cooling 实际在拉")。
|
||
func TestOutcome_String(t *testing.T) {
|
||
want := map[Outcome]string{
|
||
OutcomeDisabled: "disabled",
|
||
OutcomeAlreadyRunning: "already_running",
|
||
OutcomeLaunched: "launched",
|
||
OutcomeCooling: "cooling",
|
||
OutcomeFailed: "failed",
|
||
OutcomeCanceled: "canceled",
|
||
}
|
||
for o, w := range want {
|
||
if o.String() != w {
|
||
t.Fatalf("%d.String() = %q, want %q", int(o), o.String(), w)
|
||
}
|
||
}
|
||
}
|