fix(sync): 移除多线程下载并补齐staging复用回归

This commit is contained in:
2026-07-24 10:34:58 +08:00
parent 3f5d2a8da7
commit a729615a48
11 changed files with 173 additions and 257 deletions
+2 -2
View File
@@ -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 - 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 - 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 - [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19
+2 -2
View File
@@ -19,14 +19,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
3. 默认平台为 `Windows + Android` 3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json` 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 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 <URL>` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret``0600`)且 `clean-stable` 会清除。 7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy <URL>` 显式指定或 `--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` 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 误判为可复用。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 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 会阻止前台写命令直接修改它正在管理的同一目录。 10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 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` 权限创建。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 12. `<output>/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 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。 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 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。
仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
+1 -1
View File
@@ -13,7 +13,7 @@
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。 - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 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/<id>`、本地 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`
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
+1 -2
View File
@@ -84,7 +84,6 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h | | `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
| `--no-proxy` | 强制直连 | | `--no-proxy` | 强制直连 |
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` | | `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` |
| `--download-concurrency <N>` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 |
| `--dry-run` | 不写同步状态 | | `--dry-run` | 不写同步状态 |
| `--plan` | dry-run 时输出计划中的 URL | | `--plan` | dry-run 时输出计划中的 URL |
| `--force` | 强制下载/刷新 | | `--force` | 强制下载/刷新 |
@@ -127,7 +126,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。 - 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
- 语法:每行 `KEY=VALUE``#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。 - 语法:每行 `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_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run``.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json` - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
-`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。 -`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
+1 -1
View File
@@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--error-retry 60s --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 errorwatch 模式下错误 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 errorwatch 模式下错误 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 还会阻止前台写命令直接修改它正在管理的同一目录。 生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
+8 -6
View File
@@ -470,11 +470,13 @@
## 6. 当前关闭顺序建议 ## 6. 当前关闭顺序建议
1. issue #17/#20/#21/#22 验收并关闭或更新范围:多线程下载与指数退避实现已合入,daemon 子进程并发参数透传和单调进度上报已补齐,但 GitHub issue 仍 open 1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余
2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖 2. issue #1Rust daemon/backend API 边界收口,确认 `patch.*` / `unityfs.*` / `resource.repair` / `task.create` 的剩余阻塞
3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析 3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open
4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流 4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖
5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`issue #19 5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析
6. G-012 / G-006:翻译系统、Patch 引擎 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”决策关闭。 这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按“不引入托管 CI”决策关闭。
@@ -4471,8 +4471,6 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
} }
args.push("--curl".to_string()); args.push("--curl".to_string());
args.push(config.curl_command.to_string_lossy().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() { match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => { CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string()); args.push("--proxy".to_string());
@@ -4959,9 +4957,6 @@ BAT_AUTO_DISCOVER=1
# #
#BAT_INTERVAL_SECONDS=3600 #BAT_INTERVAL_SECONDS=3600
#BAT_ERROR_RETRY_SECONDS=60 #BAT_ERROR_RETRY_SECONDS=60
# 1~256 4 CDN
#BAT_DOWNLOAD_CONCURRENCY=4
# ---- ---- # ---- ----
# URL http/https/socks4/socks4a/socks5/socks5h # URL http/https/socks4/socks4a/socks5/socks5h
# HTTPS_PROXY / ALL_PROXY / HTTP_PROXY # HTTPS_PROXY / ALL_PROXY / HTTP_PROXY
@@ -5145,9 +5140,6 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_UNZIP") { if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v); 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") { if let Some(v) = value("BAT_PROXY") {
options.config.curl_proxy = parse_proxy_config(&v)?; options.config.curl_proxy = parse_proxy_config(&v)?;
} }
@@ -5325,11 +5317,6 @@ fn parse_args_with_env(
"--unzip" => { "--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); 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" => { "--dry-run" => {
options.config.dry_run = true; options.config.dry_run = true;
options.sync_option_explicit = true; options.sync_option_explicit = true;
@@ -5668,7 +5655,6 @@ fn print_usage(binary: &str) {
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)"); eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections"); eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)"); eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
eprintln!(" --download-concurrency <N> Parallel downloads, 1..=256 (default: 4)");
eprintln!(" --dry-run Do not write sync state"); eprintln!(" --dry-run Do not write sync state");
eprintln!(" --plan Include planned URLs in dry-run"); eprintln!(" --plan Include planned URLs in dry-run");
eprintln!(" --force Force download/refresh"); eprintln!(" --force Force download/refresh");
@@ -5743,17 +5729,6 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
.collect() .collect()
} }
/// 解析下载并发度:正整数,钳制到 `1..=256`。
fn parse_download_concurrency(value: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
}
Ok(parsed.min(256))
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> { fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() { match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows), "windows" | "win" => Ok(PatchPlatform::Windows),
@@ -6036,51 +6011,6 @@ mod tests {
assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); 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] #[test]
fn parses_auto_discover_sync_args() { fn parses_auto_discover_sync_args() {
let options = parse(&[ let options = parse(&[
@@ -6326,8 +6256,6 @@ mod tests {
"http://127.0.0.1:7890", "http://127.0.0.1:7890",
"--unzip", "--unzip",
"/usr/bin/unzip", "/usr/bin/unzip",
"--download-concurrency",
"40",
"--interval", "--interval",
"30m", "30m",
"--error-retry", "--error-retry",
@@ -6350,9 +6278,6 @@ mod tests {
assert!(args assert!(args
.windows(2) .windows(2)
.any(|pair| pair == ["--platforms", "Windows,Android"])); .any(|pair| pair == ["--platforms", "Windows,Android"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--download-concurrency", "40"]));
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
+2 -2
View File
@@ -445,8 +445,8 @@ fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。 /// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
/// ///
/// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。 /// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载 /// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。 /// 连续失败后立即重发。
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 { fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
if busy { if busy {
return 5 * attempt as u64; return 5 * attempt as u64;
+33 -132
View File
@@ -16,9 +16,6 @@ use std::fs::{self, File};
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。 /// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
@@ -64,10 +61,6 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1; const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1; const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
const DEFAULT_RETRY_ATTEMPTS: usize = 3; 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. /// Outcome for one official resource pull item.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -493,7 +486,6 @@ pub struct OfficialResourcePullService {
curl_command: PathBuf, curl_command: PathBuf,
curl_proxy: CurlProxyConfig, curl_proxy: CurlProxyConfig,
retry_attempts: usize, retry_attempts: usize,
download_concurrency: usize,
} }
impl OfficialResourcePullService { impl OfficialResourcePullService {
@@ -512,7 +504,6 @@ impl OfficialResourcePullService {
curl_command: curl_command.into(), curl_command: curl_command.into(),
curl_proxy: CurlProxyConfig::default(), curl_proxy: CurlProxyConfig::default(),
retry_attempts: DEFAULT_RETRY_ATTEMPTS, retry_attempts: DEFAULT_RETRY_ATTEMPTS,
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
} }
} }
@@ -528,17 +519,6 @@ impl OfficialResourcePullService {
self 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. /// Returns the output root used for downloaded files.
pub fn output_root(&self) -> &Path { pub fn output_root(&self) -> &Path {
&self.output_root &self.output_root
@@ -625,11 +605,8 @@ impl OfficialResourcePullService {
}); });
} }
// Phase B并发下载 need-download 项(各 URL 目标/`.part` 相互独立 // Phase B顺序下载 need-download 项。每个 URL 目标`.part` 都是独立
// 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回 // 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
// 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需
// 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一
// 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。
let download_indices: Vec<usize> = planned let download_indices: Vec<usize> = planned
.iter() .iter()
.enumerate() .enumerate()
@@ -640,65 +617,20 @@ impl OfficialResourcePullService {
(0..planned.len()).map(|_| None).collect(); (0..planned.len()).map(|_| None).collect();
let mut completed_count = 0usize; let mut completed_count = 0usize;
if !download_indices.is_empty() { for &plan_index in &download_indices {
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::<WorkerMessage>();
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]; let item = &planned[plan_index];
progress(OfficialResourcePullProgress::started( progress(OfficialResourcePullProgress::started(
completed_count, completed_count,
total, total,
item.url.clone(), item.url.clone(),
)); ));
// 停止请求:置 cancel,让 worker 在下个任务边界退出。
if should_cancel() { if should_cancel() {
cancel.store(true, Ordering::Relaxed); return Err("官方资源拉取已被停止请求中断".to_string().into());
} }
}
WorkerMessage::Done { plan_index, result } => { let result = self.pull_one(&item.url, &item.destination);
if let Ok(pull_result) = &result { match result {
let item = &planned[plan_index]; Ok(pull_result) => {
// manifest/quarantine 簿记在主线程串行执行。
if let Err(error) = self if let Err(error) = self
.clear_quarantine_entry(&item.url) .clear_quarantine_entry(&item.url)
.and_then(|_| { .and_then(|_| {
@@ -710,13 +642,25 @@ impl OfficialResourcePullService {
}) })
.and_then(|_| self.write_download_manifest(&manifest)) .and_then(|_| self.write_download_manifest(&manifest))
{ {
// 簿记失败:记为该项错误并触发 fail-fast。 let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
cancel.store(true, Ordering::Relaxed); self.record_quarantine_entry(&item.url, &item.destination, &error)?;
download_results[plan_index] = Some(Err(PullOneError::plain( progress(OfficialResourcePullProgress::failed(
format!("记录下载 manifest 失败:{error}"), completed_count,
))); total,
continue; 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; completed_count += 1;
progress(OfficialResourcePullProgress::finished( progress(OfficialResourcePullProgress::finished(
completed_count, completed_count,
@@ -726,27 +670,15 @@ impl OfficialResourcePullService {
pull_result.bytes, pull_result.bytes,
pull_result.transferred_bytes, pull_result.transferred_bytes,
)); ));
download_results[plan_index] = Some(Ok(pull_result));
} }
download_results[plan_index] = Some(result); Err(error) => {
} self.record_quarantine_entry(&item.url, &item.destination, &error)?;
}
}
});
if should_cancel() && download_results.iter().any(Option::is_none) {
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)?;
progress(OfficialResourcePullProgress::failed( progress(OfficialResourcePullProgress::failed(
completed_count, completed_count,
total, total,
item.url.clone(), item.url.clone(),
error, &error,
)); ));
return Err(DownloadError::new( return Err(DownloadError::new(
error.error_code(), error.error_code(),
@@ -1771,17 +1703,6 @@ struct PlannedDownload {
existing: Option<PullOneResult>, existing: Option<PullOneResult>,
} }
/// worker 线程经 mpsc 送回主线程的消息。
enum WorkerMessage {
/// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。
Started { plan_index: usize },
/// 某计划项下载结束(成功或失败)。
Done {
plan_index: usize,
result: Result<PullOneResult, PullOneError>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PullOneResult { struct PullOneResult {
bytes: u64, bytes: u64,
@@ -3084,16 +3005,14 @@ exit 22
} }
#[test] #[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 out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap(); let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl"); let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path); write_fake_curl(&curl_path);
// 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。 // 顺序下载:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path) let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
.with_download_concurrency(8);
assert_eq!(service.download_concurrency(), 8);
let plan = build_official_pull_plan_for_platforms( let plan = build_official_pull_plan_for_platforms(
discovery_plan(), discovery_plan(),
inventory(), inventory(),
@@ -3127,24 +3046,6 @@ exit 22
assert_eq!(manifest.entries.len(), all_urls.len()); 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] #[test]
fn retries_transient_download_failures() { fn retries_transient_download_failures() {
let out_dir = TempDir::new().unwrap(); let out_dir = TempDir::new().unwrap();
+2 -7
View File
@@ -96,8 +96,6 @@ pub struct OfficialUpdateConfig {
pub audit_local: bool, pub audit_local: bool,
/// Repair local files when the local manifest audit fails. /// Repair local files when the local manifest audit fails.
pub repair: bool, pub repair: bool,
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
pub download_concurrency: usize,
} }
impl Default for OfficialUpdateConfig { impl Default for OfficialUpdateConfig {
@@ -119,7 +117,6 @@ impl Default for OfficialUpdateConfig {
force: false, force: false,
audit_local: true, audit_local: true,
repair: true, repair: true,
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
} }
} }
} }
@@ -901,8 +898,7 @@ impl OfficialUpdateService {
&active_resource_root, &active_resource_root,
&config.curl_command, &config.curl_command,
) )
.with_proxy_config(config.curl_proxy.clone()) .with_proxy_config(config.curl_proxy.clone());
.with_download_concurrency(config.download_concurrency);
let snapshot_path = snapshot_path_for(config, &active_resource_root); let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path(); let bootstrap_cache_path = config.bootstrap_cache_path();
@@ -1341,8 +1337,7 @@ impl OfficialUpdateService {
&publish_plan.staging_path, &publish_plan.staging_path,
&config.curl_command, &config.curl_command,
) )
.with_proxy_config(config.curl_proxy.clone()) .with_proxy_config(config.curl_proxy.clone());
.with_download_concurrency(config.download_concurrency);
report.staging_path = Some(publish_plan.staging_path.clone()); report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone(); report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path(); report.download_manifest = staging_fetcher.download_manifest_path();
@@ -330,6 +330,75 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
assert!(version_state.in_progress_version.is_none()); 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 { struct TestHarness {
temp: TempDir, temp: TempDir,
curl_script: std::path::PathBuf, curl_script: std::path::PathBuf,
@@ -362,6 +431,7 @@ impl TestHarness {
&resources_assets_path, &resources_assets_path,
resources_assets.len(), resources_assets.len(),
manifest_source, manifest_source,
None,
), ),
); );
@@ -478,11 +548,16 @@ fn official_curl_script(
resources_assets_fixture: &Path, resources_assets_fixture: &Path,
resources_assets_size: usize, resources_assets_size: usize,
manifest_source: &str, manifest_source: &str,
failure_state: Option<&Path>,
) -> String { ) -> String {
let failure_state = failure_state
.map(shell_quote)
.unwrap_or_else(|| "''".to_string());
format!( format!(
r#"#!/usr/bin/env bash r#"#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
log={} log={}
failure_state={failure_state}
printf '%s\n' "$*" >> "$log" printf '%s\n' "$*" >> "$log"
output="" output=""
url="" url=""
@@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{
cp {} "$output" 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 if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
cat <<'JSON' cat <<'JSON'
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} {{"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 elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
emit_text "{android_media_catalog_hash}" emit_text "{android_media_catalog_hash}"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
maybe_fail_once
filename="${{url##*/}}" filename="${{url##*/}}"
if [[ "$filename" == *.zip ]]; then if [[ "$filename" == *.zip ]]; then
emit_zip_fixture emit_zip_fixture
@@ -604,6 +697,7 @@ fi
windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"), windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"), android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"), android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
failure_state = failure_state,
) )
} }