diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go index ffa8563..a47ff00 100644 --- a/cmd/cyrene/main.go +++ b/cmd/cyrene/main.go @@ -125,6 +125,13 @@ func run() error { return fmt.Errorf("start gateway: %w", err) } + // emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的 + // ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」, + // 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到 + // 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json), + // 失败只 WARN,绝不影响后端启动与退出。 + a.startVoiceTTSLaunch(ctx) + select { case <-ctx.Done(): a.log.Info("cyrene signal received, shutting down") diff --git a/cmd/cyrene/voice_tts_launch.go b/cmd/cyrene/voice_tts_launch.go new file mode 100644 index 0000000..c5d2ab2 --- /dev/null +++ b/cmd/cyrene/voice_tts_launch.go @@ -0,0 +1,154 @@ +package main + +// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条 +// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释): +// +// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写 +// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 —— +// 主人早前说过「开机自启先不急 后面做」。 +// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即 +// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都 +// 落不到后端的启动路径上。 +// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志, +// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。 +// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件 +// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者 +// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。 +// +// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族 +// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。 + +import ( + "context" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch" +) + +// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。 +const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch" + +// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。 +// +// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都 +// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。 +func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher { + log := logger.WithSource(a.log, voiceTTSLaunchSource) + path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg)) + tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path) + if err != nil { + // 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。 + log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off", + "path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err) + return nil + } + if !tuning.EnabledOn() { + log.Debug("emotionTTS auto-launch not enabled in panel config", + "path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`) + 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", + "probe_url", cfg.ProbeURL, + // 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后 + // 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。 + "probe_url_source", resolved.probeSource, + "script", cfg.ScriptPath, + "probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(), + "cooldown_sec", int(cfg.Cooldown/time.Second), + "config", runtimeconfig.VoiceTTSLaunchConfigRelPath) + // 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。 + launcher.Trigger(ctx) + return launcher +} + +// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。 +type probeResolution struct { + url string + probeSource string +} + +// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。 +const ( + probeSourceLaunchConfig = "voice-tts-launch.json" + probeSourceVoiceTTS = "voice-tts.json" + probeSourceDefault = "default" +) + +// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生: +// +// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过); +// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改 +// 一处;这正是"两处一起改"的 footgun 的收口); +// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized` +// 补默认 `http://127.0.0.1:9880`。 +// +// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它 +// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。 +func (a *app) resolveProbeURL(explicit string) probeResolution { + if url := strings.TrimSpace(explicit); url != "" { + return probeResolution{url: url, probeSource: probeSourceLaunchConfig} + } + log := logger.WithSource(a.log, voiceTTSLaunchSource) + tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))) + if err != nil { + // 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的 + // 事),并且必须说清楚"我没读到 baseUrl"。 + log.Warn("voice tts config unreadable, probe url falls back to default", + "path", runtimeconfig.VoiceTTSConfigRelPath, "err", err) + return probeResolution{probeSource: probeSourceDefault} + } + if base := strings.TrimSpace(tuning.BaseURL); base != "" { + log.Debug("probe url inherited from voice tts config", + "path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base) + return probeResolution{url: base, probeSource: probeSourceVoiceTTS} + } + return probeResolution{probeSource: probeSourceDefault} +} + +// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。 +func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config { + cfg.ProbeURL = r.url + 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 的配置。 +// +// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在 +// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。 +func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config { + return ttslaunch.Config{ + Enabled: t.EnabledOn(), + ScriptPath: t.ScriptPath, + ProbeURL: t.ProbeURL, + ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond, + Cooldown: time.Duration(t.CooldownSec) * time.Second, + } +} 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/cmd/cyrene/voice_tts_launch_test.go b/cmd/cyrene/voice_tts_launch_test.go new file mode 100644 index 0000000..e979304 --- /dev/null +++ b/cmd/cyrene/voice_tts_launch_test.go @@ -0,0 +1,288 @@ +package main + +// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种): +// +// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发; +// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 —— +// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量); +// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。 +// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl` +// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。 +// 启动 Info 行必须同时给出地址与来源。 +// +// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点 +// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。 + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch" +) + +// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。 +type captureBuf struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *captureBuf) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *captureBuf) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。 +func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) { + t.Helper() + buf := &captureBuf{} + log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf +} + +// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。 +func writeLaunchPanel(t *testing.T, dataDir, body string) { + t.Helper() + path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write panel: %v", err) + } +} + +// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。 +func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) { + a, buf := launchTestApp(t, t.TempDir()) + start := time.Now() + if l := a.startVoiceTTSLaunch(context.Background()); l != nil { + t.Fatal("没配过却启用了自启") + } + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("默认关也花了 %v", elapsed) + } + if !strings.Contains(buf.String(), "not enabled") { + t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String()) + } +} + +// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。 +func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) { + dir := t.TempDir() + writeLaunchPanel(t, dir, `{"enabled":false}`) + a, _ := launchTestApp(t, dir) + if l := a.startVoiceTTSLaunch(context.Background()); l != nil { + t.Fatal("enabled:false 却启用了自启") + } +} + +// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明: +// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。 +// +// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己 +// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。 +func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) { + dir := t.TempDir() + blackhole, addr := blackholeListener(t) + defer blackhole.Close() + + writeLaunchPanel(t, dir, fmt.Sprintf( + `{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`, + "http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat"))) + a, buf := launchTestApp(t, dir) + + start := time.Now() + launcher := a.startVoiceTTSLaunch(context.Background()) + elapsed := time.Since(start) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if elapsed > 150*time.Millisecond { + t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed) + } + // 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。 + launcher.Wait() + out := buf.String() + if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") { + t.Fatalf("拉起失败没有只记 WARN: %s", out) + } +} + +// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射: +// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。 +func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) { + enabled := true + cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{ + Enabled: &enabled, + ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`, + ProbeURL: "http://127.0.0.1:9880", + ProbeTimeoutMs: 250, + CooldownSec: 90, + }) + if !cfg.Enabled { + t.Fatal("enabled 没映射过来") + } + if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second { + t.Fatalf("超时/冷却没映射: %+v", cfg) + } + if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL { + t.Fatalf("路径/地址没映射: %+v", cfg) + } + + // 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。 + if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled { + t.Fatal("空配置不该启用") + } else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 { + t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty) + } +} + +// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的 +// 超时,正好用来证明挂载动作没有同步等它。 +func blackholeListener(t *testing.T) (net.Listener, string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + var conns []net.Conn + var mu sync.Mutex + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + mu.Lock() + conns = append(conns, c) + mu.Unlock() + } + }() + t.Cleanup(func() { + mu.Lock() + defer mu.Unlock() + for _, c := range conns { + _ = c.Close() + } + }) + return ln, ln.Addr().String() +} + +// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向: +// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该 +// 改一处)。 +// +// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去 +// 默认 9880,本用例立刻红。 +func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) { + dir := t.TempDir() + voiceTTS, hits := countingServer(t) + a, buf := launchTestApp(t, dir) + + // 合成面把端口改成了自定义端口;自启面**没写** probeUrl。 + writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL)) + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, + filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != voiceTTS.URL { + t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL) + } + launcher.Wait() + if n := atomic.LoadInt32(hits); n == 0 { + t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)") + } + if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") { + t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out) + } +} + +// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给 +// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。 +func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) { + dir := t.TempDir() + a, buf := launchTestApp(t, dir) + // 只有自启文件,没有 voice-tts.json。 + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, + filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL { + t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL) + } + launcher.Wait() + if out := buf.String(); !strings.Contains(out, "probe_url_source=default") { + t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out) + } +} + +// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了 +// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。 +func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) { + dir := t.TempDir() + explicit, explicitHits := countingServer(t) + inherited, inheritedHits := countingServer(t) + a, buf := launchTestApp(t, dir) + + writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL)) + writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`, + explicit.URL, filepath.Join(dir, "不存在的一键启动.bat"))) + + launcher := a.startVoiceTTSLaunch(context.Background()) + if launcher == nil { + t.Fatal("启用后应拿到 launcher") + } + if got := launcher.Config().ProbeURL; got != explicit.URL { + t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL) + } + launcher.Wait() + if atomic.LoadInt32(explicitHits) == 0 { + t.Fatal("显式 probeUrl 没被用来探活") + } + if n := atomic.LoadInt32(inheritedHits); n != 0 { + t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n) + } + if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") { + t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out) + } +} + +// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了** +// 某个地址,而不只是配置里写着那个字符串。 +func countingServer(t *testing.T) (*httptest.Server, *int32) { + t.Helper() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv, &hits +} diff --git a/internal/config/runtimeconfig/voice_tts_launch.go b/internal/config/runtimeconfig/voice_tts_launch.go new file mode 100644 index 0000000..6dc915c --- /dev/null +++ b/internal/config/runtimeconfig/voice_tts_launch.go @@ -0,0 +1,123 @@ +// voice_tts_launch.go 是 **emotionTTS 自启**(切片 M47)的可变源: +// `$DATA_DIR/config/voice-tts-launch.json`(与 `voice-tts.json` / `backthink.json` +// 同一家族口径,design/13 §2.1:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/` + +// 代码默认值;`.env` 已冻结,**零新 env 键**)。 +// +// 为什么需要它:主人 2026-10-24 逐字「tts启动脚本在这 +// `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,以后启动后端可以顺便检测 +// 一下如果TTS没起就顺带启一下」。这是**一条运维便利**,不是后端的一部分: +// +// - **默认关**(nil / false 都等于不启用)。主人早前说过「开机自启先不急 后面做」 +// ⇒ 未显式打开时,后端连一次探活都不发,更不会拉起任何进程。 +// - 拉起动作整段在后台 goroutine 里(见 `internal/runtime/voice/ttslaunch`), +// **不占**后端启动时长;失败只 WARN,绝不阻塞或改坏后端启动路径。 +// +// 与 `voice-tts.json` 分开而不是塞进同一份文件:那一份管「合成怎么调」(地址/ +// 角色映射/超时),这一份管「服务没起时要不要顺手拉一下」。两件事的失败语义、 +// 默认值、被谁读都不同(合成面读一次决定注入与否;本文件读一次决定是否起进程), +// 合在一份会让"只想开合成"的人被迫接触进程拉起这个更重的开关。 +// +// 边界(与 voicetts.go 同源的手续): +// +// 1. 本文件只管**文件形状 + 读取**:不认识 `ttslaunch.Config`,也不裁决"缺省填 +// 什么值"(那是 `ttslaunch.Config.Normalized` 的事)。 +// 2. **没有"面板编辑"的写口**:改内容仍要人手改文件 + 重启(启动期读一次)。 +// 3. **零新 env 键**,也不进面板的配置事实卡。 +// +// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开 +// = 返回分类错误,调用方记 WARN 并**回落"不启用"**(一份读不懂的配置绝不能变成 +// 「随手拉起进程」)。 +package runtimeconfig + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // voiceTTSLaunchFileName 是文件名(家族命名 = 模块名 + .json)。 + voiceTTSLaunchFileName = "voice-tts-launch.json" + // voiceTTSLaunchConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。 + voiceTTSLaunchConfigSubdir = "config" +) + +// VoiceTTSLaunchConfigRelPath 是落点相对 `$DATA_DIR` 的路径 +// (`config/voice-tts-launch.json`)。导出它是为了让日志/文档能**如实**告诉主人 +// 改哪个文件。 +const VoiceTTSLaunchConfigRelPath = voiceTTSLaunchConfigSubdir + "/" + voiceTTSLaunchFileName + +// ErrInvalidVoiceTTSLaunch 是 voice-tts-launch.json 结构不合法时的分类错误 +// (errors.Is 可判类)。 +var ErrInvalidVoiceTTSLaunch = errors.New("runtimeconfig: invalid voice tts launch config") + +// VoiceTTSLaunchTuning 是 `voice-tts-launch.json` 的形状:启动期「探一下、没起 +// 就顺手拉起」的几个旋钮。 +// +// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。 +type VoiceTTSLaunchTuning struct { + // Enabled 控制后端启动时是否顺带探活/拉起 emotionTTS。nil = 没配 + // (**不启用**);true = 启用;false = 显式关掉(与"没配"同效,写它只是为了 + // 自解释)。 + // + // 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认 + // 语义是**关**(它会**起进程**,是比"调个超时"重得多的副作用,不能靠"猜"打开)。 + Enabled *bool `json:"enabled"` + // ScriptPath 是拉起用的启动脚本(主人的 `一键启动.bat`)。 + // 空 = 代码默认(`ttslaunch.DefaultScriptPath`)。 + // + // 脚本不存在只 WARN(这台机器上没装 emotionTTS 是正常形态),不是错误。 + ScriptPath string `json:"scriptPath,omitempty"` + // ProbeURL 是探活地址。空 = 代码默认 `http://127.0.0.1:9880`。 + // + // 它回答的问题是「这个端口上已经有人在应答吗」——**任何** HTTP 回应都算在跑 + // (见 ttslaunch 的探活口径),因此填一个别的服务的地址只会让它永远"已在跑"。 + ProbeURL string `json:"probeUrl,omitempty"` + // ProbeTimeoutMs 是单次探活超时(**毫秒**)。零/负 = 代码默认。 + // + // 它决定的是「等多久才认定没起」:这是启动**之后**的后台动作,但过大的值只会让 + // "没装 TTS"的机器白等,过小则可能把"正在初始化"误判成"没起"。 + ProbeTimeoutMs int `json:"probeTimeoutMs,omitempty"` + // CooldownSec 是**一次拉起尝试之后的冷却**(**秒**)。零/负 = 代码默认。 + // + // 它的用途是防「后端重启风暴」:主人连点重启时,每次都去拉一个已在启动中的 + // TTS 只会堆出多个抢同一个端口的进程。 + CooldownSec int `json:"cooldownSec,omitempty"` +} + +// EnabledOn 报告自启是否被显式打开(nil = 没配 ⇒ 不启用)。 +func (t VoiceTTSLaunchTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled } + +// DefaultVoiceTTSLaunchPath 返回落点:`$DATA_DIR/config/voice-tts-launch.json`。 +func DefaultVoiceTTSLaunchPath(dataDir string) string { + return filepath.Join(dataDir, voiceTTSLaunchConfigSubdir, voiceTTSLaunchFileName) +} + +// LoadVoiceTTSLaunchTuning 读取 voice-tts-launch.json(**只读**)。 +// +// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"没配过(不启用)",**不是**错误 +// —— 绝大多数机器不装 emotionTTS,不该每次启动都刷 WARN。 +// +// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTSLaunch 的错误。调用方应记一条带 +// source 的 WARN 并**回落"不启用"**;**绝不得覆盖**那个文件(人工救得回来, +// 程序盖掉就丢了人写的意图)。 +func LoadVoiceTTSLaunchTuning(path string) (VoiceTTSLaunchTuning, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return VoiceTTSLaunchTuning{}, nil + } + return VoiceTTSLaunchTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err) + } + if strings.TrimSpace(string(raw)) == "" { + return VoiceTTSLaunchTuning{}, nil + } + var t VoiceTTSLaunchTuning + if err := json.Unmarshal(raw, &t); err != nil { + return VoiceTTSLaunchTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTSLaunch, path, err) + } + return t, nil +} diff --git a/internal/config/runtimeconfig/voice_tts_launch_test.go b/internal/config/runtimeconfig/voice_tts_launch_test.go new file mode 100644 index 0000000..0ad7c5f --- /dev/null +++ b/internal/config/runtimeconfig/voice_tts_launch_test.go @@ -0,0 +1,84 @@ +package runtimeconfig + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// TestLoadVoiceTTSLaunchTuning 锁定 voice-tts-launch.json 的四条读取口径: +// 缺失 = 没配过(不是错误、**不启用**)、全空白 = 没配过、合法 = 逐键读出、 +// 坏文件 = 可分类错误(调用方据此回落"不启用")。 +func TestLoadVoiceTTSLaunchTuning(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "voice-tts-launch.json") + + // ① 缺失 ⇒ 零值 + nil(默认关:这把开关会起进程,不能靠"猜"打开) + if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() { + t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err) + } + + // ② 全空白 ⇒ 同样当作没配过 + if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() { + t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err) + } + + // ③ 合法 ⇒ 逐键读出(脚本路径含中文,正是主人那台机器的形状) + body := `{"enabled":true,"scriptPath":"D:\\Project\\Service\\emotionTTS_v5.1.0\\一键启动.bat", + "probeUrl":"http://127.0.0.1:9880","probeTimeoutMs":250,"cooldownSec":90}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + tuning, err := LoadVoiceTTSLaunchTuning(path) + if err != nil { + t.Fatalf("合法文件: %v", err) + } + if !tuning.EnabledOn() { + t.Fatal("enabled:true 没被读出") + } + if tuning.ProbeTimeoutMs != 250 || tuning.CooldownSec != 90 { + t.Fatalf("超时/冷却没读出: %+v", tuning) + } + if tuning.ProbeURL != "http://127.0.0.1:9880" { + t.Fatalf("probeUrl = %q", tuning.ProbeURL) + } + if filepath.Base(tuning.ScriptPath) != "一键启动.bat" { + t.Fatalf("scriptPath 没读出: %q", tuning.ScriptPath) + } + + // ④ 显式 false ⇒ 与"没配过"同效(EnabledOn 为 false) + if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if tuning, err = LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() { + t.Fatalf("显式 false: tuning=%+v err=%v, want 不启用", tuning, err) + } + + // ⑤ 坏文件 ⇒ 分类错误(调用方据此 WARN + 不启用),且绝不覆盖原文件 + if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err = LoadVoiceTTSLaunchTuning(path); !errors.Is(err, ErrInvalidVoiceTTSLaunch) { + t.Fatalf("坏文件: err=%v, want ErrInvalidVoiceTTSLaunch", err) + } + if raw, rerr := os.ReadFile(path); rerr != nil || string(raw) != `{"enabled":` { + t.Fatalf("坏文件被改写了: %q err=%v", raw, rerr) + } +} + +// TestDefaultVoiceTTSLaunchPath 钉住落点:`$DATA_DIR/config/voice-tts-launch.json` +// —— 它是"零新 .env 键"这一条的物理形态(开关只可能来自面板文件)。 +func TestDefaultVoiceTTSLaunchPath(t *testing.T) { + got := DefaultVoiceTTSLaunchPath("/data") + want := filepath.Join("/data", "config", "voice-tts-launch.json") + if got != want { + t.Fatalf("落点 = %q, want %q", got, want) + } + if VoiceTTSLaunchConfigRelPath != "config/voice-tts-launch.json" { + t.Fatalf("相对路径 = %q", VoiceTTSLaunchConfigRelPath) + } +} 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 new file mode 100644 index 0000000..f8d5ca1 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/launcher.go @@ -0,0 +1,332 @@ +// Package ttslaunch 在**后端已经起好之后**顺手照顾半云端的 emotionTTS: +// 先探一次 `http://127.0.0.1:9880`,没在跑就异步拉起主人给的 +// `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`(切片 M47)。 +// +// 主人 2026-10-24 逐字:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便 +// 检测一下如果TTS没起就顺带启一下」。因此本包的三条口径是硬要求: +// +// 1. **只是便利,不进启动路径**:`Trigger` 把整段工作(探活 + 起进程)丢进后台 +// goroutine 后**立即返回**,探活超时/起进程耗时都**不计入**后端启动时长。 +// 2. **失败只 WARN**:探活超时、脚本不存在、起进程失败一律落到一条带 source 的 +// 日志,`Trigger` 的返回值只说「这次有没有接单」,**没有 error 通道** —— 一个 +// 没装 emotionTTS 的机器必须与改动前逐字一样地起得来。 +// 3. **绝不重复拉起**:同进程内并发触发只会有一次探活/拉起(单飞),一次尝试之后 +// 还有一段冷却,避免「后端连着重启」把 TTS 拉成好几个抢同一个端口的进程。 +// +// 默认**关**(主人早前:「开机自启先不急 后面做」):`Config.Enabled` 为 false 时 +// 连探活都不发。开关与脚本路径走面板文件 `$DATA_DIR/config/voice-tts-launch.json` +// (零新 `.env` 键,见 `internal/config/runtimeconfig`)。 +package ttslaunch + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "time" +) + +const ( + // DefaultProbeURL 是 emotionTTS 的默认探活地址(只绑回环,无鉴权)。 + DefaultProbeURL = "http://127.0.0.1:9880" + // DefaultScriptPath 是主人给的「一键启动.bat」默认路径。 + DefaultScriptPath = `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat` + // DefaultProbeTimeout 是单次探活超时:**毫秒级**。它只决定「等多久认定没起」, + // 该值故意远小于任何"服务慢慢初始化"的量级 —— 判错的代价只是多拉一次脚本 + // (已有实例会自己抢端口失败),而等太久的代价是每次都白等。 + DefaultProbeTimeout = 300 * time.Millisecond + // DefaultCooldown 是一次拉起尝试之后的冷却:这段时间内不再探、不再拉。 + DefaultCooldown = 2 * time.Minute +) + +// Config 是这一级的行为旋钮。零值经 Normalized 变成"默认关 + 默认地址/脚本/超时"。 +type Config struct { + // Enabled 是总开关(默认 false)。关着时 Trigger 连探活都不会发。 + Enabled bool + // ScriptPath 是拉起用的启动脚本;空 = DefaultScriptPath。 + ScriptPath string + // ProbeURL 是探活地址;空 = DefaultProbeURL。 + ProbeURL string + // ProbeTimeout 是单次探活超时;零/负 = DefaultProbeTimeout。 + ProbeTimeout time.Duration + // Cooldown 是一次尝试之后的冷却;零/负 = DefaultCooldown。 + Cooldown time.Duration +} + +// Normalized 补齐缺省值(**唯一**的"缺省填什么"裁决处,调用方与配置层都不做)。 +func (c Config) Normalized() Config { + if strings.TrimSpace(c.ScriptPath) == "" { + c.ScriptPath = DefaultScriptPath + } + if strings.TrimSpace(c.ProbeURL) == "" { + c.ProbeURL = DefaultProbeURL + } + if c.ProbeTimeout <= 0 { + c.ProbeTimeout = DefaultProbeTimeout + } + if c.Cooldown <= 0 { + c.Cooldown = DefaultCooldown + } + return c +} + +// Outcome 是一次同步检查(Check)的结论。它只用于日志与用例断言 —— 调用方(后端 +// 启动序列)**不**需要它,也拿不到任何 error。 +type Outcome int + +const ( + // OutcomeDisabled 表示这一级没开(连探活都没发)。 + OutcomeDisabled Outcome = iota + // OutcomeAlreadyRunning 表示探活成功:服务已经在跑,什么都不做。 + OutcomeAlreadyRunning + // OutcomeLaunched 表示探活失败且启动脚本已被拉起。 + OutcomeLaunched + // OutcomeCooling 表示还在上一次尝试的冷却期内,这次跳过。 + OutcomeCooling + // OutcomeFailed 表示要拉但没拉起来(脚本不存在/起进程失败),已记 WARN。 + OutcomeFailed + // OutcomeCanceled 表示探活被**调用方的 ctx 取消**打断(典型是后端正在关闭): + // 这不是"没起",而是"这次不看了" —— 跳过、不拉起、也不消耗冷却。 + OutcomeCanceled +) + +// String 让日志与用例输出可读("outcome":"launched")。 +func (o Outcome) String() string { + switch o { + case OutcomeDisabled: + return "disabled" + case OutcomeAlreadyRunning: + return "already_running" + case OutcomeLaunched: + return "launched" + case OutcomeCooling: + return "cooling" + case OutcomeFailed: + return "failed" + case OutcomeCanceled: + return "canceled" + default: + return "unknown" + } +} + +// Launcher 是探活 + 拉起的那一个实例:进程内只该建一个(它自己就是去重器)。 +// +// 去重分两层,各自只管一件事: +// - `inflight`(Trigger 里):同进程内并发触发只派**一个**后台 goroutine, +// 后来者立即返回 false,连探活都不发。 +// - `lastAttempt` + `checkMu`(Check 里):一次**尝试**(探活后确实去拉了)之后 +// 记下时刻,冷却期内的再次检查直接 OutcomeCooling;`checkMu` 让两个直接调用 +// Check 的路径也被串起来,避免双双越过探活各拉一次。 +type Launcher struct { + cfg Config + log *slog.Logger + probe probeFunc + spawn spawnFunc + now func() time.Time + + mu sync.Mutex + inflight bool + // lastAttempt 是上一次**拉起尝试**的时刻(零值 = 从未尝试过)。 + lastAttempt time.Time + checkMu sync.Mutex + wg sync.WaitGroup +} + +// probeFunc 探一次活:nil = 已在跑,非 nil = 没起(或探不动,同样按没起处理)。 +type probeFunc func(ctx context.Context) error + +// spawnFunc 拉起启动脚本:nil = 已拉起,非 nil = 没拉起来。 +type spawnFunc func(ctx context.Context) error + +// Option 是 New 的可选覆盖(日志、探活、起进程、时钟)。生产路径只用 WithLogger; +// 其余三项存在的意义是让"已在跑 / 拉起失败 / 冷却 / 并发"这些分支能被用例钉住, +// 而不必真的去起进程。 +type Option func(*Launcher) + +// WithLogger 注入日志器(nil 会被换成丢弃型日志,避免 nil 解引用)。 +func WithLogger(log *slog.Logger) Option { + return func(l *Launcher) { + if log != nil { + l.log = log + } + } +} + +// WithProbe 覆盖探活实现(默认用 HTTP GET 打 ProbeURL,任何 HTTP 回应都算在跑)。 +func WithProbe(p probeFunc) Option { + return func(l *Launcher) { + if p != nil { + l.probe = p + } + } +} + +// WithSpawn 覆盖起进程实现(默认真起 ScriptPath 那个脚本)。 +func WithSpawn(s spawnFunc) Option { + return func(l *Launcher) { + if s != nil { + l.spawn = s + } + } +} + +// WithClock 覆盖时钟(冷却的用例需要它,不必真等两分钟)。 +func WithClock(now func() time.Time) Option { + return func(l *Launcher) { + if now != nil { + l.now = now + } + } +} + +// New 按 cfg 造一个 Launcher。cfg 在这里补齐缺省值(Config.Normalized)。 +func New(cfg Config, opts ...Option) *Launcher { + cfg = cfg.Normalized() + l := &Launcher{ + cfg: cfg, + log: discardLogger(), + now: time.Now, + } + l.probe = httpProbe(cfg.ProbeURL, cfg.ProbeTimeout) + l.spawn = scriptSpawner(cfg.ScriptPath) + for _, opt := range opts { + if opt != nil { + 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 +} + +// Config 返回**补齐缺省值之后**的配置(接线处据此如实记一条日志)。 +func (l *Launcher) Config() Config { + if l == nil { + return Config{} + } + return l.cfg +} + +// Trigger 投递一次「探活 → 必要时拉起」,**立即返回**。 +// +// 返回值只回答「这一次有没有真的接单」(true = 派了后台任务;false = 没开 / 已在飞)。 +// 它**没有** error 通道:拉起失败在后台记 WARN,绝不回流到启动路径。 +func (l *Launcher) Trigger(ctx context.Context) bool { + if l == nil || !l.cfg.Enabled { + return false + } + if ctx == nil { + ctx = context.Background() + } + if !l.begin() { + return false + } + l.wg.Add(1) + go func() { + defer l.wg.Done() + defer l.finish() + l.Check(ctx) + }() + return true +} + +// Check 同步做一次检查(Trigger 的后台体;也供诊断/用例直接调用)。 +// +// 它**不返回 error**:所有失败都是日志(WARN)与 Outcome 的一种。 +func (l *Launcher) Check(ctx context.Context) Outcome { + if l == nil || !l.cfg.Enabled { + return OutcomeDisabled + } + if ctx == nil { + ctx = context.Background() + } + l.checkMu.Lock() + defer l.checkMu.Unlock() + if l.cooling() { + l.log.Debug("emotionTTS launch attempt in cooldown, skipping", + "url", l.cfg.ProbeURL, "cooldown", l.cfg.Cooldown) + return OutcomeCooling + } + probeErr := l.probe(ctx) + if probeErr == nil { + 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,这一段的判定与记录是同一步)。 + l.markAttempt() + if err := l.spawn(ctx); err != nil { + l.log.Warn("emotionTTS launch failed, backend startup unaffected", + "script", l.cfg.ScriptPath, "err", err) + return OutcomeFailed + } + l.log.Info("emotionTTS launch script started", + "script", l.cfg.ScriptPath, "url", l.cfg.ProbeURL) + return OutcomeLaunched +} + +// Wait 等当前在飞的后台检查结束(关闭期/用例用;没有在飞任务时立即返回)。 +func (l *Launcher) Wait() { + if l == nil { + return + } + l.wg.Wait() +} + +// begin 抢到"这一次由我检查"的资格;已在飞则拒绝。 +func (l *Launcher) begin() bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.inflight { + return false + } + if !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown { + return false + } + l.inflight = true + return true +} + +// finish 释放"在飞"标记(无论检查结果如何)。 +func (l *Launcher) finish() { + l.mu.Lock() + l.inflight = false + l.mu.Unlock() +} + +// cooling 报告上一次拉起尝试是否还在冷却期内。 +func (l *Launcher) cooling() bool { + l.mu.Lock() + defer l.mu.Unlock() + return !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown +} + +// markAttempt 记下一次拉起尝试的时刻(冷却的起点)。 +func (l *Launcher) markAttempt() { + l.mu.Lock() + l.lastAttempt = l.now() + l.mu.Unlock() +} diff --git a/internal/runtime/voice/ttslaunch/launcher_test.go b/internal/runtime/voice/ttslaunch/launcher_test.go new file mode 100644 index 0000000..29d1f7a --- /dev/null +++ b/internal/runtime/voice/ttslaunch/launcher_test.go @@ -0,0 +1,261 @@ +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) + } + } +} diff --git a/internal/runtime/voice/ttslaunch/probe.go b/internal/runtime/voice/ttslaunch/probe.go new file mode 100644 index 0000000..daa1c1c --- /dev/null +++ b/internal/runtime/voice/ttslaunch/probe.go @@ -0,0 +1,78 @@ +package ttslaunch + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +// probeBodyLimit 是探活响应的最大读取量:探活只关心"有没有人应答",回应体一律丢弃, +// 但也要有界(别被一个话痨服务的无限流拖住)。 +const probeBodyLimit = 4 << 10 + +// httpProbe 用一次 HTTP GET 回答「这个端口上已经有人在应答吗」。 +// +// 判定口径(刻意宽松):**任何** HTTP 回应都算"在跑",只有传输层错误(连不上 / +// 超时 / 连接被拒)才算"没起"。理由是这个服务的根路径回什么状态码并不稳定 +// (404/405/200 都可能),而"端口有人说话"已经足以证明不该再拉第二个实例 —— +// 误判成"没起"的代价是去起一个抢同一个端口的进程,代价明显更大。 +// +// `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, + Transport: &http.Transport{ + Proxy: nil, + DisableKeepAlives: true, + }, + } + return func(ctx context.Context) error { + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("ttslaunch: build probe request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("ttslaunch: probe %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeBodyLimit)) + return nil + } +} + +// 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)) +} diff --git a/internal/runtime/voice/ttslaunch/probe_test.go b/internal/runtime/voice/ttslaunch/probe_test.go new file mode 100644 index 0000000..f1a3124 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/probe_test.go @@ -0,0 +1,71 @@ +package ttslaunch + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestHTTPProbe_AnyResponseMeansRunning 钉住探活判据:**任何** HTTP 回应都算在跑 +// (这个服务的根路径回什么状态码不稳定,只要有人在应答就不该再拉第二个实例)。 +func TestHTTPProbe_AnyResponseMeansRunning(t *testing.T) { + for _, code := range []int{http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + })) + if err := httpProbe(srv.URL, time.Second)(context.Background()); err != nil { + t.Fatalf("状态码 %d 被判成没起: %v", code, err) + } + srv.Close() + } +} + +// TestHTTPProbe_Unreachable 钉住"连不上 ⇒ 没起":先占一个端口再放掉,探它必然被拒。 +func TestHTTPProbe_Unreachable(t *testing.T) { + addr := closedAddr(t) + if err := httpProbe("http://"+addr, 500*time.Millisecond)(context.Background()); err == nil { + t.Fatal("没人监听的端口被判成在跑") + } +} + +// TestHTTPProbe_TimeoutIsHonored 钉住"短超时"确实生效:服务故意不回话, +// 100ms 的探活必须按超时失败,而不是等到服务自己醒。 +func TestHTTPProbe_TimeoutIsHonored(t *testing.T) { + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-block })) + defer func() { + close(block) + srv.Close() + }() + + start := time.Now() + err := httpProbe(srv.URL, 100*time.Millisecond)(context.Background()) + elapsed := time.Since(start) + if err == nil { + t.Fatal("不回话的服务被判成在跑") + } + if elapsed > time.Second { + t.Fatalf("探活等了 %v, 明显没按 100ms 超时", elapsed) + } + if !strings.Contains(err.Error(), "probe") { + t.Fatalf("错误没标来源: %v", err) + } +} + +// closedAddr 返回一个"刚被放掉"的回环地址(探它 = 连接被拒)。 +func closedAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return addr +} diff --git a/internal/runtime/voice/ttslaunch/proc_other.go b/internal/runtime/voice/ttslaunch/proc_other.go new file mode 100644 index 0000000..7d05c28 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/proc_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package ttslaunch + +import "os/exec" + +// shellCommand 在非 Windows 上按 shell 脚本解释(emotionTTS 的一键启动脚本只在 +// Windows 上有;这里的实现只为让本包在 darwin/linux 上照常编译,实际拉起会因 +// 路径不存在而落到一条 WARN)。 +func shellCommand(script string) *exec.Cmd { + return exec.Command("/bin/sh", script) +} + +// startDetached 起进程(非 Windows 上不需要额外的进程组标志)。 +func startDetached(cmd *exec.Cmd) error { return cmd.Start() } diff --git a/internal/runtime/voice/ttslaunch/proc_windows.go b/internal/runtime/voice/ttslaunch/proc_windows.go new file mode 100644 index 0000000..1587392 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/proc_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package ttslaunch + +import ( + "os/exec" + "syscall" +) + +const ( + // createNewProcessGroup 让子进程成为**新进程组**的组长:后端控制台里的 Ctrl+C + // 只送给后端自己的进程组,不会连带掐掉 TTS。 + createNewProcessGroup = 0x00000200 + // createNewConsole 给子进程开**自己的**控制台窗口 —— 与主人「双击一键启动.bat」 + // 看到的形态一致(TTS 的日志照常可见,不糊进后端日志),也不受后端控制台关闭影响。 + createNewConsole = 0x00000010 +) + +// shellCommand 构造「解释一个批处理文件」的命令。 +// +// 为什么必须经 cmd.exe:`.bat` 不是 PE 可执行映像,Windows 内核无法直接 +// CreateProcess 起来(要么失败,要么依赖某个 Go 版本碰巧的兼容路径)。这里把脚本 +// 路径作为**独立参数**交给 exec.Command —— 绝不自己拼命令行字符串,空格/中文路径 +// 的引号由 os/exec 按 Windows 规则处理(主人那个路径含中文,也见过含空格的安装路径)。 +func shellCommand(script string) *exec.Cmd { + return exec.Command("cmd.exe", "/c", script) +} + +// startDetached 以"脱离后端生命周期"的方式起进程(不接管它的输入输出)。 +func startDetached(cmd *exec.Cmd) error { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: createNewProcessGroup | createNewConsole, + } + return cmd.Start() +} diff --git a/internal/runtime/voice/ttslaunch/script.go b/internal/runtime/voice/ttslaunch/script.go new file mode 100644 index 0000000..2db42f5 --- /dev/null +++ b/internal/runtime/voice/ttslaunch/script.go @@ -0,0 +1,48 @@ +package ttslaunch + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// scriptSpawner 把"起那个脚本"包成 spawnFunc(缺省实现)。 +func scriptSpawner(path string) spawnFunc { + return func(ctx context.Context) error { + return LaunchScript(ctx, path) + } +} + +// LaunchScript 起一个启动脚本并**立即返回**,不等它结束。 +// +// 细节与代价: +// - 脚本不存在 / 是目录 ⇒ 返回错误(调用方只 WARN:这台机器上没装 emotionTTS +// 是正常形态,不是后端的病)。 +// - `cmd.Dir` 设为脚本所在目录:`一键启动.bat` 里的相对路径按它自己的习惯解析, +// 而不是按后端的进程工作目录。 +// - **刻意不使用 ctx**:`exec.CommandContext` 会在 ctx 取消时杀掉子进程,而后端 +// 优雅退出**不该带走** TTS —— 服务是独立的,主人的后端重启与它是两件事。 +// - 起完由后台 goroutine `Wait` 回收(脚本通常活到 TTS 退出为止,这个 goroutine +// 跟着后端一起活,代价可忽略)。 +func LaunchScript(_ context.Context, path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("ttslaunch: empty script path") + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("ttslaunch: script %s: %w", path, err) + } + if info.IsDir() { + return fmt.Errorf("ttslaunch: script %s is a directory", path) + } + cmd := shellCommand(path) + cmd.Dir = filepath.Dir(path) + if err := startDetached(cmd); err != nil { + return fmt.Errorf("ttslaunch: start %s: %w", path, err) + } + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/runtime/voice/ttslaunch/script_test.go b/internal/runtime/voice/ttslaunch/script_test.go new file mode 100644 index 0000000..997012b --- /dev/null +++ b/internal/runtime/voice/ttslaunch/script_test.go @@ -0,0 +1,96 @@ +package ttslaunch + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// TestLaunchScript_MissingFile 钉住"脚本不存在 ⇒ 返回可读错误"(调用方只 WARN)。 +func TestLaunchScript_MissingFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "一键启动.bat") + err := LaunchScript(context.Background(), missing) + if err == nil { + t.Fatal("脚本不存在却没报错") + } + if !strings.Contains(err.Error(), "一键启动.bat") { + t.Fatalf("错误里没有脚本路径: %v", err) + } +} + +// TestLaunchScript_Directory 钉住"路径是目录 ⇒ 报错"(配置写错时别去 cmd /c 一个目录)。 +func TestLaunchScript_Directory(t *testing.T) { + if err := LaunchScript(context.Background(), t.TempDir()); err == nil { + t.Fatal("目录被当成脚本起起来了") + } +} + +// TestLaunchScript_EmptyPath 钉住空路径不会退化成"起当前目录里的东西"。 +func TestLaunchScript_EmptyPath(t *testing.T) { + if err := LaunchScript(context.Background(), " "); err == nil { + t.Fatal("空路径没报错") + } +} + +// TestLaunchScript_StartsBatchFile 是本切片唯一真起进程的用例(只在 Windows 上有意义): +// 它同时钉住三件容易踩的事 —— +// +// 1. `.bat` 必须经解释器起得来(裸 CreateProcess 不行); +// 2. 路径含**中文**(主人的 `一键启动.bat` 就是这个形状)时编码/引号不出岔子; +// 3. 工作目录按脚本所在目录解析(脚本里的相对路径是相对**它自己**)。 +func TestLaunchScript_StartsBatchFile(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("一键启动.bat 只在 Windows 上有意义") + } + // 不用 t.TempDir:脚本进程的**工作目录**就是脚本所在目录,Windows 在它退出前 + // 不允许删那个目录,t.TempDir 的自动清理会误报失败。这里自己建、自己尽力清。 + base, err := os.MkdirTemp("", "ttslaunch-test-") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + defer cleanupEventually(t, base) + dir := filepath.Join(base, "emotionTTS_语音 服务") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + script := filepath.Join(dir, "一键启动.bat") + const body = "@echo off\r\necho started> \"%~dp0marker.txt\"\r\n" + if err := os.WriteFile(script, []byte(body), 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + if err := LaunchScript(context.Background(), script); err != nil { + t.Fatalf("起脚本失败: %v", err) + } + waitForFile(t, filepath.Join(dir, "marker.txt"), 10*time.Second) +} + +// cleanupEventually 尽力删掉临时目录:给脚本进程一点退出时间,删不掉只记日志 +// (绝不让清理动作把"脚本确实起来了"这个结论打成失败)。 +func cleanupEventually(t *testing.T, dir string) { + t.Helper() + for i := 0; i < 60; i++ { + if err := os.RemoveAll(dir); err == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Logf("临时目录还没删掉(脚本进程可能仍在运行): %s", dir) +} + +// waitForFile 等一个文件出现(轮询,带 deadline)。 +func waitForFile(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("等 %v 也没等到 %s(脚本没跑起来)", timeout, path) +}