Skip to content

ci(smoke): 引擎导入探针 + tts-1-hd 契约 + 补 2.0 那格;顺带揪出 3 个从没执行过的 CI 信号 - #107

Merged
ReSerendipity merged 5 commits into
mainfrom
ci/engine-import-and-indextts20-smoke
Sep 21, 2026
Merged

ReSerendipity merged 5 commits into
mainfrom
ci/engine-import-and-indextts20-smoke

Conversation

@ReSerendipity

@ReSerendipity ReSerendipity commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

这条 PR 来自哪里

A1(#103)落地时我自己写了一句"不管选哪个,都把容器内加载引擎补进冒烟 —— 否则同类问题还会以门禁全绿的形式复现",当时没做。这次做完,做的过程中撞出三个"信号其实是空的",一并修/记。

真机验证到哪一格(RTX 5070 Ti,transformers 4.52.1)

步骤 结果
engine_imports(新) ✅ transformers=4.52.1 indextts / infer_v2 / infer_v2_5 全通
ready ✅
csrf_ticket(新) ✅
synth_tts-1(voxcpm2 真合成) ✅ HTTP 200,310,470 B,RIFF/WAVE 合法
switch_indextts2 ✅
openai_tts1hd_contract(新) ✅ HTTP 400,文案含出路
synth_indextts2(2.5 真合成,新形态) ✅ HTTP 200 → /api/audio/… 298,746 B RIFF
switch_indextts20 ✅ vram 7,036 MiB
synth_indextts20 ❌ CUDA error: device-side assert triggered
version_gate_refuses_mismatch 未执行(上一步 FAIL 就停)

最后那格没跑通,PR 没有把它粉饰成跳过 —— 见下面"发现但没修"。

三个假信号(都已处理)

  1. model=tts-1-hd 在 OpenAI 兼容口从来没成功过。_generate_indextts2 传 spk_audio_prompt=""(P0-1 刻意不给这个口开参考音频通道),而 IndexTTS 必需说话人参考 → engine.infer 抛"说话人参考音频缺失" → 上层收敛成 500 "音频生成失败"。
    修法不是猜一个默认音色(那是替用户挑音色并绕过授权语义),而是把这条结构性不支持标成 400 + 出路(改走 /api/generate/indextts2,或改 model=tts-1)。守卫:tests/test_openai_api.py 新增一条(model_loaded 是只读派生属性,喂 registry._engines 才生效)。
  2. gpu-smoke.yml 从没真跑过,而 run 顶层显示 success。三次 schedule run(09-07 / 09-14 / 09-21)的 gpu-smoke job 全部 skipped:secrets 里只有 MANIFEST_SIGNING_KEY_B64,REPO_ADMIN_TOKEN 从没配过,且 GET /actions/runners 返回零个注册 runner。原先这条走 ::notice + exit 0。现在跳过会打 ::warning 并写进 job summary。
    所以"这类断裂最坏一周内被 CI 抓到"这句话 Previously 是错的:暴露延迟不是以周计,是无穷大。每天真能跑的兜底改由 docker-smoke 承担。
  3. 冒烟脚本自身的两个潜伏缺陷(因为作业从没跑过,所以从没暴露):
    • 所有 POST 都不带 CSRF 双提交票,而 /v1/audio/speech 不在豁免路径里 → 一跑就 403 CSRF_MISSING;
    • 状态谓词抄错 key:/api/model/status 返回 loaded,脚本按 /readyz 的 model_loaded 判 → 切换成功也报 not ready。
      两个都修了(_status_loaded() 两种 key 都认,并在 detail 里回打 vram 与轮询次数)。

其它改动

  • engine_imports 判据不写版本号区间(避免与 pyproject 重复一份真相):只问"引擎推理模块导不导得进来",失败就把"引擎要求 4.52.x / 证据在 §2"打进报错;本机没装 indextts 时明确记 SKIP 而不是假绿。
  • IndexTTS 2.5 / 2.0 真合成统一走 _gen_and_check()(/api/generate/indextts2 + expected_engine,从 data-audio-filename 回取 /api/audio/<file> 验 RIFF),并保留版本门负向。
  • docker-smoke.yml:新增 Engine import probe inside the image(docker exec 进真镜像 import integrated_app.vendor.voxcpm 与 integrated_app.engines.voxcpm2.engine;本机实测 10.9s 通过)。边界写在注释里:镜像里没有 indextts,这步只钉得住 vendored VoxCPM2 那条链。同时给触发 paths 补 requirements.txt / requirements-lock.txt / pyproject.toml —— 以前"依赖区间被改坏"正好是这个作业不触发的场景。
  • indextts2_engine.py:2.5/2.0 共用类的两处报错文案硬编码了 "IndexTTS 2.5"(合成失败、模型文件不可读 + 请跑 download_indextts2.py),在 2.0 上会把人引去下载另一套权重目录。改用 self.version_str,并加一条按版本参数化的守卫(断言文案点名当前变体、且不含另一个变体名)。
  • 文档:DOD.md 与 SECURITY_DEPENDABOT_TRIAGE.md §2 按上面的实测重写(原先写"最近一次 09-14 success",暗示它跑过)。

追加:那条 CUDA assert 已定位并修掉(commit 9e41963)

原来的说法("同一进程第二次切换之后必挂")是错的 —— 第二次切换只是必然触发预热的时机。
服务端时间线是决定性证据:预热 21:05:57 开始合成 → 用户请求 21:05:59 挤进来 →
21:06:01 两条一起 device-side assert;一次触发就毒化整个 CUDA context
(之后连切换的回滚重载都 503)。

根因:model_optimizer.warmup_indextts2 从后台加载线程直接调 engine.infer,绕开了用户侧
那把 per-engine asyncio.Semaphore(1)(routes/generate/utils.py:439),而 IndexTTS 推理不可重入。

修法放在引擎层而非某个调用方:按注册名分组(2.5/2.0 各一把)的 threading.RLock,infer 变成
持锁薄包装,functools.wraps 保住签名(接口测试按 inspect.signature 查 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;
  • 守卫 test_infer_serializes_concurrent_callers(3 线程压同一实例,断言进入模型层并发 max==1)
    做过变异自证:去掉 wrapper 的 with 锁 立刻变红,装回就绿。

indextts2_engine.py 不在 16 个被签模块里 → 这条修复不需要重签;integrity 仍 PASS,
全量 2093 passed / 0 failed。

另一条红与本 PR 无关:镜像构建的确定性基础设施故障(未修)

Build & Scan Image 与 Boot hardened container & probe 不是本 PR 引起的:Dockerfile 的 apt 层报
update-alternatives: error: alternative path /usr/share/man/man7/bash-builtins.7.gz doesn't exist,
失败点在我的探针步骤之前,本 PR 也没碰 Dockerfile。判据:main 11:07 的同类构建还是 success
(41f5a12),12:19 / 12:28 两次 PR 构建红在同一步,各重试一次仍一模一样 → 确定性而非瞬时。
本机没有 docker daemon,任何 apt/dpkg 规避(path-exclude=/usr/share/man/* 之类)都是盲改且会
改变发版镜像内容,所以我没动它;已记进 docs/DOD.md 等能被真构建的环境处理。

本机门禁

ruff check / format --check 全绿;mypy 103 = 基线;check_integrity_manifest_sync PASS(16 个签名模块一致,本次没动被签文件);tests/test_openai_api.py + test_indextts2_interface.py + test_dependency_consistency.py 80 passed;全量 2092 passed / 0 failed;真机结果见上表。

三件事,都来自 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/<file> 验 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 <zengyangc@outlook.com>
冒烟第 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/<file> 验 RIFF),抽成
   `_gen_and_check()` 供 2.5 / 2.0 / 负向三处复用;OpenAI 口那格换成断言 400 契约。

Signed-off-by: ReSerendipity <zengyangc@outlook.com>
真机跑冒烟时,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 <zengyangc@outlook.com>
上一版这里写"本机真机跑通",不成立:冒烟前 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 <zengyangc@outlook.com>
@ReSerendipity
ReSerendipity force-pushed the ci/engine-import-and-indextts20-smoke branch from aee462c to 003ef9a Compare September 21, 2026 12:27
现象:`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 <zengyangc@outlook.com>
@ReSerendipity
ReSerendipity merged commit d520688 into main Sep 21, 2026
31 of 32 checks passed
ReSerendipity added a commit that referenced this pull request Sep 22, 2026
docker-smoke 的"镜像内引擎导入探针"(#107 加的那步)第一次真正跑到就红:
  FAIL integrated_app.vendor.voxcpm ModuleNotFoundError No module named 'einops'
(run 35618578940 / job 106395583267)。不是我这个分支改坏的 —— 同一镜像在 PPA 恢复正常之前
根本构建不到那一步。

真因是**依赖声明缺项**:
- `app/integrated_app/vendor/voxcpm/model/voxcpm.py:29`、`voxcpm2.py:30`、
  `modules/locenc/local_encoder.py:4` 在模块顶层 `from einops import rearrange`;
- einops 在本仓只出现在 `training` extra 里;
- 而 funasr / modelscope 只在 **extras** 里声明 einops(读发行元数据实测),我们装的是无 extras
  的核心集 —— 所以按 requirements.txt 或 `[project].dependencies` 装出来的任何干净环境都起不来
  tts-1(VoxCPM2,默认自动加载的那个引擎)。
- 本机 .venv 永远看不见此事:einops 是从训练链路传递进来的
  (`pip show` Required-by: conformer, indextts, torch-einops-utils, vector-quantize-pytorch)。

同形状的第二条:`addict` 早就声明为生产依赖(pyproject 注释写明"缺失时降噪会降级关闭、音质下降"),
却在 `requirements-lock.txt` 与 `launcher/requirements-small.txt` 里**都不存在**,modelscope 对它的
依赖同样只在 extras 里 —— 便携包那条离线钉装链与镜像一样装不出来。这条一直藏在
"桌面安装包链路无任何 workflow 覆盖、本机也无从安装"的盲区里;便携侧我只到"清单缺项"的证据强度,
没实拆过包。

改动:
- pyproject 核心依赖 += einops>=0.7.0(带实测出处与教训),requirements.txt 由
  sync_requirements.py 同步(26 个依赖,`--check` 通过);
- 两份钉版集各补 addict==2.4.0 与 einops==0.8.2(版本取本机 .venv 实测,与当初对齐锁集的做法
  一致),`--check-small` 94 项对齐通过;
- 新增 D5(tests/test_dependency_consistency.py):核心声明的每个包必须在两份钉版集里都有钉版,
  唯一豁免是 torch 三件套(便携包里作为独立组件安装),allowlist 写死以便"新增豁免"必须过评审;
  配一条变异自证(摘掉 einops 的钉版必须变红,torch 缺席不得被误判);
- 探针的模块清单点到真正 import 的那三个模块,报错文案不再只指向 transformers 区间,
  而是分成"没装(多半只在 extras 里声明)"与"版本区间不对"两类。

本地:D5 两条 + 既有 11 条 = 13 passed;docker-smoke.yml YAML 解析通过。
验收:等 `Boot hardened container & probe` 在真镜像上转绿 —— 那一步现在既是探针也是这条修复的证据。

Signed-off-by: ReSerendipity <zengyangc@outlook.com>
@ReSerendipity
ReSerendipity deleted the ci/engine-import-and-indextts20-smoke branch September 23, 2026 07:15
ReSerendipity added a commit that referenced this pull request Sep 23, 2026
CodeQL #107/#108 (py/reflective-xss, high) 复核为真:两处 HTMLResponse 的 f-string 把
edit_type / edit_info(表单原文)、上传文件名派生的 basename、tau、target basename 直接
插进 HTML,audio_filename 还进了 4 处属性上下文;basename() 不去 < 与引号,属可实现
的反射型 XSS。统一改为 html.escape(..., quote=True),数字插值与 URL 语义不变。

同批复核的另 5 条判为误报(blob: URL / textContent / 固定相对重定向 / 模型输入去标签),
判定与依据写在 PR 描述,走 CodeQL dismiss,不在本 commit 改。

验证:80 条既有测试通过,且确认导入解析到本 worktree 副本(不是主仓另一分支那份);
ruff check/format 通过、ast 可解析。未加路由级 XSS 断言(需 stub 引擎),列为后续项。

Signed-off-by: ReSerendipity <zengyangc@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant