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:
AskaEth committed 2026-11-12 23:24:19 +08:00
1 parent cc0def3415
commit d6f862a43c
13 files changed
+571 -146

No files matched your search

+70 -3
View File
@@ -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();
}