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

79 lines
3.0 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
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))
}