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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ orzmc/services → orzmc/core → orzmc/domain + orzmc/infra
- **元数据缓存与刷新(统一策略)**:`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`)共用该原语。**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/*`(父仓库)。
- **正版授权(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` 只接线。刷新期间持与状态目录对应的跨进程锁;仅明确的 `invalid_grant` 清除旧会话,临时错误保留凭据;刷新令牌轮换后先在锁内持久化,再访问 Xbox/Minecraft。`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。
- **服务端启动与关闭**:`server` 有独立 `--nogui` 选项(无窗口;老用法 `--server-args nogui` 仍兼容,`_build_server_command` 判定重不重复注入)。启动后子进程继承父进程 stdin,终端输入 `stop` 即保存退出;Ctrl-C 由 `ProcessRunner.run_stream` 优雅回收——子进程同在前台进程组也收到 SIGINT,父进程捕获 `KeyboardInterrupt` 后等待其保存退出(关闭日志经 `on_line` 透传),超 60s 未退先 SIGTERM 再 SIGKILL,最后 re-raise 让 CLI 报"已取消"返回 130,不遗留孤儿进程。
Expand Down
20 changes: 16 additions & 4 deletions orzmc/orzmc/services/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ def ensure(self) -> AuthSession:
return replace(current, source="cache")
target = current if current is not None else session
try:
return self.refresh(target.msa)
except AuthError:
return self._refresh(target.msa, previous=target)
except AuthError as exc:
if exc.code != "E_AUTH_EXPIRED":
raise
# refresh token 被拒(如用户改密/撤销授权)→ 清掉重登,不吞错。
self._store.clear()
return self.login()
Expand All @@ -87,11 +89,21 @@ def login(self) -> AuthSession:

def refresh(self, msa: MsaTokens) -> AuthSession:
"""用 MSA ``refresh_token`` 换新 MSA 令牌,再重走 Xbox 链路。"""
return self._refresh(msa)

def _refresh(self, msa: MsaTokens, *, previous: AuthSession | None = None) -> AuthSession:
status, data = self._send(auth_map.token_refresh_request(self._client_id, msa.refresh_token))
if status != 200:
if status == 400 and data.get("error") == "invalid_grant":
raise AuthError("E_AUTH_EXPIRED", _error_text(data))
action = auth_map.classify_token_error(data.get("error"))
raise AuthError(action.code or "E_AUTH_EXPIRED", _error_text(data))
return self._complete(auth_map.token_result(data, self._clock()), source="refresh")
code = "E_AUTH" if action.code == "E_AUTH_EXPIRED" else action.code or "E_AUTH"
raise AuthError(code, _error_text(data))
tokens = auth_map.token_result(data, self._clock())
if previous is not None:
# 令牌可能已经轮换,下游 Xbox / Minecraft 暂时失败时也要保留新令牌。
self._store.save(replace(previous, msa=tokens), self._client_id)
return self._complete(tokens, source="refresh")

# ── 内部 ────────────────────────────────────────────────────────────────
def _poll(self, device: DeviceCode) -> MsaTokens:
Expand Down
45 changes: 45 additions & 0 deletions orzmc/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,51 @@ def test_ensure_refreshes_stale_session(tmp_path) -> None:
assert token_calls and token_calls[0]["data"]["grant_type"] == "refresh_token"


def test_transient_refresh_failure_preserves_session(tmp_path) -> None:
http = _device_flow_http()
http.api_responses["oauth2/v2.0/token"] = (503, {"error": "temporarily_unavailable"})
store = AuthStore(str(tmp_path))
store.save(_session(expires_at=900.0), "cid")
auth = Authenticator(http, store, "cid", clock=_Clock())
with pytest.raises(AuthError):
auth.ensure()
saved = store.load()
assert saved is not None
assert saved.msa.refresh_token == "REFRESH"
assert len(http.api_requests) == 1


def test_invalid_grant_clears_session_and_starts_login(tmp_path) -> None:
http = _device_flow_http()
http.api_responses["oauth2/v2.0/token"] = (400, {"error": "invalid_grant"})
http.api_responses["/devicecode"] = (503, {"error": "temporarily_unavailable"})
store = AuthStore(str(tmp_path))
store.save(_session(expires_at=900.0), "cid")
auth = Authenticator(http, store, "cid", clock=_Clock())
with pytest.raises(AuthError):
auth.ensure()
assert store.load() is None
assert len(http.api_requests) == 2


def test_rotated_refresh_token_survives_downstream_failure(tmp_path) -> None:
http = _device_flow_http()
http.api_responses["oauth2/v2.0/token"] = (
200,
{"access_token": "NEW_MSA", "refresh_token": "NEW_REFRESH", "expires_in": 3600},
)
http.api_responses["user.auth.xboxlive.com"] = (503, {"error": "temporarily_unavailable"})
store = AuthStore(str(tmp_path))
store.save(_session(expires_at=900.0), "cid")
auth = Authenticator(http, store, "cid", clock=_Clock())
with pytest.raises(AuthError):
auth.ensure()
saved = store.load()
assert saved is not None
assert saved.msa.refresh_token == "NEW_REFRESH"
assert saved.expires_at == 900.0


def test_xsts_rejection_maps_to_e_auth_xbox(tmp_path) -> None:
http = _device_flow_http()
http.api_responses["xsts.auth.xboxlive.com"] = (401, {"XErr": 2148916233, "Message": "无 Xbox 账号"})
Expand Down
7 changes: 7 additions & 0 deletions orzmc/tests/test_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@
import time as _time
from types import SimpleNamespace

import pytest
from rich.console import Console

from orzmc.infra.progress import NullProgress, ProgressSink, RichProgress, _count_column, _percentage_column


@pytest.fixture(autouse=True)
def _terminal_supports_live_rendering(monkeypatch: pytest.MonkeyPatch) -> None:
# force_terminal alone does not override Rich's TERM=dumb detection.
monkeypatch.setenv("TERM", "xterm-256color")


def _sink() -> tuple[RichProgress, io.StringIO]:
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=True, width=120)
Expand Down
Loading