Files
AskaEth 67d7e4bd1b fix(voice): M47 r2 复审两条 P2 —— 关闭期取消不拉起、非法探活地址不启用
复审 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 观察"有没有拉起"
2026-10-26 19:03:12 +08:00

333 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}