From a729615a488c30e47f916c53dad756d591fd418d Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 24 Jul 2026 10:34:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E4=B8=8B=E8=BD=BD=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?staging=E5=A4=8D=E7=94=A8=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- CURRENT_STATUS.md | 4 +- README.md | 2 +- USERGUIDE.md | 3 +- docs/guides/official-resource-test-pull.md | 2 +- docs/reports/CURRENT_GAPS.md | 14 +- infrastructure/src/bin/bat_official_sync.rs | 75 ------ infrastructure/src/curl_transfer.rs | 4 +- infrastructure/src/official_download.rs | 219 +++++------------- infrastructure/src/official_update.rs | 9 +- .../official_game_main_config_bootstrap.rs | 94 ++++++++ 11 files changed, 173 insertions(+), 257 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645af88..8839cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ ### 新增 - Addressables catalog 提取 `m_Crc`(bundle IEEE CRC-32):`ResourceEntry` 新增 `crc` 字段(compact/expanded 两种形态均解析),SQLite 持久化并对旧库幂等迁移补列;core 新增 `crc32_ieee` 与 `ResourceEntry::verify_downloaded_bytes`(按声明的 size/CRC 校验字节)(issue #2) - UnityFS 解析新增目录条目越界校验:directory 的 `offset+size` 必须落在解压数据区内,截断/损坏 bundle 的越界目录条目不再被静默接受(issue #3) -- 官方资源下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17) +- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17) ### 修复 -- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s),并发下载时对官方 CDN 更礼貌 +- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s) ### 计划 - [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 5f96f57..54c54da 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -19,14 +19,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 3. 默认平台为 `Windows + Android`。 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 -6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),daemon 子进程会透传该并发配置;manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。 +6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载执行保持顺序处理;manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。 7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy ` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。 8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 -13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 +13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 diff --git a/README.md b/README.md index 32887af..2336ff4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 -- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、多线程下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。 +- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 diff --git a/USERGUIDE.md b/USERGUIDE.md index acfec8c..3516c97 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -84,7 +84,6 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `--proxy ` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h | | `--no-proxy` | 强制直连 | | `--unzip ` | unzip 可执行文件(默认 `unzip`) | -| `--download-concurrency ` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 | | `--dry-run` | 不写同步状态 | | `--plan` | dry-run 时输出计划中的 URL | | `--force` | 强制下载/刷新 | @@ -127,7 +126,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon - 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。 - 语法:每行 `KEY=VALUE`;`#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。 -- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。 +- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。 - `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时 `.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。 - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `/bat-tasks.json`。 - 设 `BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index e69e4d2..502d5fd 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --error-retry 60s ``` -默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,已完成计数在并发下载时保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 +默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 8be9507..383d4fd 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -470,11 +470,13 @@ ## 6. 当前关闭顺序建议 -1. issue #17/#20/#21/#22 验收并关闭或更新范围:多线程下载与指数退避实现已合入,daemon 子进程并发参数透传和单调进度上报已补齐,但 GitHub issue 仍 open。 -2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。 -3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。 -4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。 -5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。 -6. G-012 / G-006:翻译系统、Patch 引擎。 +1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。 +2. issue #1:Rust daemon/backend API 边界收口,确认 `patch.*` / `unityfs.*` / `resource.repair` / `task.create` 的剩余阻塞。 +3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。 +4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。 +5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。 +6. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。 +7. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。 +8. G-012 / G-006:翻译系统、Patch 引擎。 这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按“不引入托管 CI”决策关闭。 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 2d2b03e..970915d 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -4471,8 +4471,6 @@ fn daemon_child_args(options: &CliOptions) -> Vec { } args.push("--curl".to_string()); args.push(config.curl_command.to_string_lossy().to_string()); - args.push("--download-concurrency".to_string()); - args.push(config.download_concurrency.to_string()); match config.curl_proxy.mode() { CurlProxyMode::Auto if options.proxy_option_explicit => { args.push("--proxy".to_string()); @@ -4959,9 +4957,6 @@ BAT_AUTO_DISCOVER=1 # 正常检查间隔与失败重试间隔(秒) #BAT_INTERVAL_SECONDS=3600 #BAT_ERROR_RETRY_SECONDS=60 -# 下载并发度(并行网络下载数,1~256;默认 4,对官方 CDN 礼貌) -#BAT_DOWNLOAD_CONCURRENCY=4 - # ---- 网络 ---- # 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。 # 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。 @@ -5145,9 +5140,6 @@ fn apply_bat_env_overrides( if let Some(v) = value("BAT_UNZIP") { options.config.unzip_command = PathBuf::from(v); } - if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") { - options.config.download_concurrency = parse_download_concurrency(&v)?; - } if let Some(v) = value("BAT_PROXY") { options.config.curl_proxy = parse_proxy_config(&v)?; } @@ -5325,11 +5317,6 @@ fn parse_args_with_env( "--unzip" => { options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); } - "--download-concurrency" => { - let value = next_option_value(&mut args, &flag)?; - options.config.download_concurrency = parse_download_concurrency(&value)?; - options.sync_option_explicit = true; - } "--dry-run" => { options.config.dry_run = true; options.sync_option_explicit = true; @@ -5668,7 +5655,6 @@ fn print_usage(binary: &str) { eprintln!(" --proxy curl proxy override (default: auto from env)"); eprintln!(" --no-proxy Force direct curl connections"); eprintln!(" --unzip unzip executable (default: unzip)"); - eprintln!(" --download-concurrency Parallel downloads, 1..=256 (default: 4)"); eprintln!(" --dry-run Do not write sync state"); eprintln!(" --plan Include planned URLs in dry-run"); eprintln!(" --force Force download/refresh"); @@ -5743,17 +5729,6 @@ fn parse_platforms(value: &str) -> Result, String> { .collect() } -/// 解析下载并发度:正整数,钳制到 `1..=256`。 -fn parse_download_concurrency(value: &str) -> anyhow::Result { - let parsed = value - .parse::() - .map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?; - if parsed == 0 { - return Err(anyhow::anyhow!("下载并发度必须大于 0")); - } - Ok(parsed.min(256)) -} - fn parse_platform(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "windows" | "win" => Ok(PatchPlatform::Windows), @@ -6036,51 +6011,6 @@ mod tests { assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); } - #[test] - fn parses_download_concurrency_flag_and_env() { - // 默认值(与 OfficialUpdateConfig 默认一致)。 - assert_eq!( - parse(&["bat"]).unwrap().config.download_concurrency, - OfficialUpdateConfig::default().download_concurrency - ); - // CLI 显式设置 + 上限钳制。 - assert_eq!( - parse(&["bat", "--download-concurrency", "16"]) - .unwrap() - .config - .download_concurrency, - 16 - ); - assert_eq!( - parse(&["bat", "--download-concurrency", "9999"]) - .unwrap() - .config - .download_concurrency, - 256 - ); - // 0 与非数字报错。 - assert!(parse(&["bat", "--download-concurrency", "0"]).is_err()); - assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err()); - // 环境变量(含 .env)设置默认值;CLI 覆盖之。 - assert_eq!( - parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")]) - .unwrap() - .config - .download_concurrency, - 12 - ); - assert_eq!( - parse_with_env( - &["bat", "--download-concurrency", "3"], - &[("BAT_DOWNLOAD_CONCURRENCY", "12")] - ) - .unwrap() - .config - .download_concurrency, - 3 - ); - } - #[test] fn parses_auto_discover_sync_args() { let options = parse(&[ @@ -6326,8 +6256,6 @@ mod tests { "http://127.0.0.1:7890", "--unzip", "/usr/bin/unzip", - "--download-concurrency", - "40", "--interval", "30m", "--error-retry", @@ -6350,9 +6278,6 @@ mod tests { assert!(args .windows(2) .any(|pair| pair == ["--platforms", "Windows,Android"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--download-concurrency", "40"])); // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); diff --git a/infrastructure/src/curl_transfer.rs b/infrastructure/src/curl_transfer.rs index de0224c..530cffb 100644 --- a/infrastructure/src/curl_transfer.rs +++ b/infrastructure/src/curl_transfer.rs @@ -445,8 +445,8 @@ fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration { /// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。 /// /// - `ETXTBSY`(fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。 -/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载 -/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。 +/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免 +/// 连续失败后立即重发。 fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 { if busy { return 5 * attempt as u64; diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 787e2c5..483c913 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -16,9 +16,6 @@ use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc; -use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; /// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。 @@ -64,10 +61,6 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json"; const DOWNLOAD_MANIFEST_VERSION: u32 = 1; const DOWNLOAD_QUARANTINE_VERSION: u32 = 1; const DEFAULT_RETRY_ATTEMPTS: usize = 3; -/// 默认下载并发度。保守取值,对官方 CDN 礼貌;可经配置调到 1..=256。 -pub(crate) const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 4; -/// 下载并发度上限。 -const MAX_DOWNLOAD_CONCURRENCY: usize = 256; /// Outcome for one official resource pull item. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -493,7 +486,6 @@ pub struct OfficialResourcePullService { curl_command: PathBuf, curl_proxy: CurlProxyConfig, retry_attempts: usize, - download_concurrency: usize, } impl OfficialResourcePullService { @@ -512,7 +504,6 @@ impl OfficialResourcePullService { curl_command: curl_command.into(), curl_proxy: CurlProxyConfig::default(), retry_attempts: DEFAULT_RETRY_ATTEMPTS, - download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY, } } @@ -528,17 +519,6 @@ impl OfficialResourcePullService { self } - /// 设置下载并发度(并行执行的网络下载数),钳制到 `1..=256`。 - pub fn with_download_concurrency(mut self, concurrency: usize) -> Self { - self.download_concurrency = concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY); - self - } - - /// 返回当前下载并发度。 - pub fn download_concurrency(&self) -> usize { - self.download_concurrency - } - /// Returns the output root used for downloaded files. pub fn output_root(&self) -> &Path { &self.output_root @@ -625,11 +605,8 @@ impl OfficialResourcePullService { }); } - // Phase B:并发下载 need-download 项(各 URL 目标/`.part` 相互独立, - // 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回 - // 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需 - // 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一 - // 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。 + // Phase B:顺序下载 need-download 项。每个 URL 的目标和 `.part` 都是独立 + // 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。 let download_indices: Vec = planned .iter() .enumerate() @@ -640,113 +617,68 @@ impl OfficialResourcePullService { (0..planned.len()).map(|_| None).collect(); let mut completed_count = 0usize; - if !download_indices.is_empty() { - let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY); - let cursor = AtomicUsize::new(0); - let cancel = AtomicBool::new(false); - let (sender, receiver) = mpsc::channel::(); - - thread::scope(|scope| { - for _ in 0..concurrency.min(download_indices.len()) { - let sender = sender.clone(); - let cursor = &cursor; - let cancel = &cancel; - let download_indices = &download_indices; - let planned = &planned; - let service = &*self; - scope.spawn(move || loop { - if cancel.load(Ordering::Relaxed) { - break; - } - let slot = cursor.fetch_add(1, Ordering::Relaxed); - let Some(&plan_index) = download_indices.get(slot) else { - break; - }; - let item = &planned[plan_index]; - if sender.send(WorkerMessage::Started { plan_index }).is_err() { - break; - } - let result = service.pull_one(&item.url, &item.destination); - if result.is_err() { - cancel.store(true, Ordering::Relaxed); - } - if sender - .send(WorkerMessage::Done { plan_index, result }) - .is_err() - { - break; - } - }); - } - // 主线程持有的 sender 副本必须丢弃,否则 receiver 永不结束。 - drop(sender); - - for message in receiver { - match message { - WorkerMessage::Started { plan_index } => { - let item = &planned[plan_index]; - progress(OfficialResourcePullProgress::started( - completed_count, - total, - item.url.clone(), - )); - // 停止请求:置 cancel,让 worker 在下个任务边界退出。 - if should_cancel() { - cancel.store(true, Ordering::Relaxed); - } - } - WorkerMessage::Done { plan_index, result } => { - if let Ok(pull_result) = &result { - let item = &planned[plan_index]; - // manifest/quarantine 簿记在主线程串行执行。 - if let Err(error) = self - .clear_quarantine_entry(&item.url) - .and_then(|_| { - self.record_download_manifest_entry( - &mut manifest, - &item.url, - &item.destination, - ) - }) - .and_then(|_| self.write_download_manifest(&manifest)) - { - // 簿记失败:记为该项错误并触发 fail-fast。 - cancel.store(true, Ordering::Relaxed); - download_results[plan_index] = Some(Err(PullOneError::plain( - format!("记录下载 manifest 失败:{error}"), - ))); - continue; - } - completed_count += 1; - progress(OfficialResourcePullProgress::finished( - completed_count, - total, - item.url.clone(), - pull_result.status, - pull_result.bytes, - pull_result.transferred_bytes, - )); - } - download_results[plan_index] = Some(result); - } - } - } - }); - - if should_cancel() && download_results.iter().any(Option::is_none) { + for &plan_index in &download_indices { + let item = &planned[plan_index]; + progress(OfficialResourcePullProgress::started( + completed_count, + total, + item.url.clone(), + )); + if should_cancel() { return Err("官方资源拉取已被停止请求中断".to_string().into()); } - // fail-fast:按 plan 顺序取首个失败项,记 quarantine、发 failed 进度并中止。 - for &plan_index in &download_indices { - if let Some(Some(Err(error))) = download_results.get(plan_index) { - let item = &planned[plan_index]; - self.record_quarantine_entry(&item.url, &item.destination, error)?; + let result = self.pull_one(&item.url, &item.destination); + match result { + Ok(pull_result) => { + if let Err(error) = self + .clear_quarantine_entry(&item.url) + .and_then(|_| { + self.record_download_manifest_entry( + &mut manifest, + &item.url, + &item.destination, + ) + }) + .and_then(|_| self.write_download_manifest(&manifest)) + { + let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}")); + self.record_quarantine_entry(&item.url, &item.destination, &error)?; + progress(OfficialResourcePullProgress::failed( + completed_count, + total, + item.url.clone(), + &error, + )); + return Err(DownloadError::new( + error.error_code(), + format!( + "官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={};{}", + item.url, + self.download_quarantine_path().display(), + error.message + ), + )); + } + + completed_count += 1; + progress(OfficialResourcePullProgress::finished( + completed_count, + total, + item.url.clone(), + pull_result.status, + pull_result.bytes, + pull_result.transferred_bytes, + )); + download_results[plan_index] = Some(Ok(pull_result)); + } + Err(error) => { + self.record_quarantine_entry(&item.url, &item.destination, &error)?; progress(OfficialResourcePullProgress::failed( completed_count, total, item.url.clone(), - error, + &error, )); return Err(DownloadError::new( error.error_code(), @@ -1771,17 +1703,6 @@ struct PlannedDownload { existing: Option, } -/// worker 线程经 mpsc 送回主线程的消息。 -enum WorkerMessage { - /// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。 - Started { plan_index: usize }, - /// 某计划项下载结束(成功或失败)。 - Done { - plan_index: usize, - result: Result, - }, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PullOneResult { bytes: u64, @@ -3084,16 +3005,14 @@ exit 22 } #[test] - fn downloads_run_concurrently_and_each_url_reports_once() { + fn downloads_run_sequentially_and_each_url_reports_once() { let out_dir = TempDir::new().unwrap(); let bin_dir = TempDir::new().unwrap(); let curl_path = bin_dir.path().join("curl"); write_fake_curl(&curl_path); - // 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。 - let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path) - .with_download_concurrency(8); - assert_eq!(service.download_concurrency(), 8); + // 顺序下载:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。 + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); let plan = build_official_pull_plan_for_platforms( discovery_plan(), inventory(), @@ -3127,24 +3046,6 @@ exit 22 assert_eq!(manifest.entries.len(), all_urls.len()); } - #[test] - fn download_concurrency_is_clamped() { - let service = OfficialResourcePullService::with_curl_command("/tmp/unused", "curl"); - assert_eq!(service.download_concurrency(), DEFAULT_DOWNLOAD_CONCURRENCY); - assert_eq!( - OfficialResourcePullService::with_curl_command("/tmp/unused", "curl") - .with_download_concurrency(0) - .download_concurrency(), - 1 - ); - assert_eq!( - OfficialResourcePullService::with_curl_command("/tmp/unused", "curl") - .with_download_concurrency(9999) - .download_concurrency(), - MAX_DOWNLOAD_CONCURRENCY - ); - } - #[test] fn retries_transient_download_failures() { let out_dir = TempDir::new().unwrap(); diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index dda4b53..e8b6c8e 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -96,8 +96,6 @@ pub struct OfficialUpdateConfig { pub audit_local: bool, /// Repair local files when the local manifest audit fails. pub repair: bool, - /// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。 - pub download_concurrency: usize, } impl Default for OfficialUpdateConfig { @@ -119,7 +117,6 @@ impl Default for OfficialUpdateConfig { force: false, audit_local: true, repair: true, - download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY, } } } @@ -901,8 +898,7 @@ impl OfficialUpdateService { &active_resource_root, &config.curl_command, ) - .with_proxy_config(config.curl_proxy.clone()) - .with_download_concurrency(config.download_concurrency); + .with_proxy_config(config.curl_proxy.clone()); let snapshot_path = snapshot_path_for(config, &active_resource_root); let bootstrap_cache_path = config.bootstrap_cache_path(); @@ -1341,8 +1337,7 @@ impl OfficialUpdateService { &publish_plan.staging_path, &config.curl_command, ) - .with_proxy_config(config.curl_proxy.clone()) - .with_download_concurrency(config.download_concurrency); + .with_proxy_config(config.curl_proxy.clone()); report.staging_path = Some(publish_plan.staging_path.clone()); report.snapshot_path = staging_snapshot_path.clone(); report.download_manifest = staging_fetcher.download_manifest_path(); diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 1823a36..92e2292 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -330,6 +330,75 @@ fn official_update_second_run_audits_existing_resources_before_reuse() { assert!(version_state.in_progress_version.is_none()); } +#[test] +fn official_update_reuses_failed_staging_after_interrupted_download() { + let harness = TestHarness::new(); + let bootstrap = harness.fetch_bootstrap(); + let fetcher = harness.fetcher(); + let server_info_url = bootstrap + .game_main_config + .server_info_data_url + .as_ref() + .unwrap() + .clone(); + let server_info_bytes = fetcher.fetch_bytes(&server_info_url).unwrap(); + let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap(); + let discovery = server_info + .discovery_plan( + bootstrap + .game_main_config + .default_connection_group + .as_deref() + .unwrap(), + &bootstrap.game_config.game_latest_version, + &verified_official_platforms(), + ) + .unwrap(); + let inventory = fetch_platform_inventory(&fetcher, &discovery); + let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory); + let all_urls = plan.all_urls().unwrap(); + let resume_url = all_urls + .iter() + .find(|url| url.ends_with("/Windows_PatchPack/catalog_StandaloneWindows64.zip")) + .expect("fixture plan should contain Windows addressables catalog zip") + .clone(); + let resources_assets_path = harness.temp.path().join("resources.assets"); + let zip_fixture = harness.temp.path().join("downloaded.zip"); + let curl_failure_state = harness.temp.path().join("curl-failure.state"); + write_executable( + &harness.curl_script, + &official_curl_script( + &harness.curl_log, + &zip_fixture, + &resources_assets_path, + fs::metadata(&resources_assets_path).unwrap().len() as usize, + TEST_LAUNCHER_MANIFEST_SOURCE, + Some(&curl_failure_state), + ), + ); + + let config = harness.sync_config("failed-staging-output"); + let first_error = OfficialUpdateService::new().run(&config).unwrap_err(); + assert!(first_error.to_string().contains("quarantine")); + assert!(first_error.to_string().contains("simulated failure")); + + let first_log = fs::read_to_string(&harness.curl_log).unwrap(); + assert!(first_log.contains(&resume_url)); + + let report = OfficialUpdateService::new().run(&config).unwrap(); + assert_eq!(report.update_status, OfficialUpdateStatus::Downloaded); + + let second_log = fs::read_to_string(&harness.curl_log).unwrap(); + assert_eq!(second_log.matches(&resume_url).count(), 1); + + let version_state = read_version_state(&report.version_state_path) + .unwrap() + .unwrap(); + assert!(version_state.failed_versions.is_empty()); + assert!(version_state.in_progress_version.is_none()); + assert!(version_state.current_completed_version.is_some()); +} + struct TestHarness { temp: TempDir, curl_script: std::path::PathBuf, @@ -362,6 +431,7 @@ impl TestHarness { &resources_assets_path, resources_assets.len(), manifest_source, + None, ), ); @@ -478,11 +548,16 @@ fn official_curl_script( resources_assets_fixture: &Path, resources_assets_size: usize, manifest_source: &str, + failure_state: Option<&Path>, ) -> String { + let failure_state = failure_state + .map(shell_quote) + .unwrap_or_else(|| "''".to_string()); format!( r#"#!/usr/bin/env bash set -euo pipefail log={} +failure_state={failure_state} printf '%s\n' "$*" >> "$log" output="" url="" @@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{ cp {} "$output" }} +maybe_fail_once() {{ + if [[ -z "$failure_state" ]]; then + return 0 + fi + local attempt=0 + if [[ -f "$failure_state" ]]; then + attempt="$(cat "$failure_state")" + fi + attempt=$((attempt + 1)) + mkdir -p "$(dirname "$failure_state")" + printf '%s' "$attempt" > "$failure_state" + if [[ "$attempt" -le 3 ]]; then + echo "simulated failure for $url attempt=$attempt" >&2 + exit 22 + fi +}} + if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then cat <<'JSON' {{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} @@ -573,6 +665,7 @@ elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then emit_text "{android_media_catalog_hash}" elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then + maybe_fail_once filename="${{url##*/}}" if [[ "$filename" == *.zip ]]; then emit_zip_fixture @@ -604,6 +697,7 @@ fi windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"), android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"), android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"), + failure_state = failure_state, ) }