diff --git a/AGENTS.md b/AGENTS.md index 00b9b94..3f13c62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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`)共用该原语。**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 沙盒**:运行时安装在 `/java//`,用 `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,不遗留孤儿进程。 diff --git a/orzmc/orzmc/services/auth.py b/orzmc/orzmc/services/auth.py index fde8e72..343eb4f 100644 --- a/orzmc/orzmc/services/auth.py +++ b/orzmc/orzmc/services/auth.py @@ -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() @@ -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: diff --git a/orzmc/tests/test_auth.py b/orzmc/tests/test_auth.py index 0f79cc0..aa9cc52 100644 --- a/orzmc/tests/test_auth.py +++ b/orzmc/tests/test_auth.py @@ -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 账号"}) diff --git a/orzmc/tests/test_progress.py b/orzmc/tests/test_progress.py index f9905e6..3c76f69 100644 --- a/orzmc/tests/test_progress.py +++ b/orzmc/tests/test_progress.py @@ -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)