Files
Cyrene_v2/cmd/cyrene/voice_tts_launch_test.go
T
AskaEth 6906d274fc feat(voice): M47 后端启动后探活 emotionTTS,未起则异步拉起一键启动脚本
主人 2026-10-24:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便检测
一下如果TTS没起就顺带启一下」。本片只做「启动时顺带探一下」,**默认关**(主人
早前说过「开机自启先不急 后面做」),未开开关时连探活都不发。

- 新包 internal/runtime/voice/ttslaunch:探 http://127.0.0.1:9880(毫秒级超时),
  已在跑什么都不做;没起则异步起脚本(cmd.exe /c 传参,不拼命令行字符串)
- 防重复:in-flight 单飞 + 一次尝试后的冷却(失败也算一次,重启风暴不会变成重试风暴)
- 失败只 WARN:探活超时/脚本不存在/起进程失败都只记带 source 的日志,
  组合根那条路径**没有 error 通道**
- 配置走面板文件 $DATA_DIR/config/voice-tts-launch.json(零新 .env 键)
- 接线在 cmd/cyrene 的网关 Listen 之后,整段在后台 goroutine 里,不占启动时长
  (用例以「挂载耗时 < 150ms 而探活超时 800ms」证明)
- 不新开端点、不加帧族;不碰 gateway 包
2026-10-26 18:47:36 +08:00

185 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
//
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
//
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
import (
"bytes"
"context"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"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()
}