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:
1 parent
53de2a3922
commit
ad99303a95
4 files changed
+481
-59
No files matched your search
@@ -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`] 的空闲判定管;为什么选
|
/// 上界只覆盖「请求发出 → 响应头到手」,正文由 [`read_step`] 的空闲判定管;为什么选
|
||||||
/// 「单次请求上界」而不是「整作业总预算」、这些数字怎么来的,见 [`super::stall`] 模块头
|
/// 「单次请求上界」而不是「整作业总预算」、这些数字怎么来的,见 [`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` 映射成我们的错误分类。
|
/// 把 `reqwest::Error` 映射成我们的错误分类。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
|
pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::download::{http, stall::Deadlines};
|
||||||
|
|
||||||
use super::error::{JavaError, JavaErrorKind};
|
use super::error::{JavaError, JavaErrorKind};
|
||||||
use super::release::version_from_release_name;
|
use super::release::version_from_release_name;
|
||||||
use super::target::AdoptiumTarget;
|
use super::target::AdoptiumTarget;
|
||||||
@@ -86,13 +88,32 @@ pub async fn fetch_latest(
|
|||||||
target: AdoptiumTarget,
|
target: AdoptiumTarget,
|
||||||
) -> Result<JdkRelease, JavaError> {
|
) -> Result<JdkRelease, JavaError> {
|
||||||
let url = latest_url(major, target);
|
let url = latest_url(major, target);
|
||||||
let response = client.get(&url).send().await.map_err(|error| {
|
let body = fetch_body(client, major, &url, http::default_deadlines()).await?;
|
||||||
JavaError::new(
|
parse_latest(&body, target)
|
||||||
JavaErrorKind::Network,
|
}
|
||||||
format!("连不上 Adoptium API:{error}"),
|
|
||||||
)
|
/// `fetch_latest` 的 HTTP 那一跳:GET → 状态分类 → 整段文本。**两处 `await` 都有界**(M290):
|
||||||
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
|
/// 等响应头走 [`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<String, JavaError> {
|
||||||
|
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();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::NOT_FOUND {
|
if status == reqwest::StatusCode::NOT_FOUND {
|
||||||
return Err(JavaError::new(
|
return Err(JavaError::new(
|
||||||
@@ -107,13 +128,14 @@ pub async fn fetch_latest(
|
|||||||
format!("Adoptium API 返回 HTTP {status}({url})"),
|
format!("Adoptium API 返回 HTTP {status}({url})"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let body = response.text().await.map_err(|error| {
|
http::text_bounded(response, url, deadlines)
|
||||||
JavaError::new(
|
.await
|
||||||
JavaErrorKind::Network,
|
.map_err(|error| {
|
||||||
format!("读 Adoptium 响应失败:{error}"),
|
JavaError::new(
|
||||||
)
|
JavaErrorKind::Network,
|
||||||
})?;
|
format!("读 Adoptium 响应失败:{error}"),
|
||||||
parse_latest(&body, target)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 响应里的 `package` 子对象。
|
/// 响应里的 `package` 子对象。
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
|
//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
|
||||||
//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
|
//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
|
||||||
//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
|
//! 让它换下一家(这是 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};
|
use super::error::{JavaError, JavaErrorKind};
|
||||||
|
|
||||||
@@ -17,12 +23,24 @@ pub async fn get_text(
|
|||||||
url: &str,
|
url: &str,
|
||||||
what: &str,
|
what: &str,
|
||||||
) -> Result<String, JavaError> {
|
) -> Result<String, JavaError> {
|
||||||
let response = client.get(url).send().await.map_err(|error| {
|
get_text_with(client, url, what, http::default_deadlines()).await
|
||||||
JavaError::new(
|
}
|
||||||
JavaErrorKind::Network,
|
|
||||||
format!("取 {what} 失败:{error}({url})"),
|
/// 真正干活的那一层:两段界是参数(生产只用上面的出厂值,用例要把它压到毫秒级)。
|
||||||
)
|
///
|
||||||
})?;
|
/// 失败时 `message` 里带**阶段**:等响应头(`await_headers`)/ 等字节(`await_bytes`),
|
||||||
|
/// 外加上游那句错。这不是「请求失败」四个字——用户要能分辨「对面不说话」与「说到一半停住」。
|
||||||
|
async fn get_text_with(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
what: &str,
|
||||||
|
deadlines: Deadlines,
|
||||||
|
) -> Result<String, JavaError> {
|
||||||
|
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();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||||
return Err(JavaError::new(
|
return Err(JavaError::new(
|
||||||
@@ -42,12 +60,14 @@ pub async fn get_text(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
let body = response.text().await.map_err(|error| {
|
let body = http::text_bounded(response, url, deadlines)
|
||||||
JavaError::new(
|
.await
|
||||||
JavaErrorKind::Network,
|
.map_err(|error| {
|
||||||
format!("读 {what} 响应失败:{error}"),
|
JavaError::new(
|
||||||
)
|
JavaErrorKind::Network,
|
||||||
})?;
|
format!("读 {what} 响应失败:{error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
// 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
|
// 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
|
||||||
// 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
|
// 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
|
||||||
if body.trim().is_empty() {
|
if body.trim().is_empty() {
|
||||||
@@ -58,3 +78,303 @@ pub async fn get_text(
|
|||||||
}
|
}
|
||||||
Ok(body)
|
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<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,8 +54,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::download::cache::now_ms;
|
use crate::download::cache::now_ms;
|
||||||
|
use crate::download::stall::Deadlines;
|
||||||
use crate::download::{
|
use crate::download::{
|
||||||
Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
|
http, Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest,
|
||||||
|
ResourceKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::channel::Channel;
|
use super::channel::Channel;
|
||||||
@@ -227,41 +229,61 @@ impl GitHubReleaseSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
|
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
|
||||||
|
///
|
||||||
|
/// 界取**引擎自己的** `deadlines()`:本源手里就有引擎,不另抄秒数(与 M289 给
|
||||||
|
/// `download_bytes` 的口径同源)。
|
||||||
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
||||||
let client = self.engine.client();
|
let deadlines = self.engine.config().deadlines();
|
||||||
crate::download::block_on(async {
|
crate::download::block_on(candidate_text(self.engine.client(), url, deadlines))
|
||||||
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))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一个候选地址的整段文本(`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<Option<String>, 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 {
|
impl UpdateSource for GitHubReleaseSource {
|
||||||
fn source_id(&self) -> &str {
|
fn source_id(&self) -> &str {
|
||||||
&self.id
|
&self.id
|
||||||
|
|||||||
Reference in new issue
Block a user