修正完美像素并发闸测试竞态
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Repository checks (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 11m0s

删除过期预算测试对进程级队列 Atomic 的 before/after 断言

保留独立 Drop 覆盖并仅断言预算耗尽返回 504

同步更新完美像素、SpacetimeDB 取消边界与共享项目记忆文档
This commit is contained in:
2026-08-04 04:35:59 +00:00
parent f99abce109
commit 193c17d6ed
6 changed files with 22 additions and 23 deletions
@@ -6024,10 +6024,10 @@
- A1 持久化标记漏洞:`snap_editor_image_to_pixel_art``persist_editor_generated_image_owned` 用的是裸 `?`,而该 helper 内部顺序是 `PUT → HEAD → confirm_asset_object`。HEAD 或 confirm 失败时 OSS 对象已存在,错误却不带 `resultPersistenceStarted`,客户端的 `outcomeMayBePersisted` 因此为假、对账根本不执行,直接报普通失败;用户重试会用新 `task_id` 生成新 object key,首个对象成为无从发现的孤儿。这是三条里唯一会让收口机制完全不触发的。
- A1 的标记边界:标在 helper 内部而不是调用点——调用方拿到的是同一个 `AppError`,无法自行判断内部走到了哪一步。边界取在第一次 PUT:`prepare_put_object` 与「OSS 未配置」这两处失败都在 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是与本条镜像的反向谎报。PUT 自身也标——响应丢失时字节可能已落盘,属于契约要覆盖的未知结果。共四处:PUT、HEAD、asset object 入参构造、`confirm_asset_object`。该 helper 为 9 条编辑器持久化流程共用,新增的 details 字段对其余调用方语义同样成立(持久化确实已开始),只是目前只有完美像素前端消费。
- A3 失败反馈第三态:catch 尾部只处理「有占位」与「没有 dialogId」,缺「有 dialogId 但占位已被删」。删除生成中占位是产品支持的流程(`requestRemoveCanvasGenerationDialog``generating` 会先弹确认),用户删完之后请求才失败时,`errorMessage` 被算出来又整段丢弃,界面零反馈。丢的不只是失败提示——对账得出的「请确认素材库是否已生成派生图」在同一句里,用户会在毫不知情的情况下重试。改为 `else` 兜底走全局提示。兄弟路径 `split-atlas` 无条件 alert、`remove-background` 直接 rethrow,都不存在这个第三态。
- E1 测试并行竞态:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 对进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 做绝对断言 `== 0`,而相邻用例会在自己的作用域里持有两个 guard,Rust 测试默认并行,两者撞上就随机变红。改为相对断言。同时更正该用例的注释:它覆盖的是「预算预检先于入队」,断言的是深度未变化,不是「guard 被正确归还」——后者由相邻用例的 Drop 断言负责;真正进入队列后再失败的两条路径(等待信号量超时、队列已满)目前仍无覆盖
- E1 测试并行竞态:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 对进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 做绝对断言 `== 0`,而相邻用例会在自己的作用域里持有两个 guard,Rust 测试默认并行,两者撞上就随机变红。当时改为 before/after 相对断言,但后续第三批确认两次 load 之间仍可被并行用例插入,该方案未修复竞态并已撤回。过期预算用例只应断言 `504`;guard Drop 由相邻独立用例负责
- 验证方法:两条修复都先回退生产代码确认测试变红,再恢复。前端新用例在缺 `else` 分支时超时失败;服务端守卫在去掉任一处标记时报 `left: 3, right: 4`。首次验证时跑错了测试名——`explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 里已有一组同名断言(钉的是 handler 内四处),新加的这组在 `editor_matting_releases_source_buffers_at_oss_boundaries`,两者同名不同域。
- 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。
- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。
- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。E1 当时仅改成相对断言,后续第三批重新打开并完成修正。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-03 完美像素静默路径收口(审查第一批续)
@@ -6067,7 +6067,7 @@
## 2026-08-03 完美像素并发闸竞争路径与 snapper 错误分档补测
- 背景:审查留下的两处覆盖缺口。并发闸的三条竞争路径(队列满 503 + `retry-after`、等待槽位超时 504、信号量关闭 503)此前零测试——`retry-after` 在整个 crate 里只出现在生产代码一处;`map_editor_pixel_art_snapper_error` 的四档状态码只有 `GridNotDetected → 422` 被间接覆盖。
- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——正是本文件同日刚修掉的 E1 类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。
- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——这与当时误以为已修掉、后续第三批才真正纠正的 E1 属于同类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。
- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。
- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。
- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
@@ -6193,7 +6193,7 @@
- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。
- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。
- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。
- 2026-08-03 第三批边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
- 2026-08-03 前端 hydrate 收口边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-04 完美像素第二批:object-only 上传与 GET-only unknown 收口
@@ -6203,5 +6203,13 @@
- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journalhydrate 会先把可接受的未来 `submittedAt` 规范化到当前时间,再把截止收紧到规范化提交时间和当前时间各自允许的 75 秒上限,并在下一次布局持久化时写回仍可再次 hydrate 的收紧形状。
- identity 与删除:unknown 保留原 dialog 上的完整 `perfectPixelOperation`,人工重试原样发送持久化 request;普通按 ID 删除和随源图层删除均保留未收口 durable operation。对话框删除入口会激活原占位并提示继续核对 / 原样重试;Delete 快捷键若只命中受保护 operation 则在写历史、清选择或执行副作用前完整 no-op,混合选择只统计并删除其它可删除目标。刷新恢复只做 GET,owner / project 切换或卸载会取消旧观察。完全没有 operation journal 字段的 legacy inline 占位仍沿用既有 TTL;字段存在但损坏时保留失败关闭标记,不能降级成可清理的旧占位。
- 投影刷新:`refreshAssetLibrary` 只在项目终态后 best-effort 触发,并同时吞掉同步 throw 与异步 reject;永不 settle 的刷新 Promise 也不参与 await,因此不能阻塞项目应用、提示或 `finally` 解锁。
- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它第三批文档清理仍未纳入本批
- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它文档清理在本次第二批提交时尚未纳入,后续由下一条第三批完成
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-04 完美像素第三批:移除全局 Atomic 相对断言并完成文档收口
- 竞态根因:过期预算用例先读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH`,再在断言前读取一次;相邻 Drop 用例可在两次 load 之间创建或释放 guard。相对 before/after 与绝对 `== 0` 一样没有跨测试隔离,Rust 默认并行时仍会随机失败。
- 测试边界:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 只构造过期 deadline 并断言 `504`,不再观察全局队列深度。`pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop` 继续作为独立 Drop 契约用例;未引入 `--test-threads=1`、全局串行锁或其它掩盖手段。
- 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。
- 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt``npm run check:encoding`5153 个文件)与 `git diff --check` 通过。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -19,7 +19,7 @@
- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。
- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。
- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。
- 验证:用已过期的 deadline 调用获取函数,断言返回 `504` 而不是许可;同时断言队列深度计数回到 0。仅靠「信号量占满时超时」的用例发现不了,必须在**有空闲许可**的状态下测
- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题
- 关联:`server-rs/crates/api-server/src/editor_project.rs``acquire_editor_pixel_art_snap_permit``acquire_editor_pixel_art_cpu_permit`)。
## 有界等待队列的计数递减必须写在 Drop 里
@@ -27,7 +27,7 @@
- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。
- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。
- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。
- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败)guard 离开作用域后计数归零、以及失败路径(预算耗尽、获取超时)也归还名额
- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题
- 关联:`server-rs/crates/api-server/src/editor_project.rs``try_enter_bounded_queue``EditorPixelArtSnapQueueGuard`)。
## Jenkins 异步备份不能用 nohup 脱离作业
@@ -3212,9 +3212,9 @@
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib``dropped_lease_releases_slot_and_permit``acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
- 关联:`server-rs/crates/spacetime-client/src/lib.rs``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`
- 关联:`server-rs/crates/spacetime-client/src/active.rs``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`
## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取
@@ -54,6 +54,7 @@
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstonecompletion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。
### 角色动作帧抠图像素边界
@@ -2,7 +2,7 @@
- 日期:2026-06-11
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
- 涉及代码:`server-rs/crates/spacetime-client/src/active.rs`
## 故障根因
@@ -28,6 +28,8 @@
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;`dropped_lease_releases_slot_and_permit` 只覆盖本地 Drop,不构成远端取消测试。
## 验收
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试)
File diff suppressed because one or more lines are too long
@@ -12534,25 +12534,12 @@ mod tests {
#[tokio::test]
async fn pixel_art_snap_permit_reports_exhausted_budget_without_waiting() {
// 中文注释:队列深度是进程级 static,而 Rust 测试默认并行。这里必须比相对值——
// 相邻的 pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop 会在自己的
// 作用域里持有两个 guard,绝对断言 `== 0` 会在两者撞上时随机变红,且与生产代码
// 有没有缺陷无关。
let before = EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire);
let expired = Instant::now() - Duration::from_secs(1);
let error = acquire_editor_pixel_art_snap_permit(expired)
.await
.expect_err("expired budget should not acquire a permit");
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
// 中文注释:本用例覆盖的是「预算预检先于入队」——预算已耗尽时应当在 try_enter
// 之前就返回,连队列名额都不占。所以这里断言的是深度「没有变化」,不是「guard
// 被正确归还」;后者由上一条用例的 Drop 断言负责。等待信号量超时、队列已满这两条
// 真正进入队列后再失败的路径目前仍无覆盖。
assert_eq!(
EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire),
before
);
}
#[test]