Merge branch 'feat/m47-ttsspawn-detect-and-auto-launch-emot' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-26 19:08:45 +08:00
commit 4224fc2b12
15 files changed
+1871

No files matched your search

+7
View File
@@ -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")
+154
View File
@@ -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,
}
}
+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)
}
}
+288
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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()
}
@@ -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)
}
}
}
+78
View File
@@ -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))
}
@@ -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
}
@@ -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() }
@@ -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()
}
@@ -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
}
@@ -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)
}