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
@@ -1,15 +1,43 @@
|
||||
//! 失速诊断与下载的三段上界(M289 / 台账 P19)。
|
||||
//! 失速诊断与下载的每一处上界(M289 / 台账 P19)。
|
||||
//!
|
||||
//! # 事实:下载引擎曾是全仓唯一没有超时的那条路
|
||||
//! # 事实:下载引擎曾是全仓唯一没有超时的那条路(r1 复审 p2-1 后修正)
|
||||
//!
|
||||
//! 「全仓下载路径只有两处无界 `await`」那句话**是错的**,被复审用一个 `silent()` 探针
|
||||
//! 当场打穿。真实的清单一共有**五处**,M289 修掉了其中五处:
|
||||
//!
|
||||
//! * 单连接请求的 `send()`(`transfer.rs`);
|
||||
//! * 分块请求的 `send()`(`chunked.rs`);
|
||||
//! * `download_bytes` 的 `send()`(`bytes.rs`,生产路径:版本清单 / 版本 JSON /
|
||||
//! Modrinth 元数据)——整包读 `response.bytes()` 同样无界,而且它**只管大小不管时间**
|
||||
//! (`MAX_IN_MEMORY_BYTES` 不是时间上界);
|
||||
//! * `acquire_owned()`(`engine.rs`):等许可也是一次 `await`,卡死的作业攥着许可,
|
||||
//! 同缓存根的 `download()` 就全堵在这里(P19 (c′) 的同族形态)。
|
||||
//!
|
||||
//! 全仓下载路径此前只有两处**无界** `await`:分块请求与单连接请求的 `send()`。
|
||||
//! `build_client` 只设了 `connect_timeout`,reqwest 自己的 `timeout` 默认 `None` ——
|
||||
//! 于是「服务器接了连接、不回一个字节」= 这个 future 永不结束,而下 188MB 的作业
|
||||
//! 就在那里静静挂着(`ull java install` 走的是 `silent()` 引擎:零事件、零日志,
|
||||
//! 用户只能看到一个不动的主界面)。
|
||||
//!
|
||||
//! 同仓两个先例:`region.rs` 给探测的 `send()` 包了 3s,`font.rs` 给整个
|
||||
//! `download_bytes` 包了总预算——下载引擎自己反而是唯一没有界的那条路。
|
||||
//! # 哪些入口有界、谁托管给谁
|
||||
//!
|
||||
//! | 入口 | 上界 | 阶段 |
|
||||
//! |------|------|------|
|
||||
//! | `DownloadEngine::download` 单连接 | `http::send_bounded` + `http::read_step` | 等响应头 / 等字节 |
|
||||
//! | `DownloadEngine::download` 分块 | 同上 + [`StallStage::AwaitChunkJoin`](无进度窗口与总期限) | 等分块回收 |
|
||||
//! | `DownloadEngine::download_bytes` | `http::send_bounded` + `read_step` 流式累加(顺带用 `MAX_IN_MEMORY_BYTES` 卡住无 `Content-Length` 的洪流) | 等响应头 / 等字节 |
|
||||
//! | `DownloadEngine::acquire`(许可) | `EngineConfig::permit_timeout` | [`StallStage::AcquirePermit`] |
|
||||
//! | 校验(`verify_part`) | `EngineConfig::verify_timeout` | [`StallStage::Verify`] |
|
||||
//! | `font::fetch_blocking` | 自带的整作业预算(外加上面全部) | —— |
|
||||
//! | `region::probe_reachable` | 自带的 3s | —— |
|
||||
//!
|
||||
//! 因此**本模块内**裸 `.send()` 只剩两处:本模块的 [`super::http::send_bounded`](唯一构造点)
|
||||
//! 与 `region.rs` 的 3s 探测(自带界)。新加下载入口时**先看这张表**,别再出现第二个
|
||||
//! `download_bytes`。
|
||||
//!
|
||||
//! 模块外还有几处用**同一个** `reqwest::Client`(只设了 `connect_timeout`)的地方,M289 没碰
|
||||
//! (scope 是 `download/**`):`auth/http.rs` 自带 `.timeout(REQUEST_TIMEOUT)`;而
|
||||
//! `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 是裸 `send()`——**未审**,
|
||||
//! 与这里修掉的是同族形态,已用 TowerFinding 报给塔台另立 mission。
|
||||
//!
|
||||
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
|
||||
//!
|
||||
@@ -34,12 +62,24 @@
|
||||
//! 字节一直在动就一直续期。每秒只挤几 KB 的链路也算「在动」,只有**真的不动**了才判失速;
|
||||
//! 3. [`StallStage::AwaitChunkJoin`](分块作业):一个分块卡住不许 4 条连接陪着挂到天荒地老。
|
||||
//! 这里同时给「无进度窗口」与「总期限」,但总期限按**平均吞吐下限**反推
|
||||
//! ([`job_budget`]),不是拍一个固定秒数:能把 188MB 在期限内下完的慢链路照样活。
|
||||
//! ([`job_budget`]),不是拍一个固定秒数。
|
||||
//!
|
||||
//! **不要**把它改成 `reqwest::ClientBuilder::timeout(...)`:reqwest 的 `timeout` 覆盖
|
||||
//! **整个响应体**读取,那正好就是「慢但有效被判死」的那个坑(它的默认 `None` 也是 P19
|
||||
//! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。
|
||||
//!
|
||||
//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明)
|
||||
//!
|
||||
//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来**。
|
||||
//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而预算只有 100.3 min ⇒ 必被杀。而且一旦被杀,
|
||||
//! `DownloadError::job_budget_exhausted()` 让引擎**不再重试、也不再换源**(同一份字节、
|
||||
//! 同一条链路,候选源不可能更快)——所以在 <32 KiB/s 的链路上,这个文件大小是「下不完」,
|
||||
//! 不是「慢一点」。要让用户自己调这几个数,得接 `download.*` 的 schema(`setting/keys.rs`
|
||||
//! 不在 M289 的 scope,是另一个 mission);在那之前出厂值就是唯一值。
|
||||
//!
|
||||
//! 这条边界的命中面不远:仓内就有 ≈67 KB/s 的真机观测(`font/tests/support.rs` 的文件头)。
|
||||
//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项)。
|
||||
//!
|
||||
//! # 数字从哪来(全仓只写这一处)
|
||||
//!
|
||||
//! * `response_timeout` = 60s:连接已有 10s 的 `connect_timeout` 兜底,这 60s 是**连接
|
||||
@@ -49,16 +89,19 @@
|
||||
//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是
|
||||
//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。
|
||||
//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s
|
||||
//! (188MB ≈ 100 分钟)。即「整作业的平均速度掉到 32 KiB/s 以下才判死」;比这更快的
|
||||
//! 慢链路仍会下完。**这是平均吞吐门槛,不是慢链路的死刑**:真实公网慢链路的取值
|
||||
//! 没有真机验证过(M289 报告里列为未验项)。
|
||||
//! (188MB ≈ 100 分钟)。适用边界与后果见上一节——**这是「≥32 KiB/s 才活得下来」
|
||||
//! 的等价说法**,不是「慢一点的链路也没事」。
|
||||
//! * `permit_timeout` = 120min:等许可的上界。它必须 **≥ 单个作业的总期限**
|
||||
//! (最坏 ≈ 100 min 预算 + 120s 校验),否则 `download.concurrency = 1` 时正常排队
|
||||
//! 会被误杀;它挡的是「许可被永久占住」(M289 之前每一处无界 `await` 都能造出来)。
|
||||
//! * `verify_timeout` = 120s:188MB 的 SHA-1 在正常盘上不到 1s,120s 只抓「整文件重算
|
||||
//! 摘要被卡住」(网络盘掉线、杀软锁文件),不参与慢链路判定。
|
||||
//!
|
||||
//! # 口径(读这两个数之前先看这里)
|
||||
//!
|
||||
//! `received` / `expected` 是**该阶段自己的口径**:等字节 = 这条连接收到多少 / 该连接
|
||||
//! 被要求收多少(分块就是那一段的长度);等分块回收 = 整个作业收了多少 / 作业总长。
|
||||
//! 被要求收多少(分块就是那一段的长度);等分块回收 = 整个作业收了多少 / 作业总长;
|
||||
//! 等许可 = 都还没有(`received = 0`、`expected` 是这次请求的期望大小)。
|
||||
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
@@ -75,6 +118,12 @@ pub enum StallStage {
|
||||
AwaitHeaders,
|
||||
/// 响应头已到,等下一块正文字节(读空闲判定的上界)。
|
||||
AwaitBytes,
|
||||
/// 还没开始下载:在等引擎的并发许可(`Semaphore` 队列)。
|
||||
///
|
||||
/// 单独一档而不是塞进 `AwaitBytes`:这里上游根本还没轮到我们,「服务器不给数据」与
|
||||
/// 「引擎不给我们许可」是两种完全不同的故障(复审 r1 p2-1:挂死的作业攥着许可会把
|
||||
/// 同缓存根的 `download()` 全堵死)。
|
||||
AcquirePermit,
|
||||
/// 分块作业在等分块任务回收(整作业的无进度 / 总期限上界)。
|
||||
AwaitChunkJoin,
|
||||
/// 整文件重算摘要(校验阶段)。
|
||||
@@ -89,6 +138,7 @@ impl StallStage {
|
||||
Self::Connect => "connect",
|
||||
Self::AwaitHeaders => "await_headers",
|
||||
Self::AwaitBytes => "await_bytes",
|
||||
Self::AcquirePermit => "acquire_permit",
|
||||
Self::AwaitChunkJoin => "await_chunk_join",
|
||||
Self::Verify => "verify",
|
||||
}
|
||||
@@ -101,6 +151,7 @@ impl StallStage {
|
||||
Self::Connect => "建立连接",
|
||||
Self::AwaitHeaders => "等响应头",
|
||||
Self::AwaitBytes => "等字节",
|
||||
Self::AcquirePermit => "等许可",
|
||||
Self::AwaitChunkJoin => "等分块回收",
|
||||
Self::Verify => "校验",
|
||||
}
|
||||
@@ -293,6 +344,7 @@ mod tests {
|
||||
(StallStage::Connect, "connect", "建立连接"),
|
||||
(StallStage::AwaitHeaders, "await_headers", "等响应头"),
|
||||
(StallStage::AwaitBytes, "await_bytes", "等字节"),
|
||||
(StallStage::AcquirePermit, "acquire_permit", "等许可"),
|
||||
(StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"),
|
||||
(StallStage::Verify, "verify", "校验"),
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user