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

@@ -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)
}