Files
Unlucky-Launcher/crates/core/src/download/stall.rs
T
AskaEth bde6649aab fix(download): text_bounded 不再信 Content-Length 预分配,补两道内存闸;api/version 第四处裸 send 也上界(M290 r1)
复审 r1(p2-1 / p3-1 / p3-2 / p3-5)返工,三处既有结论(send_bounded 逐字节未动、三个既有调用方
零改动、阶段只用两档)原样保留。

**p2-1 `http.rs::text_bounded` 的内存**(复审判据成立):`Content-Length` 是「对面说了算」的数字,
拿它喂 `Vec::with_capacity` 等于让远端决定我们的内存,而分配失败是 abort、进不了 `Result`。
改成三道闸:
1. 头里声明 > `MAX_IN_MEMORY_BYTES`(64 MiB)⇒ 读完头立刻有界失败,**不预分配、不读正文**;
2. 容量只按真实到达增长(`Vec::new()`);
3. 读循环逐块卡同一道上限(在累加前判,不会先撑过上限一块)。
两道闸的 `message` 分别说「声明的长度」与「内存上限」,`kind` 都是 `InvalidRequest` ⇒ 与超时
(`Timeout` + 阶段)可区分。

**p3-1 第四处裸 `send()`**:`game/java/api/version.rs::fetch_release_page` 原本
`client.get(url).send()` + `response.text()`、整文件 0 处 timeout(生产路径:`source::pinned` →
`api::fetch_version`)。改走 `http::send_bounded` + `http::text_bounded`,界是参数(生产
`http::default_deadlines()`,用例压到 200ms)。

**p3-2 `download/stall.rs` 模块头**:把「`game/java/api.rs`、`game/java/net.rs`、`update/github.rs`
是裸 `send()`——未审」这句假事实换成实测清单(谁已收、谁可以留、为什么留:`auth/http.rs` 自带
`.timeout`、`preflight.rs` 由 `check_with_deadline` 套住、`region.rs` 自带 3s),并给
`http::text_bounded` 补一行「入口/上界/阶段」。

**p3-5 doc**:删掉「与 `Response::text()` 的唯一差别是 charset 解码」这条过度披露——本仓 reqwest
没开 `charset` feature,`text()` 本来就是 `from_utf8_lossy`,解码逐字节等价;改成真实差别
(读空闲 + 内存上限)。

离线用例(全在 `#[cfg(test)]`,夹具 `game::java::net::probe` 新增 `Mode::Declared{declared,sent}`
与 `Mode::Flood(n)`,外加共用的 `bounded()` 护栏助手):
* `api/version.rs::bounded_probes`:静默 ⇒ 阶段等响应头;正文停顿 ⇒ 阶段等字节;
  声明 `Content-Length: 999999999999` 且不发正文 ⇒ <2s 返回 `InvalidRequest` 且文案含
  「声明的长度」;不声明长度灌 65 MiB ⇒ 读循环在 64 MiB 处拒。
* `net.rs::tests` 的六条(4 条原有 + 2 条正常路径)改用同一个 `bounded()` 助手,断言一条未放宽。

变异实跑(贴红原文见 mission 报告):
1. 退回 `Vec::with_capacity(declared)` 且删提前拒绝 ⇒ `memory allocation of 999999999999 bytes
   failed` + 进程 abort(0xc0000409),测试二进制消失——不是一条红;
2. 只删提前拒绝(预分配仍是 `Vec::new()`)⇒ 干净红:`left: Timeout / right: InvalidRequest`(0.21s);
3. 删循环里的上限卡口 ⇒ 干净红:`expect_err` 拿到 `Ok("xxxx…")`(0.54s);
4. `fetch_release_page` 退回裸 `send()` + `text()` ⇒ 两条 version 用例各在护栏 5s 处 panic(5.01s)。

门禁(真实退出码):check=0 / lib=896 passed / game=120 passed / launch=91 passed /
engine_mock=17 passed / clippy -D warnings=0 / fmt=0。行数:http 389、stall 378、api 389、
net 390、version 397、github 363(全 ≤400)。
2026-11-13 00:22:22 +08:00

