Skip to content
45 changes: 45 additions & 0 deletions .github/workflows/docker-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,22 @@ on:
- "deploy/kubernetes/**"
- ".dockerignore"
- ".github/workflows/docker-smoke.yml"
# 2026-09-21 补:镜像里的 Python 依赖就是这三份文件决定的,而"引擎推理模块导入失败"
# 这类断裂恰好由依赖区间变化引起(transformers 抬到 4.57 那次)。以前触发器不含它们,
# 于是本作业对最该拦的那类改动**根本不会跑** —— 引擎导入探针这一步也就形同虚设。
- "requirements.txt"
- "requirements-lock.txt"
- "pyproject.toml"
push:
branches: [main]
paths:
- "Dockerfile"
- "docker-compose.yml"
- "deploy/kubernetes/**"
- ".dockerignore"
- "requirements.txt"
- "requirements-lock.txt"
- "pyproject.toml"
schedule:
- cron: "30 3 * * 1" # 每周一 03:30 UTC,与 gpu-smoke(03:00)错峰;捕捉 base 镜像漂移

Expand Down Expand Up @@ -146,6 +155,42 @@ jobs:
docker logs --tail 200 tts-smoke
exit 1

- name: Engine import probe inside the image
# 此前这一步不存在,而引擎模块在容器里**从没被导入过**:注册表是懒加载
# (model_registry.py 里 `from .engines.voxcpm2.engine import VoxCPM2Engine` 在函数内),
# 冒烟又用 TTS_AUTO_LOAD_MODEL=0 启动 → "依赖被改坏导致引擎导入失败"这类断裂
# 在 CI 上完全隐身(2026-09-21 实测:transformers 4.57.6 下 indextts 的
# infer_v2 / infer_v2_5 直接 ImportError,而全部门禁绿)。
# 只 import 不推理:CPU runner 无 GPU 属预期,真合成归 gpu-smoke.yml。
# 边界要如实说:镜像里**没有** indextts(.dockerignore 排除 reference_repos/,
# requirements.txt 也不含它),所以这里钉得住的只有 vendored VoxCPM2 那一条链;
# IndexTTS 2.5 / 2.0 的导入由 gpu-smoke.yml 的 engine_imports 步骤覆盖(每周一次)。
run: |
docker exec -i tts-smoke python3.12 - <<'PY'
import importlib
import sys
import transformers

print("transformers", transformers.__version__)
mods = [
"integrated_app.vendor.voxcpm",
"integrated_app.engines.voxcpm2.engine",
]
bad = []
for m in mods:
try:
importlib.import_module(m)
print("OK ", m)
except Exception as exc: # noqa: BLE001
bad.append((m, type(exc).__name__, str(exc)[:200]))
print("FAIL", m, type(exc).__name__, str(exc)[:200])
if bad:
print("::error::镜像内的引擎模块导入失败 —— 检查 pyproject/requirements.txt 的 "
"transformers 与 tokenizers 区间(引擎要求 4.52.x,见 "
"docs/SECURITY_DEPENDABOT_TRIAGE.md §2)")
sys.exit(1)
PY

