Files
Cyrene_v2/internal/runtime/voice/ttslaunch/launcher_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

261 lines
8.3 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 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",
}
for o, w := range want {
if o.String() != w {
t.Fatalf("%d.String() = %q, want %q", int(o), o.String(), w)
}
}
}