379 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 失速诊断与下载的每一处上界(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′) 的同族形态)。
//!
//! `build_client` 只设了 `connect_timeout`,reqwest 自己的 `timeout` 默认 `None` ——
//! 于是「服务器接了连接、不回一个字节」= 这个 future 永不结束,而下 188MB 的作业
//! 就在那里静静挂着(`ull java install` 走的是 `silent()` 引擎:零事件、零日志,
//! 用户只能看到一个不动的主界面)。
//!
//! # 哪些入口有界、谁托管给谁
//!
//! | 入口 | 上界 | 阶段 |
//! |------|------|------|
//! | `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` 的洪流) | 等响应头 / 等字节 |
//! | `http::text_bounded`(模块外几条「一锤子 GET 取文本」的正文) | `read_step` 空闲判定 + 头/循环两道 `MAX_IN_MEMORY_BYTES` | 等字节 |
//! | `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`。
//!
//! # 模块外那几处 `send()` 的实测清单(M290 / M290 r1 收口)
//!
//! 口径是 `git grep -n "\.send()" crates/core/src`(M290 r1 之后的 tip)。**core 里已经没有
//! 「既无自身上界、又没有外部 deadline」的 `send()` 了**:
//!
//! | 位置 | 现状 |
//! |------|------|
//! | `game/java/api.rs`(Adoptium latest 查询) | 已收:`http::send_bounded` + `http::text_bounded`(M290) |
//! | `game/java/net.rs`(四家发行版的一锤子 GET) | 同上(M290) |
//! | `game/java/api/version.rs`(GA 清单翻页) | 同上(M290 r1,这一处是复审打穿的第四处) |
//! | `update/github.rs`(更新清单) | 同上;界取 `engine.config().deadlines()`(M290) |
//! | `auth/http.rs` | **可以留**:RequestBuilder 自己挂了 `.timeout(REQUEST_TIMEOUT)`,reqwest 的该超时覆盖到响应体读完 |
//! | `auth/microsoft/preflight.rs` | **可以留**:`probe()` 由 `check_with_deadline` 的整体 deadline 套住 |
//! | `download/region.rs` | **可以留**:自带 3s 探测界(上面那张表里也有它) |
//!
//! 这几条文本路径只用 `await_headers` / `await_bytes` 两档:它们没有分块回收、也不经引擎许可,
//! 所以 `await_chunk_join` / `acquire_permit` 在这里不适用(不是「没做到」,是「不适用」)。
//!
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
//!
//! 两个先例为什么取值天差地别,看它们的作业有多大:
//!
//! * `region.rs:288` 的 3s —— 探测一个**极小响应**(`Range: bytes=0-0`)。整个作业就是
//! 一次只用拿几个字节的 GET,所以「单次请求上界」与「整作业预算」在这里是同一件事,
//! 3s 对两者都成立;它紧是因为**慢探测本身就是坏结果**。
//! * `font.rs:232-238` 的 `ATTEMPT_DEADLINE` / `TOTAL_DEADLINE` —— 字体是几百 KB 的
//! 单文件,整作业几秒就完,所以能一刀切死「慢」;代价是它必须对最后一个候选放宽
//! (每个候选都从 0 字节重下,单次上限就是一条吞吐门槛,卡死最后一个等于把
//! 「慢但能下完」判成永久失败——那里的注释已经记了这笔账)。
//!
//! 下 188MB 的 JDK 与两者都不同:整作业预算必须 ≥ 慢链路上的真实耗时,否则
//! 「慢但有效」被判死——这是加超时最常见的自杀方式。所以这里**不设**「作业必须 N 秒
//! 内下完」,而设三段互不顶替的界:
//!
//! 1. [`StallStage::AwaitHeaders`](`EngineConfig::response_timeout`,**单次请求**):
//! `send()` 只到「响应头到手」为止,不含正文,所以对 188MB 的作业它是一次性开销,
//! 取多紧都不会误杀正在下的大文件;
//! 2. [`StallStage::AwaitBytes`](`EngineConfig::read_timeout`,**空闲**判定):
//! 字节一直在动就一直续期。每秒只挤几 KB 的链路也算「在动」,只有**真的不动**了才判失速;
//! 3. [`StallStage::AwaitChunkJoin`](分块作业):一个分块卡住不许 4 条连接陪着挂到天荒地老。
//! 这里同时给「无进度窗口」与「总期限」,但总期限按**平均吞吐下限**反推
//! ([`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 是**连接
//! 建立之后**等响应头的余额——CDN 排队、回源冷启动都在几十秒量级;响应头本身只有
//! 几百字节,能回的话 60s 内必回。
//! * `read_timeout` = 30s(既有值,不动):空闲判定。持续有字节就永远不触发。
//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是
//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。
//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s
//! (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;
/// 分块作业的**最低平均吞吐**(字节/秒):整作业总期限按它反推(见 [`job_budget`])。
pub const MIN_AVG_THROUGHPUT: u64 = 32 * 1024;
/// 卡在哪一段(诊断字段;`as_str` 是稳定 id,`label` 是给人看的中文)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StallStage {
/// 连接还没建立(DNS / TCP / TLS)。
Connect,
/// 连接已建立,等响应头(`send()` 的上界)。
AwaitHeaders,
/// 响应头已到,等下一块正文字节(读空闲判定的上界)。
AwaitBytes,
/// 还没开始下载:在等引擎的并发许可(`Semaphore` 队列)。
///
/// 单独一档而不是塞进 `AwaitBytes`:这里上游根本还没轮到我们,「服务器不给数据」与
/// 「引擎不给我们许可」是两种完全不同的故障(复审 r1 p2-1:挂死的作业攥着许可会把
/// 同缓存根的 `download()` 全堵死)。
AcquirePermit,
/// 分块作业在等分块任务回收(整作业的无进度 / 总期限上界)。
AwaitChunkJoin,
/// 整文件重算摘要(校验阶段)。
Verify,
}
impl StallStage {
/// 稳定字符串 id(日志 / 诊断页用,不要拿来显示给用户)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Connect => "connect",
Self::AwaitHeaders => "await_headers",
Self::AwaitBytes => "await_bytes",
Self::AcquirePermit => "acquire_permit",
Self::AwaitChunkJoin => "await_chunk_join",
Self::Verify => "verify",
}
}
/// 给人看的中文短名。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Connect => "建立连接",
Self::AwaitHeaders => "等响应头",
Self::AwaitBytes => "等字节",
Self::AcquirePermit => "等许可",
Self::AwaitChunkJoin => "等分块回收",
Self::Verify => "校验",
}
}
}
/// 为什么判失速:期限用完,还是「一段时间没有新字节」。
///
/// 两者必须分得开:一条 1 KB/s 但一直在动的链路**没有**无进展,只是慢;
/// 而 4 条连接同时不动两分钟与「整作业平均吞吐跌破下限」是两种不同的坏法,
/// 修法也不同(前者等一等/换源,后者基本只能换源)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StallReason {
/// 一段期限用完(等响应头 / 校验 / 整作业总期限)。
Deadline,
/// 已有一段时间没有任何新字节。
NoProgress,
}
impl StallReason {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Deadline => "deadline",
Self::NoProgress => "no_progress",
}
}
/// 给人看的中文短名。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Deadline => "期限用完",
Self::NoProgress => "没有新字节",
}
}
}
/// 一次失速的现场:阶段 + 已收 / 预期字节 + 无进展时长(口径见模块头)。
///
/// 挂在 [`super::error::DownloadError::stall`] 上(结构化),同时由
/// [`super::error::DownloadError::stalled`] 抄一份进 `message`——因为整体失败时
/// `suppressed` 只搬 `message`,而上层(CLI / 诊断页)正靠那一行回答「卡在哪」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StallReport {
/// 卡在哪一段。
pub stage: StallStage,
/// 为什么判失速。
pub reason: StallReason,
/// 已收字节(口径见模块头)。
pub received: u64,
/// 预期字节(未知时 `None`:总长不明的大文件)。
pub expected: Option<u64>,
/// 已经多久没有新字节。
pub idle: Duration,
}
impl StallReport {
/// 「一段期限用完」的现场。
#[must_use]
pub const fn deadline(
stage: StallStage,
received: u64,
expected: Option<u64>,
waited: Duration,
) -> Self {
Self {
stage,
reason: StallReason::Deadline,
received,
expected,
idle: waited,
}
}
/// 「已 `idle` 没有新字节」的现场。
#[must_use]
pub const fn no_progress(
stage: StallStage,
received: u64,
expected: Option<u64>,
idle: Duration,
) -> Self {
Self {
stage,
reason: StallReason::NoProgress,
received,
expected,
idle,
}
}
}
impl fmt::Display for StallReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"阶段={}({});已收 {} 字节",
self.stage.label(),
self.stage.as_str(),
self.received
)?;
match self.expected {
Some(expected) => write!(f, " / 预期 {expected} 字节")?,
None => write!(f, " / 预期未知")?,
}
write!(
f,
";已 {}ms 无进展;原因={}",
self.idle.as_millis(),
self.reason.label()
)
}
}
/// 两段可以共存的界(等响应头 / 等下一块字节),由 `EngineConfig::deadlines()` 构造。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Deadlines {
/// 发出请求 → 响应头到手的上界(单次请求,不含正文)。
pub response: Duration,
/// 两块正文之间的空闲上界(字节在动就一直续期)。
pub read_idle: Duration,
}
/// 分块作业的总期限:`max(floor, 总字节 / MIN_AVG_THROUGHPUT)`。
///
/// 为什么不是固定秒数:固定值对小作业太松、对大作业太紧;按平均吞吐反推,
/// 「能下得完的慢链路」永远不会撞上它,撞上的只有「慢到已经没意义」的作业。
#[must_use]
pub fn job_budget(total: u64, floor: Duration) -> Duration {
floor.max(Duration::from_secs(total / MIN_AVG_THROUGHPUT))
}
#[cfg(test)]
mod tests {
use super::{job_budget, Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT};
use std::time::Duration;
/// 总期限按平均吞吐反推:188MB 的作业拿到 ≈100 分钟,小作业只拿到下限。
///
/// 变异证明:把 `job_budget` 写成常量(比如 `Duration::from_secs(30)`)⇒ 第一条红;
/// 把 `floor.max(..)` 写成 `min` ⇒ 第二条红(188MB 只剩 100 分钟里的 `floor`)。
#[test]
fn the_job_budget_scales_with_size_and_never_drops_below_the_floor() {
let floor = Duration::from_secs(600);
let big = job_budget(188 * 1024 * 1024, floor);
let by_throughput = Duration::from_secs(188 * 1024 * 1024 / MIN_AVG_THROUGHPUT);
assert_eq!(big, by_throughput, "大作业的期限由平均吞吐决定");
assert!(big > Duration::from_secs(90 * 60), "188MB 至少给到 90 分钟");
assert_eq!(
job_budget(8 * 1024, floor),
floor,
"小作业只拿到下限(吞吐算式在这里没有意义)"
);
}
/// 诊断串必须同时有:阶段(中英文都给)、已收字节、预期字节、无进展时长、原因。
///
/// 变异证明:删掉 `stage` 那一段 ⇒ 前两条 red;把 `received` / `expected` 写成常量
/// 或删掉 ⇒ 对应断言 red;把 `reason` 删掉 ⇒ 最后一条 red。
#[test]
fn the_report_spells_out_stage_three_numbers_and_reason() {
let report = StallReport::no_progress(
StallStage::AwaitBytes,
4096,
Some(188 * 1024 * 1024),
Duration::from_millis(30_000),
);
let text = report.to_string();
assert!(text.contains("等字节"), "{text}");
assert!(text.contains("await_bytes"), "{text}");
assert!(text.contains("已收 4096 字节"), "{text}");
assert!(text.contains("预期 197132288 字节"), "{text}");
assert!(text.contains("已 30000ms 无进展"), "{text}");
assert!(text.contains("原因=没有新字节"), "{text}");
assert!(report.reason == StallReason::NoProgress);
// 总长未知也要能说清楚(不是「预期 0」)。
let unknown =
StallReport::deadline(StallStage::AwaitHeaders, 0, None, Duration::from_secs(60));
assert!(unknown.to_string().contains("预期未知"), "{unknown}");
assert_eq!(unknown.reason, StallReason::Deadline);
}
/// 阶段 id 是日志里的 grep 锚点:改名字等于改诊断口径,得先改这里。
#[test]
fn stage_and_reason_ids_are_stable() {
let stages = [
(StallStage::Connect, "connect", "建立连接"),
(StallStage::AwaitHeaders, "await_headers", "等响应头"),
(StallStage::AwaitBytes, "await_bytes", "等字节"),
(StallStage::AcquirePermit, "acquire_permit", "等许可"),
(StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"),
(StallStage::Verify, "verify", "校验"),
];
for (stage, id, label) in stages {
assert_eq!(stage.as_str(), id);
assert_eq!(stage.label(), label);
}
assert_eq!(StallReason::Deadline.as_str(), "deadline");
assert_eq!(StallReason::NoProgress.as_str(), "no_progress");
let deadlines = Deadlines {
response: Duration::from_secs(60),
read_idle: Duration::from_secs(30),
};
assert_eq!(deadlines.response, Duration::from_secs(60));
}
}