补测完美像素并发闸竞争路径与 snapper 错误分档
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 24s
Project CI / Native shell tests (pull_request) Failing after 3m4s

并发闸的三条竞争路径此前零测试,retry-after 在整个 crate 里只出现在生产代码
一处;snapper 四档状态码只有 GridNotDetected 被间接覆盖。

不去打满全局信号量或队列计数——两者都是进程级 static,填满会让并行跑的其他
用例连带失败,正是刚修掉的那类竞态。改为把三条路径的错误抽成构造函数直接断言
状态码、文案与 retry-after 头;路径与构造函数的对应由既有顺序守卫钉住,CAS
边界本来就有本地计数器用例覆盖。

这样覆盖的是错误形状与分档,不是端到端竞争行为;后者需要把限流器与队列计数
改成依赖注入,改动面超出补测本身,未做。

删掉 retry-after 或把 Decode 改判 500,两条新用例分别精确变红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 07:28:07 +00:00
parent 62c02f22ab
commit 3f6a8e7a0c
2 changed files with 122 additions and 15 deletions
@@ -6052,3 +6052,12 @@
- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。
- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 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 边界本来就有本地计数器的用例覆盖。
- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。
- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。
- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
+113 -15
View File
@@ -3341,6 +3341,32 @@ impl Drop for EditorPixelArtSnapQueueGuard {
// 中文注释:端点级并发闸,必须在第一次 IO 之前取得,许可持有到 handler 结束。等待时间与
// 后续处理共用同一份 processing_deadline,所以排队不会让请求重新获得完整预算。
// 中文注释:三条竞争路径的错误各自抽成构造函数。它们要靠全局信号量被打满或队列计数到顶
// 才走得到,而那两个都是进程级 static——在测试里把它们填满会让并行跑的其他用例连带失败,
// 正是本文件刚修掉的那类竞态。抽出来之后状态码、文案和 retry-after 可以直接断言,
// 「哪条路径用哪个构造函数」则由 snap_editor_image_to_pixel_art 的顺序守卫钉住。
fn editor_pixel_art_snap_queue_full_error() -> AppError {
editor_pixel_art_snap_failure(
StatusCode::SERVICE_UNAVAILABLE,
"完美像素排队已满,请稍后重试。",
)
.with_header("retry-after", HeaderValue::from_static("1"))
}
fn editor_pixel_art_snap_limiter_unavailable_error(error: impl std::fmt::Display) -> AppError {
editor_pixel_art_snap_failure(
StatusCode::SERVICE_UNAVAILABLE,
format!("完美像素并发门限不可用:{error}"),
)
}
fn editor_pixel_art_snap_wait_timeout_error() -> AppError {
editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素等待处理槽位时预算已耗尽。",
)
}
async fn acquire_editor_pixel_art_snap_permit(
processing_deadline: Instant,
) -> Result<tokio::sync::OwnedSemaphorePermit, AppError> {
@@ -3353,13 +3379,8 @@ async fn acquire_editor_pixel_art_snap_permit(
"完美像素处理预算已耗尽。",
));
}
let queue_guard = EditorPixelArtSnapQueueGuard::try_enter().ok_or_else(|| {
editor_pixel_art_snap_failure(
StatusCode::SERVICE_UNAVAILABLE,
"完美像素排队已满,请稍后重试。",
)
.with_header("retry-after", HeaderValue::from_static("1"))
})?;
let queue_guard = EditorPixelArtSnapQueueGuard::try_enter()
.ok_or_else(editor_pixel_art_snap_queue_full_error)?;
let acquired = tokio::time::timeout_at(
tokio::time::Instant::from_std(processing_deadline),
Arc::clone(&*EDITOR_PIXEL_ART_SNAP_LIMITER).acquire_owned(),
@@ -3369,14 +3390,8 @@ async fn acquire_editor_pixel_art_snap_permit(
drop(queue_guard);
match acquired {
Ok(Ok(permit)) => Ok(permit),
Ok(Err(error)) => Err(editor_pixel_art_snap_failure(
StatusCode::SERVICE_UNAVAILABLE,
format!("完美像素并发门限不可用:{error}"),
)),
Err(_) => Err(editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素等待处理槽位时预算已耗尽。",
)),
Ok(Err(error)) => Err(editor_pixel_art_snap_limiter_unavailable_error(error)),
Err(_) => Err(editor_pixel_art_snap_wait_timeout_error()),
}
}
@@ -12198,6 +12213,89 @@ mod tests {
);
}
#[test]
fn pixel_art_snap_contended_paths_carry_their_documented_responses() {
use axum::response::IntoResponse;
// 中文注释:队列满必须是 503 且带 retry-after——客户端与网关据此决定退避,删掉这个头
// 会让重试立刻打回来,把保险丝变成放大器。此前 `retry-after` 在整个 crate 里只出现在
// 生产代码一处,零断言。
let queue_full = editor_pixel_art_snap_queue_full_error();
assert_eq!(queue_full.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert!(queue_full.body_text().contains("完美像素排队已满"));
let queue_full_response = queue_full.into_response();
assert_eq!(
queue_full_response.headers().get("retry-after"),
Some(&HeaderValue::from_static("1")),
"queue-full responses must tell the caller when to retry"
);
// 中文注释:等待槽位超时是 504 而不是 503——预算耗尽属于超时语义,错成 503 会让客户端
// 把「这次来不及了」当成「服务暂时不可用」而立刻重试。
let wait_timeout = editor_pixel_art_snap_wait_timeout_error();
assert_eq!(wait_timeout.status_code(), StatusCode::GATEWAY_TIMEOUT);
assert!(wait_timeout.body_text().contains("预算已耗尽"));
// 中文注释:信号量被关闭是服务端自身不可用,503;同时必须把底层错误带进文案,否则
// 这条实践中极难复现的路径在排障时只剩一句无信息的通用文案。
let unavailable = editor_pixel_art_snap_limiter_unavailable_error("semaphore closed");
assert_eq!(unavailable.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert!(unavailable.body_text().contains("完美像素并发门限不可用"));
assert!(unavailable.body_text().contains("semaphore closed"));
}
#[test]
fn pixel_art_snapper_errors_map_to_their_documented_status_codes() {
// 中文注释:四个状态码分档此前只有 GridNotDetected 被间接覆盖。分档错位的后果是双向的:
// 把用户上传的坏图(Decode)报成 500 会让客户端当作服务端故障去重试,把服务端自身失败
// Encode / Processing)报成 400 又会让用户以为是自己的输入有问题。
for (error, expected_status) in [
(
platform_image::PixelArtSnapError::InvalidInput("空输入".to_string()),
StatusCode::BAD_REQUEST,
),
(
platform_image::PixelArtSnapError::Decode {
input: "source",
message: "corrupt png".to_string(),
},
StatusCode::BAD_REQUEST,
),
(
platform_image::PixelArtSnapError::GridNotDetected,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
platform_image::PixelArtSnapError::DeadlineExceeded {
stage: "输入解码"
},
StatusCode::GATEWAY_TIMEOUT,
),
(
platform_image::PixelArtSnapError::Encode("encode failed".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
platform_image::PixelArtSnapError::Processing("resize failed".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
),
] {
let rendered = error.to_string();
let mapped = map_editor_pixel_art_snapper_error(error);
assert_eq!(
mapped.status_code(),
expected_status,
"unexpected status for {rendered}"
);
// 中文注释:底层文案必须原样带上。丢掉它,客户端只会看到一句通用失败,
// 「识别不到网格」和「解码失败」在用户侧变得无法区分。
assert!(
mapped.body_text().contains(rendered.as_str()),
"mapped error should carry the snapper message: {rendered}"
);
}
}
#[test]
fn pixel_art_server_worst_case_fits_inside_the_client_timeout() {
// 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端