补测完美像素并发闸竞争路径与 snapper 错误分档
并发闸的三条竞争路径此前零测试,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:
@@ -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`。
|
||||
|
||||
@@ -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() {
|
||||
// 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端
|
||||
|
||||
Reference in New Issue
Block a user