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)。
This commit is contained in:
AskaEth committed 2026-11-13 00:22:22 +08:00
1 parent ad99303a95
commit bde6649aab
4 files changed
+274 -101

No files matched your search

+50 -12
View File
@@ -14,6 +14,7 @@ use reqwest::header::{
};
use reqwest::redirect::Policy;
use super::bytes::MAX_IN_MEMORY_BYTES;
use super::config::EngineConfig;
use super::error::{DownloadError, DownloadErrorKind};
use super::stall::{Deadlines, StallReport, StallStage};
@@ -128,31 +129,46 @@ where
///
/// 为什么不是 `response.text()`:它是一次无界的整段读,上游「回了头但正文不动」时永远不返回
/// —— 与 M289 在 `download_bytes` 里修掉的是同一种形态。这里换成流式累加 + 空闲判定,
/// 口径与 [`super::bytes`] 的读循环逐字一致(只是落成 `String`)。
/// 口径与 [`super::bytes`] 的读循环一致(同一道 `MAX_IN_MEMORY_BYTES` 卡口、同一个 `read_step`
/// 空闲判定;区别只是它落成 `Vec<u8>`、这里落成 `String`,且这里的循环在累加**前**判上限,
/// 不会先撑过上限一块)。
///
/// 只给「一次 GET 取一小段文本」用:分块/落盘作业走 [`read_step`] 自己的循环(它们要进度、
/// 要边读边写盘、要卡内存上限),别把那条路改成经过这里。
/// 要边读边写盘),别把那条路改成经过这里。
///
/// 与 `reqwest::Response::text()` 的**唯一**差别:不按 `Content-Type` 的 charset 解码,一律
/// UTF-8 lossy(声明了非 UTF-8 字符集时中文字节会变成替换符)。三条消费者(Adoptium JSON /
/// GitHub 清单 JSON / 各镜像的目录与校验文本)上游都是 UTF-8 或纯 ASCII。
/// **内存有三道闸**(复审 r1 p2-1:`Content-Length` 是「对面说了算」的数字,拿它去预分配等于让
/// 远端决定我们的内存,而 `Vec::with_capacity` 分配失败是 abort、进不了 `Result`):
/// 1. 头里声明的长度超 [`MAX_IN_MEMORY_BYTES`] ⇒ 立刻有界失败,**不预分配、不读正文**;
/// 2. 容量只按真实到达的字节增长(`Vec::new()`,不拿 `declared` 预分配);
/// 3. 读循环逐块卡上限(声明可以撒谎、也可以缺失 —— 关连接分帧)⇒ 边读边拒。
///
/// 解码:本仓 reqwest **没开 `charset`** feature(`Cargo.toml`),`Response::text()` 内部本来就是
/// `String::from_utf8_lossy` ⇒ 这里与它在解码上逐字节等价(复审 r1 p3-5 核过)。将来一旦打开
/// `charset`,`text()` 才会按 `Content-Type` 转码,那时两边才有区别。
///
/// # Errors
///
/// 正文读取超时 ⇒ `kind = Timeout` 的 [`DownloadError`],阶段「等字节」+ 已收/预期/无进展
/// 三个数(`message` 里带一份,见 [`DownloadError::stalled`])。
/// 三个数(`message` 里带一份,见 [`DownloadError::stalled`]);声明长度超上限、或正文读着读着
/// 超上限 ⇒ `kind = InvalidRequest`,`message` 分别说「声明的长度」与「内存上限」(两种可区分)。
pub async fn text_bounded(
response: reqwest::Response,
url: &str,
deadlines: Deadlines,
) -> Result<String, DownloadError> {
let declared = content_length(response.headers());
if let Some(len) = declared {
if len > MAX_IN_MEMORY_BYTES {
return Err(too_large(
format!(
"文本资源声明的长度超上限(声明 {len} 字节 > 上限 {MAX_IN_MEMORY_BYTES} 字节)"
),
url,
));
}
}
let mut stream = response.bytes_stream();
let mut body: Vec<u8> = Vec::with_capacity(
declared
.and_then(|len| usize::try_from(len).ok())
.unwrap_or(0),
);
let mut body: Vec<u8> = Vec::new();
loop {
let next = read_step(
futures_util::StreamExt::next(&mut stream),
@@ -163,11 +179,33 @@ pub async fn text_bounded(
)
.await?;
let Some(chunk) = next else { break };
body.extend_from_slice(&chunk.map_err(|err| classify_reqwest(&err, url))?);
let chunk = chunk.map_err(|err| classify_reqwest(&err, url))?;
if body.len() as u64 + chunk.len() as u64 > MAX_IN_MEMORY_BYTES {
return Err(too_large(
format!(
"文本资源在读入时超过内存上限(已收 {} 字节 + 本块 {} 字节 > 上限 {MAX_IN_MEMORY_BYTES} 字节)",
body.len(),
chunk.len()
),
url,
));
}
body.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&body).into_owned())
}
/// 「文本资源太大」这一类的错误(两道闸共用形状,`message` 各自说清是哪一道)。
///
/// 不挂 `.with_source(url)`:地址已经在 `message` 里了,再挂一次只是让 `Display` 重复一遍
/// (与 `send_bounded` 的失速错误同一取舍)。
fn too_large(reason: String, url: &str) -> DownloadError {
DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("{reason},拒绝载入:{url}"),
)
}
/// 手里没有 `EngineConfig` 的消费者用的两段界:**取 [`EngineConfig::default`],不另抄秒数**。
///
/// `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 的签名只收 `reqwest::Client`