复审 p2-2items(P2-1 跨进程去重按裁决只报不修):
(a) **关闭期 ctx 取消 ≠ 没起**:后端关闭时 ctx 被取消,探活返回
`context.Canceled`;原实现把它判成"没起" ⇒ **在关闭过程中拉起 TTS**。
现在按 `errors.Is(err, context.Canceled)` 判为跳过:不记 markAttempt(不消耗
冷却)、不拉、也不 WARN 成故障。探活**自身超时**(DeadlineExceeded)仍是"没起",
照常拉 —— 两者用 errors.Is 区分,不按文案。新增 OutcomeCanceled。
(b) **探活地址必须合法**:写成 `127.0.0.1:9880`(漏 scheme)会让 http.NewRequest
每次都失败 ⇒「没起」永远成立 ⇒ 每次启动都拉一次进程。新增
`ttslaunch.ValidateProbeURL`(url.Parse + scheme ∈ {http,https} + host 非空):
- `New` 里校验 ⇒ 不合法即 WARN + 不启用(兜底闸,覆盖任何调用方);
- 组合根 `validateResolvedProbeURL` 里同一把闸再校验一次,WARN 带
`probe_url_source` 指明该改哪个文件 —— **baseUrl 兜底那条路也被覆盖**。
顺带按复审记录项 1 修正 `Proxy: nil` 的注释:net/http 本来就不给回环 IP 走代理,
这行真正保护的是 probeUrl 被配成主机名的情况(原注释高估了它)。
用例(均变异验证打红):
- TestCheck_ParentCanceled_SkipsLaunch:取消 ⇒ OutcomeCanceled + 0 次拉起 + 不进冷却
(变异:跳过改回"没起" ⇒ Outcome=launched、marker 落地)
- TestCheck_ProbeOwnTimeout_StillLaunches:探活自身超时仍照常拉
- TestNew_InvalidProbeURL_Disables:7 种非法形状 ⇒ 不接单、0 次拉起、有 WARN
- TestStartVoiceTTSLaunch_{ShutdownCancelDoesNotLaunch,InvalidProbeURLStaysOff,
InvalidBaseURLFallbackStaysOff}:组合根三条,均用真 .bat marker 观察"有没有拉起"
333 lines
12 KiB
Go
333 lines
12 KiB
Go
// 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()
|
||
}
|