- name: Probe readiness semantics (/readyz must be 503 without model)
run: |
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7869/readyz)
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/gpu-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ jobs:
GH_TOKEN: ${{ secrets.REPO_ADMIN_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::REPO_ADMIN_TOKEN secret not configured - cannot query runner status, skipping smoke"
# 以前这里是 ::notice::,整个 run 以"success"收场 —— 而真推理 job 一次都没跑过
# (本仓 2026-09-21 实测:3 次 schedule run 全部 gpu-smoke=skipped,
# 且 REPO_ADMIN_TOKEN 这个 secret 从未配置)。跳过必须显眼,不能长得像通过。
echo "::warning::REPO_ADMIN_TOKEN 未配置 → 无法查询 runner 在线状态,本次 GPU 真机冒烟**没有跑**。真推理覆盖率为 0,别把本 run 当验收依据。"
echo "### GPU 真机冒烟未执行" >> "$GITHUB_STEP_SUMMARY"
echo "原因:secret \`REPO_ADMIN_TOKEN\` 未配置,无法判断有没有在线的 \`gpu\` self-hosted runner。" >> "$GITHUB_STEP_SUMMARY"
echo "available=false" >> "$GITHUB_OUTPUT"
exit 0
fi
Expand All @@ -60,7 +65,9 @@ jobs:
if [ "${COUNT}" -gt 0 ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::no online self-hosted runner with label 'gpu'; weekly smoke skipped"
echo "::warning::没有在线的 self-hosted \`gpu\` runner → 本次 GPU 真机冒烟**没有跑**,真推理覆盖率为 0(别把本 run 的 green 当验收依据)。"
echo "### GPU 真机冒烟未执行" >> "$GITHUB_STEP_SUMMARY"
echo "原因:仓库里没有在线且带 \`gpu\` 标签的 self-hosted runner(注册数见上一步输出)。" >> "$GITHUB_STEP_SUMMARY"
echo "available=false" >> "$GITHUB_OUTPUT"
fi

Expand Down
53 changes: 50 additions & 3 deletions app/integrated_app/engines/indextts2_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@
"""

import contextlib
import functools
import gc
import logging
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -144,6 +146,30 @@ def _save_pcm_wav_soundfile(path: str, wav: Any, sampling_rate: int) -> None:
sf.write(path, arr, int(sampling_rate), subtype="PCM_16")


# ---------------------------------------------------------------------------
# 推理串行锁:IndexTTS 的推理**不可重入**
# ---------------------------------------------------------------------------
# WHY:预热(model_optimizer.warmup_indextts2)是从后台加载线程直接调 infer 的,
# 绕开了用户侧那把 per-engine asyncio.Semaphore(routes/generate/utils.py)。
# 2026-09-21 真机实测:预热开始 2 秒后插入一条用户合成请求,两个请求同时进
# 同一个模型 → `CUDA error: device-side assert triggered`,并且**整个 CUDA context
# 被毒化**,之后同进程内所有推理(含回切时的重新加载)全部连带失败。
# 用户侧那把信号量管不到预热,所以在引擎这一层补一把按注册名分组的 RLock:
# 任何调用方(队列、SSE、预热、OpenAI 口)都串行,锁在 infer 之外不可见。
_INFER_LOCKS: dict[str, threading.RLock] = {}
_INFER_LOCKS_GUARD = threading.Lock()


def _engine_infer_lock(key: str) -> threading.RLock:
"""取该引擎实例的推理串行锁(首次访问时线程安全地创建)。"""
with _INFER_LOCKS_GUARD:
lock = _INFER_LOCKS.get(key)
if lock is None:
lock = threading.RLock()
_INFER_LOCKS[key] = lock
return lock


class IndexTTS2Engine(TTSEngine):
"""IndexTTS 2.5 引擎适配器。

Expand Down Expand Up @@ -471,7 +497,9 @@ def _validate_model_files(self) -> None:

if problematic_files:
raise EngineLoadError(
f"IndexTTS 2.5 模型文件不可读: {problematic_files}\n"
# 同上:变体名必须取自 self.version_str,2.0 报"2.5 模型文件不可读"会把人
# 引去下载错的权重目录(model/IndexTTS-2.0 vs IndexTTS-2.5)。
f"IndexTTS {self.version_str} 模型文件不可读: {problematic_files}\n"
f"请运行: python scripts/download_indextts2.py 下载模型,"
f"或检查目录权限。",
engine=self._engine_name,
Expand Down Expand Up @@ -629,7 +657,13 @@ def _log_memory_info(self) -> None:
except Exception as e:
logger.debug(f"[IndexTTS2] 获取显存信息失败: {e}")

def infer(
@property
def _serial_infer_lock(self) -> threading.RLock:
"""本实例(按注册名分组,2.5 与 2.0 各一把)的推理串行锁。"""
key = str(getattr(self, "_engine_name", "") or self.version_str)
return _engine_infer_lock(key)

def _infer_impl(
self,
text: str,
spk_audio_prompt: str,
Expand Down Expand Up @@ -917,10 +951,23 @@ def infer(
with contextlib.suppress(OSError):
os.unlink(output_path)
raise GenerationError(
f"IndexTTS 2.5 合成失败: {type(e).__name__}: {e}",
# 2.5 与 2.0 共用这个类,报错必须点名**当前实例**的变体:
# 原先硬编码 "IndexTTS 2.5",于是在 2.0 上失败时文案指向另一个引擎
# (2026-09-21 真机跑冒烟时亲眼看到 indextts20 报 "IndexTTS 2.5 合成失败")。
f"IndexTTS {self.version_str} 合成失败: {type(e).__name__}: {e}",
engine=self._engine_name,
) from e

@functools.wraps(_infer_impl)
def infer(self, *args: Any, **kwargs: Any) -> tuple[int, "np.ndarray", str]: # noqa: ANN401
"""对外入口:整次推理持有该引擎的串行锁。

用 ``functools.wraps`` 保住原签名 —— 接口测试是拿
``inspect.signature(IndexTTS2Engine.infer)`` 检参数表的。
"""
with self._serial_infer_lock:
return self._infer_impl(*args, **kwargs)

def synthesize(
self,
text: str,
Expand Down
18 changes: 18 additions & 0 deletions app/integrated_app/openai_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,24 @@ async def create_speech(request: Request, body: SpeechRequest):
detail="模型未加载,请先加载模型",
)

if engine_name == "indextts2":
# 2026-09-21 实测:本端点走 IndexTTS 时 `spk_audio_prompt` 恒为空(P0-1 刻意
# 不给参考音频通道),而引擎**必须**有说话人参考,于是 engine.infer 抛
# "说话人参考音频缺失" → 上层收敛成 500 "音频生成失败"。也就是说
# `model=tts-1-hd` 从来没成功过 —— GPU 冒烟第 4 步首次真跑就撞出来了
# (此前该作业在 CI 里从未执行,见 docs/SECURITY_DEPENDABOT_TRIAGE.md §2)。
# 这里不擅自定"默认说话人"策略(那等于替用户选一条音色并绕过授权语义),
# 只把这条结构性不支持从 500 改成 400,并指明可操作的出路。
raise HTTPException(
status_code=400,
detail=(
"OpenAI 兼容端点不提供说话人参考音频通道(P0-1),而 IndexTTS 2.5/2.0 "
"必需该参考,因此 model=tts-1-hd 在此端点不可用。请改用 "
"POST /api/generate/indextts2(表单带 ref_audio 与 has_consent),"
"或改用 model=tts-1(VoxCPM2,走 voice 预设音色或已授权的 persona 名)。"
),
)

# 如果请求的引擎与当前引擎不同,提示切换
if registry.current_engine != engine_name:
raise HTTPException(
Expand Down
54 changes: 51 additions & 3 deletions docs/DOD.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,57 @@
引擎加载失败时的报错也不再断言"PyPI 无 indextts 包",改为带上底层 ImportError 与版本不匹配提示。
20 条 Dependabot 告警因此**没有一条能靠现在就升级消掉**,分诊见 `docs/SECURITY_DEPENDABOT_TRIAGE.md`;
且那 20 条只覆盖有 GHSA 记录的 10 个公告,**8 条 PYSEC-only 的 Dependabot 从不开单**。
* **已知缺口**:CI 冒烟 `scripts/gpu_smoke_minimal.py` 只覆盖 voxcpm2 + indextts2(走 OpenAI 口,
而 `tts-1` / `tts-1-hd` 两个模型名里没有 IndexTTS **2.0** 的位置);2.0 的真推理今天人工验过,
要接进冒烟需改用 `/api/generate/indextts2` 形态并在 GPU runner 上复验,另案。
* **GPU 冒烟覆盖面(本轮补齐 2.0,并发现"每周兜底"其实从没跑过)**:
`scripts/gpu_smoke_minimal.py` 原先只覆盖 voxcpm2 + indextts2 —— `tts-1` / `tts-1-hd` 两个
OpenAI 模型名里没有 IndexTTS **2.0** 的位置。现在第 0 步是引擎导入探针(`engine_imports`,
用服务所在解释器 import `indextts.infer_v2` / `infer_v2_5`,失败即硬停并点名
`transformers>=4.52.1,<4.53`),2.0 走 `/api/generate/indextts2` + `expected_engine` 真合成并
从 `data-audio-filename` 回取 `/api/audio/<file>` 校验 RIFF,另加一条版本门负向
(加载 2.0 却声明 `indextts2` 必须被点名拒绝)。
**本机真机逐步验到**:`engine_imports` / `csrf_ticket` / `synth_tts-1`(310,470 B RIFF)/
`switch_indextts2` / `openai_tts1hd_contract`(400)/ `synth_indextts2`(298,746 B RIFF)
全部 OK;`switch_indextts20` OK,但其后的 `synth_indextts20` **没跑通**,
撞在下面那条 CUDA 缺陷上(冒烟如实报 FAIL,没有粉饰成跳过)。
过程中还修掉一个潜伏缺陷:脚本所有 POST 都不带 CSRF 双提交票,而 `/v1/audio/speech`
并不在豁免路径里 —— 不带就 403 `CSRF_MISSING`,也就是说这条冒烟只要真跑就会红。
更要紧的一条:**它从没真跑过**。`gpu-smoke.yml` 三次 schedule run(09-07 / 09-14 / 09-21)
的 `gpu-smoke` job 全是 `skipped`,run 顶层却是 success —— secret 里根本没有
`REPO_ADMIN_TOKEN`,且仓库**零个注册 runner**。本轮把跳过改成 `::warning` + 写进 job summary,
并把每天真能跑的引擎导入兜底放到 `docker-smoke.yml`(CPU 托管 runner,只 import 不推理),
同时给它的触发器补上 `requirements.txt` / `requirements-lock.txt` / `pyproject.toml`
(以前依赖区间被改坏时这个作业压根不会触发)。
**仍未消掉**:runner 不存在 → IndexTTS 2.5/2.0 的真推理与"两个 IndexTTS 变体的导入"在 CI 上
依然零覆盖,只有本机能验;要让这条变成 CI 事实,需要注册一台带 `gpu` 标签的
self-hosted runner 并配 `REPO_ADMIN_TOKEN`(归所有者)。
* **真机跑冒烟时定位并修掉一个 CUDA 崩溃(原以为是"第二次切换",其实是预热抢占)**:
症状 `CUDA error: device-side assert triggered`,且**整个 CUDA context 被毒化** ——
之后同进程所有推理连带失败,连切换的回滚重载都报 503。服务端时间线是决定性证据:
`21:05:57 [req=bg-model-startup-load] 开始合成 text='你好'`(预热)→
`21:05:59 [req=f663d3ae…] 开始合成`(用户请求)→ `21:06:01` **两条一起** assert。
根因:预热走 `model_optimizer.warmup_indextts2`,从后台加载线程**直接调 `engine.infer`**,
绕开了用户侧那把 per-engine `asyncio.Semaphore(1)`(`routes/generate/utils.py:439`),
而 IndexTTS 推理不可重入。"第二次切换才挂"是巧合 —— 切换必然触发预热,而 `loaded`
状态早于预热完成,脚本/用户就是会在预热那 2~5 秒里把请求挤进去。
(先前记的三个"嫌疑"全被证伪:冷切换、参考音频素材、异步错位归因都排除过。)
修法:在引擎这一层加按注册名分组的 `threading.RLock`(`_engine_infer_lock`),`infer`
变成持锁薄包装并用 `functools.wraps` 保住签名(接口测试仍按 14 个参数检查),
于是队列 / SSE / 预热 / OpenAI 口任何入口都被串行化。
**真机验证**:① 原并发条件(预热进行中就压请求)从必崩变成 2.5 = 336,642 B、
2.0 = 239,674 B,且与串行跑出来的**字节数完全一致**(锁只串行化,不改结果);
② 完整冒烟 10 格全绿:`engine_imports` → `ready` → `csrf_ticket` → voxcpm2 321,962 B →
切 2.5 → `openai_tts1hd_contract` 400 → 2.5 = 386,796 B → 切 2.0 → 2.0 = 402,400 B →
版本门负向按预期拒绝;结束显存回落 2,666 MiB。
* **镜像构建今天起在 CI 上确定性失败(与本仓改动无关的基础设施故障,未修)**:
`Dockerfile` 的 `apt-get install` 层报
`update-alternatives: error: alternative path /usr/share/man/man7/bash-builtins.7.gz doesn't exist`
→ buildx 失败,`Build & Scan Image` 与 `Boot hardened container & probe` 两个作业同时红。
判据链:main 上 11:07 的同类构建还是 **success**(`41f5a12`/`1b29d0f`),12:19 与 12:28 两次
PR 构建红在**同一步**,且**各重试一次仍然一模一样** → 不是瞬时网络、也不是本 PR 引入
(本 PR 没碰 `Dockerfile`,失败发生在我的探针步骤之前)。jammy 已进入归档期,
疑点是 `software-properties-common` 一条依赖链带进来的 man-db/manpages 组合。
**我没有改 Dockerfile**:本机没有 docker daemon,任何 apt/dpkg 层的规避手法(
`path-exclude=/usr/share/man/*` 之类)在我这儿都是盲改,而它会改变发版镜像的内容 ——
要改就该在能真构建的环境里改并验,不该靠 CI 试错。交所有者定谁来做。
* **仍未覆盖**:桌面安装包链路(staging → data 7z → NSIS)**无任何 workflow 调用**、本机也无从安装
(`scripts/installer/` 只有一个 4.3 MB `Setup.exe`、无同目录分卷),所以 `unpack_desktop.ps1`
新加的许可/字体落地核对只过了语法层,`release_gate.ps1` 的第 ⑥ 步也只在发版/dispatch 时跑;
Expand Down
21 changes: 17 additions & 4 deletions docs/SECURITY_DEPENDABOT_TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,23 @@ transformers 4.52.1 + tokenizers 0.21.0(引擎元数据要求的组合,最
也不含 `indextts`,镜像里只有 vendored 的 VoxCPM2(`app/integrated_app/vendor/voxcpm`)。
所以"下界≥4.57 会弄坏容器里的两个 IndexTTS"这个说法是**错的**——真实情况是容器部署形态
只有 1 个引擎可用。受影响的是"源码安装 + 自带 indextts"的环境(正是 `GPU Smoke` 那条路径)。
2. **唯一能抓到这类运行时断裂的 CI 作业是每周一次、跑在 self-hosted GPU runner 上的
`GPU Smoke (real inference, self-hosted)`**:最近一次记录是 2026-09-14 success
(正好是那条错误下界进 main 的当天),此后没有新 run。也就是说:这类问题在 CI 上的
暴露延迟是以"周"计的,且依赖 runner 在线。
2. **过去说"唯一能抓这类运行时断裂的是每周一次的 GPU Smoke",这句话高估了它**:
`gpu-smoke.yml` 至今只有 3 次 schedule run(2026-09-07 / 09-14 / 09-21),
**三次的 `gpu-smoke` job 全部是 `skipped`**,而 run 顶层显示 success。原因有两道,各自独立成立:
仓库 secrets 里只有 `MANIFEST_SIGNING_KEY_B64`,**`REPO_ADMIN_TOKEN` 从没配过**(precheck 拿不到
runner 状态就直接判 `available=false`);且 `GET /actions/runners` 返回**零个注册 runner**。
也就是说这份"每周兜底"的暴露延迟不是以周计,而是**无穷大 —— 它一次都没跑过**,
原先的 `::notice` 又让跳过长得像通过。本轮把两处都改了:precheck 的跳过改为
`::warning` + 写进 job summary(见 `ci/engine-import-and-indextts20-smoke`),
并把引擎导入探针前移成 `gpu_smoke_minimal.py` 的第 0 步(`engine_imports`),
让"哪天 runner 在线了"这件事一开机就给结论;真正每天都能跑的兜底改由
`docker-smoke.yml` 的 Engine import probe 承担(GitHub 托管 CPU runner,只需 import 不需 GPU),
同时给它的触发器补上 `requirements.txt` / `requirements-lock.txt` / `pyproject.toml`
—— 以前依赖区间被改坏时这个作业根本不会跑。
仍要如实说:容器镜像里**没有 indextts**(`.dockerignore` 排除 `reference_repos/`,
`requirements.txt` 也不含它),所以 docker 那步只钉得住 vendored VoxCPM2 的导入链;
IndexTTS 2.5 / 2.0 的导入只有 runner 在线时才覆盖得到。受影响最大的场景是
"源码安装 + 自带 indextts" 的用户。

> 复现这套对比时的坑(已记 GOTCHAS #137):本服务的端口被占时会**自动顺延到下一个端口**,
> 而沙箱里"停掉后台命令"只杀外层 shell、不杀 `python` 子进程。结果是新起的干净服务落在 7870,
Expand Down
Loading
Loading