Files
Cyrene_v2/cmd/cyrene/voice_tts_launch_guard_test.go
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

151 lines
5.8 KiB
Go

package main
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
//
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
// 都拉一次进程")。
//
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
// 进行中。
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
//
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
dir := t.TempDir()
blackhole, addr := blackholeListener(t)
defer blackhole.Close()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
"http://"+addr+"/", script))
ctx, cancel := context.WithCancel(context.Background())
launcher := a.startVoiceTTSLaunch(ctx)
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
// 模拟后端关闭:探活还挂在黑洞上时取消。
cancel()
launcher.Wait()
assertNotLaunched(t, marker)
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
//
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
// 拉起(marker 出现),两条断言都会红。
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
t.Fatalf("空地址应交给默认值: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
t.Fatalf("默认地址必须合法: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
t.Fatal("漏 scheme 的地址被放过了")
}
}
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
func markerBat(t *testing.T, dir string) (script, marker string) {
t.Helper()
if runtime.GOOS != "windows" {
return "", ""
}
script = filepath.Join(dir, "一键启动.bat")
marker = filepath.Join(dir, "launched.txt")
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
t.Fatalf("write script: %v", err)
}
return script, marker
}
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
// 返回值;空 marker = 非 Windows,跳过观察)。
func assertNotLaunched(t *testing.T, marker string) {
t.Helper()
if marker == "" {
return
}
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
t.Fatalf("脚本被拉起了: %s", marker)
}
time.Sleep(50 * time.Millisecond)
}
}