主人 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 包
97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
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)
|
|
}
|