Files
AskaEth 67d7e4bd1b fix(voice): M47 r2 复审两条 P2 —— 关闭期取消不拉起、非法探活地址不启用
复审 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 观察"有没有拉起"
2026-10-26 19:03:12 +08:00

262 lines
8.4 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 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)
}
}
}