CI 偶发失败修复:独立 crate 的 crates.io 预热缺口与两条用例自身缺陷 #328

Merged
lhk229 merged 4 commits from fix/ci-flaky-and-crates-io-fetch into master 2026-09-12 14:23:47 +08:00
Member

背景

PR #316 的 push 3bfc3f084 上 CI 红了 Backend testsNative shell tests,但两处都与该 PR 的业务改动无关:

  • git diff 39a96aa41 3bfc3f084 只含前端 TS/TSX/CSS 与 .md
  • server-rs 树哈希在两个提交之间完全相同(9edd75582c0f7ea7557a24c9ac9d9dec662e2e55),apps/ai-game-creator-shell/src-tauri 也相同(f6d25066d1a5826a407cfa663783429b55baf6b7);
  • 同一份 Rust 代码在 run 1947(39a96aa41)Backend/Native 全绿,在 run 1950(3bfc3f084)两红 → 同字节、不同结果 = 间歇性。

按用户要求拆到本独立分支修。追踪 #327。

① 独立 crate 未预热:Native shell 在测试阶段才解析 crates.io index(#327 问题 1)

根因server-rs/Cargo.tomlcrates/agent-runtime-corecrates/agent-runtime-orchestrationcrates/platform-agent 放在 exclude 里(Cargo 以 exclude 为准),它们不参与 workspace 锁文件。其中前两个由 npm run check:native-shellsai-game-creator-shell:checkagent-runtime-*:checkcargo test --manifest-path … 单独执行,而 Prepare native Rust dependencies 只对 3 个 workspace manifest 做 cargo fetch --locked ⇒ 覆盖不到它们,于是测试阶段现场 Updating crates.io index,crates.io 一抖动整条作业就红(run 1950:download of se/rd/serde_json failed → curl failed → Timeout was reached after 30000ms)。

platform-agent 同在 exclude,但 CI 里没有任何 standalone 调用(它只作为 AGC Tauri 的 path 依赖,在已锁定且已预热的 AGC workspace 内构建),因此不需要、也不应加进预热列表;新增用例把「只覆盖这两个 crate」的口径钉住。

修法Native shell tests 新增独立步骤 Prepare standalone Rust crate dependencies,对这两个 crate 先 cargo fetch。它们没有提交 Cargo.lock,所以这一步不能--locked(缺锁文件会直接失败)——这点由新增的 workflow 钉住用例显式固定,避免以后被顺手改坏。没有用 || true 之类掩盖。

验证(空 CARGO_HOME,模拟镜像里没有这两个 crate 的缓存)

步骤 结果
cargo fetch --target x86_64-unknown-linux-gnu(CI 同款,两个 crate) exit 0,各自生成 Cargo.lock
同 flags 追加 --offline 复验 exit 0 ⇒ 锁文件 + 缓存自足,测试阶段不再需要 index
变异:不预热 + 空 home + --offline exit 101 no matching package named serde found ⇒ 缺口真实存在
cargo test --offline(两个 crate) exit 0,测试正常执行

即:CI 测试阶段不再触碰 live crates.io。准备阶段仍需联网下载(不预置镜像无法避免),但现在它是显式步骤、有界重试、失败直接指名 manifest,而不是测试中途爆炸。

bgfilter_worker 测试端口 TOCTOU(#327 问题 2.1)

根因reserved_loopback_port()bind("127.0.0.1:0") 取内核临时端口后 drop(listener),用例要等 700ms(另一条冷启动用例 2.8s)才重新 bind 同一端口。这段空窗里该端口是自由的,同一测试二进制里其它用例的 bind(0) 完全可能拿到它,重新 bind 就报 AddrInUse(run 1950 job 7088:rebind test port: Os { code: 98, kind: AddrInUse })。两条「重启窗口」用例共用该 helper。

修法:改从内核动态端口范围之外的固定测试带(20000-29999)按进程内游标递增分配,并先探测可用性。Linux 默认 ip_local_port_range=32768-60999、Windows/macOS 默认动态范围 49152-65535,因此 bind(0) 永远不会派发该带内的端口,空窗期内没人能抢走它。「端口先关闭、稍后再监听」的用例结构没有改动

为什么不采用「把 sleep 挪到 bind 之后、accept 之前」:这样 connect() 会立刻成功(TCP 握手由 listen backlog 完成),is_connect_failure() 恒为 false,request_bgfilter_worker_with_connect_retry 在第一个 HTTP 响应就返回 —— 用例名承诺的 connect 重试路径一次都不会被执行。变异验证已证实(同一份生产变异分别配两种测试形态):

变异 结果
生产代码移除 connect 重试 + 本次修法 用例未通过(挂在 mock 的 accept:不再有客户端连接)⇒ 能抓到
生产代码移除 connect 重试 + 「先 bind 再 sleep」形态 exit 0 / 1 passed ⇒ 该形态对同样的生产回归是盲的

验收证据:整条 api-server bin 在默认并行下连跑 6 轮,AddrInUse 出现 0 次bgfilter_worker::tests:: 定向 31 条全过。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,连续 200 次 bind(0) 无一重复,刚释放的端口也不会被立即重新派发)——它是 Linux 侧特性,CI 日志是它的 ground truth。

顺带发现(本次未改):该用例的 mock 用无超时的 accept(),所以一旦 connect 重试被改坏,表现的会是挂起而不是断言失败。建议后续给 server.awaittokio::time::timeout,把回归变成可诊断的失败。

③ BGM 提示词助手限流用例的断言口径(#327 问题 2.2)

根因consecutive_valid_requests_are_not_subject_to_a_feature_rate_limit 用开放式 mock(来多少答多少)却断言 assert_eq!(mock.finish().len(), 4)。该模块的简化路径本身允许一轮内容重试(同文件 simplification_retries_* 四条用例正是它的定向覆盖),所以 provider 的精确调用次数是实现细节;一次合法重试就变成假红(run 1950:left: 5, right: 4)。

修法:断言改为用例名真正承诺的客户端口径 —— 4 次客户端调用都 200、都不是 429,且都真正到达 provider(requests.len() >= 4);该断言失败时会打印捕获到的请求原文,把不确定性变成可诊断的失败。没有放宽业务判据:短路、提前拒绝、限流仍然会红。

变异验证:在生产侧给 completion 路由加一个「第 2 次起 429」的特性限流 → 用例 exit 101,panic 文案 4 次客户端调用都必须到达 provider,实际 3 次:[…捕获到的请求…] ⇒ 新断言有牙齿,且给出可诊断信息。

门禁

门禁 结果
cargo check --locked -p api-server --all-targets exit 0
cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check exit 0
cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check exit 0
cargo test … bgfilter_worker::tests:: 31 passed / 0 failed
cargo test … editor_background_music_prompt_assist::tests:: 27 passed / 0 failed
整条 api-server bin 默认并行 ×6 AddrInUse;本机固定 11 条 wallet_refund_outbox 权限失败(Windows ACL,仓库决策日志多处记为「本机环境失败,与基线一致」)
npx vitest run scripts/project-ci-workflow.test.ts 9 passed
npm run check:encoding 4321 files
git diff --check 干净

不做

  • 不改业务语义,不放宽断言本意;
  • 不动 PR #316 的 V3 文件与 apps/ai-game-creator-shell/**
  • 未提交这两个 crate 的 Cargo.lock(现有决策日志记载独立 crate 的本地 Cargo.lock/target 不进入提交)。若后续希望「准备阶段也不再联网」,需要提交锁文件 + 让受信任 CI 镜像按锁预置缓存,那是独立一步,本 PR 不含。
## 背景 PR #316 的 push `3bfc3f084` 上 CI 红了 `Backend tests` 与 `Native shell tests`,但两处都与该 PR 的业务改动无关: - `git diff 39a96aa41 3bfc3f084` 只含前端 TS/TSX/CSS 与 `.md`; - `server-rs` 树哈希在两个提交之间完全相同(`9edd75582c0f7ea7557a24c9ac9d9dec662e2e55`),`apps/ai-game-creator-shell/src-tauri` 也相同(`f6d25066d1a5826a407cfa663783429b55baf6b7`); - 同一份 Rust 代码在 run 1947(`39a96aa41`)Backend/Native 全绿,在 run 1950(`3bfc3f084`)两红 → 同字节、不同结果 = 间歇性。 按用户要求拆到本独立分支修。追踪 #327。 ## ① 独立 crate 未预热:Native shell 在测试阶段才解析 crates.io index(#327 问题 1) **根因**:`server-rs/Cargo.toml` 把 `crates/agent-runtime-core`、`crates/agent-runtime-orchestration`、`crates/platform-agent` 放在 `exclude` 里(Cargo 以 `exclude` 为准),它们不参与 workspace 锁文件。其中前两个由 `npm run check:native-shells` → `ai-game-creator-shell:check` → `agent-runtime-*:check` 用 `cargo test --manifest-path …` 单独执行,而 `Prepare native Rust dependencies` 只对 3 个 workspace manifest 做 `cargo fetch --locked` ⇒ 覆盖不到它们,于是测试阶段现场 `Updating crates.io index`,crates.io 一抖动整条作业就红(run 1950:`download of se/rd/serde_json failed → curl failed → Timeout was reached after 30000ms`)。 `platform-agent` 同在 `exclude`,但 CI 里没有任何 standalone 调用(它只作为 AGC Tauri 的 path 依赖,在已锁定且已预热的 AGC workspace 内构建),因此不需要、也不应加进预热列表;新增用例把「只覆盖这两个 crate」的口径钉住。 **修法**:`Native shell tests` 新增独立步骤 `Prepare standalone Rust crate dependencies`,对这两个 crate 先 `cargo fetch`。它们没有提交 `Cargo.lock`,所以这一步**不能**用 `--locked`(缺锁文件会直接失败)——这点由新增的 workflow 钉住用例显式固定,避免以后被顺手改坏。没有用 `|| true` 之类掩盖。 **验证(空 `CARGO_HOME`,模拟镜像里没有这两个 crate 的缓存)** | 步骤 | 结果 | |---|---| | `cargo fetch --target x86_64-unknown-linux-gnu`(CI 同款,两个 crate) | exit 0,各自生成 `Cargo.lock` | | 同 flags 追加 `--offline` 复验 | **exit 0** ⇒ 锁文件 + 缓存自足,测试阶段不再需要 index | | **变异**:不预热 + 空 home + `--offline` | **exit 101** `no matching package named serde found` ⇒ 缺口真实存在 | | `cargo test --offline`(两个 crate) | **exit 0**,测试正常执行 | 即:**CI 测试阶段不再触碰 live crates.io**。准备阶段仍需联网下载(不预置镜像无法避免),但现在它是显式步骤、有界重试、失败直接指名 manifest,而不是测试中途爆炸。 ## ② `bgfilter_worker` 测试端口 TOCTOU(#327 问题 2.1) **根因**:`reserved_loopback_port()` 用 `bind("127.0.0.1:0")` 取内核临时端口后 `drop(listener)`,用例要等 700ms(另一条冷启动用例 2.8s)才重新 `bind` 同一端口。这段空窗里该端口是**自由**的,同一测试二进制里其它用例的 `bind(0)` 完全可能拿到它,重新 bind 就报 `AddrInUse`(run 1950 job 7088:`rebind test port: Os { code: 98, kind: AddrInUse }`)。两条「重启窗口」用例共用该 helper。 **修法**:改从**内核动态端口范围之外**的固定测试带(20000-29999)按进程内游标递增分配,并先探测可用性。Linux 默认 `ip_local_port_range=32768-60999`、Windows/macOS 默认动态范围 49152-65535,因此 `bind(0)` 永远不会派发该带内的端口,空窗期内没人能抢走它。**「端口先关闭、稍后再监听」的用例结构没有改动**。 **为什么不采用「把 sleep 挪到 bind 之后、accept 之前」**:这样 `connect()` 会立刻成功(TCP 握手由 listen backlog 完成),`is_connect_failure()` 恒为 false,`request_bgfilter_worker_with_connect_retry` 在第一个 HTTP 响应就返回 —— 用例名承诺的 connect 重试路径**一次都不会被执行**。变异验证已证实(同一份生产变异分别配两种测试形态): | 变异 | 结果 | |---|---| | 生产代码移除 connect 重试 + **本次修法** | 用例未通过(挂在 mock 的 `accept`:不再有客户端连接)⇒ 能抓到 | | 生产代码移除 connect 重试 + 「先 bind 再 sleep」形态 | **exit 0 / 1 passed** ⇒ 该形态对同样的生产回归是盲的 | **验收证据**:整条 `api-server` bin 在**默认并行**下连跑 6 轮,`AddrInUse` 出现 **0 次**;`bgfilter_worker::tests::` 定向 31 条全过。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,连续 200 次 `bind(0)` 无一重复,刚释放的端口也不会被立即重新派发)——它是 Linux 侧特性,CI 日志是它的 ground truth。 > 顺带发现(本次未改):该用例的 mock 用无超时的 `accept()`,所以一旦 connect 重试被改坏,表现的会是**挂起**而不是断言失败。建议后续给 `server.await` 套 `tokio::time::timeout`,把回归变成可诊断的失败。 ## ③ BGM 提示词助手限流用例的断言口径(#327 问题 2.2) **根因**:`consecutive_valid_requests_are_not_subject_to_a_feature_rate_limit` 用开放式 mock(来多少答多少)却断言 `assert_eq!(mock.finish().len(), 4)`。该模块的简化路径本身允许一轮内容重试(同文件 `simplification_retries_*` 四条用例正是它的定向覆盖),所以 provider 的精确调用次数是实现细节;一次合法重试就变成假红(run 1950:`left: 5, right: 4`)。 **修法**:断言改为用例名真正承诺的客户端口径 —— 4 次客户端调用都 200、都不是 429,且都真正到达 provider(`requests.len() >= 4`);该断言失败时会打印捕获到的请求原文,把不确定性变成可诊断的失败。**没有放宽业务判据**:短路、提前拒绝、限流仍然会红。 **变异验证**:在生产侧给 completion 路由加一个「第 2 次起 429」的特性限流 → 用例 **exit 101**,panic 文案 `4 次客户端调用都必须到达 provider,实际 3 次:[…捕获到的请求…]` ⇒ 新断言有牙齿,且给出可诊断信息。 ## 门禁 | 门禁 | 结果 | |---|---| | `cargo check --locked -p api-server --all-targets` | exit 0 | | `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` | exit 0 | | `cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` | exit 0 | | `cargo test … bgfilter_worker::tests::` | 31 passed / 0 failed | | `cargo test … editor_background_music_prompt_assist::tests::` | 27 passed / 0 failed | | 整条 api-server bin 默认并行 ×6 | 无 `AddrInUse`;本机固定 11 条 `wallet_refund_outbox` 权限失败(Windows ACL,仓库决策日志多处记为「本机环境失败,与基线一致」) | | `npx vitest run scripts/project-ci-workflow.test.ts` | 9 passed | | `npm run check:encoding` | 4321 files | | `git diff --check` | 干净 | ## 不做 - 不改业务语义,不放宽断言本意; - 不动 PR #316 的 V3 文件与 `apps/ai-game-creator-shell/**`; - 未提交这两个 crate 的 `Cargo.lock`(现有决策日志记载独立 crate 的本地 `Cargo.lock/target` 不进入提交)。若后续希望「准备阶段也不再联网」,需要提交锁文件 + 让受信任 CI 镜像按锁预置缓存,那是独立一步,本 PR 不含。
suzmii added 3 commits 2026-09-11 17:23:52 +08:00
- .gitea/workflows/project-ci.yml:Native shell tests 新增「Prepare standalone Rust crate dependencies」步骤,
  对被 server-rs workspace exclude 的 agent-runtime-core / agent-runtime-orchestration 先做 cargo fetch,
  把它们在测试阶段的 registry 解析与下载提前到依赖准备阶段;两 crate 未提交 Cargo.lock,故不能带 --locked
- scripts/project-ci-workflow.test.ts:新增用例钉住该步骤存在、覆盖两个独立 crate manifest、且命令不带锁标志

根因与影响见 #327:此前这两个 crate 只在 agent-runtime-*:check 中现场 `Updating crates.io index`,
crates.io 一抖动整条 native shell 作业就红(PR #316 run 1950)。
- server-rs/crates/api-server/src/bgfilter_worker.rs:reserved_loopback_port 不再 bind("127.0.0.1:0") 取临时端口后 drop,
  改为在内核动态端口范围之外的固定测试带(20000-29999)内按进程内游标递增分配,并先探测可用性落点
- 同一文件:4 处调用点去掉不再需要的 .await(该 helper 现在是同步分配)

这样「先保持端口关闭、约 700ms 后才开始监听」的重启用例在整个空窗期内独占该端口,
重新 bind 不会再被同一测试二进制里其它用例的 bind(0) 抢走。
根因与证据见 #327 问题 2.1。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,
连续 200 次 bind(0) 无一重复、也已实测刚释放的端口不被立即重新派发),它是 Linux 侧特性。
修正 BGM 提示词助手限流用例的断言口径:只承诺客户端可见语义
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 6m32s
Project CI / Native shell tests (pull_request) Successful in 18m5s
2c9e6fe272
- server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs:consecutive_valid_requests_are_not_subject_to_a_feature_rate_limit
  的 assert_eq!(mock.finish().len(), 4) 改为「到达 provider 的请求不少于 4 次」,并在该断言失败时打印捕获到的请求原文

为什么新形态才是它真正承诺的语义:用例名只承诺「连续合法请求不被特性限流」,那是客户端可见口径;
本模块的简化路径本身允许一轮内容重试(同文件 simplification_retries_* 四条用例正是它的定向覆盖),
因此 provider 的精确调用次数是实现细节,把它钉死会让一次合法重试变成假红。
「4 次调用都到达 provider」与原有的 200/非 429 断言都保留:短路、提前拒绝、限流仍然会红。
根因见 #327 问题 2.2(PR #316 run 1950:left: 5, right: 4)。
suzmii requested review from lhk229 2026-09-11 22:05:49 +08:00
lhk229 added 1 commit 2026-09-12 13:56:25 +08:00
Merge branch 'master' into fix/ci-flaky-and-crates-io-fetch
Project CI / Repository checks (pull_request) Successful in 2m38s
Project CI / Frontend tests (pull_request) Successful in 3m23s
Project CI / Backend tests (pull_request) Successful in 13m24s
Project CI / Native shell tests (pull_request) Successful in 23m40s
e602d3b420
lhk229 approved these changes 2026-09-12 14:07:30 +08:00
lhk229 left a comment
Owner

代码审查结论:通过

已核对 diff 全文及相关文件上下文,本地验证 scripts/project-ci-workflow.test.ts(9 个用例全过)、check:encodinggit diff --check 均通过。三处改动的前提均逐一核实成立:

1. CI 预热 excluded crate 依赖(project-ci.yml)

  • server-rs/Cargo.tomlexclude 确实包含 agent-runtime-core / agent-runtime-orchestration,workspace 级 cargo fetch --locked 覆盖不到它们。
  • 两个 crate 均未提交 Cargo.lock.gitignore 已忽略 /Cargo.lock,不带 --locked fetch 是必然选择,注释与事实一致。
  • check:native-shellsai-game-creator-shell:checkagent-runtime-*:checkcargo test --manifest-path)调用链属实,缺口定位准确;步骤顺序有测试钉住。同样被 exclude 的 platform-agent 不经此作业测试,不预热是对的。
  • 重试写法(seq 1 5 + 递增 sleep)与既有 workspace fetch 步骤一致。

2. 测试端口带(bgfilter_worker.rs)

  • 20000–29999 落在 Linux(32768–60999)与 Windows/macOS(49152–65535)默认动态端口范围之外,消除了"drop 后空窗期被内核重新派发"的 TOCTOU;AtomicUsize 游标保证同进程不重号,占用探测兜底宿主机已有监听。4 个调用点已全部同步改为同步调用,无遗漏。

3. BGM 限流用例断言口径(editor_background_music_prompt_assist.rs)

  • 用例语义是"连续合法请求不被特性限流",>= 4 + 全 200 / 无 429 仍守住该契约;mock 为 open-ended 无限应答,>= 不存在"少到 0 也通过"的空洞;精确次数已由 simplification_retries_* 定向用例覆盖。

可选建议(不阻塞合并)

  1. BGM 用例注释的归因略不精确:该路径下内容重试确定性不触发,run 1950 多出的第 5 次 provider 请求更可能来自 HTTP 客户端层对复用失效连接的重试,而非注释所述"一轮内容重试"。断言改法不受影响,后续顺手修正注释即可。
  2. 两个独立 crate 的依赖解析仍是非锁定的cargo fetch 生成的 Cargo.lock 只留在容器里,每次 CI 可能解析到不同的 semver 最新版本(改动前即存在,非本 PR 引入)。若想彻底消除漂移,可后续提交这两个 crate 的 Cargo.lock 并改用 --locked,作为独立事项跟进。

无必须修改项,建议合并。

## 代码审查结论:通过 ✅ 已核对 diff 全文及相关文件上下文,本地验证 `scripts/project-ci-workflow.test.ts`(9 个用例全过)、`check:encoding` 与 `git diff --check` 均通过。三处改动的前提均逐一核实成立: **1. CI 预热 excluded crate 依赖(project-ci.yml)** - `server-rs/Cargo.toml` 的 `exclude` 确实包含 `agent-runtime-core` / `agent-runtime-orchestration`,workspace 级 `cargo fetch --locked` 覆盖不到它们。 - 两个 crate 均未提交 `Cargo.lock` 且 `.gitignore` 已忽略 `/Cargo.lock`,不带 `--locked` fetch 是必然选择,注释与事实一致。 - `check:native-shells` → `ai-game-creator-shell:check` → `agent-runtime-*:check`(`cargo test --manifest-path`)调用链属实,缺口定位准确;步骤顺序有测试钉住。同样被 exclude 的 `platform-agent` 不经此作业测试,不预热是对的。 - 重试写法(`seq 1 5` + 递增 sleep)与既有 workspace fetch 步骤一致。 **2. 测试端口带(bgfilter_worker.rs)** - 20000–29999 落在 Linux(32768–60999)与 Windows/macOS(49152–65535)默认动态端口范围之外,消除了"drop 后空窗期被内核重新派发"的 TOCTOU;`AtomicUsize` 游标保证同进程不重号,占用探测兜底宿主机已有监听。4 个调用点已全部同步改为同步调用,无遗漏。 **3. BGM 限流用例断言口径(editor_background_music_prompt_assist.rs)** - 用例语义是"连续合法请求不被特性限流",`>= 4` + 全 200 / 无 429 仍守住该契约;mock 为 open-ended 无限应答,`>=` 不存在"少到 0 也通过"的空洞;精确次数已由 `simplification_retries_*` 定向用例覆盖。 ### 可选建议(不阻塞合并) 1. **BGM 用例注释的归因略不精确**:该路径下内容重试确定性不触发,run 1950 多出的第 5 次 provider 请求更可能来自 HTTP 客户端层对复用失效连接的重试,而非注释所述"一轮内容重试"。断言改法不受影响,后续顺手修正注释即可。 2. **两个独立 crate 的依赖解析仍是非锁定的**:`cargo fetch` 生成的 `Cargo.lock` 只留在容器里,每次 CI 可能解析到不同的 semver 最新版本(改动前即存在,非本 PR 引入)。若想彻底消除漂移,可后续提交这两个 crate 的 `Cargo.lock` 并改用 `--locked`,作为独立事项跟进。 无必须修改项,建议合并。
lhk229 marked the pull request as ready for review 2026-09-12 14:23:39 +08:00
lhk229 merged commit 013c756b9e into master 2026-09-12 14:23:47 +08:00
lhk229 deleted branch fix/ci-flaky-and-crates-io-fetch 2026-09-12 14:23:47 +08:00
Sign in to join this conversation.