From aac0eef1b8e9a3fd715f990ceeed85505c381de4 Mon Sep 17 00:00:00 2001 From: worker-javapin Date: Thu, 12 Nov 2026 22:04:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(java):=20=E8=B6=8A=E7=95=8C=E9=A1=B5=20404?= =?UTF-8?q?=20=E4=B8=8D=E8=AE=B8=E4=BD=9C=E5=BA=9F=E5=B7=B2=E5=8F=96?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E6=B8=85=E5=8D=95=20+=20=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3/=E6=96=87=E6=A1=88=EF=BC=88M280=20r2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审 p2-1items / fix-then-merge,只做指定的四项。 ## P2-1 满页收尾会多打一页 404,把前几页一起丢掉 `collect_releases` 原来靠「本页不满 PAGE_SIZE」收尾;某条 GA 线的条数恰好是 20 的倍数时 最后一页是满页 ⇒ 会多要一页 ⇒ Adoptium 对越界页回 **HTTP 404 + 空体**(实测,不是空数组) ⇒ 那一页的 Metadata 错误把**已经取到的前几页**一起作废,整条 `--version` 路径失败。 - `page > 0` 的 Metadata(404 / 空响应)视为「翻到尾巴」:返回已取到的全部 + `truncated=false`; - `page == 0` 的 404 仍然报错(那才是「这个 major 没有 GA 清单」); - **网络错误**(`JavaErrorKind::Network`)在任何一页都不被吞,照样冒泡。 用例(全离线,注入夹具): - `a_404_on_the_page_after_a_full_page_ends_the_tail_cleanly`:page0=Ok(20 条)、 page1=Err(Metadata 404) ⇒ 断言 `Ok((20 条, false))` 且 `calls == 2`(把「今天上游越界页就是 404」钉住); - `a_404_on_the_first_page_is_still_an_error`:page0 就 404 ⇒ 必须 `Err`; - `a_network_error_on_a_later_page_is_not_swallowed`:page1 网络错误 ⇒ 必须 `Err`。 ## P3-1 `install/plan.rs` 文档与事实矛盾 改成如实描述:搬移的直接原因(400 行硬线)+ **三处语义变化**(签名接 `&InstallRequest`、 函数体改走 `chain_fetch` 与 `install` 共用同一份判据、`request.priority` 现在参与选择顺序)。 ## P3-2 `api.rs` 头注释 补一句:latest 那条走本模块,「指定版本」走子模块 `version`(`feature_releases` 翻页枚举 → `assets/release_name`),两者共用同一套响应结构。 ## P3-3 Oracle 拒绝文案多一个空格 `source` 自带连接词(`Adoptium 里` / `Oracle 镜像目录里`),模板改成 `({source}可用:…)`。 真机:`(Oracle 镜像目录里可用:21.0.2 / 21.0.1 / 21)`、`(Adoptium 里可用:… 等 26 个)`。 用例补三条断言钉住这两种拼法,并断言 Oracle 那句不许出现「镜像目录 里」。 门禁(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-280):core --lib 873 / --test game 120 / --test launch 91、cli --all-targets、fmt --check、clippy -D warnings 全部 EXIT=0。 --- crates/core/src/game/java/api.rs | 4 +- crates/core/src/game/java/api/version.rs | 16 ++++- .../core/src/game/java/api/version/tests.rs | 69 +++++++++++++++++++ crates/core/src/game/java/catalog.rs | 8 ++- crates/core/src/game/java/catalog/tests.rs | 17 +++++ crates/core/src/game/java/install/plan.rs | 14 +++- 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs index 467e93d..1174e71 100644 --- a/crates/core/src/game/java/api.rs +++ b/crates/core/src/game/java/api.rs @@ -1,6 +1,8 @@ //! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。 //! -//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA): +//! **最新 GA**(`assets/latest/{major}/hotspot`,D13 的自动下载口径)走本模块; +//! 「指定版本」走子模块 [`version`](`assets/feature_releases` 翻页枚举 → `assets/release_name` +//! 取那一份),两者共用同一套响应结构。本模块只描述 latest 这条: //! //! ```text //! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs index 6ed0f9d..a1a5120 100644 --- a/crates/core/src/game/java/api/version.rs +++ b/crates/core/src/game/java/api/version.rs @@ -135,7 +135,9 @@ pub fn parse_release( /// /// # Errors /// -/// 任何一页取不到(网络 / 解析失败)时直接把该错误抛出去。 +/// **第一页**取不到(网络 / 404 / 解析失败)时直接抛错——那才是「这个 major 真没有 GA 清单」。 +/// 第二页及以后取不到时**不抛**:上游对越界页回 HTTP 404 + 空体(2026-11-12 实测), +/// 所以那是「翻到尾巴」的正常收尾(见下方注释)。 pub async fn collect_releases(mut fetch_page: F) -> Result<(Vec, bool), JavaError> where F: FnMut(usize) -> Fut, @@ -143,7 +145,17 @@ where { let mut all: Vec = Vec::new(); for page in 0..MAX_PAGES { - let names = fetch_page(page).await?; + let names = match fetch_page(page).await { + Ok(names) => names, + // 越界页不是空数组,而是 404(`net::get_text` 把它归成 Metadata)。某条 GA 线的条数 + // 恰好是 20 / 40 / 60 时,最后一页是满页 ⇒ 这里会多要一页 ⇒ 那一页 404。 + // 这时**必须把已经取到的前几页交出去**(否则整条 `--version` 路径被一句与用户请求 + // 无关的 404 打死);第一页的 404 仍然报错,走下面的 `Err(error) => return`。 + Err(error) if page > 0 && error.kind() == JavaErrorKind::Metadata => { + return Ok((all, false)); + } + Err(error) => return Err(error), + }; let short_page = names.len() < PAGE_SIZE; all.extend(names); if short_page { diff --git a/crates/core/src/game/java/api/version/tests.rs b/crates/core/src/game/java/api/version/tests.rs index e2bea9a..26e543c 100644 --- a/crates/core/src/game/java/api/version/tests.rs +++ b/crates/core/src/game/java/api/version/tests.rs @@ -205,3 +205,72 @@ async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() { assert_eq!(releases.len(), MAX_PAGES * PAGE_SIZE); assert!(truncated, "到上限时必须标成截断,文案才能如实说"); } + +/// **越界页是 404 而不是空数组**(2026-11-12 实测):某条 GA 线的条数恰好是 20 的整数倍时, +/// 最后一页是满页 ⇒ 会多要一页 ⇒ 那一页 404。这时必须把**已经取到的**交出去。 +/// +/// 变异证明:把 `collect_releases` 里 `page > 0` 的分支去掉(改回无条件 `?`) +/// ⇒ 本用例红(`Err(Metadata)` 被抛出去,前 20 条连同一句无关的 404 一起作废)。 +#[tokio::test] +async fn a_404_on_the_page_after_a_full_page_ends_the_tail_cleanly() { + let calls = std::cell::Cell::new(0usize); + let result = collect_releases(|page| { + calls.set(calls.get() + 1); + async move { + match page { + 0 => Ok(page_zero()), + // 上游越界页的真实形态:HTTP 404 + 空体,被 `net::get_text` 归成 Metadata。 + _ => Err(JavaError::new( + JavaErrorKind::Metadata, + "Adoptium GA 清单 不存在(HTTP 404):https://api.adoptium.net/…&page=1", + )), + } + } + }) + .await; + + let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页的 404 不该作废它"); + assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确"); + assert_eq!(releases, page_zero()); + assert!(!truncated, "这是「翻到尾巴」,不是「到上限」"); + assert_eq!( + calls.get(), + 2, + "确实多要了第二页(这正是上游 404 的那一发)" + ); +} + +/// **反例**:第一页就 404 ⇒ 仍然是 Err(那才是「这个 major 没有 GA 清单」),不许被吞。 +/// +/// 变异证明:把 `page > 0` 的判断改成 `page >= 0`(即第一页也吞)⇒ 本用例红。 +#[tokio::test] +async fn a_404_on_the_first_page_is_still_an_error() { + let result = collect_releases(|_page| async { + Err::, JavaError>(JavaError::new( + JavaErrorKind::Metadata, + "Adoptium 没有 99 的 GA 产物(HTTP 404)", + )) + }) + .await; + + let error = result.expect_err("第一页 404 = 这个 major 真没有清单,必须报错"); + assert_eq!(error.kind(), JavaErrorKind::Metadata); + assert!(error.message().contains("404"), "{}", error.message()); +} + +/// 网络错误(非 Metadata)在任何一页都不许被当成「翻到尾巴」——那是真坏了。 +#[tokio::test] +async fn a_network_error_on_a_later_page_is_not_swallowed() { + let result = collect_releases(|page| async move { + match page { + 0 => Ok(page_zero()), + _ => Err(JavaError::new( + JavaErrorKind::Network, + "连不上 Adoptium API:connection reset", + )), + } + }) + .await; + let error = result.expect_err("网络错误要冒泡"); + assert_eq!(error.kind(), JavaErrorKind::Network); +} diff --git a/crates/core/src/game/java/catalog.rs b/crates/core/src/game/java/catalog.rs index 1c521ba..3361aeb 100644 --- a/crates/core/src/game/java/catalog.rs +++ b/crates/core/src/game/java/catalog.rs @@ -237,14 +237,16 @@ pub fn version_pin_error( let vendor_id = vendor.id(); match vendor { JdkVendor::Temurin | JdkVendor::Oracle => { + // `source` 自己带连接词:拉丁文名与中文名需要的空格不一样, + // 模板里再补一个空格就会拼出「Oracle 镜像目录 里可用」。 let source = if vendor == JdkVendor::Temurin { - "Adoptium" + "Adoptium 里" } else { - "Oracle 镜像目录" + "Oracle 镜像目录里" }; JavaError::new( JavaErrorKind::Metadata, - format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source} 里可用:{listed})"), + format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source}可用:{listed})"), ) .with_hint("不给 --version 就装该大版本最新的 GA;指定版本要写上面列出的形态") } diff --git a/crates/core/src/game/java/catalog/tests.rs b/crates/core/src/game/java/catalog/tests.rs index 3745363..5203d19 100644 --- a/crates/core/src/game/java/catalog/tests.rs +++ b/crates/core/src/game/java/catalog/tests.rs @@ -170,6 +170,23 @@ fn pin_refusals_are_distinct_and_each_explains_itself() { errors[0].message().contains("21.0.13+9"), "可用版本要列出来" ); + // P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通, + // 且 Oracle 那句不许出现「镜像目录 里」这种多一个空格的样子。 + assert!( + errors[0].message().contains("Adoptium 里可用"), + "{}", + errors[0].message() + ); + assert!( + errors[1].message().contains("Oracle 镜像目录里可用"), + "{}", + errors[1].message() + ); + assert!( + !errors[1].message().contains("镜像目录 里"), + "多一个空格:{}", + errors[1].message() + ); assert!(errors[2].message().contains("只内置了 21.0.13.0.2")); assert!(errors[2] .hint() diff --git a/crates/core/src/game/java/install/plan.rs b/crates/core/src/game/java/install/plan.rs index ff60a0d..87d635b 100644 --- a/crates/core/src/game/java/install/plan.rs +++ b/crates/core/src/game/java/install/plan.rs @@ -1,8 +1,16 @@ //! `install` 的**只读取**规划段:`--dry-run` 用它算出「会装哪一版、打哪个 URL」。 //! -//! 从 `install.rs` 拆出来只为守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行); -//! 纯搬移,语义未变。与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架) -//! 同一个拆法。 +//! 从 `install.rs` 拆出来的直接原因是守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行), +//! 但**不是纯搬移**,三处语义变了,别照着「搬移」去读它: +//! +//! 1. 签名从 `plan(engine, location, major)` 变成 `plan(engine, location, &InstallRequest)` +//! —— 版本这一维(`request.version`)现在参与选择; +//! 2. 函数体不再自己写 `for` + `continue`,改成走 [`super::chain::chain_fetch`], +//! **与 `install` 共用同一份判据**(取版本 + 「版本必须对得上」的兜底闸); +//! 3. `request.priority`(设置里的 `java.vendorPriority`)现在参与选择顺序;旧实现用 +//! `vendor::order(&[], domestic)`,等于忽略这条设置。 +//! +//! 与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架)同一个拆法。 use std::path::PathBuf;