复审 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 观察"有没有拉起"
155 lines
7.5 KiB
Go
155 lines
7.5 KiB
Go
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,
|
||
}
|
||
}
|