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 观察"有没有拉起"
This commit is contained in:
AskaEth committed 2026-10-26 19:03:12 +08:00
1 parent faf18afb91
commit 67d7e4bd1b
6 files changed
+357 -2

No files matched your search

+22
View File
@@ -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 的配置。
//
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
+150
View File
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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,这一段的判定与记录是同一步)。
@@ -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 {
+27 -2
View File
@@ -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))