Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<v>.json`(内容寻址 + sha1 校验,refresh 也强制绕过)、`cache/meta/<adapter>/<key>.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 沙盒**:运行时安装在 `<root>/java/<major>/`,用 `bin/java` 启动;版本要求读自版本 JSON `javaVersion.majorVersion`(缺失默认 8)。JRE 即可满足所有类型运行,无需完整 JDK。
Expand Down Expand Up @@ -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` 开头。
Expand Down
22 changes: 22 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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")
35 changes: 34 additions & 1 deletion orzmc/orzmc/infra/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -193,6 +214,10 @@ def stream_range(
``Range: bytes=<已落盘>-<end>`` 只补齐剩余部分;若服务器忽略 ``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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions orzmc/orzmc/services/java.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,30 @@

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

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."""

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading