fix(java/update): 三处模块外的一锤子 GET 也上界,失败时报出卡在哪一段(M290)

`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。
This commit is contained in:
AskaEth committed 2026-11-13 00:22:21 +08:00
1 parent 53de2a3922
commit ad99303a95
4 files changed
+481 -59

No files matched your search

+59 -1
View File
@@ -60,7 +60,9 @@ pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError
})
}
/// **两处 `send()` 的唯一上界构造点**(`transfer.rs` 的单连接请求、`chunked.rs` 的分块请求)。
/// **每一条 `send()` 的唯一上界构造点**:`transfer.rs` 的单连接请求、`chunked.rs` 的分块请求、
/// `bytes.rs` 的 `download_bytes`,以及模块外的三条「一锤子 GET 取文本」(`game/java/api.rs`、
/// `game/java/net.rs`、`update/github.rs`,M290)。
///
/// 上界只覆盖「请求发出 → 响应头到手」,正文由 [`read_step`] 的空闲判定管;为什么选
/// 「单次请求上界」而不是「整作业总预算」、这些数字怎么来的,见 [`super::stall`] 模块头
@@ -122,6 +124,62 @@ where
}
}
/// 把整段正文读成文本(**文本/JSON 一锤子 GET** 的正文上界):与 [`read_step`] 同一处构造。
///
/// 为什么不是 `response.text()`:它是一次无界的整段读,上游「回了头但正文不动」时永远不返回
/// —— 与 M289 在 `download_bytes` 里修掉的是同一种形态。这里换成流式累加 + 空闲判定,
/// 口径与 [`super::bytes`] 的读循环逐字一致(只是落成 `String`)。
///
/// 只给「一次 GET 取一小段文本」用:分块/落盘作业走 [`read_step`] 自己的循环(它们要进度、
/// 要边读边写盘、要卡内存上限),别把那条路改成经过这里。
///
/// 与 `reqwest::Response::text()` 的**唯一**差别:不按 `Content-Type` 的 charset 解码,一律
/// UTF-8 lossy(声明了非 UTF-8 字符集时中文字节会变成替换符)。三条消费者(Adoptium JSON /
/// GitHub 清单 JSON / 各镜像的目录与校验文本)上游都是 UTF-8 或纯 ASCII。
///
/// # Errors
///
/// 正文读取超时 ⇒ `kind = Timeout` 的 [`DownloadError`],阶段「等字节」+ 已收/预期/无进展
/// 三个数(`message` 里带一份,见 [`DownloadError::stalled`])。
pub async fn text_bounded(
response: reqwest::Response,
url: &str,
deadlines: Deadlines,
) -> Result<String, DownloadError> {
let declared = content_length(response.headers());
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),
);
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 {