复审 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)。
379 lines
19 KiB
Rust
379 lines
19 KiB
Rust
//! 失速诊断与下载的每一处上界(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));
|
||
}
|
||
}
|