diff --git a/AGENTS.md b/AGENTS.md index 02c2845..892432f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ orzmc/services → orzmc/core → orzmc/domain + orzmc/infra - **进度协议(两类任务)**:`ProgressSink.start(desc, total)` 是**计数任务**(几千个资源文件,`advance(n)` 加 n 个),`ProgressSink.start_bytes(desc, total)` 是**字节任务**(单个下载,`advance(n)` 加 n 字节);后者是**非抽象**方法、默认转调 `start`,所以第三方 sink 不实现也能工作,只有 `RichProgress` 覆写它来换列。两者必须分开:rich 的 `TransferSpeedColumn` 把速度格式化成「每秒多少字节」,用在文件计数上就是胡说。`RichProgress` 因此按任务类型**在 start 前换 `progress.columns`**(计数:描述+条+计数+百分比+耗时+转圈;字节:描述+条+`23.4/39.2 MB`+`4.1 MB/s`+剩余时间+转圈)。写测试要注意:rich 的速度来自**采样队列**(`Task.speed` 用首尾样本时间差估),只有一次 `advance` 时 `total_time == 0` → 渲染成 `?`,测试里要两次带间隔的 `advance` 才能断言 `B/s`。`status(desc)` 仍是「不定长」(`start(desc, None)`)。**协议解耦**:`orzmc/infra/log.py` 定义 `Reporter`,`orzmc/infra/progress.py` 定义 `ProgressSink`。库内置 rich 默认实现(`RichReporter`/`RichProgress`)。**禁止**库内直接 `print` / `os.system`。`RichProgress` 的 live 判活必须用 `Progress.live.is_started`(`live` 是 Live 实例、恒真,旧 `if not live` 导致 live 永不 start、进度条从不渲染);`finish()` 在任务清空后主动 `stop()` 收掉 live 区,避免后续 plain 日志与残留清行序列互相干扰;字节计数列 `_count_column()` 对 `total is None` 渲染空串,纯文本渲染函数可单测。 - **元数据缓存与刷新(统一策略)**:`orzmc/infra/cache.py` 的 `MetadataCache` 是唯一缓存层——Mojang 版本清单、version JSON、fabric-meta、Paper Fill API、Forge promotions 全部走它。策略三条:**TTL 24h**(`DEFAULT_TTL`,按文件 mtime + 注入时钟 `now` 判定,`ttl<=0` 表示永不复用);**`refresh=True` 绕过所有缓存读**(CLI `--refresh` → `RuntimeOptions.refresh` → `Services.cache`,`for_version` 也保留);**拉取失败时回退到磁盘上(可能过期的)旧副本**并 warn,只有完全没有缓存才抛错。判定细节:过期条件是 `-_FUTURE_SKEW(300s) <= now-mtime < ttl` —— Windows 上刚写入的文件时间戳可能比 `time.time()` 领先几毫秒(CI 曾因此只在 Windows 上缓存不命中),故容忍小幅未来时间;远超该幅度视为时钟异常、判过期。缓存文件:`cache/version_manifest.json`(沿用原路径)、`cache/versions/.json`(内容寻址 + sha1 校验,refresh 也强制绕过)、`cache/meta//.json`(`meta_path(*parts)` 生成,片段做可移植字符清洗)。名称语义:`update` 专指「升级 CLI 自身」(见下文 `orzmc update`),**不**用于元数据刷新。 - **下载进度统一**:`orzmc/infra/transfer.py` 的 `download_with_progress(http, url, dest, sink, desc)` 是唯一「带字节进度下载」入口(`Downloader.download_file`、`JavaEnv`、`Mojang.version_json` 共用);元数据 JSON 请求用 `ProgressSink.status(desc)`(默认 = `start(desc, None)`,不定长进度条),慢网不再静默等待。**总量取自流式 GET 的响应头**(`HttpClient.download(..., on_open=...)` 在写盘前回调 `Content-Length`),不再发探测用的 HEAD —— 那个 HEAD 每个单文件多一次往返(实测高延迟链路上 0.5-3s);`HttpClient.content_length()` 因此已删除。 -- **下载并发与超时(实测结论,别重复调研)**:资源阶段(4000-5000 个 <50KB 的对象)耗时几乎完全由「**每请求延迟 乘 并发数**」决定,不是带宽 —— 实测同一台机器(经 TUN 代理,单请求中位 528ms)并发 8/16/24/32 分别为 8.7/15.8/20.3/23.3 req/s,32 线程约 2.2-2.7 倍。因此:`RuntimeOptions.download_threads`(`--download-threads/-j`,1-64,默认 16 `DEFAULT_DOWNLOAD_THREADS`);`Downloader(workers=...)` 传给 `ThreadPoolExecutor`;**`HTTPAdapter(pool_connections=pool_maxsize)` 必须 ≥ 并发数**(`Services` 里 `max(DEFAULT_POOL_SIZE, download_threads)`)—— 池小于并发时 urllib3 丢弃 keep-alive 连接、每个请求重建 TCP+TLS,比复用慢 3 倍。小文件批量走 `SMALL_FILE_READ_TIMEOUT=15s`(读超时是**单次 socket 读**的上限,不是总时长,所以不长档大文件不会因此被掐断),单文件/大文件用默认 30s。资源索引的 SHA-1 全量校验 480MB 只要 1.19s(404 MB/s)、rich 进度渲染 2µs/次、连接复用本身正常(8 线程 120 请求只建 8 条连接)——**这三项都不是瓶颈**。**大文件分块并行**(批次 2):`download_with_progress(..., parts=N)` 对 ≥`PARALLEL_MIN_SIZE`(8MB)的文件分 N 路 `Range` 取再拼接。实测中位数(交错多轮,单发测量在这条链路上完全不可信:同一文件同一模式在 1.0-5.0 MB/s 之间跳):piston-data 39MB 客户端 jar 单流 1.93 / 2 路 3.19 / **4 路 4.07 MB/s**;Adoptium JRE 42MB 1.12 / 1.71 / **1.86**;libraries.minecraft.net 24MB 0.70 / 0.66 / **0.79** —— 故 `PARALLEL_PARTS=4`(8 路无额外收益)。首块(`_PROBE_SIZE`=1MiB)就地取并兼作「服务器是否支持 Range」探针:返回 206 就继续并行取剩余,返回 200(代理/镜像忽略 `Range`)说明该响应就是完整正文,直接按单流写完 —— 既不多发探测请求,也不会出现「每个分块各下一份完整文件」;拼接完成后校验总字节数,` .part*`/`.tmp` 在 `finally` 里清理,上游还有 SHA-1 兜底。**流式中途断连(代理重置 / 丢包)会重试 + `Range` 续传**:urllib3 的 `Retry` 只覆盖建连、不覆盖 `iter_content` 逐块读取(实测随机 `Connection broken: IncompleteRead` 会把整份下载打断),故 `HttpClient.stream_range` 自己按**已落盘字节**续传(`RESUME_ATTEMPTS=4`),服务器忽略 `Range` 回 200 时整份重来;`download()` 与分块(`fetch_range`)共用该原语。进度由**调用线程**独占喂给 `sink`(工作线程只做原子累加),避免多线程渲染。接线:`Downloader._parts`/`JavaEnv(parts=)`(均随 `download_threads` 走,`-j 1` 即关掉分块)与 `SelfUpdater`(自升级二进制 16MB)。第三方镜像(如 BMCLAPI)实测**更慢**(同一台机器上中位延迟 2.9-3.4s,是 Mojang 的 5 倍,因为它每请求先 302 跳转),已明确不做。`RichProgress` 列:描述 + 条 + 计数 + 百分比(`_percentage_column`,`total is None` 时渲染空串)+ 耗时 + 转圈;不显示速度是有意的 —— 同一个 task 既承载字节数也承载文件数,速度列必有一边是错的。 +- **下载并发与超时(实测结论,别重复调研)**:资源阶段(4000-5000 个 <50KB 的对象)耗时几乎完全由「**每请求延迟 乘 并发数**」决定,不是带宽 —— 实测同一台机器(经 TUN 代理,单请求中位 528ms)并发 8/16/24/32 分别为 8.7/15.8/20.3/23.3 req/s,32 线程约 2.2-2.7 倍。因此:`RuntimeOptions.download_threads`(`--download-threads/-j`,1-64,默认 16 `DEFAULT_DOWNLOAD_THREADS`);`Downloader(workers=...)` 传给 `ThreadPoolExecutor`;**`HTTPAdapter(pool_connections=pool_maxsize)` 必须 ≥ 并发数**(`Services` 里 `max(DEFAULT_POOL_SIZE, download_threads)`)—— 池小于并发时 urllib3 丢弃 keep-alive 连接、每个请求重建 TCP+TLS,比复用慢 3 倍。小文件批量走 `SMALL_FILE_READ_TIMEOUT=15s`(读超时是**单次 socket 读**的上限,不是总时长,所以不长档大文件不会因此被掐断),单文件/大文件用默认 30s。资源索引的 SHA-1 全量校验 480MB 只要 1.19s(404 MB/s)、rich 进度渲染 2µs/次、连接复用本身正常(8 线程 120 请求只建 8 条连接)——**这三项都不是瓶颈**。**大文件分块并行**(批次 2):`download_with_progress(..., parts=N)` 对 ≥`PARALLEL_MIN_SIZE`(8MB)的文件分 N 路 `Range` 取再拼接。实测中位数(交错多轮,单发测量在这条链路上完全不可信:同一文件同一模式在 1.0-5.0 MB/s 之间跳):piston-data 39MB 客户端 jar 单流 1.93 / 2 路 3.19 / **4 路 4.07 MB/s**;Adoptium JRE 42MB 1.12 / 1.71 / **1.86**;libraries.minecraft.net 24MB 0.70 / 0.66 / **0.79** —— 故 `PARALLEL_PARTS=4`(8 路无额外收益)。首块(`_PROBE_SIZE`=1MiB)就地取并兼作「服务器是否支持 Range」探针:返回 206 就继续并行取剩余,返回 200(代理/镜像忽略 `Range`)说明该响应就是完整正文,直接按单流写完 —— 既不多发探测请求,也不会出现「每个分块各下一份完整文件」;拼接完成后校验总字节数,` .part*`/`.tmp` 在 `finally` 里清理,上游还有 SHA-1 兜底。**流式中途断连(代理重置 / 丢包)会重试 + `Range` 续传**:urllib3 的 `Retry` 只覆盖建连、不覆盖 `iter_content` 逐块读取(实测随机 `Connection broken: IncompleteRead` 会把整份下载打断),故 `HttpClient.stream_range` 自己按**已落盘字节**续传(`RESUME_ATTEMPTS=4`),服务器忽略 `Range` 回 200 时整份重来;`download()` 与分块(`fetch_range`)共用该原语。**HTTP 错误状态(4xx/5xx)不续传重试**:`raise_for_status()` 抛的 `requests.HTTPError` 立即转 `HttpStatusError`(带 `status`/`url`,消息保留 requests 原文)——否则「上游根本没有这个文件」(如 Adoptium 没有 Java 25 的 windows/aarch64,404)会被当成抖动重试 4 次、最终报「下载中途多次断开」,既浪费请求又把真因掩盖(夜间验收也靠这段原文判平台缺口 `UP(gap)`)。唯一例外:续传时服务器回 **416 且本地已取全** → 按成功返回。`JavaEnv` 再按 404 补一句可执行的提示(上游未发布该平台构建)。放弃续传时的消息也带上最后一次的底层错误,方便定位。进度由**调用线程**独占喂给 `sink`(工作线程只做原子累加),避免多线程渲染。接线:`Downloader._parts`/`JavaEnv(parts=)`(均随 `download_threads` 走,`-j 1` 即关掉分块)与 `SelfUpdater`(自升级二进制 16MB)。第三方镜像(如 BMCLAPI)实测**更慢**(同一台机器上中位延迟 2.9-3.4s,是 Mojang 的 5 倍,因为它每请求先 302 跳转),已明确不做。`RichProgress` 列:描述 + 条 + 计数 + 百分比(`_percentage_column`,`total is None` 时渲染空串)+ 耗时 + 转圈;不显示速度是有意的 —— 同一个 task 既承载字节数也承载文件数,速度列必有一边是错的。 - **正版授权(Microsoft 设备码)**:契约在父仓库 `docs/spec/auth.md`。分层:`orzmc/domain/auth.py`(数据形状) + `orzmc/core/auth.py`(纯映射:请求构造/响应解析/错误分类,无 IO) + `orzmc/infra/auth_store.py`(令牌存 `<状态目录>/auth.json`,原子写 + `0600`) + `orzmc/services/auth.py`(`Authenticator`:缓存→刷新→设备码,按 `interval` 轮询);CLI `orzmc client --auth` 只接线。`client_id` 由 `resolve_client_id()` 解析:显式 override → 环境变量 `ORZMC_AZURE_CLIENT_ID` → **内置公共 `DEFAULT_CLIENT_ID`**(开箱即用);仅当最终为空串才报 `E_AUTH_NOT_CONFIGURED`。已认证会话以 `accessToken`/`auth_uuid`(带连字符)/`user_type=msa` 注入启动参数(离线仍为 `"0"`/随机/`legacy`);token 禁止进日志/`--json` 信封。一致性样例 `auth/*`(父仓库)。 - **路径纯函数**:`PathLayout`(domain)只拼路径、**不建目录**;建目录统一在 service 内 `fs.ensure_dir`。 - **Java 沙盒**:运行时安装在 `/java//`,用 `bin/java` 启动;版本要求读自版本 JSON `javaVersion.majorVersion`(缺失默认 8)。JRE 即可满足所有类型运行,无需完整 JDK。 @@ -186,7 +186,7 @@ uv lock # 锁定依赖 - **ruff**:`select = E,F,W,I,UP,B,SIM,C4,RUF`,`line-length = 120`;isort first-party = `orzmc, orzmc_app`。 - **mypy**:`check_untyped_defs`,`no_implicit_optional`。 -- **pytest**:库测试用 **fake `Reporter`/`ProgressSink`/`HttpClient` + 真实 tmp 目录**(见 `orzmc/tests/fakes.py`),直接打公共 API 与 domain;不碰网络、不碰系统 java。测试文件放 `orzmc/tests/`、`orzmc_app/tests/`。 +- **pytest**:库测试用 **fake `Reporter`/`ProgressSink`/`HttpClient` + 真实 tmp 目录**(见 `orzmc/tests/fakes.py`),直接打公共 API 与 domain;不碰网络、不碰系统 java。测试文件放 `orzmc/tests/`、`orzmc_app/tests/`。**根 `conftest.py` 的 autouse fixture 把 `ORZMC_RUNTIME` 钉成 `python`**:未钉时 `resolve_runtime()` 会读**本机**状态目录的 `install.conf`,装过 Swift 通道(`orzmc runtime install`)的开发机上 `main()` 会转发给 Swift 真身,而 POSIX 下那是 `os.execv` —— **pytest 进程被整个替换**(退出码 1、无 traceback、无 summary,用例凭空"消失",2026-10-22 本机踩到)。CI 也一直钉着这个变量;要测转发/判定的用例自己 `monkeypatch.setenv` 覆盖即可。 - **导入规范**:库内按层引用(`from orzmc.domain...`);应用只 `from orzmc import ...` 公共 API。 - **异常**:网络 / 文件错误在 service 层统一捕获并转 `RuntimeError`(中文消息),不裸抛 requests 异常。 - **类型注解**:公共 API 全量注解;`from __future__ import annotations` 开头。 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..c653fd1 --- /dev/null +++ b/conftest.py @@ -0,0 +1,22 @@ +"""Worktree-wide pytest defaults (covers both ``orzmc/tests`` and ``orzmc_app/tests``).""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _pin_python_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Every test runs on the Python implementation, whatever this machine has installed. + + 未设 ``ORZMC_RUNTIME`` 时 :func:`orzmc.services.runtime.resolve_runtime` 会读**本机** + 状态目录的 ``install.conf``:开发者只要跑过 ``orzmc runtime install``(或装过 Swift 原生版), + ``orzmc_app.cli.main()`` 就会把整条 argv 交给 Swift 真身,而 POSIX 下那是 ``os.execv`` + —— **pytest 进程被整个替换**,表现为退出码 1、没有 traceback、连 summary 都没有, + 单个用例凭空"消失"(2026-10-22 本机实测;首例 ``test_usage_error_exit_code_is_one``)。 + + CI 一直是钉住的(``ci.yml`` 的工作流级 ``env`` 就是 ``ORZMC_RUNTIME=python``),现在本地 + 也一样:测试结果不该取决于"这台机器上装没装过实验通道"。需要验证转发/判定的用例 + 自己 ``monkeypatch.setenv``,fixture 先跑、用例后设,互不影响。 + """ + monkeypatch.setenv("ORZMC_RUNTIME", "python") diff --git a/orzmc/orzmc/infra/http.py b/orzmc/orzmc/infra/http.py index 50e94d0..ef8ba87 100644 --- a/orzmc/orzmc/infra/http.py +++ b/orzmc/orzmc/infra/http.py @@ -31,6 +31,27 @@ RESUME_ATTEMPTS = 4 +class HttpStatusError(RuntimeError): + """上游返回 4xx/5xx —— 确定性失败,重试不会变好。 + + 与「中途断连」必须分开:断连要续传重试,4xx/5xx 重试只是把「上游没有这个文件」 + 拖成 4 次无谓请求、还把真实原因(如 404)掩盖成「下载中途多次断开」。 + ``status`` / ``url`` 留给上层给出可执行的提示(如 Adoptium 没有该平台的构建)。 + """ + + def __init__(self, message: str, *, status: int, url: str) -> None: + super().__init__(message) + self.status = status + self.url = url + + +def _has_everything(have: int, expected: int | None, total: int | None) -> bool: + """已落盘字节是否已经取全(续传时服务端会以 416 拒绝越界 ``Range``)。""" + if expected is not None: + return have >= expected + return total is not None and have >= total + + def parse_content_length(headers: Mapping[str, str]) -> int | None: """Content-Length from response headers, or None when absent/not a number.""" value = headers.get("Content-Length") @@ -193,6 +214,10 @@ def stream_range( ``Range: bytes=<已落盘>-`` 只补齐剩余部分;若服务器忽略 ``Range`` 回 200, 则作废已落盘字节整份重来。首块(未落盘)且非强制 Range 时不发 ``Range``, 以便从 200 的 ``Content-Length`` 得到总大小。 + + HTTP 错误状态(4xx/5xx)不是抖动,立刻以 :class:`HttpStatusError` 失败(消息保留 + 原 ``requests`` 文案,便于上层按状态码/地址判断)。唯一的例外是续传时服务端返回 + ``416`` 且本地已取全 —— 那是「已经下完了」,按成功返回。 """ os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True) expected = None if end is None else end - start + 1 @@ -222,10 +247,18 @@ def stream_range( if on_chunk: on_chunk(len(chunk)) return have, total, status + except requests.HTTPError as exc: + # 状态码错误(确定性失败):不重试,保留 requests 的原文 + # ("404 Client Error: Not Found for url: …"),让上层能按状态码给提示。 + http_status = exc.response.status_code if exc.response is not None else 0 + if http_status == 416 and _has_everything(have, expected, total): + return have, total, http_status + raise HttpStatusError(str(exc), status=http_status, url=url) from exc except requests.RequestException as exc: + # 传输层抖动(连接重置 / IncompleteRead):续传重试。 last = exc continue - raise RuntimeError(f"下载中途多次断开,已重试 {attempts} 次仍失败: {url}") from last + raise RuntimeError(f"下载中途多次断开,已重试 {attempts} 次仍失败: {url}(最后一次:{last})") from last def fetch_range( self, diff --git a/orzmc/orzmc/services/java.py b/orzmc/orzmc/services/java.py index 46c3d8b..b4a4243 100644 --- a/orzmc/orzmc/services/java.py +++ b/orzmc/orzmc/services/java.py @@ -15,7 +15,7 @@ from orzmc.domain.paths import PathLayout from orzmc.infra.fs import FileStore -from orzmc.infra.http import HttpClient +from orzmc.infra.http import HttpClient, HttpStatusError from orzmc.infra.log import Reporter from orzmc.infra.progress import ProgressSink from orzmc.infra.transfer import PARALLEL_PARTS, download_with_progress @@ -23,6 +23,22 @@ ADOPTIUM_BINARY = "https://api.adoptium.net/v3/binary/latest/{major}/ga/{os}/{arch}/{image}/hotspot/normal/eclipse" +def _install_failure(exc: HttpStatusError, *, major: int, image: str, os_name: str, arch: str) -> str: + """把 Adoptium 的 HTTP 错误转成用户能执行的提示。 + + Temurin 并非每个 OS/arch 都有每个 Java 大版本(实测 Java 25 没有 windows/aarch64 + 的 jre 与 jdk,Java 21 有)—— 那是上游缺包,不是网络问题。原文保留在末尾: + 验收 harness 靠 `404 Client Error: Not Found for url: https://api.adoptium.net/…` + 把这类平台缺口归为 UP(gap)(见 `scripts/acceptance.py` 的 GAP_MARKS)。 + """ + if exc.status == 404: + return ( + f"Temurin 没有 Java {major} 的 {os_name}/{arch} 构建(上游未发布,HTTP 404)。" + f" 可自行安装 Java {major} 并加入 PATH,或改用其它平台/架构。原始错误: {exc}" + ) + return f"下载 Java {major} {image.upper()} 失败(HTTP {exc.status}): {exc}" + + class JavaEnv: """Resolve (installing on demand) a sandboxed Java runtime.""" @@ -77,7 +93,10 @@ def _install(self, major: int, need_jdk: bool) -> None: ext = ".zip" if os_name == "windows" else ".tar.gz" archive = os.path.join(tmp_dir, f"temurin-{major}-{image}-{os_name}-{arch}{ext}") - download_with_progress(self._http, url, archive, self._sink, desc, parts=self._parts) + try: + download_with_progress(self._http, url, archive, self._sink, desc, parts=self._parts) + except HttpStatusError as exc: + raise RuntimeError(_install_failure(exc, major=major, image=image, os_name=os_name, arch=arch)) from exc dest_dir = self._paths.java_major_dir(major) self._fs.ensure_dir(dest_dir) diff --git a/orzmc/tests/fakes.py b/orzmc/tests/fakes.py index 39a63d3..17bd119 100644 --- a/orzmc/tests/fakes.py +++ b/orzmc/tests/fakes.py @@ -13,6 +13,8 @@ from types import SimpleNamespace from typing import Any, Literal +import requests + from orzmc.infra.http import HttpClient from orzmc.infra.log import Reporter from orzmc.infra.progress import ProgressSink @@ -74,10 +76,11 @@ def finish(self) -> None: class FakeResponse: """Minimal response object: ``with`` / ``raise_for_status`` / ``iter_content`` / headers.""" - def __init__(self, status: int, body: bytes, headers: dict[str, str]) -> None: + def __init__(self, status: int, body: bytes, headers: dict[str, str], url: str = "") -> None: self.status_code = status self.headers = headers self._body = body + self._url = url def __enter__(self) -> FakeResponse: return self @@ -87,7 +90,15 @@ def __exit__(self, *exc: object) -> Literal[False]: def raise_for_status(self) -> None: if self.status_code >= 400: - raise AssertionError(f"HTTP {self.status_code}") + # 用 requests 自己的实现生成异常:文案("404 Client Error: Not Found for url: …") + # 与真实网络逐字一致 —— 上层与 `scripts/acceptance.py` 的 GAP_MARKS 都依赖它。 + response = requests.Response() + response.status_code = self.status_code + response.reason = {404: "Not Found", 403: "Forbidden", 416: "Requested Range Not Satisfiable"}.get( + self.status_code, "Error" + ) + response.url = self._url + response.raise_for_status() def json(self) -> Any: return _json.loads(self._body.decode("utf-8")) @@ -135,6 +146,8 @@ def __init__(self) -> None: # 认证(``--auth``) API:URL 子串 → ``(status, JSON payload)``;记录全部请求。 self.api_responses: dict[str, tuple[int, Any]] = {} self.api_requests: list[dict[str, Any]] = [] + # URL 子串 → 强制 HTTP 错误状态(测「4xx/5xx 不重试且保留原文」这类路径)。 + self.status_overrides: dict[str, int] = {} def head_location(self, url: str) -> str | None: """Seeded 302 target for the rate-limit-proof fallback resolver.""" @@ -165,6 +178,12 @@ def get(self, url: str, params=None, headers=None, stream=False, timeout=None) - ``HttpClient.get`` returns a ``requests.Response``. """ self.requests.append(("get", url)) + forced = _longest_match(url, self.status_overrides) + if forced is not None: + # 与 ``HttpClient.get`` 一致:4xx/5xx 在 get 内就抛(stream_range 依赖这个契约)。 + response = FakeResponse(forced, b"", {}, url=url) + response.raise_for_status() + return response data = self._payload(url) rng = (headers or {}).get("Range") if rng and self.ignore_ranges: diff --git a/orzmc/tests/test_services.py b/orzmc/tests/test_services.py index d588da3..b86503f 100644 --- a/orzmc/tests/test_services.py +++ b/orzmc/tests/test_services.py @@ -94,6 +94,22 @@ def test_install_uses_jre_by_default(self, tmp_path, reporter, sink, http) -> No env.resolve(8, need_jdk=False) assert fs.is_file(paths.java_bin(8)) + def test_missing_platform_build_explains_the_gap(self, tmp_path, reporter, sink, http) -> None: + """Adoptium 缺该平台/大版本的构建(404)时,必须说清楚是上游缺包。 + + 实测:Java 25 没有 windows/aarch64(jre 与 jdk 均 404),Java 21 有。夜间验收 + 靠 ``404 Client Error: Not Found for url: https://api.adoptium.net/v3/binary/latest`` + 把这类平台缺口归为 UP(gap)(``scripts/acceptance.py`` 的 GAP_MARKS)。 + """ + http.status_overrides = {"api.adoptium.net": 404} + env = JavaEnv(http, FileStore(), reporter, sink, PathLayout(root=str(tmp_path))) + with pytest_raises(RuntimeError) as info: + env.resolve(25) + message = str(info.value) + assert "Temurin 没有 Java 25 的" in message + assert "HTTP 404" in message + assert "404 Client Error: Not Found for url: https://api.adoptium.net/v3/binary/latest" in message + def test_resolve_java_uses_jre_for_every_type(self, tmp_path, reporter, sink, http) -> None: # Every supported type runs on a sandboxed JRE — no game type needs a # full JDK at run time (fabric/forge install steps use JREs too). diff --git a/orzmc/tests/test_transfer.py b/orzmc/tests/test_transfer.py index 523efa4..c48bc83 100644 --- a/orzmc/tests/test_transfer.py +++ b/orzmc/tests/test_transfer.py @@ -14,10 +14,10 @@ import pytest import requests -from fakes import FakeHttp, parse_range +from fakes import FakeHttp, FakeResponse, parse_range from requests.adapters import HTTPAdapter -from orzmc.infra.http import HttpClient, parse_content_length +from orzmc.infra.http import HttpClient, HttpStatusError, parse_content_length from orzmc.infra.transfer import _PROBE_SIZE, PARALLEL_MIN_SIZE, download_with_progress # 略高于分块阈值:够触发分块,又不至于让测试变慢。 @@ -278,6 +278,53 @@ def test_download_is_atomic_and_reports_before_chunks(self, tmp_path) -> None: assert not (tmp_path / "f.bin.tmp").exists() +class _StatusSession: + """每个请求都返回同一个 HTTP 错误状态(并记录被请求了几次)。""" + + def __init__(self, status: int) -> None: + self.status = status + self.calls: list[str] = [] + + def get(self, url: str, **kwargs: object) -> FakeResponse: + self.calls.append(url) + return FakeResponse(self.status, b"", {}, url=url) + + +class TestHttpStatusIsNotRetried: + """4xx/5xx 是确定性失败:不能当「中途断连」重试 4 次。 + + 回归背景:Adoptium 没有 Java 25 的 windows/aarch64 构建(url 回 404),旧代码把 + ``raise_for_status()`` 抛的 ``HTTPError`` 也归入"断连重试",最终报 + "下载中途多次断开,已重试 4 次仍失败" —— 既发了 4 次无谓请求,又把真实原因(404) + 掩盖掉,导致夜间验收把"上游缺包"误判为 FAIL 而不是 UP(gap) + (``scripts/acceptance.py`` 的 GAP_MARKS 就靠 requests 的原始文案)。 + """ + + URL = "https://api.adoptium.net/v3/binary/latest/25/ga/windows/aarch64/jre/hotspot/normal/eclipse" + + def test_404_fails_fast_and_keeps_the_original_message(self, tmp_path) -> None: + client = HttpClient() + session = _StatusSession(404) + client._session = session # type: ignore[assignment] + with pytest.raises(HttpStatusError) as info: + client.download(self.URL, str(tmp_path / "f.bin")) + assert session.calls == [self.URL] # 只请求一次:没有重试预算 + assert info.value.status == 404 + assert info.value.url == self.URL + # 验收 harness 的 GAP_MARKS 片段必须保留在这条消息里 + assert "404 Client Error: Not Found for url: https://api.adoptium.net/v3/binary/latest" in str(info.value) + assert not (tmp_path / "f.bin").exists() + + def test_416_on_an_already_complete_range_successes(self, tmp_path) -> None: + """续传的分块已经写全时,服务端会以 416 拒绝越界 Range —— 那是「下完了」。""" + part = tmp_path / "p" + part.write_bytes(b"x" * 2000) + client = HttpClient() + client._session = _StatusSession(416) # type: ignore[assignment] + written, _, status = client.stream_range("https://x/f.bin", 1000, 2999, str(part)) + assert (written, status) == (2000, 416) + + class TestResumeAfterMidStreamBreak: """urllib3 的 Retry 只覆盖建连,不覆盖 body 读取 —— 中途掉线必须自己续传。""" @@ -305,8 +352,10 @@ def test_download_restarts_when_the_server_ignores_range(self, tmp_path) -> None def test_gives_up_after_the_attempt_budget(self, tmp_path) -> None: client = HttpClient() client._session = _FlakySession(b"x" * 100, always_break=True) # type: ignore[assignment] - with pytest.raises(RuntimeError, match="多次断开"): + with pytest.raises(RuntimeError, match="多次断开") as info: client.download("https://x/f.bin", str(tmp_path / "f.bin")) + # 放弃时要说清楚最后一次究竟怎么了(否则只剩一句看不到原因的文案) + assert "IncompleteRead" in str(info.value) assert not (tmp_path / "f.bin").exists() def test_fetch_range_resumes_and_requires_206(self, tmp_path) -> None: diff --git a/orzmc_app/tests/test_acceptance_gap.py b/orzmc_app/tests/test_acceptance_gap.py new file mode 100644 index 0000000..651b206 --- /dev/null +++ b/orzmc_app/tests/test_acceptance_gap.py @@ -0,0 +1,75 @@ +"""验收 harness 与实现之间的契约:上游缺包应判成 UP(gap),而不是 FAIL。 + +背景(2026-10-22):夜间验收在 `windows-11-arm` 连红 7 天,两侧各有一个缺口: + +1. 实现侧把 Adoptium 的 404 当成「流式中途断连」重试 4 次,最终报 + 「下载中途多次断开」—— 4 次无谓请求,真实原因(上游没有这个构建)被掩盖 + (已修:`HttpClient.stream_range` 对 4xx/5xx 立即抛 ``HttpStatusError``); +2. 判定侧靠 requests 的原文做标记,而 rich 在无 tty 时会软换行把 URL 切开, + 旧实现只把空白压成一个空格 —— 断点落在标记内部时仍然匹配不到 + (已修:`log_contains` 两侧都去掉全部空白后再比)。 + +这个测试把两件事绑在一起守:实现的消息里必须留着标记文本,且断行不能影响匹配。 +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +from orzmc.domain.paths import PathLayout +from orzmc.infra.fs import FileStore +from orzmc.infra.http import HttpClient, HttpStatusError +from orzmc.infra.log import NullReporter +from orzmc.infra.progress import NullProgress +from orzmc.services.java import JavaEnv + +ROOT = Path(__file__).resolve().parents[2] + + +def _harness() -> Any: + """Load the acceptance harness from the checkout (needs ``psutil``).""" + pytest.importorskip("psutil") + spec = importlib.util.spec_from_file_location("orzmc_acceptance_harness", ROOT / "scripts" / "acceptance.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclass 解析需要模块已注册 + spec.loader.exec_module(module) + return module + + +class _NoBuild: + """Adoptium 上没有该 OS/arch/major 的构建:所有下载入口都 404。""" + + def _raise(self, url: str) -> None: + raise HttpStatusError(f"404 Client Error: Not Found for url: {url}", status=404, url=url) + + def download(self, url: str, dest_path: str, **kwargs: Any) -> int: + self._raise(url) + return 0 + + def stream_range(self, url: str, start: int, end: int | None, dest_path: str, **kwargs: Any) -> Any: + self._raise(url) + return None + + +def test_upstream_404_is_a_platform_gap_not_a_failure(tmp_path: Path) -> None: + env = JavaEnv( + cast(HttpClient, _NoBuild()), FileStore(), NullReporter(), NullProgress(), PathLayout(root=str(tmp_path)) + ) + with pytest.raises(RuntimeError) as info: + env.resolve(25) + message = str(info.value) + # 面向用户的说明:是上游缺包,不是网络问题 + assert "Temurin 没有 Java 25 的" in message + assert "HTTP 404" in message + + # rich 软换行会把长行切开(断点可能落在标记内部);harness 必须仍然认出来 + log = tmp_path / "case.log" + log.write_text("\n".join(message[index : index + 76] for index in range(0, len(message), 76)), encoding="utf-8") + acceptance = _harness() + assert acceptance.log_contains(log, acceptance.GAP_MARKS) diff --git a/scripts/acceptance.py b/scripts/acceptance.py index 774461b..212006d 100644 --- a/scripts/acceptance.py +++ b/scripts/acceptance.py @@ -141,15 +141,17 @@ def log_contains(path: Path, needles: tuple[str, ...]) -> bool: orzmc prints through rich's Console, which soft-wraps long lines at its fallback width when there's no tty — e.g. the Adoptium 404 URL gets split - across lines with blank gaps. Collapse all whitespace runs first so a - marker spanning a wrapped line still matches. + across lines with blank gaps. The wrap can land **inside** a marker, so + strip whitespace from both sides before comparing: keeping a single space + (the old behaviour) still missed `…/binary/latest` when the break fell + inside it. Markers never depend on whitespace, so this is safe. """ try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return False - text = re.sub(r"\s+", " ", text) - return any(n in text for n in needles) + squeezed = re.sub(r"\s+", "", text) + return any(re.sub(r"\s+", "", needle) in squeezed for needle in needles) def log_tail(path: Path, size: int = 4096) -> str: