diff --git a/cmd/cyrene/voice_tts_launch.go b/cmd/cyrene/voice_tts_launch.go index 25abe1c..c5d2ab2 100644 --- a/cmd/cyrene/voice_tts_launch.go +++ b/cmd/cyrene/voice_tts_launch.go @@ -51,6 +51,15 @@ func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher { return nil } resolved := a.resolveProbeURL(tuning.ProbeURL) + if err := validateResolvedProbeURL(resolved); err != nil { + // 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次 + // 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。 + log.Warn("emotionTTS auto-launch stays off: invalid probe url", + "probe_url", resolved.url, + "probe_url_source", resolved.probeSource, + "err", err) + return nil + } launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log)) cfg := launcher.Config() log.Info("emotionTTS auto-launch enabled", @@ -117,6 +126,19 @@ func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config { return cfg } +// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由 +// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。 +// +// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上 +// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl, +// 还是 voice-tts.json 的 baseUrl)。 +func validateResolvedProbeURL(r probeResolution) error { + if r.url == "" { + return nil + } + return ttslaunch.ValidateProbeURL(r.url) +} + // voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。 // // 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在 diff --git a/cmd/cyrene/voice_tts_launch_guard_test.go b/cmd/cyrene/voice_tts_launch_guard_test.go new file mode 100644 index 0000000..ee2a544 --- /dev/null +++ b/cmd/cyrene/voice_tts_launch_guard_test.go @@ -0,0 +1,150 @@ +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) + } +} diff --git a/internal/runtime/voice/ttslaunch/guard_test.go b/internal/runtime/voice/ttslaunch/guard_test.go new file mode 100644 index 0000000..e20ddf0 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/guard_test.go @@ -0,0 +1,129 @@ +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) + } + } +} diff --git a/internal/runtime/voice/ttslaunch/launcher.go b/internal/runtime/voice/ttslaunch/launcher.go index 651229a..f8d5ca1 100644 --- a/internal/runtime/voice/ttslaunch/launcher.go +++ b/internal/runtime/voice/ttslaunch/launcher.go @@ -20,6 +20,7 @@ package ttslaunch import ( "context" + "errors" "log/slog" "strings" "sync" @@ -85,6 +86,9 @@ const ( OutcomeCooling // OutcomeFailed 表示要拉但没拉起来(脚本不存在/起进程失败),已记 WARN。 OutcomeFailed + // OutcomeCanceled 表示探活被**调用方的 ctx 取消**打断(典型是后端正在关闭): + // 这不是"没起",而是"这次不看了" —— 跳过、不拉起、也不消耗冷却。 + OutcomeCanceled ) // String 让日志与用例输出可读("outcome":"launched")。 @@ -100,6 +104,8 @@ func (o Outcome) String() string { return "cooling" case OutcomeFailed: return "failed" + case OutcomeCanceled: + return "canceled" default: return "unknown" } @@ -190,6 +196,17 @@ func New(cfg Config, opts ...Option) *Launcher { opt(l) } } + // 🔴 探活地址是一道**闸**(复审 P2-2b):写成 `127.0.0.1:9880` 这种漏了 scheme + // 的形状,`http.NewRequest` 每次都失败 ⇒「没起」永远成立 ⇒ **每次启动都拉一次 + // 进程**(只有进程内冷却挡着)。宁可不启用,也不做这种自动重试机器。 + // + // 校验放在这里而不是只放组合根:`voice-tts.json` 的 `baseUrl` 兜底同样要过这道 + // 闸,而那条路也是流进同一个 Config 的 —— 一处校验覆盖两个来源。 + if err := ValidateProbeURL(l.cfg.ProbeURL); err != nil { + l.log.Warn("emotionTTS auto-launch disabled: invalid probe url", + "url", l.cfg.ProbeURL, "err", err) + l.cfg.Enabled = false + } return l } @@ -246,6 +263,17 @@ func (l *Launcher) Check(ctx context.Context) Outcome { l.log.Debug("emotionTTS already listening", "url", l.cfg.ProbeURL) return OutcomeAlreadyRunning } + // 🔴 **调用方取消 ≠ 没起**(复审 P2-2a):后端关闭时 ctx 被取消,探活随之返回 + // `context.Canceled`;若把它当"没起",就会在**关闭过程中拉起 TTS**。这条必须是 + // 跳过 —— 不记 markAttempt、不拉、也不 WARN 成故障(正常关闭不是故障)。 + // + // 判据只用 errors.Is,不按文案:探活**自身超时**是 context.DeadlineExceeded, + // 那支仍然是"没起"(继续往下拉)。 + if errors.Is(probeErr, context.Canceled) { + l.log.Debug("emotionTTS probe canceled by caller, skipping launch", + "url", l.cfg.ProbeURL, "err", probeErr) + return OutcomeCanceled + } l.log.Debug("emotionTTS not listening", "url", l.cfg.ProbeURL, "err", probeErr) // 先记时刻再起进程:两个检查若都走到这里,冷却已经把后来者挡住(配合 // checkMu,这一段的判定与记录是同一步)。 diff --git a/internal/runtime/voice/ttslaunch/launcher_test.go b/internal/runtime/voice/ttslaunch/launcher_test.go index 67cd341..29d1f7a 100644 --- a/internal/runtime/voice/ttslaunch/launcher_test.go +++ b/internal/runtime/voice/ttslaunch/launcher_test.go @@ -251,6 +251,7 @@ func TestOutcome_String(t *testing.T) { OutcomeLaunched: "launched", OutcomeCooling: "cooling", OutcomeFailed: "failed", + OutcomeCanceled: "canceled", } for o, w := range want { if o.String() != w { diff --git a/internal/runtime/voice/ttslaunch/probe.go b/internal/runtime/voice/ttslaunch/probe.go index 7c63834..daa1c1c 100644 --- a/internal/runtime/voice/ttslaunch/probe.go +++ b/internal/runtime/voice/ttslaunch/probe.go @@ -6,6 +6,8 @@ import ( "io" "log/slog" "net/http" + "net/url" + "strings" "time" ) @@ -20,8 +22,11 @@ const probeBodyLimit = 4 << 10 // (404/405/200 都可能),而"端口有人说话"已经足以证明不该再拉第二个实例 —— // 误判成"没起"的代价是去起一个抢同一个端口的进程,代价明显更大。 // -// `Proxy: nil` 是必须的:探的是回环地址,走环境里的 HTTP_PROXY 只会问到代理上, -// 得到"有人在跑"的假结论。 +// `Proxy: nil` 的实际作用范围(复审记录项 1:别高估它):net/http 本来就不给 +// **回环 IP** 走代理,所以探 `127.0.0.1` 时这行可有可无(去掉再设 HTTP_PROXY, +// 用例照样全绿)。它真正保护的是 `probeUrl` 被配成**主机名**(如 +// `http://tts.local:9880`)的情况:那种地址会被环境里的 HTTP_PROXY 接管,从而 +// 得到"有人在跑"的假结论。要覆盖这一点得用主机名 probeUrl 写用例。 func httpProbe(url string, timeout time.Duration) probeFunc { client := &http.Client{ Timeout: timeout, @@ -47,6 +52,26 @@ func httpProbe(url string, timeout time.Duration) probeFunc { } } +// ValidateProbeURL 校验探活地址的形状:必须是**绝对 http(s) URL 且带主机**。 +// +// 为什么值得单独一道闸(复审 P2-2b):探活地址写错(`127.0.0.1:9880` 漏 scheme、 +// 拼错主机)不是"报个错就完了" —— 它会让探活**每次都失败**,于是"没起"永远成立, +// 后端每启动一次就拉一次进程。所以调用方应当把非法地址当作"不启用"。 +func ValidateProbeURL(raw string) error { + trimmed := strings.TrimSpace(raw) + u, err := url.Parse(trimmed) + if err != nil { + return fmt.Errorf("ttslaunch: parse probe url %q: %w", raw, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("ttslaunch: probe url %q is not an absolute http(s) url", raw) + } + if u.Host == "" { + return fmt.Errorf("ttslaunch: probe url %q has no host", raw) + } + return nil +} + // discardLogger 是没注入日志器时的兜底(绝不 nil 解引用)。 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil))