From ad99303a954961563ed96a145197ccb1ac930c5c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 23:48:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(java/update):=20=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=A4=96=E7=9A=84=E4=B8=80=E9=94=A4=E5=AD=90?= =?UTF-8?q?=20GET=20=E4=B9=9F=E4=B8=8A=E7=95=8C=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E6=8A=A5=E5=87=BA=E5=8D=A1=E5=9C=A8=E5=93=AA=E4=B8=80?= =?UTF-8?q?=E6=AE=B5=EF=BC=88M290=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `download_bytes`(M289)修掉之后,core 里还剩三处裸 `send()`,都在「拿上游文本/JSON」的 路径上:`game/java/api.rs::fetch_latest`(Adoptium latest)、`game/java/net.rs::get_text` (四家发行版的一锤子 GET)、`update/github.rs::get_candidate_text`(更新清单)。上游接了连接 就装死时它们永不返回,与 M289 修掉的是同族形态。 改动(全部复用 M289 的构造点,没有第二套超时包装): * `download/http.rs` 新增 `text_bounded`(正文按 `read_step` 的空闲判定读成 `String`,与 `bytes.rs` 的读循环同口径)与 `default_deadlines()`(取 `EngineConfig::default()` 的两段界, 不另抄秒数);`send_bounded` 的签名逐字节未动,`transfer.rs`/`chunked.rs`/`bytes.rs` 三处 调用方零改动。 * 三处一律 `send_bounded`(等响应头)+ `text_bounded`(等字节):失败时 `message` 里带 `StallReport` 的阶段(`await_headers` / `await_bytes`)与已收/预期/无进展三个数。 这三条路径只用到这两档:没有分块回收、也没有引擎许可等待,所以 `await_chunk_join` / `acquire_permit` 不适用。 * `update/github.rs` 的界取 `engine.config().deadlines()`(手里有引擎就不抄默认值)。 正常路径零改变:`fetch_latest` / `get_text` 的公开签名与错误分类一字未动(`fetch_latest` 的 404 文案与 hint 保留),`update` 的 `github_cases` 全部断言未改。 离线用例(`game::java::net::tests`,6 条;夹具 `net::probe::Hanging` 也是 M290 新增): silent 服务端 ⇒ 引擎自己在 200ms 上界内返回(外层 5s 护栏没触发),并断言 message 含 「等响应头/await_headers」;正文停住 ⇒ 「等字节/await_bytes」;200 + 真 JSON ⇒ 正文逐字节 原样(含中文)。变异已实跑:`send_bounded` 退回裸 `send()` 三处各自在外层 5s 处挂断; `text_bounded` 退回 `response.text()` ⇒ 等字节用例 5s 挂断;正文改成空串 ⇒ 正常路径用例红。 门禁:cargo check(core+cli, --all-targets)/ test --lib(892 passed)/ --test game(120)/ --test launch(91)/ --test engine_mock(17)/ clippy -D warnings / fmt --check,退出码全 0。 --- crates/core/src/download/http.rs | 60 +++++- crates/core/src/game/java/api.rs | 50 +++-- crates/core/src/game/java/net.rs | 344 +++++++++++++++++++++++++++++-- crates/core/src/update/github.rs | 86 +++++--- 4 files changed, 481 insertions(+), 59 deletions(-) diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs index 33263ce..8fcab69 100644 --- a/crates/core/src/download/http.rs +++ b/crates/core/src/download/http.rs @@ -60,7 +60,9 @@ pub fn build_client(cfg: &EngineConfig) -> Result Result { + let declared = content_length(response.headers()); + let mut stream = response.bytes_stream(); + let mut body: Vec = Vec::with_capacity( + declared + .and_then(|len| usize::try_from(len).ok()) + .unwrap_or(0), + ); + loop { + let next = read_step( + futures_util::StreamExt::next(&mut stream), + deadlines.read_idle, + body.len() as u64, + declared, + url, + ) + .await?; + let Some(chunk) = next else { break }; + body.extend_from_slice(&chunk.map_err(|err| classify_reqwest(&err, url))?); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + +/// 手里没有 `EngineConfig` 的消费者用的两段界:**取 [`EngineConfig::default`],不另抄秒数**。 +/// +/// `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 的签名只收 `reqwest::Client` +/// (`update/github.rs` 有引擎,直接用 `engine.config().deadlines()`),所以它们只能取出厂值。 +/// 今天这不产生分歧:生产里每一处 `EngineConfig` 都是 `default()`(`game/java/install/chain.rs`), +/// `download.*` 还没接进设置页。**缺口**:将来这几个数可配置时,这三处不会跟着变——那时得给它们 +/// 加一条传配置的路(改签名会牵到 scope 外的 6 个调用点,M290 没动)。 +#[must_use] +pub fn default_deadlines() -> Deadlines { + EngineConfig::default().deadlines() +} + /// 把 `reqwest::Error` 映射成我们的错误分类。 #[must_use] pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError { diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs index 1174e71..743c78f 100644 --- a/crates/core/src/game/java/api.rs +++ b/crates/core/src/game/java/api.rs @@ -19,6 +19,8 @@ use serde::Deserialize; +use crate::download::{http, stall::Deadlines}; + use super::error::{JavaError, JavaErrorKind}; use super::release::version_from_release_name; use super::target::AdoptiumTarget; @@ -86,13 +88,32 @@ pub async fn fetch_latest( target: AdoptiumTarget, ) -> Result { let url = latest_url(major, target); - let response = client.get(&url).send().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("连不上 Adoptium API:{error}"), - ) - .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到") - })?; + let body = fetch_body(client, major, &url, http::default_deadlines()).await?; + parse_latest(&body, target) +} + +/// `fetch_latest` 的 HTTP 那一跳:GET → 状态分类 → 整段文本。**两处 `await` 都有界**(M290): +/// 等响应头走 [`http::send_bounded`]、正文走 [`http::text_bounded`],与 `net::get_text` 同一套 +/// 构造点(两段界是参数:生产用出厂值,用例把它压到毫秒级)。 +/// +/// 失败时 `message` 里带**阶段**(等响应头 / 等字节)+ 三个数,不是一句「请求失败」。 +/// 离线用例住在 `game::java::net` 的 tests 里——本文件贴着 400 行红线,塞不下(那份用例 +/// 与本模块共用同一个探针夹具)。 +pub(super) async fn fetch_body( + client: &reqwest::Client, + major: u32, + url: &str, + deadlines: Deadlines, +) -> Result { + let response = http::send_bounded(client.get(url), url, deadlines, None) + .await + .map_err(|error| { + JavaError::new( + JavaErrorKind::Network, + format!("连不上 Adoptium API:{error}"), + ) + .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到") + })?; let status = response.status(); if status == reqwest::StatusCode::NOT_FOUND { return Err(JavaError::new( @@ -107,13 +128,14 @@ pub async fn fetch_latest( format!("Adoptium API 返回 HTTP {status}({url})"), )); } - let body = response.text().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("读 Adoptium 响应失败:{error}"), - ) - })?; - parse_latest(&body, target) + http::text_bounded(response, url, deadlines) + .await + .map_err(|error| { + JavaError::new( + JavaErrorKind::Network, + format!("读 Adoptium 响应失败:{error}"), + ) + }) } /// 响应里的 `package` 子对象。 diff --git a/crates/core/src/game/java/net.rs b/crates/core/src/game/java/net.rs index 991d994..64523af 100644 --- a/crates/core/src/game/java/net.rs +++ b/crates/core/src/game/java/net.rs @@ -3,6 +3,12 @@ //! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎), //! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链, //! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。 +//! +//! 两处 `await` 都有界(M290):等响应头与等正文都走 `core::download::http` 的既有构造点 +//! (`http::send_bounded` / `http::text_bounded`),与引擎里 `download_bytes` 同一套口径。 + +use crate::download::http; +use crate::download::stall::Deadlines; use super::error::{JavaError, JavaErrorKind}; @@ -17,12 +23,24 @@ pub async fn get_text( url: &str, what: &str, ) -> Result { - let response = client.get(url).send().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("取 {what} 失败:{error}({url})"), - ) - })?; + get_text_with(client, url, what, http::default_deadlines()).await +} + +/// 真正干活的那一层:两段界是参数(生产只用上面的出厂值,用例要把它压到毫秒级)。 +/// +/// 失败时 `message` 里带**阶段**:等响应头(`await_headers`)/ 等字节(`await_bytes`), +/// 外加上游那句错。这不是「请求失败」四个字——用户要能分辨「对面不说话」与「说到一半停住」。 +async fn get_text_with( + client: &reqwest::Client, + url: &str, + what: &str, + deadlines: Deadlines, +) -> Result { + let response = http::send_bounded(client.get(url), url, deadlines, None) + .await + .map_err(|error| { + JavaError::new(JavaErrorKind::Network, format!("取 {what} 失败:{error}")) + })?; let status = response.status(); if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { return Err(JavaError::new( @@ -42,12 +60,14 @@ pub async fn get_text( .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_string(); - let body = response.text().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("读 {what} 响应失败:{error}"), - ) - })?; + let body = http::text_bounded(response, url, deadlines) + .await + .map_err(|error| { + JavaError::new( + JavaErrorKind::Network, + format!("读 {what} 响应失败:{error}"), + ) + })?; // 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML, // 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。 if body.trim().is_empty() { @@ -58,3 +78,303 @@ pub async fn get_text( } Ok(body) } + +/// 「接了连接不回话 / 说到一半停住」的回环服务端:M290 给**模块外**那三条一锤子 GET +/// 配的定点探针(`game/java/api.rs`、本模块、`update/github.rs` 各用一次)。 +/// +/// 为什么住在这里:夹具必须和它守的四个文件同属一个 scope,而 `download/http.rs` 加完 +/// 正文上界已贴着 400 行红线;本模块行数最宽裕,且它就是「取一段文本」这条路径本身。 +/// +/// 与 `tests/download/support` 的 `MockServer::silent` 是**两种夹具**:那一份服务集成测试里的 +/// 引擎公开 API,这一份只能服务 `cfg(test)` 下的库里私有路径(`get_text_with` 等)。 +#[cfg(test)] +pub(crate) mod probe { + use std::io::{Read as _, Write as _}; + use std::net::SocketAddr; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + use crate::download::stall::Deadlines; + + /// 服务端的坏法(外加一种正常应答,用来证明这几条路径的正常语义没被改坏)。 + #[derive(Debug, Clone, Copy)] + pub(crate) enum Mode { + /// 读完请求头、**一个字节都不写**(对面接了连接就装死)。 + Silent, + /// 回完响应头 + 这么多正文字节,然后停住(连接不断)。 + StalledAfter(usize), + /// 正常应答:200 + 这段正文(`Content-Length` 如实声明)。 + Answer(&'static str), + } + + /// 挂住的时长:比用例的外层护栏([`GATE`])长,读方不设界就只能被外层打断。 + const HOLD: Duration = Duration::from_secs(30); + + /// 外层护栏:用例调用外面再套这一层,**只在读方自己无界时才触发**。 + pub(crate) const GATE: Duration = Duration::from_secs(5); + + /// 用例用的两段界(200ms 量级):与 [`GATE`] 差一个数量级,「引擎自己返回」与 + /// 「被外层打断」就分得开——断言里还要求耗时 < 2s,落在两者之间。 + pub(crate) fn tight() -> Deadlines { + let short = Duration::from_millis(200); + Deadlines { + response: short, + read_idle: short, + } + } + + /// 只在 `127.0.0.1` 上说坏话的服务端(线程泄漏到进程结束,用例之间互不影响)。 + pub(crate) struct Hanging { + addr: SocketAddr, + hits: Arc, + } + + impl Hanging { + /// 起服务(每个连接一个线程:reqwest 可能重连,脚本不是一次性的)。 + pub(crate) fn start(mode: Mode) -> Self { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定回环端口"); + let addr = listener.local_addr().expect("取本地地址"); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&hits); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut socket) = stream else { break }; + counter.fetch_add(1, Ordering::SeqCst); + std::thread::spawn(move || { + let _ = socket.set_read_timeout(Some(Duration::from_secs(5))); + let mut seen = Vec::new(); + loop { + let mut chunk = [0_u8; 1024]; + match socket.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(read) => seen.extend_from_slice(&chunk[..read]), + } + if seen.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + match mode { + Mode::StalledAfter(bytes) => { + // 头部声称 4 KiB、正文只给 `bytes` 字节:读方按空闲判定就该收手。 + let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: 4096\r\n\r\n"; + if socket.write_all(head).is_err() { + return; + } + let _ = socket.write_all(&vec![b'{'; bytes]); + let _ = socket.flush(); + } + Mode::Answer(body) => { + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + if socket.write_all(head.as_bytes()).is_err() { + return; + } + let _ = socket.write_all(body.as_bytes()); + let _ = socket.flush(); + return; + } + Mode::Silent => {} + } + // 无论回没回字节,都把连接攥住:这就是「无界 `await`」在真网络上的形态。 + std::thread::sleep(HOLD); + }); + } + }); + Self { addr, hits } + } + + /// 指到这个服务端上的一个地址。 + pub(crate) fn url(&self, path: &str) -> String { + format!("http://{}{path}", self.addr) + } + + /// 已经收到几个请求。 + pub(crate) fn hits(&self) -> usize { + self.hits.load(Ordering::SeqCst) + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::probe::{tight, Hanging, Mode, GATE}; + use super::{get_text, get_text_with}; + use crate::game::java::error::JavaErrorKind; + + /// 服务端接了连接、一个字不回:`get_text` 必须自己返回,且说清卡在**等响应头**。 + /// + /// 变异证明:把 `get_text_with` 的 `send_bounded` 退回裸 `client.get(url).send()` + /// ⇒ 本用例在外层 5s 处挂断(`.expect(...)` 的文案就是那句 bug 说明),红。 + #[tokio::test] + async fn a_silent_server_is_bounded_and_the_stage_is_named() { + let server = Hanging::start(Mode::Silent); + let started = Instant::now(); + let error = tokio::time::timeout( + GATE, + get_text_with( + &reqwest::Client::new(), + &server.url("/list.json"), + "华为云 openjdk 目录", + tight(), + ), + ) + .await + .expect("get_text 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") + .expect_err("silent 服务端必须失败"); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(2), + "必须在上界附近自己返回,实际 {elapsed:?}" + ); + assert_eq!(error.kind(), JavaErrorKind::Network); + assert!( + error.message().contains("等响应头") && error.message().contains("await_headers"), + "要说清卡在哪一段:{}", + error.message() + ); + assert_eq!(server.hits(), 1, "只该问一次(这里没有重试)"); + } + + /// 头到手、正文挤出几个字节就停住:正文那一段同样有界,阶段是**等字节**。 + /// + /// 变异证明:把 `text_bounded` 换回 `response.text()` ⇒ 本用例在外层 5s 处挂断,红。 + #[tokio::test] + async fn a_stalled_body_is_bounded_and_says_await_bytes() { + let server = Hanging::start(Mode::StalledAfter(16)); + let started = Instant::now(); + let error = tokio::time::timeout( + GATE, + get_text_with( + &reqwest::Client::new(), + &server.url("/list.json"), + "Zulu 包列表", + tight(), + ), + ) + .await + .expect("正文读取又无界了:外层 5s 挂断(`text()` 是整段读,没有空闲判定)") + .expect_err("说到一半停住必须失败"); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(2), + "应在读空闲附近返回,实际 {elapsed:?}" + ); + assert!( + error.message().contains("等字节") && error.message().contains("await_bytes"), + "要说清卡在哪一段:{}", + error.message() + ); + } + + /// 正常路径不动:真回了一段 JSON 就原样交出来(含中文,证明解码没被打坏)。 + /// + /// 变异证明:把 `text_bounded` 的解码换成按字节硬塞(`body.iter().map(|b| *b as char)`)⇒ + /// 中文那条断言红;把状态分类挪到正文读之后 ⇒ 这里仍绿(本用例只管 200 的正常语义)。 + #[tokio::test] + async fn a_normal_response_is_handed_over_unchanged() { + let body = r#"{"major": 21, "note": "中文也要活"}"#; + let server = Hanging::start(Mode::Answer(body)); + let text = get_text(&reqwest::Client::new(), &server.url("/ok.json"), "正常清单") + .await + .expect("正常响应不该失败"); + assert_eq!(text, body, "正文必须逐字节交出来"); + assert!(text.contains("中文也要活")); + } + + /// `game/java/api.rs` 那条 latest 查询的 HTTP 跳(`fetch_latest` → `fetch_body`):两处 + /// `await` 同样有界、阶段同样说得出。**用例住在这里**而不是 `api.rs`:那份文件已贴着 + /// 400 行红线(389 行),再塞一条就撞上,而它测的正是本模块这几行同款 GET(同一套构造点)。 + /// + /// 变异证明:把 `fetch_body` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。 + #[tokio::test] + async fn the_adoptium_latest_hop_is_bounded_and_the_stage_is_named() { + let server = Hanging::start(Mode::Silent); + let started = Instant::now(); + let error = tokio::time::timeout( + GATE, + crate::game::java::api::fetch_body( + &reqwest::Client::new(), + 21, + &server.url("/assets/latest/21/hotspot"), + tight(), + ), + ) + .await + .expect("fetch_latest 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") + .expect_err("silent 服务端必须失败"); + + assert!( + started.elapsed() < Duration::from_secs(2), + "必须在上界附近自己返回,实际 {:?}", + started.elapsed() + ); + assert_eq!(error.kind(), JavaErrorKind::Network); + assert!( + error.message().contains("等响应头") && error.message().contains("await_headers"), + "要说清卡在哪一段:{}", + error.message() + ); + } + + /// 正常路径不变:200 + 真 JSON 时 `fetch_body` 逐字节交出正文(`fetch_latest` 就靠它解析)。 + /// + /// 变异证明:把 `text_bounded` 的返回值改成空串或按字符重编码 ⇒ 本用例红。 + #[tokio::test] + async fn the_adoptium_latest_hop_still_returns_the_body_on_200() { + let body = r#"[{"release_name":"jdk-21.0.13+9"}]"#; + let server = Hanging::start(Mode::Answer(body)); + let text = crate::game::java::api::fetch_body( + &reqwest::Client::new(), + 21, + &server.url("/assets/latest/21/hotspot"), + tight(), + ) + .await + .expect("200 不该失败"); + assert_eq!(text, body); + } + + /// `update/github.rs` 取更新清单那一跳(`fetch_manifest_documents` → `candidate_text`): + /// 同样是「一锤子 GET 拿一段 JSON」,两处 `await` 都必须有界。**用例住在这里**而不是 + /// `github.rs`:它内部是 `block_on` 的同步签名——在 tokio 用例里直接调它就会嵌套 + /// `block_on` panic,只有这一层能被直接测到。 + /// + /// 变异证明:把 `candidate_text` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。 + #[tokio::test] + async fn the_manifest_fetch_is_bounded_and_the_stage_is_named() { + let server = Hanging::start(Mode::Silent); + let started = Instant::now(); + let error = tokio::time::timeout( + GATE, + crate::update::github::candidate_text( + &reqwest::Client::new(), + &server.url("/manifest.json"), + tight(), + ), + ) + .await + .expect("取更新清单的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") + .expect_err("silent 服务端必须失败"); + + assert!( + started.elapsed() < Duration::from_secs(2), + "必须在上界附近自己返回,实际 {:?}", + started.elapsed() + ); + assert_eq!(error.kind, crate::update::UpdateErrorKind::Network); + assert!( + error.message.contains("等响应头") && error.message.contains("await_headers"), + "要说清卡在哪一段:{}", + error.message + ); + } +} diff --git a/crates/core/src/update/github.rs b/crates/core/src/update/github.rs index ac0d059..ca48504 100644 --- a/crates/core/src/update/github.rs +++ b/crates/core/src/update/github.rs @@ -54,8 +54,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::download::cache::now_ms; +use crate::download::stall::Deadlines; use crate::download::{ - Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind, + http, Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, + ResourceKind, }; use super::channel::Channel; @@ -227,41 +229,61 @@ impl GitHubReleaseSource { } /// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。 + /// + /// 界取**引擎自己的** `deadlines()`:本源手里就有引擎,不另抄秒数(与 M289 给 + /// `download_bytes` 的口径同源)。 fn get_candidate_text(&self, url: &str) -> Result, UpdateError> { - let client = self.engine.client(); - crate::download::block_on(async { - let response = client.get(url).send().await.map_err(|error| { - UpdateError::new( - UpdateErrorKind::Network, - format!("取更新清单失败:{error}({url})"), - ) - })?; - let status = response.status(); - if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { - return Ok(None); - } - if !status.is_success() { - return Err(UpdateError::new( - UpdateErrorKind::Network, - format!("更新清单返回 HTTP {status}({url})"), - )); - } - let text = response.text().await.map_err(|error| { - UpdateError::new( - UpdateErrorKind::Network, - format!("读更新清单响应失败:{error}({url})"), - ) - })?; - if text.trim().is_empty() { - return Err(UpdateError::invalid_manifest(format!( - "清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}" - ))); - } - Ok(Some(text)) - }) + let deadlines = self.engine.config().deadlines(); + crate::download::block_on(candidate_text(self.engine.client(), url, deadlines)) } } +/// 一个候选地址的整段文本(`Ok(None)` = 404/410):**两处 `await` 都有界**(M290)。 +/// +/// 等响应头走 [`http::send_bounded`]、正文走 [`http::text_bounded`],与引擎里 +/// `download_bytes` 同一套构造点;失败时 `message` 里带**阶段**(等响应头 / 等字节)+ 三个数, +/// 不再是「取更新清单失败」一句话。 +/// +/// 为什么是自由函数、`deadlines` 是参数:这条路的公开方法要 `&self`(引擎在 `Arc` 里),而 +/// 它内部是 `block_on` 的同步签名——同步路径塞进 tokio 用例就会嵌套 `block_on`(panic), +/// 所以离线用例只能走到这一层;界从外面给,用例才压得到毫秒级。 +/// `pub(crate)` 也只是为了那条用例(它住在 `game::java::net` 的 tests 里,那里才有行数余量)。 +pub(crate) async fn candidate_text( + client: &reqwest::Client, + url: &str, + deadlines: Deadlines, +) -> Result, UpdateError> { + let response = http::send_bounded(client.get(url), url, deadlines, None) + .await + .map_err(|error| { + UpdateError::new(UpdateErrorKind::Network, format!("取更新清单失败:{error}")) + })?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { + return Ok(None); + } + if !status.is_success() { + return Err(UpdateError::new( + UpdateErrorKind::Network, + format!("更新清单返回 HTTP {status}({url})"), + )); + } + let text = http::text_bounded(response, url, deadlines) + .await + .map_err(|error| { + UpdateError::new( + UpdateErrorKind::Network, + format!("读更新清单响应失败:{error}"), + ) + })?; + if text.trim().is_empty() { + return Err(UpdateError::invalid_manifest(format!( + "清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}" + ))); + } + Ok(Some(text)) +} + impl UpdateSource for GitHubReleaseSource { fn source_id(&self) -> &str { &self.id From bde6649aabeefb34b99296bbbbfc64ec1528aa35 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 00:12:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(download):=20text=5Fbounded=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BF=A1=20Content-Length=20=E9=A2=84=E5=88=86?= =?UTF-8?q?=E9=85=8D=EF=BC=8C=E8=A1=A5=E4=B8=A4=E9=81=93=E5=86=85=E5=AD=98?= =?UTF-8?q?=E9=97=B8=EF=BC=9Bapi/version=20=E7=AC=AC=E5=9B=9B=E5=A4=84?= =?UTF-8?q?=E8=A3=B8=20send=20=E4=B9=9F=E4=B8=8A=E7=95=8C=EF=BC=88M290=20r?= =?UTF-8?q?1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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)。 --- crates/core/src/download/http.rs | 62 +++++++-- crates/core/src/download/stall.rs | 22 +++- crates/core/src/game/java/api/version.rs | 139 ++++++++++++++++++--- crates/core/src/game/java/net.rs | 152 ++++++++++++----------- 4 files changed, 274 insertions(+), 101 deletions(-) diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs index 8fcab69..8dc7b52 100644 --- a/crates/core/src/download/http.rs +++ b/crates/core/src/download/http.rs @@ -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`、这里落成 `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 { 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 = Vec::with_capacity( - declared - .and_then(|len| usize::try_from(len).ok()) - .unwrap_or(0), - ); + let mut body: Vec = 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` diff --git a/crates/core/src/download/stall.rs b/crates/core/src/download/stall.rs index dbcc218..6e9ab21 100644 --- a/crates/core/src/download/stall.rs +++ b/crates/core/src/download/stall.rs @@ -25,6 +25,7 @@ //! | `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` | 自带的整作业预算(外加上面全部) | —— | @@ -34,10 +35,23 @@ //! 与 `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。 +//! # 模块外那几处 `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 秒内下完」 //! diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs index f5af093..6cba9a4 100644 --- a/crates/core/src/game/java/api/version.rs +++ b/crates/core/src/game/java/api/version.rs @@ -24,6 +24,9 @@ use serde::Deserialize; +use crate::download::http; +use crate::download::stall::Deadlines; + use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE}; use crate::game::java::catalog::{ canonical_version, matches_version, version_pin_error, VersionListScope, @@ -207,24 +210,34 @@ pub fn page_outcome(status: u16, body: &str) -> Result>, Java } /// 取一页 GA 清单(HTTP 那一跳)。判定全交给 [`page_outcome`],这里只负责把响应取回来。 +/// +/// 两处 `await` 都有界(M290 r1 p3-1:这一处原先既无上界也无内存上限,是「上游接了连不回话就 +/// 永久挂」那一族的最后一份):等响应头走 [`http::send_bounded`]、正文走 [`http::text_bounded`], +/// 与 `net::get_text` 同一套构造点,失败时 `message` 里带**阶段**。界是参数只为让用例压到 +/// 毫秒级,生产走 [`http::default_deadlines`]。 async fn fetch_release_page( client: &reqwest::Client, url: &str, + deadlines: Deadlines, ) -> Result>, JavaError> { - let response = client.get(url).send().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("取 Adoptium GA 清单失败:{error}({url})"), - ) - .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到") - })?; + let response = http::send_bounded(client.get(url), url, deadlines, None) + .await + .map_err(|error| { + JavaError::new( + JavaErrorKind::Network, + format!("取 Adoptium GA 清单失败:{error}"), + ) + .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到") + })?; let status = response.status().as_u16(); - let body = response.text().await.map_err(|error| { - JavaError::new( - JavaErrorKind::Network, - format!("读 Adoptium GA 清单响应失败:{error}({url})"), - ) - })?; + let body = http::text_bounded(response, url, deadlines) + .await + .map_err(|error| { + JavaError::new( + JavaErrorKind::Network, + format!("读 Adoptium GA 清单响应失败:{error}"), + ) + })?; page_outcome(status, &body) } @@ -247,7 +260,7 @@ pub async fn fetch_version( ) -> Result { let (releases, truncated) = collect_releases(|page| { let url = releases_url(major, target, page); - async move { fetch_release_page(client, &url).await } + async move { fetch_release_page(client, &url, http::default_deadlines()).await } }) .await?; let Some(name) = pick_release(&releases, wanted).cloned() else { @@ -284,3 +297,101 @@ pub async fn fetch_version( #[cfg(test)] mod tests; + +/// `fetch_release_page` 的两处上界 + `http::text_bounded` 的两道内存闸;夹具与 `net::probe` 同源。 +#[cfg(test)] +mod bounded_probes { + use std::time::Duration; + + use super::fetch_release_page; + use crate::download::bytes::MAX_IN_MEMORY_BYTES; + use crate::download::error::{DownloadError, DownloadErrorKind}; + use crate::download::http::text_bounded; + use crate::game::java::error::JavaErrorKind; + use crate::game::java::net::probe::{bounded, tight, Hanging, Mode}; + + async fn fetch_body(url: &str) -> Result { + let client = reqwest::Client::new(); + let response = client.get(url).send().await.expect("响应头应到手"); + text_bounded(response, url, tight()).await + } + + /// 服务端接了连接、一个字不回:取 GA 清单必须自己返回,并说清卡在**等响应头**。 + /// 变异:把 `fetch_release_page` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏 5s panic,红。 + #[tokio::test] + async fn a_silent_release_list_is_bounded_and_the_stage_is_named() { + let url = Hanging::start(Mode::Silent).url("/ga"); + let (result, elapsed) = bounded( + "取 GA 清单的 send() 又无界了", + fetch_release_page(&reqwest::Client::new(), &url, tight()), + ) + .await; + assert!( + elapsed < Duration::from_secs(2), + "必须自己返回:{elapsed:?}" + ); + let error = result.expect_err("silent 服务端必须失败"); + assert_eq!(error.kind(), JavaErrorKind::Network); + assert!( + error.message().contains("等响应头") && error.message().contains("await_headers"), + "{}", + error.message() + ); + } + + /// 头到手、正文挤出几个字节就停住:阶段是**等字节**(变异:换回 `text()` ⇒ 5s panic,红)。 + #[tokio::test] + async fn a_stalled_release_body_says_await_bytes() { + let mode = Mode::Declared { + declared: 4096, + sent: 8, + }; + let url = Hanging::start(mode).url("/ga"); + let (result, _) = bounded( + "正文读取又无界了", + fetch_release_page(&reqwest::Client::new(), &url, tight()), + ) + .await; + let error = result.expect_err("说到一半停住必须失败"); + assert!( + error.message().contains("等字节") && error.message().contains("await_bytes"), + "{}", + error.message() + ); + } + + /// 头里离谱的 `Content-Length`:**有界失败**,绝不拿它预分配(复审 r1 p2-1)。 + /// 变异:删掉提前拒绝并保留 `Vec::with_capacity(declared)` ⇒ 测试进程 abort(报告里贴了); + /// 只删提前拒绝(预分配是 `Vec::new()`)⇒ 本用例红(错误从「声明的长度」变成「等字节」)。 + #[tokio::test] + async fn a_lying_content_length_is_a_bounded_error_not_an_abort() { + let mode = Mode::Declared { + declared: 999_999_999_999, + sent: 0, + }; + let url = Hanging::start(mode).url("/lying.json"); + let (result, elapsed) = bounded("离谱的声明长度没人管", fetch_body(&url)).await; + assert!( + elapsed < Duration::from_secs(2), + "读完头就该返回:{elapsed:?}" + ); + let error = result.expect_err("离谱的声明长度必须失败"); + assert_eq!(error.kind, DownloadErrorKind::InvalidRequest); + assert!( + error.message.contains("声明的长度") && error.message.contains("999999999999"), + "要看出是「声明的长度超上限」而不是超时:{}", + error.message + ); + } + + /// 声明缺失但正文是洪流:读循环逐块卡 [`MAX_IN_MEMORY_BYTES`](变异:删掉那道卡口 ⇒ 本 + /// 用例拿到 `Ok`,红)。 + #[tokio::test] + async fn a_flooding_body_is_cut_at_the_memory_cap() { + let url = Hanging::start(Mode::Flood(MAX_IN_MEMORY_BYTES + 1024 * 1024)).url("/flood.txt"); + let (result, _) = bounded("正文没有内存上限", fetch_body(&url)).await; + let error = result.expect_err("洪流必须在接近上限处被拒"); + assert_eq!(error.kind, DownloadErrorKind::InvalidRequest); + assert!(error.message.contains("内存上限"), "{}", error.message); + } +} diff --git a/crates/core/src/game/java/net.rs b/crates/core/src/game/java/net.rs index 64523af..cb8aae7 100644 --- a/crates/core/src/game/java/net.rs +++ b/crates/core/src/game/java/net.rs @@ -79,11 +79,9 @@ async fn get_text_with( Ok(body) } -/// 「接了连接不回话 / 说到一半停住」的回环服务端:M290 给**模块外**那三条一锤子 GET -/// 配的定点探针(`game/java/api.rs`、本模块、`update/github.rs` 各用一次)。 -/// -/// 为什么住在这里:夹具必须和它守的四个文件同属一个 scope,而 `download/http.rs` 加完 -/// 正文上界已贴着 400 行红线;本模块行数最宽裕,且它就是「取一段文本」这条路径本身。 +/// 「接了连接不回话 / 停住 / 谎报长度 / 灌洪流」的回环服务端:M290 给**模块外**那几条一锤子 +/// GET 配的定点探针(`game/java/api.rs`、`api/version.rs`、本模块、`update/github.rs`、以及 +/// `download/http.rs` 的正文上界各用一次)。住在这里只因为 scope 里的文件只有本模块有余量。 /// /// 与 `tests/download/support` 的 `MockServer::silent` 是**两种夹具**:那一份服务集成测试里的 /// 引擎公开 API,这一份只能服务 `cfg(test)` 下的库里私有路径(`get_text_with` 等)。 @@ -102,8 +100,10 @@ pub(crate) mod probe { pub(crate) enum Mode { /// 读完请求头、**一个字节都不写**(对面接了连接就装死)。 Silent, - /// 回完响应头 + 这么多正文字节,然后停住(连接不断)。 - StalledAfter(usize), + /// 头里声明 `declared` 字节、只发 `sent` 字节,然后停住(连接不断)。 + Declared { declared: u64, sent: usize }, + /// 不发 `Content-Length`,灌 `Flood` 那么多字节后关连接(超长流:只能靠读方自己卡)。 + Flood(u64), /// 正常应答:200 + 这段正文(`Content-Length` 如实声明)。 Answer(&'static str), } @@ -124,6 +124,19 @@ pub(crate) mod probe { } } + /// 跑一次「被测调用」,外面套 [`GATE`] 护栏并带回耗时:**护栏只会在读方自己无界时触发**, + /// 所以耗时远小于 `GATE` 就等于「它自己返回的」。`what` 是失败时那句 bug 说明。 + pub(crate) async fn bounded( + what: &str, + step: impl std::future::Future, + ) -> (T, Duration) { + let started = std::time::Instant::now(); + let value = tokio::time::timeout(GATE, step) + .await + .unwrap_or_else(|_| panic!("{what}:外层 {GATE:?} 挂断(M290 修的就是这条)")); + (value, started.elapsed()) + } + /// 只在 `127.0.0.1` 上说坏话的服务端(线程泄漏到进程结束,用例之间互不影响)。 pub(crate) struct Hanging { addr: SocketAddr, @@ -155,25 +168,39 @@ pub(crate) mod probe { } } match mode { - Mode::StalledAfter(bytes) => { - // 头部声称 4 KiB、正文只给 `bytes` 字节:读方按空闲判定就该收手。 - let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: 4096\r\n\r\n"; - if socket.write_all(head).is_err() { - return; - } - let _ = socket.write_all(&vec![b'{'; bytes]); + Mode::Declared { declared, sent } => { + // 头里说 `declared`、正文只给 `sent` 字节:读方按空闲判定就该收手; + // `declared` 也可以离谱到远超上限(那种情况读方必须连正文都不读)。 + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {declared}\r\n\r\n" + ); + socket.write_all(head.as_bytes()).expect("写响应头失败"); + let _ = socket.write_all(&vec![b'{'; sent]); let _ = socket.flush(); } + Mode::Flood(total) => { + // 不声明长度(靠关连接分帧):读方只能自己卡上限。 + let chunk = vec![b'x'; 64 * 1024]; + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n", + ) + .expect("写响应头失败"); + for _ in 0..total.div_ceil(chunk.len() as u64) { + if socket.write_all(&chunk).is_err() { + return; + } + } + return; + } Mode::Answer(body) => { let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\n\ Content-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); - if socket.write_all(head.as_bytes()).is_err() { - return; - } + socket.write_all(head.as_bytes()).expect("写响应头失败"); let _ = socket.write_all(body.as_bytes()); let _ = socket.flush(); return; @@ -202,22 +229,21 @@ pub(crate) mod probe { #[cfg(test)] mod tests { - use std::time::{Duration, Instant}; + use std::time::Duration; - use super::probe::{tight, Hanging, Mode, GATE}; + use super::probe::{bounded, tight, Hanging, Mode}; use super::{get_text, get_text_with}; use crate::game::java::error::JavaErrorKind; /// 服务端接了连接、一个字不回:`get_text` 必须自己返回,且说清卡在**等响应头**。 /// /// 变异证明:把 `get_text_with` 的 `send_bounded` 退回裸 `client.get(url).send()` - /// ⇒ 本用例在外层 5s 处挂断(`.expect(...)` 的文案就是那句 bug 说明),红。 + /// ⇒ 护栏在外层 5s 处 panic(文案就是那句 bug 说明),红。 #[tokio::test] async fn a_silent_server_is_bounded_and_the_stage_is_named() { let server = Hanging::start(Mode::Silent); - let started = Instant::now(); - let error = tokio::time::timeout( - GATE, + let (result, elapsed) = bounded( + "get_text 的 send() 又无界了", get_text_with( &reqwest::Client::new(), &server.url("/list.json"), @@ -225,15 +251,12 @@ mod tests { tight(), ), ) - .await - .expect("get_text 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") - .expect_err("silent 服务端必须失败"); - let elapsed = started.elapsed(); - + .await; assert!( elapsed < Duration::from_secs(2), - "必须在上界附近自己返回,实际 {elapsed:?}" + "必须自己返回,实际 {elapsed:?}" ); + let error = result.expect_err("silent 服务端必须失败"); assert_eq!(error.kind(), JavaErrorKind::Network); assert!( error.message().contains("等响应头") && error.message().contains("await_headers"), @@ -245,13 +268,15 @@ mod tests { /// 头到手、正文挤出几个字节就停住:正文那一段同样有界,阶段是**等字节**。 /// - /// 变异证明:把 `text_bounded` 换回 `response.text()` ⇒ 本用例在外层 5s 处挂断,红。 + /// 变异证明:把 `text_bounded` 换回 `response.text()` ⇒ 护栏在外层 5s 处 panic,红。 #[tokio::test] async fn a_stalled_body_is_bounded_and_says_await_bytes() { - let server = Hanging::start(Mode::StalledAfter(16)); - let started = Instant::now(); - let error = tokio::time::timeout( - GATE, + let server = Hanging::start(Mode::Declared { + declared: 4096, + sent: 16, + }); + let (result, elapsed) = bounded( + "正文读取又无界了", get_text_with( &reqwest::Client::new(), &server.url("/list.json"), @@ -259,15 +284,12 @@ mod tests { tight(), ), ) - .await - .expect("正文读取又无界了:外层 5s 挂断(`text()` 是整段读,没有空闲判定)") - .expect_err("说到一半停住必须失败"); - let elapsed = started.elapsed(); - + .await; assert!( elapsed < Duration::from_secs(2), "应在读空闲附近返回,实际 {elapsed:?}" ); + let error = result.expect_err("说到一半停住必须失败"); assert!( error.message().contains("等字节") && error.message().contains("await_bytes"), "要说清卡在哪一段:{}", @@ -277,8 +299,7 @@ mod tests { /// 正常路径不动:真回了一段 JSON 就原样交出来(含中文,证明解码没被打坏)。 /// - /// 变异证明:把 `text_bounded` 的解码换成按字节硬塞(`body.iter().map(|b| *b as char)`)⇒ - /// 中文那条断言红;把状态分类挪到正文读之后 ⇒ 这里仍绿(本用例只管 200 的正常语义)。 + /// 变异证明:把 `text_bounded` 的解码换成按字节硬塞 ⇒ 中文那条断言红。 #[tokio::test] async fn a_normal_response_is_handed_over_unchanged() { let body = r#"{"major": 21, "note": "中文也要活"}"#; @@ -291,16 +312,15 @@ mod tests { } /// `game/java/api.rs` 那条 latest 查询的 HTTP 跳(`fetch_latest` → `fetch_body`):两处 - /// `await` 同样有界、阶段同样说得出。**用例住在这里**而不是 `api.rs`:那份文件已贴着 - /// 400 行红线(389 行),再塞一条就撞上,而它测的正是本模块这几行同款 GET(同一套构造点)。 + /// `await` 同样有界、阶段同样说得出。**用例住在这里**而不是 `api.rs`:那份文件贴着 400 行 + /// 红线,而它测的正是本模块这几行同款 GET(同一套构造点)。 /// - /// 变异证明:把 `fetch_body` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。 + /// 变异证明:把 `fetch_body` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏在外层 5s 处 panic,红。 #[tokio::test] async fn the_adoptium_latest_hop_is_bounded_and_the_stage_is_named() { let server = Hanging::start(Mode::Silent); - let started = Instant::now(); - let error = tokio::time::timeout( - GATE, + let (result, elapsed) = bounded( + "fetch_latest 的 send() 又无界了", crate::game::java::api::fetch_body( &reqwest::Client::new(), 21, @@ -308,15 +328,12 @@ mod tests { tight(), ), ) - .await - .expect("fetch_latest 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") - .expect_err("silent 服务端必须失败"); - + .await; assert!( - started.elapsed() < Duration::from_secs(2), - "必须在上界附近自己返回,实际 {:?}", - started.elapsed() + elapsed < Duration::from_secs(2), + "必须自己返回,实际 {elapsed:?}" ); + let error = result.expect_err("silent 服务端必须失败"); assert_eq!(error.kind(), JavaErrorKind::Network); assert!( error.message().contains("等响应头") && error.message().contains("await_headers"), @@ -326,8 +343,6 @@ mod tests { } /// 正常路径不变:200 + 真 JSON 时 `fetch_body` 逐字节交出正文(`fetch_latest` 就靠它解析)。 - /// - /// 变异证明:把 `text_bounded` 的返回值改成空串或按字符重编码 ⇒ 本用例红。 #[tokio::test] async fn the_adoptium_latest_hop_still_returns_the_body_on_200() { let body = r#"[{"release_name":"jdk-21.0.13+9"}]"#; @@ -344,32 +359,27 @@ mod tests { } /// `update/github.rs` 取更新清单那一跳(`fetch_manifest_documents` → `candidate_text`): - /// 同样是「一锤子 GET 拿一段 JSON」,两处 `await` 都必须有界。**用例住在这里**而不是 - /// `github.rs`:它内部是 `block_on` 的同步签名——在 tokio 用例里直接调它就会嵌套 - /// `block_on` panic,只有这一层能被直接测到。 + /// 同样是「一锤子 GET 拿一段 JSON」。**用例住在这里**而不是 `github.rs`:它内部是 `block_on` + /// 的同步签名——在 tokio 用例里直接调它会嵌套 `block_on` panic,只有这一层能直接测到。 /// - /// 变异证明:把 `candidate_text` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。 + /// 变异证明:把 `candidate_text` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏在外层 5s 处 panic,红。 #[tokio::test] async fn the_manifest_fetch_is_bounded_and_the_stage_is_named() { let server = Hanging::start(Mode::Silent); - let started = Instant::now(); - let error = tokio::time::timeout( - GATE, + let (result, elapsed) = bounded( + "取更新清单的 send() 又无界了", crate::update::github::candidate_text( &reqwest::Client::new(), &server.url("/manifest.json"), tight(), ), ) - .await - .expect("取更新清单的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)") - .expect_err("silent 服务端必须失败"); - + .await; assert!( - started.elapsed() < Duration::from_secs(2), - "必须在上界附近自己返回,实际 {:?}", - started.elapsed() + elapsed < Duration::from_secs(2), + "必须自己返回,实际 {elapsed:?}" ); + let error = result.expect_err("silent 服务端必须失败"); assert_eq!(error.kind, crate::update::UpdateErrorKind::Network); assert!( error.message.contains("等响应头") && error.message.contains("await_headers"),