CI 偶发失败修复:独立 crate 的 crates.io 预热缺口与两条用例自身缺陷 #328
Reference in New Issue
Block a user
Delete Branch "fix/ci-flaky-and-crates-io-fetch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
背景
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);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)Cargo.lock--offline复验--offlineno matching package named serde found⇒ 缺口真实存在cargo test --offline(两个 crate)即: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 重试路径一次都不会被执行。变异验证已证实(同一份生产变异分别配两种测试形态):accept:不再有客户端连接)⇒ 能抓到验收证据:整条
api-serverbin 在默认并行下连跑 6 轮,AddrInUse出现 0 次;bgfilter_worker::tests::定向 31 条全过。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,连续 200 次bind(0)无一重复,刚释放的端口也不会被立即重新派发)——它是 Linux 侧特性,CI 日志是它的 ground truth。③ 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-targetscargo fmt --all --manifest-path server-rs/Cargo.toml -- --checkcargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --checkcargo test … bgfilter_worker::tests::cargo test … editor_background_music_prompt_assist::tests::AddrInUse;本机固定 11 条wallet_refund_outbox权限失败(Windows ACL,仓库决策日志多处记为「本机环境失败,与基线一致」)npx vitest run scripts/project-ci-workflow.test.tsnpm run check:encodinggit diff --check不做
apps/ai-game-creator-shell/**;Cargo.lock(现有决策日志记载独立 crate 的本地Cargo.lock/target不进入提交)。若后续希望「准备阶段也不再联网」,需要提交锁文件 + 让受信任 CI 镜像按锁预置缓存,那是独立一步,本 PR 不含。- 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 侧特性。代码审查结论:通过 ✅
已核对 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覆盖不到它们。Cargo.lock且.gitignore已忽略/Cargo.lock,不带--lockedfetch 是必然选择,注释与事实一致。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)
AtomicUsize游标保证同进程不重号,占用探测兜底宿主机已有监听。4 个调用点已全部同步改为同步调用,无遗漏。3. BGM 限流用例断言口径(editor_background_music_prompt_assist.rs)
>= 4+ 全 200 / 无 429 仍守住该契约;mock 为 open-ended 无限应答,>=不存在"少到 0 也通过"的空洞;精确次数已由simplification_retries_*定向用例覆盖。可选建议(不阻塞合并)
cargo fetch生成的Cargo.lock只留在容器里,每次 CI 可能解析到不同的 semver 最新版本(改动前即存在,非本 PR 引入)。若想彻底消除漂移,可后续提交这两个 crate 的Cargo.lock并改用--locked,作为独立事项跟进。无必须修改项,建议合并。