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

130 lines
4.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 ttslaunch
// 本文件锁定复审 P2-2 的两条收口(都是"改坏就会静默拉进程"的那种):
//
// 1. **调用方 ctx 取消 ≠ 没起**:后端关闭时探活被打断,必须**跳过**(不拉、不记
// 冷却、不当故障),否则会在关闭过程中拉起 TTS;
// 2. **探活地址必须合法**:漏 scheme / 拼错主机会让"没起"永远成立 ⇒ 每次启动都
// 拉一次进程。非法地址 ⇒ WARN + 不启用,一次都不拉。
import (
"context"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestCheck_ParentCanceled_SkipsLaunch 钉住复审 P2-2a:
// 探活挂在黑洞地址时取消父 ctx ⇒ 探活返回 context.Canceled ⇒ **跳过**。
//
// 变异点:把"取消"改回判"没起" ⇒ spawned 变 1、Outcome 变 launched/failed,本用例红。
func TestCheck_ParentCanceled_SkipsLaunch(t *testing.T) {
var spawned int32
l := New(Config{Enabled: true, ProbeTimeout: time.Second},
WithProbe(func(ctx context.Context) error {
<-ctx.Done()
return fmt.Errorf("ttslaunch: probe: %w", ctx.Err())
}),
WithSpawn(countingSpawn(&spawned)))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan Outcome, 1)
go func() { done <- l.Check(ctx) }()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case got := <-done:
if got != OutcomeCanceled {
t.Fatalf("Outcome = %v, want canceled(关闭期取消不该判成没起)", got)
}
case <-time.After(2 * time.Second):
t.Fatal("取消后 Check 没返回")
}
if n := atomic.LoadInt32(&spawned); n != 0 {
t.Fatalf("关闭期取消仍拉起了 %d 次(want 0)", n)
}
// 取消不是一次"尝试":不该消耗冷却,否则关闭期恰好撞上冷却会让重启后第一次
// 探活被跳过。
if l.cooling() {
t.Fatal("调用方取消被记成了一次拉起尝试(进了冷却)")
}
}
// TestCheck_ProbeOwnTimeout_StillLaunches 钉住与上一条的分界线:**探活自身超时**
// (context.DeadlineExceeded)仍然是"没起",要照常拉。
//
// 变异点:把"任何 ctx 错误都跳过"写进去 ⇒ 本用例红(变成 canceled、spawned=0)。
func TestCheck_ProbeOwnTimeout_StillLaunches(t *testing.T) {
var spawned int32
l := New(Config{Enabled: true},
WithProbe(func(context.Context) error {
return fmt.Errorf("ttslaunch: probe: %w", context.DeadlineExceeded)
}),
WithSpawn(countingSpawn(&spawned)))
if got := l.Check(context.Background()); got != OutcomeLaunched {
t.Fatalf("Outcome = %v, want launched(探活自身超时 = 没起)", got)
}
if n := atomic.LoadInt32(&spawned); n != 1 {
t.Fatalf("拉起 %d 次, want 1", n)
}
}
// TestNew_InvalidProbeURL_Disables 钉住复审 P2-2b:非法探活地址 ⇒ WARN + **不启用**,
// 一次探活都不发、一次都不拉。
//
// 变异点:去掉 New 里的 ValidateProbeURL ⇒ 这些地址下 Trigger 会接单(最终每轮
// 都去拉进程),本用例红。
func TestNew_InvalidProbeURL_Disables(t *testing.T) {
cases := []string{
"127.0.0.1:9880", // 漏 scheme(url.Parse 把 127.0.0.1 当 scheme)
"localhost:9880", // 同上
"//127.0.0.1:9880", // 协议相对,没有 scheme
"http://", // 有 scheme 没主机
"ftp://127.0.0.1:9880", // scheme 不对
"http://tts local:9880", // 空格
"http://[::1", // 未闭合方括号
}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
var spawned int32
log, buf := captureLogger()
l := New(Config{Enabled: true, ProbeURL: raw}, WithLogger(log),
WithProbe(func(context.Context) error {
t.Error("非法地址不该再发探活")
return nil
}),
WithSpawn(countingSpawn(&spawned)))
if l.Trigger(context.Background()) {
t.Fatal("非法 probeUrl 仍接单了(会每次启动都拉一次进程)")
}
if got := l.Check(context.Background()); got != OutcomeDisabled {
t.Fatalf("Check = %v, want disabled", got)
}
l.Wait()
if n := atomic.LoadInt32(&spawned); n != 0 {
t.Fatalf("非法地址却拉起了 %d 次", n)
}
if out := buf.String(); !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法地址没记 WARN: %s", out)
}
})
}
}
// TestNew_ValidProbeURL_StaysEnabled 是对照组:合法地址(含主机名与 https)必须
// 照常启用 —— 免得把校验写成"一律不启用"。
func TestNew_ValidProbeURL_StaysEnabled(t *testing.T) {
for _, raw := range []string{"http://127.0.0.1:9880", "https://tts.local:9880/health"} {
l := New(Config{Enabled: true, ProbeURL: raw},
WithProbe(func(context.Context) error { return nil }))
if got := l.Check(context.Background()); got != OutcomeAlreadyRunning {
t.Fatalf("%s: Outcome = %v, want already_running", raw, got)
}
}
}