fix(download): 补齐 download_bytes 与等许可两处无界 await,预算耗尽不再重试(M289 r1)
r1 复审 p2-1:`stall.rs` 那句「全仓只有两处无界 await」是假话。真实清单五处, M289 之前只修了两处;这次补齐: * `bytes.rs::fetch_bytes` 的 `send()` 改走 `http::send_bounded`(与文件下载同一个构造点); 整包 `response.bytes()` 换成 `bytes_stream` + `http::read_step` 的空闲判定,并边读边卡 `MAX_IN_MEMORY_BYTES`(`Content-Length` 可缺失/可撒谎)。生产调用点:版本清单、 版本 JSON、Modrinth 元数据。 * `engine::acquire` 的 `acquire_owned()` 加上界(`EngineConfig::permit_timeout`,默认 120min ≥ 单个作业总期限),到点报新阶段 `StallStage::AcquirePermit`(「等许可」), 不冒充「服务器不给数据」。这就是「卡死作业攥着许可把队列堵死」的连坐形态。 * `stall.rs` 模块头改成一张「哪些入口有界、谁托管给谁」的表,并写清五处 await 的真实清单。 r1 复审 p2-2:总期限用完是链路级判决,不再按满预算重试/换源: * `DownloadError::job_budget_exhausted()` + `DownloadError::is_retryable()`(覆盖 `kind.is_retryable()`),引擎与 `download_bytes` 都改用它; * 引擎在预算耗尽时直接结束候选循环,不再换源(同一份字节、同一条链路,换源只会再 烧满一整个预算)。188MB/20 KiB/s 的用户不再从「1.6 小时慢慢下完」变成「几小时反复重下」。 * `stall.rs` 写明「慢链路照样活」的前提 = **平均吞吐 ≥ 32 KiB/s**,以及它挡掉哪类链路; `download.*` schema 接线留给另一个 mission(`setting/keys.rs` 不在 scope)。 顺带(复审 p3): * `transfer.rs` 的读空闲统一走 `EngineConfig::deadlines()`(不再各读字段); * `verify_part` 补一条代价说明:`spawn_blocking` 到点不可取消,可能让同名重建拿到 `Access is denied`(**该假设未验**,注释里已标); * `engine.rs` 因这轮新增又贴到 410 行 ⇒ `finish` / `materialize` / `millis` 逐字节搬到 `engine/finish.rs`(`engine::millis` 路径用 `pub(crate) use` 保持可用)。 新增用例(全离线、<2s):`download_bytes` 静默服务端 / 正文中途卡住、等许可超时、 预算耗尽不重试(`max_attempts_per_source = 3` 下仍只发一轮)、预算耗尽不换源 (官方源 `hits == 0`)。
This commit is contained in:
1 parent
cc0def3415
commit
d6f862a43c
13 files changed
+571
-146
No files matched your search
@@ -124,17 +124,21 @@ async fn a_chunked_job_with_no_progress_is_named_await_chunk_join() {
|
||||
/// 分块作业的**总期限**:一直在动,但平均吞吐低到没意义 ⇒ 「等分块回收 + 期限用完」。
|
||||
///
|
||||
/// 与上一条的区别只有 `reason`:这条的 `received > 0`(一直在动),上一条是 0。
|
||||
/// 另外钉住 r1 p2-2 的第一条要求:预算用完**不重试**(`max_attempts_per_source = 3` 之下
|
||||
/// 仍然只发一轮分块请求,`hits` 停在 4)。变异:让 `DownloadError::is_retryable()` 忽略
|
||||
/// `job_budget_exhausted()` ⇒ `hits` 变成 8 / 12,本条红。
|
||||
#[tokio::test]
|
||||
async fn a_chunked_job_that_is_too_slow_hits_the_total_budget() {
|
||||
async fn a_chunked_job_that_is_too_slow_hits_the_total_budget_without_retrying() {
|
||||
let payload = vec![2_u8; 16 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
// 每条连接每 20ms 滴 1 字节:一直在动(不会触发无进度),但远低于平均吞吐下限。
|
||||
let dripping = |_| MockResponse::ok(payload.clone()).dripping(1, Duration::from_millis(20));
|
||||
let server = MockServer::start((0..4).map(dripping).collect()).await;
|
||||
let server = MockServer::start((0..12).map(dripping).collect()).await;
|
||||
let (engine, dir) = harness(
|
||||
"chunked-timeout-budget",
|
||||
EngineConfig {
|
||||
chunk_job_floor: Duration::from_millis(300),
|
||||
max_attempts_per_source: 3,
|
||||
..config()
|
||||
},
|
||||
);
|
||||
@@ -147,7 +151,7 @@ async fn a_chunked_job_that_is_too_slow_hits_the_total_budget() {
|
||||
)
|
||||
.await
|
||||
.expect("分块作业总期限必须有界(外层挂断 = 无界)")
|
||||
.expect_err("慢到没有意义的作业必须在总期限处失败");
|
||||
.expect_err("慢到没意义的作业必须在总期限处失败");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
@@ -166,5 +170,68 @@ async fn a_chunked_job_that_is_too_slow_hits_the_total_budget() {
|
||||
"这条一直在动,已收字节必须 > 0(0 就说明它其实是卡住而不是慢):{stall}"
|
||||
);
|
||||
assert_eq!(stall.expected, Some(size), "{stall}");
|
||||
assert!(
|
||||
!error.is_retryable(),
|
||||
"整作业总期限用完是链路级判决,不许重试:{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
server.hits(),
|
||||
4,
|
||||
"预算用完只许发一轮分块请求(重试会再烧满一整个预算)"
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// 预算耗尽**连候选源都不再换**(r1 p2-2 的「必然失败的形态」):镜像慢到撞预算,
|
||||
/// 官方源一次都不该被碰——同一份字节、同一条链路,换源只会再烧一轮。
|
||||
///
|
||||
/// 变异:去掉 `engine.rs` 里的 `if exhausted { break; }` ⇒ `official.hits()` 变成 1,红。
|
||||
#[tokio::test]
|
||||
async fn a_budget_exhaustion_does_not_try_the_next_source() {
|
||||
use unlucky_core::download::mirrors::PrefixRule;
|
||||
|
||||
let payload = vec![8_u8; 16 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let mirror = MockServer::start(
|
||||
(0..12)
|
||||
.map(|_| MockResponse::ok(payload.clone()).dripping(1, Duration::from_millis(20)))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
let mut table = MirrorTable::builtin();
|
||||
table.add_custom_rule(PrefixRule::new(
|
||||
"mock-mirror",
|
||||
format!("http://127.0.0.1:{}", official.port()),
|
||||
format!("http://localhost:{}", mirror.port()),
|
||||
));
|
||||
let dir = temp_dir("chunked-timeout-no-failover");
|
||||
let engine = DownloadEngine::new(
|
||||
EngineConfig {
|
||||
chunk_job_floor: Duration::from_millis(300),
|
||||
max_attempts_per_source: 3,
|
||||
..config()
|
||||
},
|
||||
Arc::new(table),
|
||||
Arc::new(RegionProfile::mirror_first(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(dir.join("cache"))),
|
||||
);
|
||||
let url = official.url("/jdk.zip");
|
||||
|
||||
let error = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(&url, dir.join("jdk.zip"), size)),
|
||||
)
|
||||
.await
|
||||
.expect("预算耗尽必须有界返回")
|
||||
.expect_err("慢到没意义的镜像必须让整次下载失败");
|
||||
assert_eq!(
|
||||
official.hits(),
|
||||
0,
|
||||
"预算用完不该再换源(同一链路再烧一轮 100 分钟没有意义)"
|
||||
);
|
||||
assert!(error.job_budget_exhausted(), "{error}");
|
||||
official.stop();
|
||||
mirror.stop();
|
||||
}
|
||||
Reference in new issue
Block a user