From 37679cd971bae41ea6a7be32a672b20fe5ca0428 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Mon, 21 Sep 2026 19:08:08 +0800 Subject: [PATCH 1/5] =?UTF-8?q?ci(smoke):=20=E5=BC=95=E6=93=8E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8E=A2=E9=92=88=E5=89=8D=E7=BD=AE=20+=20=E8=A1=A5?= =?UTF-8?q?=20IndexTTS=202.0=20=E9=82=A3=E4=B8=80=E6=A0=BC=20+=20=E6=8A=8A?= =?UTF-8?q?"=E8=B7=B3=E8=BF=87"=E4=BB=8E=20success=20=E9=87=8C=E6=8F=AA?= =?UTF-8?q?=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事,都来自 A1 那条我自己列了却没做的附带建议("把容器内加载引擎补进冒烟")。 1) scripts/gpu_smoke_minimal.py: - 新增第 0 步 `engine_imports`:用**服务所在的那个解释器** subprocess import `indextts` / `indextts.infer_v2` / `infer_v2_5` 并打印 transformers 版本。判据刻意不写 版本号区间(不与 pyproject 重复一份真相):导入失败就硬停,并把"引擎要求 4.52.x、 证据在分诊文档 §2"打进错误信息里;本机没装 indextts 时判 SKIP 并在报告里写清,不假绿。 - 补 `switch_indextts20` + `synth_indextts20`:OpenAI 口只有 tts-1 / tts-1-hd,2.0 没有位置, 所以走 POST /api/generate/indextts2(expected_engine=indextts20 + examples/reference_speaker.wav + has_consent=true),再从响应里的 data-audio-filename 回取 GET /api/audio/ 验 RIFF/WAVE。 - 补 `version_gate_refuses_mismatch`:加载 2.0 却声明 indextts2 的页面必须被点名拒绝 (断言无 data-audio-filename 且文案含"但当前加载的是"),钉住静默代打那一类。 - 修一个潜伏缺陷:脚本所有 POST 都不带 CSRF 双提交票。CSRF 中间件只豁免 GET/HEAD、 /docs 与 /api/sse/,`/v1/audio/speech` 不在其中 —— 实测无票直接 403 `{"code":"CSRF_MISSING"}`(本机 curl 复现,带票才继续往下走)。也就是说这条冒烟 **只要真跑就会红**。现在开机取一次票(新增 `csrf_ticket` 步骤)并复用给全部 POST。 2) .github/workflows/docker-smoke.yml:新增 `Engine import probe inside the image` (docker exec 进真镜像 import `integrated_app.vendor.voxcpm` 与 `integrated_app.engines.voxcpm2.engine`;本机实测两枚 import 10.9s 通过), 并把 `requirements.txt` / `requirements-lock.txt` / `pyproject.toml` 加进触发 paths —— 以前"依赖区间被改坏"正好是这个作业不触发的场景。边界如实写进注释:镜像里没有 indextts, 这步只钉得住 vendored VoxCPM2 那条链。 3) .github/workflows/gpu-smoke.yml:precheck 的跳过从 ::notice 改成 ::warning + 写 job summary。 原因不是风格:本仓该 workflow 至今 3 次 schedule run(09-07 / 09-14 / 09-21)的 `gpu-smoke` job **全部 skipped**,run 顶层却显示 success —— secrets 里只有 `MANIFEST_SIGNING_KEY_B64`,`REPO_ADMIN_TOKEN` 从没配过,且 `GET /actions/runners` 返回零个注册 runner。所谓"每周兜底"的暴露延迟不是以周计,是无穷大。 文档同步:分诊文档 §2 第 2 点重写(原先写"最近一次 09-14 success",暗示它跑过); DOD 的"已知缺口"改成实测的现状 + 仍未消掉的那半(runner 不存在 → 两个 IndexTTS 变体 的真推理与导入在 CI 上仍零覆盖,需要所有者注册带 `gpu` 标签的 runner 并配 REPO_ADMIN_TOKEN)。 Signed-off-by: ReSerendipity --- .github/workflows/docker-smoke.yml | 45 ++++++ .github/workflows/gpu-smoke.yml | 11 +- docs/DOD.md | 21 ++- docs/SECURITY_DEPENDABOT_TRIAGE.md | 21 ++- scripts/gpu_smoke_minimal.py | 221 +++++++++++++++++++++++++---- 5 files changed, 282 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml index 7c1c1e1f..c17238ab 100644 --- a/.github/workflows/docker-smoke.yml +++ b/.github/workflows/docker-smoke.yml @@ -20,6 +20,12 @@ 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: @@ -27,6 +33,9 @@ on: - "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 镜像漂移 @@ -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) diff --git a/.github/workflows/gpu-smoke.yml b/.github/workflows/gpu-smoke.yml index 832068d7..f4e4249b 100644 --- a/.github/workflows/gpu-smoke.yml +++ b/.github/workflows/gpu-smoke.yml @@ -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 @@ -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 diff --git a/docs/DOD.md b/docs/DOD.md index 1e34fdf6..098a0902 100644 --- a/docs/DOD.md +++ b/docs/DOD.md @@ -143,9 +143,24 @@ 引擎加载失败时的报错也不再断言"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/` 校验 RIFF,另加一条版本门负向 + (加载 2.0 却声明 `indextts2` 必须被点名拒绝)。**本机真机跑通**(RTX 5070 Ti,见本轮记录)。 + 过程中还修掉一个潜伏缺陷:脚本所有 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`(归所有者)。 * **仍未覆盖**:桌面安装包链路(staging → data 7z → NSIS)**无任何 workflow 调用**、本机也无从安装 (`scripts/installer/` 只有一个 4.3 MB `Setup.exe`、无同目录分卷),所以 `unpack_desktop.ps1` 新加的许可/字体落地核对只过了语法层,`release_gate.ps1` 的第 ⑥ 步也只在发版/dispatch 时跑; diff --git a/docs/SECURITY_DEPENDABOT_TRIAGE.md b/docs/SECURITY_DEPENDABOT_TRIAGE.md index a3ab708a..bd776902 100644 --- a/docs/SECURITY_DEPENDABOT_TRIAGE.md +++ b/docs/SECURITY_DEPENDABOT_TRIAGE.md @@ -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, diff --git a/scripts/gpu_smoke_minimal.py b/scripts/gpu_smoke_minimal.py index c0bdc1ee..e3b5182a 100644 --- a/scripts/gpu_smoke_minimal.py +++ b/scripts/gpu_smoke_minimal.py @@ -4,9 +4,14 @@ 在 self-hosted GPU runner 上,对**真实加载**的 TTS 引擎各跑一条最短合成, 校验返回的是真实音频字节(非空、合法 WAV/RIFF),并上报显存峰值。 -覆盖两个引擎: +覆盖三个引擎(+ 两条负向): - voxcpm2 (OpenAI model "tts-1") — 服务启动自动加载(TTS_AUTO_LOAD_MODEL=1) - indextts2 (OpenAI model "tts-1-hd") — 经 POST /api/model/switch 切换后加载 + - indextts20(IndexTTS 2.0) — OpenAI 口没有它的位置,只能走 + POST /api/generate/indextts2 + expected_engine=indextts20,再从响应里的 + data-audio-filename 回取 /api/audio/ + - 开机第一步的引擎模块导入探针(transformers 版本一错就地硬失败) + - 版本门负向:加载 2.0 却声明 indextts2 的页面必须被点名拒绝,不许静默代打 用法: python scripts/gpu_smoke_minimal.py \ @@ -21,6 +26,9 @@ import argparse import json import os +import re +import subprocess +import sys import time import urllib.error import urllib.request @@ -44,29 +52,7 @@ def _get(url: str, timeout: float, parse: bool = True): return 0, (None if parse else b"") -def _post_form(url: str, fields: dict, timeout: float): - """最小 multipart/form-data 实现(仅文本字段),兼容 FastAPI Form。""" - boundary = "----gpusmokeboundary" - parts = [] - for k, v in fields.items(): - parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) - body = b"".join(parts) + f"--{boundary}--\r\n".encode() - req = urllib.request.Request( - url, - data=body, - method="POST", - headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return r.status, r.read().decode("utf-8", "replace") - except urllib.error.HTTPError as e: - return e.code, e.read().decode("utf-8", "replace") - except Exception as e: # noqa: BLE001 - return 0, str(e) - - -def _speech(base: str, model: str, timeout: float): +def _speech(base: str, model: str, timeout: float, headers: dict | None = None): url = f"{base}/v1/audio/speech" body = { "model": model, @@ -80,7 +66,7 @@ def _speech(base: str, model: str, timeout: float): url, data=data, method="POST", - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", **(headers or {})}, ) try: with urllib.request.urlopen(req, timeout=timeout) as r: @@ -95,6 +81,106 @@ def _is_wav(raw: bytes) -> bool: return raw[:4] == b"RIFF" and raw[8:12] == b"WAVE" +def _post_form(url: str, fields: dict, timeout: float, files: dict | None = None, headers: dict | None = None): + """最小 multipart/form-data 实现,兼容 FastAPI Form / File。 + + `files` 形如 ``{"ref_audio": ("name.wav", b"...", "audio/wav")}``; + `headers` 用于带 CSRF 双提交头(`/api/generate/*` 不在豁免路径里)。 + """ + boundary = "----gpusmokeboundary" + parts = [] + for k, v in fields.items(): + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) + for k, (fname, blob, mime) in (files or {}).items(): + parts.append( + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"; filename="{fname}"\r\n' + f"Content-Type: {mime}\r\n\r\n" + ).encode() + + blob + + b"\r\n" + ) + body = b"".join(parts) + f"--{boundary}--\r\n".encode() + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": f"multipart/form-data; boundary={boundary}", **(headers or {})}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + except Exception as e: # noqa: BLE001 + return 0, str(e) + + +def _csrf_headers(base: str) -> tuple[dict, str]: + """GET 一次首页拿 ``csrf_token`` cookie,再按双提交约定回填 Header。 + + CSRF 中间件只豁免 GET/HEAD、/docs 与 /api/sse/,`/api/generate/*` 要带票; + 浏览器里这件事由 htmx 自动完成(GOTCHAS #132 那条就是它没配对时伪装成 403)。 + """ + url = f"{base}/" + try: + with urllib.request.urlopen(urllib.request.Request(url, method="GET"), timeout=30) as r: + cookies = r.headers.get_all("Set-Cookie") or [] + except Exception as e: # noqa: BLE001 + return {}, f"取 CSRF cookie 失败:{e}" + token = "" + for c in cookies: + if "csrf_token=" in c: + token = c.split("csrf_token=", 1)[1].split(";", 1)[0].strip() + if not token: + return {}, "响应里没有 csrf_token cookie(CSRF 中间件没签发)" + return {"Cookie": f"csrf_token={token}", "X-CSRF-Token": token}, "" + + +def _switch_engine(base: str, engine: str, headers: dict, timeout_s: int = 420) -> tuple[bool, str]: + """POST /api/model/switch 并轮询 /api/model/status 直到该引擎真加载完。""" + _post_form(f"{base}/api/model/switch", {"engine": engine}, 30, headers=headers) + deadline = time.time() + timeout_s + while time.time() < deadline: + st, sb = _get(f"{base}/api/model/status", 15) + if st == 200 and isinstance(sb, dict) and sb.get("current_engine") == engine and sb.get("model_loaded"): + return True, "switched + loaded" + time.sleep(4) + return False, f"{engine} 在 {timeout_s}s 内没到 loaded" + + +def _probe_engine_imports() -> tuple[str, dict]: + """用**服务所在的那个解释器**做一次引擎模块导入探针,返回 (transformers 版本, 结果表)。 + + WHY:IndexTTS 的 `indextts.infer_v2` / `infer_v2_5` 在 transformers 4.57 下直接 + ImportError(4.52.1 正常,A/B 见 docs/SECURITY_DEPENDABOT_TRIAGE.md §2),但这件事过去 + 只有等到有人真去点"切换引擎"才暴露;每周一次的 GPU 冒烟也只跑 voxcpm2 + indextts2, + 于是"依赖声明被改坏"可以全绿存活一周以上。这一条把它变成开机第一步的硬失败。 + """ + src = ( + "import json,sys\n" + "out={}\n" + "try:\n" + " import transformers\n" + " out['transformers']=transformers.__version__\n" + "except Exception as e:\n" + " out['transformers']='IMPORT_FAIL '+type(e).__name__+' '+str(e)[:120]\n" + "for m in ('indextts','indextts.infer_v2','indextts.infer_v2_5'):\n" + " try:\n" + " __import__(m)\n" + " out[m]='ok'\n" + " except Exception as e:\n" + " out[m]=type(e).__name__+': '+str(e)[:160]\n" + "print(json.dumps(out))\n" + ) + p = subprocess.run([sys.executable, "-c", src], capture_output=True, text=True, timeout=240) + try: + data = json.loads(p.stdout.strip().splitlines()[-1]) + except Exception: # noqa: BLE001 + return "", {"error": f"探针没吐出 JSON:rc={p.returncode} err={p.stderr[:200]}"} + return str(data.get("transformers", "?")), data + + def _finish(report: dict, output: str) -> int: report["passed"] = report.get("passed", False) print(f"[{'PASS' if report['passed'] else 'FAIL'}] gpu smoke {'passed' if report['passed'] else 'failed'}") @@ -118,6 +204,24 @@ def step(name: str, ok: bool, detail: str) -> bool: print(f"[{'OK ' if ok else 'FAIL'}] {name}: {detail}") return ok + # 0) 引擎模块导入探针(不等引擎切换,先把"依赖被改坏"这类断裂挡在最前面) + tf_ver, probe = _probe_engine_imports() + report["probe"] = {"transformers": tf_ver, **probe} + if probe.get("indextts", "").startswith("ModuleNotFoundError"): + step("engine_imports", True, f"SKIP:本机没装 indextts(transformers={tf_ver}),只报不拦") + else: + bad = {k: v for k, v in probe.items() if k.startswith("indextts") and v != "ok"} + step( + "engine_imports", + not bad, + f"transformers={tf_ver} indextts 模块全通" + if not bad + else f"transformers={tf_ver} 下引擎推理模块导入失败 {bad} —— " + "引擎元数据要求 transformers==4.52.1 + tokenizers==0.21.0;" + "按 pyproject 的 transformers>=4.52.1,<4.53 重装环境,别抬下界" + "(证据见 docs/SECURITY_DEPENDABOT_TRIAGE.md §2)", + ) + # 1) /readyz:model_loaded 闸门(服务启动时 TTS_AUTO_LOAD_MODEL=1 自动加载 voxcpm2) ready = False for _ in range(120): @@ -129,14 +233,20 @@ def step(name: str, ok: bool, detail: str) -> bool: if not step("ready", ready, "model loaded" if ready else "model not loaded within 360s"): return _finish(report, args.output) + # 1.5) CSRF 双提交取票:/v1/audio/speech 与 /api/* 的 POST 都要带,浏览器里由 htmx + # 自动注入,脚本必须自己做(不带时 middleware 一律 403 CSRF_MISSING)。 + headers, csrf_err = _csrf_headers(base) + if not step("csrf_ticket", not csrf_err, csrf_err or "拿到 csrf_token cookie 并可回填 Header") or csrf_err: + return _finish(report, args.output) + # 2) voxcpm2 (tts-1) - st, raw = _speech(base, "tts-1", 120) + st, raw = _speech(base, "tts-1", 120, headers) ok = st == 200 and len(raw) > 44 and _is_wav(raw) if not step("synth_tts-1", ok, f"HTTP {st} bytes={len(raw)} wav={_is_wav(raw)}"): return _finish(report, args.output) # 3) 切换到 indextts2 并等待加载完成 - st, _ = _post_form(f"{base}/api/model/switch", {"engine": "indextts2"}, 30) + st, _ = _post_form(f"{base}/api/model/switch", {"engine": "indextts2"}, 30, headers=headers) switched = False for _ in range(200): st2, sb = _get(f"{base}/api/model/status", 10, parse=True) @@ -148,11 +258,66 @@ def step(name: str, ok: bool, detail: str) -> bool: return _finish(report, args.output) # 4) indextts2 (tts-1-hd) - st, raw = _speech(base, "tts-1-hd", 600) + st, raw = _speech(base, "tts-1-hd", 600, headers) ok = st == 200 and len(raw) > 44 and _is_wav(raw) if not step("synth_tts-1-hd", ok, f"HTTP {st} bytes={len(raw)} wav={_is_wav(raw)}"): return _finish(report, args.output) + # 5) indextts20(IndexTTS 2.0)—— OpenAI 口只有 tts-1 / tts-1-hd 两个模型名, + # 2.0 在其中没有位置,所以这一格只能走 /api/generate/indextts2 + expected_engine。 + indextts_present = not probe.get("indextts", "").startswith("ModuleNotFoundError") and "error" not in probe + if not indextts_present: + step("synth_indextts20", True, "SKIP:本机没有 indextts,交给装了它的 runner") + step("version_gate_refuses_mismatch", True, "SKIP:同上") + report["passed"] = True + return _finish(report, args.output) + + switched20, why = _switch_engine(base, "indextts20", headers, 420) + if not step("switch_indextts20", switched20, why): + return _finish(report, args.output) + + fields = { + "text": "这是一条 IndexTTS 2.0 的冒烟音频。", + "lang": "Auto", + "seed": "0", + "has_consent": "true", + "expected_engine": "indextts20", + } + ref = os.path.join(ROOT, "examples", "reference_speaker.wav") + files = {} + if os.path.exists(ref): + with open(ref, "rb") as f: + files = {"ref_audio": ("reference_speaker.wav", f.read(), "audio/wav")} + st, html = _post_form(f"{base}/api/generate/indextts2", fields, 900, files=files, headers=headers) + m = re.search(r'data-audio-filename="([^"]+)"', html or "") + audio = b"" + if m: + a_st, a_raw = _get(f"{base}/api/audio/{m.group(1)}", 60, parse=False) + audio = a_raw if isinstance(a_raw, bytes) else b"" + got = a_st == 200 and len(audio) > 44 and _is_wav(audio) + else: + got = False + detail = ( + f"HTTP {st} 音频文件={m.group(1) if m else '(响应里没有 data-audio-filename)'} " + f"bytes={len(audio)} wav={_is_wav(audio) if audio else False}" + ) + if not got and html: + detail += f" 响应片段={html[:200]!r}" + step("synth_indextts20", st == 200 and got, detail) + if not got: + return _finish(report, args.output) + + # 6) 版本门负向:当前加载 2.0,页面却声明 indextts2 → 必须被点名拒绝, + # 不能静默拿 2.0 的结果当 2.5 返回(GOTCHAS #130 那一类)。 + bad_fields = dict(fields, expected_engine="indextts2") + st2, html2 = _post_form(f"{base}/api/generate/indextts2", bad_fields, 300, files=files, headers=headers) + refused = st2 == 200 and "data-audio-filename" not in (html2 or "") and "但当前加载的是" in (html2 or "") + step( + "version_gate_refuses_mismatch", + refused, + f"HTTP {st2},无音频且点名两边={refused}" if refused else f"HTTP {st2} 未按预期拒绝:{(html2 or '')[:200]!r}", + ) + report["passed"] = True return _finish(report, args.output) From bf2ff859c938510e3309f9464f45b409b96ab84b Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Mon, 21 Sep 2026 19:43:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(openai):=20tts-1-hd=20=E4=BB=8E?= =?UTF-8?q?=E5=BF=85=E7=84=B6=20500=20=E6=94=B9=E6=88=90=20400=20+=20?= =?UTF-8?q?=E5=87=BA=E8=B7=AF=EF=BC=9Bsmoke=20=E8=A1=A5=202.5=20=E7=9C=9F?= =?UTF-8?q?=E5=90=88=E6=88=90=E4=B8=8E=E7=8A=B6=E6=80=81=E8=B0=93=E8=AF=8D?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冒烟第 4 步第一次真跑就撞出两个潜伏缺陷(这条作业在 CI 里从没执行过,所以它们一直隐身): 1) `model=tts-1-hd` 在这个端点**从来没成功过**。`_generate_indextts2` 传 `spk_audio_prompt=""`(P0-1 刻意不给 OpenAI 兼容口开参考音频通道),而 IndexTTS 必需说话人参考 → engine.infer 抛"说话人参考音频缺失" → 上层收敛成 500"音频生成失败"。 服务端日志实测:`[OpenAI API] IndexTTS2 生成失败: 说话人参考音频缺失`。 这里**不**擅自引入"默认说话人"(那等于替用户挑一条音色并绕过授权语义), 只把这条结构性不支持标成 400 并给出可操作出路(/api/generate/indextts2 或 model=tts-1)。 守卫:tests/test_openai_api.py 新增一条,喂 registry._engines 让 model_loaded 为真 (它是只读派生属性,硬设会 AttributeError: no setter),断言 400 + 文案含 "参考音频" 与 "/api/generate/indextts2"。 2) 状态谓词写错:`/api/model/status` 返回 `loaded`,脚本却按 `/readyz` 的 `model_loaded` 判, 那个 key 在响应里不存在 → 切换成功也报 not ready。改为 `_status_loaded()` 两种 key 都认, 并在 detail 里回打 vram 与轮询次数。 3) 覆盖形态重排:indextts2(2.5) 的真合成改走与 2.0 同一形态(/api/generate/indextts2 + expected_engine + 从 data-audio-filename 回取 /api/audio/ 验 RIFF),抽成 `_gen_and_check()` 供 2.5 / 2.0 / 负向三处复用;OpenAI 口那格换成断言 400 契约。 Signed-off-by: ReSerendipity --- app/integrated_app/openai_api.py | 18 ++++ scripts/gpu_smoke_minimal.py | 170 ++++++++++++++++++++----------- tests/test_openai_api.py | 23 +++++ 3 files changed, 153 insertions(+), 58 deletions(-) diff --git a/app/integrated_app/openai_api.py b/app/integrated_app/openai_api.py index f1b7b3d9..665458fd 100644 --- a/app/integrated_app/openai_api.py +++ b/app/integrated_app/openai_api.py @@ -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( diff --git a/scripts/gpu_smoke_minimal.py b/scripts/gpu_smoke_minimal.py index e3b5182a..79b69dcd 100644 --- a/scripts/gpu_smoke_minimal.py +++ b/scripts/gpu_smoke_minimal.py @@ -4,14 +4,14 @@ 在 self-hosted GPU runner 上,对**真实加载**的 TTS 引擎各跑一条最短合成, 校验返回的是真实音频字节(非空、合法 WAV/RIFF),并上报显存峰值。 -覆盖三个引擎(+ 两条负向): - - voxcpm2 (OpenAI model "tts-1") — 服务启动自动加载(TTS_AUTO_LOAD_MODEL=1) - - indextts2 (OpenAI model "tts-1-hd") — 经 POST /api/model/switch 切换后加载 - - indextts20(IndexTTS 2.0) — OpenAI 口没有它的位置,只能走 - POST /api/generate/indextts2 + expected_engine=indextts20,再从响应里的 - data-audio-filename 回取 /api/audio/ - - 开机第一步的引擎模块导入探针(transformers 版本一错就地硬失败) - - 版本门负向:加载 2.0 却声明 indextts2 的页面必须被点名拒绝,不许静默代打 +覆盖三个引擎(+ 一条契约 + 一条负向): + - voxcpm2 (OpenAI model "tts-1") — 服务启动自动加载(TTS_AUTO_LOAD_MODEL=1),真合成 + - indextts2 (IndexTTS 2.5) — /api/model/switch 加载后走 /api/generate 真合成 + - indextts20(IndexTTS 2.0) — 同上(expected_engine=indextts20);OpenAI 口没有它的位置 + - engine_imports(第 0 步) — 引擎推理模块导入探针,transformers 一错就地硬失败 + - openai_tts1hd_contract — model=tts-1-hd 必须回 400 并指明改走哪条口 + (本端点按 P0-1 不传说话人参考,而 IndexTTS 必需它;2026-09-21 首次真跑时它回的是 500) + - version_gate_refuses_mismatch(末步) — 加载 2.0 却声明 indextts2 必须被点名拒绝,不许静默代打 用法: python scripts/gpu_smoke_minimal.py \ @@ -137,16 +137,85 @@ def _csrf_headers(base: str) -> tuple[dict, str]: return {"Cookie": f"csrf_token={token}", "X-CSRF-Token": token}, "" +def _status_loaded(sb: dict, engine: str) -> bool: + """`/api/model/status` 的判据:当前引擎是 engine 且已 loaded。 + + WHY 两条 key 都认:这个端点返回的是 `loaded` / `current_engine`,而 `/readyz` 返回的是 + `model_loaded`。原脚本第 3 步拿 `model_loaded` 去判 `/api/model/status`,那个 key 在 + 响应里根本不存在 → 谓词恒 False,切换其实成功了也会报"not ready"。这条作业从来没在 + CI 上真跑过(gpu-smoke job 三次全 skipped),所以缺陷一直隐身。 + """ + if not isinstance(sb, dict): + return False + if sb.get("current_engine") != engine: + return False + return bool(sb.get("loaded") or sb.get("model_loaded")) + + def _switch_engine(base: str, engine: str, headers: dict, timeout_s: int = 420) -> tuple[bool, str]: - """POST /api/model/switch 并轮询 /api/model/status 直到该引擎真加载完。""" - _post_form(f"{base}/api/model/switch", {"engine": engine}, 30, headers=headers) + """POST /api/model/switch 并轮询 /api/model/status 直到该引擎真加载完。 + + 切换在服务端串行执行,可能远超客户端超时 —— 那不算失败,以 status 为准。 + """ + st, _ = _post_form(f"{base}/api/model/switch", {"engine": engine}, 30, headers=headers) deadline = time.time() + timeout_s + polls = 0 while time.time() < deadline: - st, sb = _get(f"{base}/api/model/status", 15) - if st == 200 and isinstance(sb, dict) and sb.get("current_engine") == engine and sb.get("model_loaded"): - return True, "switched + loaded" + polls += 1 + st2, sb = _get(f"{base}/api/model/status", 15) + if _status_loaded(sb, engine): + vram = sb.get("vram_used_mb", "?") + return True, f"loaded({polls} 次轮询,切换 POST 客户端状态 {st},vram={vram} MiB)" time.sleep(4) - return False, f"{engine} 在 {timeout_s}s 内没到 loaded" + return False, f"{engine} 在 {timeout_s}s 内没到 loaded({polls} 次轮询,切换 POST 客户端状态 {st})" + + +_REF_WAV = os.path.join(ROOT, "examples", "reference_speaker.wav") + + +def _gen_and_check( + base: str, + headers: dict, + engine_id: str, + text: str, + expect_mismatch: str | None = None, +) -> tuple[bool, str]: + """POST /api/generate/indextts2 真合成,再从 data-audio-filename 回取音频验 RIFF。 + + IndexTTS 2.5 与 2.0 共用这个端点,靠 `expected_engine` 区分;OpenAI 兼容口 + 只有 tts-1 / tts-1-hd 两个模型名,2.0 在其中没有位置(而 tts-1-hd 按 P0-1 + 压根不给说话人参考,见 openai_api.py 的 400 契约),所以真合成只能走这条形态。 + + `expect_mismatch` 非空时表示这是一次**负向**请求:页面声明的引擎与实际加载不一致, + 必须被点名拒绝(无音频 + 文案含"但当前加载的是"),不许静默拿当前引擎的结果代打。 + """ + fields = { + "text": text, + "lang": "Auto", + "seed": "0", + "has_consent": "true", + "expected_engine": expect_mismatch or engine_id, + } + files: dict = {} + if os.path.exists(_REF_WAV): + with open(_REF_WAV, "rb") as f: + files = {"ref_audio": ("reference_speaker.wav", f.read(), "audio/wav")} + st, html = _post_form(f"{base}/api/generate/indextts2", fields, 900, files=files, headers=headers) + + if expect_mismatch: + refused = "data-audio-filename" not in (html or "") and "但当前加载的是" in (html or "") + detail = f"HTTP {st},期望被拒绝(声明 {expect_mismatch} vs 加载 {engine_id})" + ( + "" if refused else f",响应片段={(html or '')[:200]!r}" + ) + return refused, detail + + m = re.search(r'data-audio-filename="([^"]+)"', html or "") + if not m: + return False, f"HTTP {st},响应里没有 data-audio-filename;片段={(html or '')[:200]!r}" + a_st, a_raw = _get(f"{base}/api/audio/{m.group(1)}", 60, parse=False) + audio = a_raw if isinstance(a_raw, bytes) else b"" + ok = st == 200 and a_st == 200 and len(audio) > 44 and _is_wav(audio) + return ok, f"HTTP {st} → /api/audio/{m.group(1)} HTTP {a_st} bytes={len(audio)} wav={_is_wav(audio)}" def _probe_engine_imports() -> tuple[str, dict]: @@ -250,21 +319,33 @@ def step(name: str, ok: bool, detail: str) -> bool: switched = False for _ in range(200): st2, sb = _get(f"{base}/api/model/status", 10, parse=True) - if st2 == 200 and isinstance(sb, dict) and sb.get("current_engine") == "indextts2" and sb.get("model_loaded"): + if _status_loaded(sb, "indextts2"): switched = True break time.sleep(3) if not step("switch_indextts2", switched, "switched + loaded" if switched else f"switch HTTP {st}, not ready"): return _finish(report, args.output) - # 4) indextts2 (tts-1-hd) - st, raw = _speech(base, "tts-1-hd", 600, headers) - ok = st == 200 and len(raw) > 44 and _is_wav(raw) - if not step("synth_tts-1-hd", ok, f"HTTP {st} bytes={len(raw)} wav={_is_wav(raw)}"): + # 4) indextts2 的真实覆盖走 /api/generate/indextts2(见下),这里只钉 OpenAI 口的契约: + # model=tts-1-hd 必须给 **400 + 可操作说明**,不能是 500 "音频生成失败"。 + # (2026-09-21 首次真跑发现:本端点按 P0-1 不传说话人参考,而 IndexTTS 必需它, + # 于是这个模型口 500 是必然 —— 以前没人看见是因为这条冒烟从没在 CI 真跑过。) + st, raw = _speech(base, "tts-1-hd", 120, headers) + body = (raw or b"").decode("utf-8", "replace") + contract_ok = st == 400 and "参考音频" in body and "/api/generate/indextts2" in body + if not step( + "openai_tts1hd_contract", + contract_ok, + f"HTTP {st} bytes={len(raw)} body={body[:150]!r}", + ): + return _finish(report, args.output) + + # 4b) indextts2 (IndexTTS 2.5) 真合成 —— 与 2.0 同一形态,验的是"能出真音频"本身 + ok25, why25 = _gen_and_check(base, headers, "indextts2", "这是一条 IndexTTS 2.5 的冒烟音频。") + if not step("synth_indextts2", ok25, why25): return _finish(report, args.output) - # 5) indextts20(IndexTTS 2.0)—— OpenAI 口只有 tts-1 / tts-1-hd 两个模型名, - # 2.0 在其中没有位置,所以这一格只能走 /api/generate/indextts2 + expected_engine。 + # 5) indextts20(IndexTTS 2.0) indextts_present = not probe.get("indextts", "").startswith("ModuleNotFoundError") and "error" not in probe if not indextts_present: step("synth_indextts20", True, "SKIP:本机没有 indextts,交给装了它的 runner") @@ -276,47 +357,20 @@ def step(name: str, ok: bool, detail: str) -> bool: if not step("switch_indextts20", switched20, why): return _finish(report, args.output) - fields = { - "text": "这是一条 IndexTTS 2.0 的冒烟音频。", - "lang": "Auto", - "seed": "0", - "has_consent": "true", - "expected_engine": "indextts20", - } - ref = os.path.join(ROOT, "examples", "reference_speaker.wav") - files = {} - if os.path.exists(ref): - with open(ref, "rb") as f: - files = {"ref_audio": ("reference_speaker.wav", f.read(), "audio/wav")} - st, html = _post_form(f"{base}/api/generate/indextts2", fields, 900, files=files, headers=headers) - m = re.search(r'data-audio-filename="([^"]+)"', html or "") - audio = b"" - if m: - a_st, a_raw = _get(f"{base}/api/audio/{m.group(1)}", 60, parse=False) - audio = a_raw if isinstance(a_raw, bytes) else b"" - got = a_st == 200 and len(audio) > 44 and _is_wav(audio) - else: - got = False - detail = ( - f"HTTP {st} 音频文件={m.group(1) if m else '(响应里没有 data-audio-filename)'} " - f"bytes={len(audio)} wav={_is_wav(audio) if audio else False}" - ) - if not got and html: - detail += f" 响应片段={html[:200]!r}" - step("synth_indextts20", st == 200 and got, detail) - if not got: + ok20, why20 = _gen_and_check(base, headers, "indextts20", "这是一条 IndexTTS 2.0 的冒烟音频。") + if not step("synth_indextts20", ok20, why20): return _finish(report, args.output) # 6) 版本门负向:当前加载 2.0,页面却声明 indextts2 → 必须被点名拒绝, - # 不能静默拿 2.0 的结果当 2.5 返回(GOTCHAS #130 那一类)。 - bad_fields = dict(fields, expected_engine="indextts2") - st2, html2 = _post_form(f"{base}/api/generate/indextts2", bad_fields, 300, files=files, headers=headers) - refused = st2 == 200 and "data-audio-filename" not in (html2 or "") and "但当前加载的是" in (html2 or "") - step( - "version_gate_refuses_mismatch", - refused, - f"HTTP {st2},无音频且点名两边={refused}" if refused else f"HTTP {st2} 未按预期拒绝:{(html2 or '')[:200]!r}", + # 不许静默拿 2.0 的结果当 2.5 返回(GOTCHAS #130 那一类)。 + refused, why_neg = _gen_and_check( + base, + headers, + "indextts20", + "这一条不应该被合成。", + expect_mismatch="indextts2", ) + step("version_gate_refuses_mismatch", refused, why_neg) report["passed"] = True return _finish(report, args.output) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 1ca830b5..6ba4332a 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -258,6 +258,29 @@ def test_create_speech_no_model_loaded(self, openai_client): ) assert resp.status_code == 503 + def test_create_speech_tts1hd_is_not_served_here(self, openai_client, monkeypatch): + """model=tts-1-hd 必须回 **400 + 出路**,不能是 500 "音频生成失败"。 + + 2026-09-21 首次真跑 GPU 冒烟时它是 500:本端点按 P0-1 不传说话人参考音频 + (``spk_audio_prompt=""``),而 IndexTTS 必需该参考,engine.infer 抛 + "说话人参考音频缺失" 被上层收敛成 500。修法不是猜一个默认音色(那是替用户 + 选音色并绕过授权语义),而是把这条结构性不支持如实标成 400 并指出可走的口。 + """ + from integrated_app.model_registry import registry + + # `model_loaded` 是只读派生属性(看 _voxcpm_model / _indextts2_engine / _engines), + # 所以喂 _engines 而不是硬设 model_loaded。 + monkeypatch.setattr(registry, "_engines", {"indextts2": object()}, raising=False) + resp = openai_client.post( + "/v1/audio/speech", + json={"input": "hello", "model": "tts-1-hd", "voice": "alloy"}, + ) + assert resp.status_code == 400, resp.text + detail = resp.json()["detail"] + assert "参考音频" in detail and "/api/generate/indextts2" in detail, detail + # 出路要真的可用:tts-1(VoxCPM2)这条口不能也被同一条 400 挡掉 + assert "tts-1(" in detail or "model=tts-1" in detail, detail + def test_create_speech_invalid_model(self, openai_client): """POST /v1/audio/speech with invalid model should return 422.""" resp = openai_client.post( From a68055be7909cdc7854b8daa718d020c9ea4b859 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Mon, 21 Sep 2026 20:14:45 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(engines):=202.5/2.0=20=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E7=B1=BB=E7=9A=84=E4=B8=A4=E5=A4=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=96=87=E6=A1=88=E7=82=B9=E5=90=8D=E4=BA=86=E9=94=99=E7=9A=84?= =?UTF-8?q?=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机跑冒烟时,indextts20 的失败被报成 "IndexTTS 2.5 合成失败";同类的加载期文案 `IndexTTS 2.5 模型文件不可读 ... 请运行 python scripts/download_indextts2.py` 会把人直接引去下载**另一套**权重目录(model/IndexTTS-2.0 vs IndexTTS-2.5)。 两处都改成取 self.version_str。 守卫:tests/test_indextts2_interface.py 新增按版本参数化的一条 —— 用现成的 _bare_engine 造两个变体,让底层 infer 抛异常,断言 GenerationError 文案以 "IndexTTS <该变体> 合成失败" 开头,且**不含另一个变体名**。 本机:该文件 18 passed;全量 2092 passed / 0 failed;ruff / mypy 103 = 基线 / integrity 清单同步 PASS。 Signed-off-by: ReSerendipity --- .../engines/indextts2_engine.py | 9 ++++++-- tests/test_indextts2_interface.py | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/integrated_app/engines/indextts2_engine.py b/app/integrated_app/engines/indextts2_engine.py index c5ade284..e0bc86f5 100644 --- a/app/integrated_app/engines/indextts2_engine.py +++ b/app/integrated_app/engines/indextts2_engine.py @@ -471,7 +471,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, @@ -917,7 +919,10 @@ 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 diff --git a/tests/test_indextts2_interface.py b/tests/test_indextts2_interface.py index 948e1710..d83927cc 100644 --- a/tests/test_indextts2_interface.py +++ b/tests/test_indextts2_interface.py @@ -174,3 +174,25 @@ def test_duration_factor_rejected_on_2_0(self, tmp_path): assert exc.value.field == "duration_scale" assert str(exc.value).startswith("IndexTTS 2.0 ") eng.tts.infer.assert_not_called() + + @pytest.mark.parametrize("version", ["2.5", "2.0"]) + def test_generation_failure_names_the_running_variant(self, tmp_path, version): + """推理内部抛异常时,GenerationError 的文案必须点名**当前实例**的变体。 + + WHY:2.5 与 2.0 共用 IndexTTS2Engine,原先文案硬编码 "IndexTTS 2.5", + 2026-09-21 真机跑冒烟时在 indextts20 上报出 "IndexTTS 2.5 合成失败", + 同一函数里的加载错误也曾说 "IndexTTS 2.5 模型文件不可读 + 请跑 + download_indextts2.py",会把人引去下载另一套权重目录。 + """ + from integrated_app.exceptions import GenerationError + + eng = self._bare_engine(version, supports_duration=True) + eng.tts.infer.side_effect = RuntimeError("CUDA error: device-side assert triggered") + with pytest.raises(GenerationError) as exc: + eng.infer( + text="你好", + spk_audio_prompt=self._ref_audio(tmp_path), + output_path=str(tmp_path / "out.wav"), + ) + assert str(exc.value).startswith(f"IndexTTS {version} 合成失败"), str(exc.value) + assert f"IndexTTS {'2.0' if version == '2.5' else '2.5'}" not in str(exc.value) From 003ef9a572301ca646fe2ad8146f815b07dd8527 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Mon, 21 Sep 2026 20:18:04 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(DOD):=20=E8=AE=B0=E5=88=B0=E7=AC=AC?= =?UTF-8?q?=E5=87=A0=E6=A0=BC=E5=B0=B1=E5=86=99=E5=88=B0=E7=AC=AC=E5=87=A0?= =?UTF-8?q?=E6=A0=BC=20=E2=80=94=E2=80=94=20=E8=A1=A5=E4=B8=8A"=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E6=AC=A1=E5=88=87=E6=8D=A2=E5=90=8E=20CUDA=20assert"?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E6=9C=AA=E5=AE=9A=E7=BA=A7=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版这里写"本机真机跑通",不成立:冒烟前 7 格真机验过(探针 / CSRF 取票 / voxcpm2 合成 310,470 B / 切 2.5 / tts-1-hd 400 契约 / 2.5 合成 298,746 B / 切 2.0),但第 8 格 `synth_indextts20` 撞在 CUDA device-side assert 上,如实 FAIL。 同时把定位过程写进去,免得下次有人重新猜一遍: - 冷切换不是原因(以 indextts2 直接启动 → 386,796 B;voxcpm2→indextts2 冷切 → 290,944 B,都成功); - 参考音频 examples/reference_speaker.wav 不是原因(同一文件在冷启动路径出真音频); - 开 CUDA_LAUNCH_BLOCKING=1 后断言仍落在第二次切换之后那一步,排除异步错位归因。 现状只复现到"第二次切换"这一层,未定位到具体 kernel;是否 v2.2.2 拦路交所有者判。 Signed-off-by: ReSerendipity --- docs/DOD.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/DOD.md b/docs/DOD.md index 098a0902..9f16550d 100644 --- a/docs/DOD.md +++ b/docs/DOD.md @@ -149,7 +149,11 @@ 用服务所在解释器 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/` 校验 RIFF,另加一条版本门负向 - (加载 2.0 却声明 `indextts2` 必须被点名拒绝)。**本机真机跑通**(RTX 5070 Ti,见本轮记录)。 + (加载 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) @@ -161,6 +165,17 @@ **仍未消掉**:runner 不存在 → IndexTTS 2.5/2.0 的真推理与"两个 IndexTTS 变体的导入"在 CI 上 依然零覆盖,只有本机能验;要让这条变成 CI 事实,需要注册一台带 `gpu` 标签的 self-hosted runner 并配 `REPO_ADMIN_TOKEN`(归所有者)。 + * **本机真机跑冒烟时撞出一个未定级的运行时缺陷(发版前值得看一眼)**:同一进程里 + **第二次切换引擎之后**的那次合成会 `CUDA error: device-side assert triggered`。 + 复现序列(RTX 5070 Ti 12,227 MiB,transformers 4.52.1): + voxcpm2 自动加载 → `synth_tts-1` 成功(310,470 B)→ 切 indextts2 → 2.5 合成成功(298,746 B) + → 切 indextts20 → **2.0 合成报 device-side assert**(HTTP 400 的错误块里)。 + 两个对照实验排除了我先前的猜想:(a) 冷切换不是原因 —— 直接以 indextts2 启动、或 + voxcpm2→indextts2 冷切之后首次合成都成功(386,796 B / 290,944 B);(b) 参考音频 + `examples/reference_speaker.wav` 不是原因 —— 同一个文件在冷启动路径下能出真音频。 + 开 `CUDA_LAUNCH_BLOCKING=1` 后断言仍落在**第二次切换之后**那一步,说明不是异步错位归因。 + 现状:只复现到"第二次切换"这一层,未定位到具体 kernel;冒烟现在会**如实报 FAIL** + (不是静默跳过),所以一旦 runner 上线,这条会被 CI 抓住。归所有者定是否 v2.2.2 拦路。 * **仍未覆盖**:桌面安装包链路(staging → data 7z → NSIS)**无任何 workflow 调用**、本机也无从安装 (`scripts/installer/` 只有一个 4.3 MB `Setup.exe`、无同目录分卷),所以 `unpack_desktop.ps1` 新加的许可/字体落地核对只过了语法层,`release_gate.ps1` 的第 ⑥ 步也只在发版/dispatch 时跑; From 9e41963238d421f89945cda4e6ce753b9f73cbe5 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Mon, 21 Sep 2026 21:39:32 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(engines):=20=E9=A2=84=E7=83=AD=E7=BB=95?= =?UTF-8?q?=E5=BC=80=E4=B8=B2=E8=A1=8C=E9=98=9F=E5=88=97=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=AF=B7=E6=B1=82=E5=B9=B6=E5=8F=91=20=E2=86=92=20CUD?= =?UTF-8?q?A=20device-side=20assert=EF=BC=88=E5=B7=B2=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:`CUDA error: device-side assert triggered`,且一次触发会**毒化整个 CUDA context** —— 之后同进程内所有推理连带失败,连引擎切换的回滚重载都报 503。我先前把它记成 "同一进程第二次切换之后必挂",那是错的:第二次切换只是必然触发预热的时机。 服务端时间线是决定性证据(本机 RTX 5070 Ti,transformers 4.52.1): 21:05:57 [req=bg-model-startup-load] [IndexTTS2] 开始合成 text='你好' ← 预热 21:05:59 [req=f663d3ae…] [IndexTTS2] 开始合成 text='等完预热后的…' ← 用户请求挤进来 21:06:01 两条**一起** device-side assert 根因:`model_optimizer.warmup_indextts2` 从后台加载线程**直接调 `engine.infer`**,绕开了 用户侧那把 per-engine `asyncio.Semaphore(1)`(`routes/generate/utils.py:439`,全文件无锁), 而 IndexTTS 推理不可重入。 修法放在引擎这一层而不是调用方:按注册名分组(2.5 / 2.0 各一把)的 `threading.RLock`, `infer` 变成持锁的薄包装 —— 队列 / SSE / 预热 / OpenAI 口任何入口都被串行化, 新加入口忘了拿锁也不会再炸。签名靠 `functools.wraps` 保住(接口测试是按 `inspect.signature` 查 14 个参数的)。 真机验证(两步): ① 原并发条件(预热进行中就压请求):修复前必崩 → 修复后 2.5 = 336,642 B、2.0 = 239,674 B, 与串行跑的字节数**完全一致**,说明锁只串行化不改结果; ② 完整冒烟 10 格全绿:engine_imports / ready / csrf_ticket / voxcpm2 321,962 B / switch_indextts2 / tts-1-hd 400 契约 / 2.5 = 386,796 B / switch_indextts20 / 2.0 = 402,400 B / 版本门负向按预期拒绝;结束显存回落 2,666 MiB。 守卫:`test_infer_serializes_concurrent_callers` 用三条线程压同一实例,断言进入模型层的 并发计数 max == 1;变异自证过 —— 把 wrapper 的 `with 锁` 去掉该条立刻变红,装回就绿。 门禁:ruff / format 全绿,mypy 103 = 基线,integrity 清单同步 PASS(本文件不在 16 个被签模块里, 所以不需要重签),全量 2093 passed / 0 failed。 顺带:DOD 里那条"发现但没修"的缺陷改写为已定位已修 + 三个被证伪的嫌疑(冷切换、素材、 异步错位归因),并新增一条**与本仓改动无关的 CI 基础设施故障**:`Dockerfile` 的 apt 层 `update-alternatives: error: alternative path /usr/share/man/man7/bash-builtins.7.gz doesn't exist`, main 11:07 的构建还是 success,12:19 起两次 PR 构建 + 各一次重试全红在同一步;本机没有 docker daemon,任何 apt/dpkg 规避都是盲改且会改变发版镜像内容,所以没动 Dockerfile。 Signed-off-by: ReSerendipity --- .../engines/indextts2_engine.py | 44 ++++++++++++++++- docs/DOD.md | 40 +++++++++++----- tests/test_indextts2_interface.py | 48 +++++++++++++++++++ 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/app/integrated_app/engines/indextts2_engine.py b/app/integrated_app/engines/indextts2_engine.py index e0bc86f5..34c1cfa2 100644 --- a/app/integrated_app/engines/indextts2_engine.py +++ b/app/integrated_app/engines/indextts2_engine.py @@ -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 @@ -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 引擎适配器。 @@ -631,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, @@ -926,6 +958,16 @@ def infer( 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, diff --git a/docs/DOD.md b/docs/DOD.md index 9f16550d..89558a1b 100644 --- a/docs/DOD.md +++ b/docs/DOD.md @@ -165,17 +165,35 @@ **仍未消掉**:runner 不存在 → IndexTTS 2.5/2.0 的真推理与"两个 IndexTTS 变体的导入"在 CI 上 依然零覆盖,只有本机能验;要让这条变成 CI 事实,需要注册一台带 `gpu` 标签的 self-hosted runner 并配 `REPO_ADMIN_TOKEN`(归所有者)。 - * **本机真机跑冒烟时撞出一个未定级的运行时缺陷(发版前值得看一眼)**:同一进程里 - **第二次切换引擎之后**的那次合成会 `CUDA error: device-side assert triggered`。 - 复现序列(RTX 5070 Ti 12,227 MiB,transformers 4.52.1): - voxcpm2 自动加载 → `synth_tts-1` 成功(310,470 B)→ 切 indextts2 → 2.5 合成成功(298,746 B) - → 切 indextts20 → **2.0 合成报 device-side assert**(HTTP 400 的错误块里)。 - 两个对照实验排除了我先前的猜想:(a) 冷切换不是原因 —— 直接以 indextts2 启动、或 - voxcpm2→indextts2 冷切之后首次合成都成功(386,796 B / 290,944 B);(b) 参考音频 - `examples/reference_speaker.wav` 不是原因 —— 同一个文件在冷启动路径下能出真音频。 - 开 `CUDA_LAUNCH_BLOCKING=1` 后断言仍落在**第二次切换之后**那一步,说明不是异步错位归因。 - 现状:只复现到"第二次切换"这一层,未定位到具体 kernel;冒烟现在会**如实报 FAIL** - (不是静默跳过),所以一旦 runner 上线,这条会被 CI 抓住。归所有者定是否 v2.2.2 拦路。 + * **真机跑冒烟时定位并修掉一个 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 时跑; diff --git a/tests/test_indextts2_interface.py b/tests/test_indextts2_interface.py index d83927cc..b691b7e6 100644 --- a/tests/test_indextts2_interface.py +++ b/tests/test_indextts2_interface.py @@ -3,6 +3,7 @@ 覆盖目标模块: app/integrated_app/engines/indextts2_engine.py """ +import contextlib import inspect import pytest @@ -175,6 +176,53 @@ def test_duration_factor_rejected_on_2_0(self, tmp_path): assert str(exc.value).startswith("IndexTTS 2.0 ") eng.tts.infer.assert_not_called() + # ── 以下两条挂在 TestDurationControlGuard 是因为要复用它的 _bare_engine / _ref_audio + # 两个桩(单独成类会把 fixture 甩在后面);语义与时长无关:一条管并发串行,一条管报错文案。 + def test_infer_serializes_concurrent_callers(self, tmp_path): + """同一实例上的并发 infer 必须串行 —— 这是预热与用户请求抢占的真机故障守卫。 + + WHY:预热(model_optimizer.warmup_indextts2)从后台线程直接调 `engine.infer`, + 绕开了用户侧的 per-engine asyncio.Semaphore(1)。2026-09-21 真机实测:预热开始 2 秒后 + 插入一条用户合成,两条并发进同一个 IndexTTS 模型 → `CUDA error: device-side assert + triggered`,整个 CUDA context 被毒化,之后同进程所有推理连带失败。 + 引擎层的 RLock 是"任何入口都串行"的兜底,所以这里直接压并发,不测上游用了哪把锁。 + """ + import threading + import time + from unittest.mock import MagicMock + + eng = self._bare_engine("2.5", supports_duration=True) + live: list[int] = [] + seen: list[int] = [] + guard = threading.Lock() + + def _fake_infer(*args: object, **kwargs: object) -> tuple: + with guard: + live.append(1) + seen.append(len(live)) + time.sleep(0.2) + with guard: + live.pop() + return (22050, MagicMock()) + + eng.tts.infer.side_effect = _fake_infer + + def _call(i: int) -> None: + with contextlib.suppress(Exception): # 后续步骤缺 stub 不重要,看重叠没有 + eng.infer( + text=f"并发第 {i} 条", + spk_audio_prompt=self._ref_audio(tmp_path), + output_path=str(tmp_path / f"out{i}.wav"), + ) + + threads = [threading.Thread(target=_call, args=(i,)) for i in range(3)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + assert seen, "一次都没进到模型层,说明桩没接上(本守卫会空转)" + assert max(seen) == 1, f"并发进入了模型层 {max(seen)} 次 —— device-side assert 的形状回来了" + @pytest.mark.parametrize("version", ["2.5", "2.0"]) def test_generation_failure_names_the_running_variant(self, tmp_path, version): """推理内部抛异常时,GenerationError 的文案必须点名**当前实例**的变体。