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:
1 parent
ad99303a95
commit
bde6649aab
4 files changed
+274
-101
No files matched your search
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user