mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(sync): 移除多线程下载并补齐staging复用回归
This commit is contained in:
+2
-2
@@ -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
@@ -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。
|
||||||
|
|||||||
@@ -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
@@ -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` 的生成与加载。
|
||||||
|
|||||||
@@ -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 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 还会阻止前台写命令直接修改它正在管理的同一目录。
|
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||||
|
|
||||||
|
|||||||
@@ -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 #1:Rust 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")));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user