主人 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 包
185 lines
6.2 KiB
Go
185 lines
6.2 KiB
Go
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()
|
||
}
|