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 包
This commit is contained in:
AskaEth committed 2026-10-26 18:47:36 +08:00
1 parent af61b8ccfe
commit 6906d274fc
13 files changed
+1354

No files matched your search

@@ -0,0 +1,304 @@
// 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"
"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
)
// 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"
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)
}
}
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
}
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,260 @@
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)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package ttslaunch
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)
// probeBodyLimit 是探活响应的最大读取量:探活只关心"有没有人应答",回应体一律丢弃,
// 但也要有界(别被一个话痨服务的无限流拖住)。
const probeBodyLimit = 4 << 10
// httpProbe 用一次 HTTP GET 回答「这个端口上已经有人在应答吗」。
//
// 判定口径(刻意宽松):**任何** HTTP 回应都算"在跑",只有传输层错误(连不上 /
// 超时 / 连接被拒)才算"没起"。理由是这个服务的根路径回什么状态码并不稳定
// (404/405/200 都可能),而"端口有人说话"已经足以证明不该再拉第二个实例 ——
// 误判成"没起"的代价是去起一个抢同一个端口的进程,代价明显更大。
//
// `Proxy: nil` 是必须的:探的是回环地址,走环境里的 HTTP_PROXY 只会问到代理上,
// 得到"有人在跑"的假结论。
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
}
}
// 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)
}