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

@@ -215,6 +215,12 @@ impl DownloadEngine {
// 整文件重算是同步阻塞 IO:挪到 blocking 池并套上界,否则「校验阶段卡住」
// (网络盘掉线、杀软锁文件)照样是永久挂住,且它发生在字节全部收完之后,
// 从进度上看与「下载完成」一模一样。
//
// 代价(复审 r1 p3-1,未验):`spawn_blocking` 的线程**不可取消**——到点后它还在
// 读文件、还持着句柄,重试会再堆一个(每源最多 3 个)。**假设未验**:Windows 上
// 那个句柄可能让随后的同名重建(`attempt_chunked` 的 `OpenOptions::create(true)`)
// 拿到 `Access is denied`,表现为「预分配中间文件失败」而不是干净重试
// (`PartFile::discard` 忽略错误)。仍然有界、有分类,只是诊断会拐一道。
let on_disk = part.len();
let path = part.path().to_path_buf();
let algo = integrity.algo;