mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
@@ -17,6 +17,7 @@
|
||||
- 新增官方日服资源同步链路:auto-discover、pull plan、snapshot、marker diff、本地 manifest audit/repair
|
||||
- 新增 Rust 官方同步 binary,当前正式入口为 `bat`,支持 one-shot、`--watch`、默认 1 小时间隔、人类可读默认输出和 `--json` 结构化 report
|
||||
- 官方同步正式入口统一为 `bat`,新增 `--daemon` 后台模式和基于 `bat.sock` 的 Unix socket JSON-RPC 控制面,管理命令覆盖 `status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`
|
||||
- 新增 daemon 控制锁 `bat-control.lock`、daemon 与前台同资源目录写入互斥,以及失效/损坏 PID 和锁文件恢复逻辑
|
||||
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
||||
- 新增 Addressables 当前真实形态 fixture/golden 测试
|
||||
- 新增 SQLite Resource Repository 和粗粒度 FFI JSON 接口
|
||||
|
||||
+2
-2
@@ -20,9 +20,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`。
|
||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
|
||||
7. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner 和 progress log,需要机器输出时使用 `--json --no-progress`。
|
||||
7. `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-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner 和 progress log,需要机器输出时使用 `--json --no-progress`。
|
||||
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;该目录包含 `bat.sock`、`bat.pid`、`bat-status.json` 和 `bat-daemon.log`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录。
|
||||
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;该目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--error-retry 60s
|
||||
```
|
||||
|
||||
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json` 和 `bat-daemon.log` 作为快照和故障排查文件:
|
||||
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`;前三者用于快照和故障排查,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh` 等控制命令:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -92,7 +92,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
cargo run -p bat-infrastructure --bin bat -- stop
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
|
||||
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
||||
|
||||
@@ -105,7 +105,7 @@ cargo run -p bat-infrastructure --bin bat -- doctor
|
||||
cargo run -p bat-infrastructure --bin bat -- clean-stable
|
||||
```
|
||||
|
||||
`verify` 会以只读方式检查当前官方计划、本地 download manifest 的 size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;发现缺失、远端变化或本地损坏会返回非 0。`repair` 会在异常资源存在时复用当前同步链路重新下载必要文件。`clean-stable` 只清理 `.part`、临时状态文件和失效锁/PID,不删除正式资源。
|
||||
`verify` 会以只读方式检查当前官方计划、本地 download manifest 的 size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;发现缺失、远端变化或本地损坏会返回非 0。`repair` 会在异常资源存在时复用当前同步链路重新下载必要文件。`clean-stable` 只清理 `.part`、临时状态文件、失效或损坏的 PID/锁/socket,不删除正式资源。
|
||||
|
||||
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载第 N/总数个 URL 等;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`。
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ official-sync-snapshot.json + official-download-manifest.json
|
||||
- `--watch` / `--daemon` 常驻检查,正常检查默认 1 小时,失败重试默认 60 秒,每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制刷新一次。
|
||||
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash;`repair` 尝试修复异常资源。
|
||||
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control plane;PID、状态和日志文件是快照与 fallback。
|
||||
- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;`restart` 负责重启或替换启动参数;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效状态。
|
||||
- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
|
||||
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
||||
- 本地文件损坏时 repair。
|
||||
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
9. 远端变化、本地 audit 发现 repair_needed,或首次空目录运行时,下载并校验官方 URL。
|
||||
10. 下载成功后写回新的 snapshot。
|
||||
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
@@ -231,7 +231,7 @@ Linux 生产路径:
|
||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改
|
||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
|
||||
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
||||
|
||||
@@ -173,7 +173,7 @@ target/release/bat
|
||||
/opt/bluearchive-toolkit/bin/bat stop --state-dir /run/bluearchive-toolkit
|
||||
```
|
||||
|
||||
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json` 和 `bat-daemon.log`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
||||
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
||||
|
||||
生产维护时可以用以下单次命令:
|
||||
|
||||
@@ -186,7 +186,7 @@ target/release/bat
|
||||
/opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit
|
||||
```
|
||||
|
||||
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 等显式参数时,`refresh` 会作为一次性前台同步运行。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件;`clean-stable` 只清理 `.part`、`.tmp`、失效 PID、失效 socket 和失效锁,不删除正式资源。
|
||||
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
|
||||
|
||||
### systemd service 示例
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ target/release/bat \
|
||||
--watch
|
||||
```
|
||||
|
||||
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json` 和 `bat-daemon.log`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,其他文件是快照和故障排查用。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令,其他文件是快照和故障排查用。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||
|
||||
## 1. 当前流程
|
||||
|
||||
@@ -213,7 +213,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
cargo run -p bat-infrastructure --bin bat -- stop
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
|
||||
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`:
|
||||
|
||||
@@ -240,11 +240,11 @@ cargo run -p bat-infrastructure --bin bat -- clean-stable
|
||||
```
|
||||
|
||||
- `refresh` 执行一次同步检查;`refresh --force` 强制刷新并重新匹配当前官方资源。
|
||||
- 如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套资源目录、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程执行;否则作为一次性前台同步运行。
|
||||
- 如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套资源目录、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程执行;否则作为一次性前台同步运行。一次性前台 `run/watch/refresh/repair` 如果要写入 live daemon 正在管理的同一资源目录,会返回 locked 错误;需要先 `stop` 或改用默认 `refresh` 走 RPC。
|
||||
- `verify` 只读验证当前官方计划、本地 manifest size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;资源缺失、远端变化或本地损坏时返回非 0。
|
||||
- `repair` 在有异常资源时尝试重新下载并修复,成功后重新写 manifest 和 snapshot。
|
||||
- `doctor` 检查输出目录、后台状态目录、curl/unzip、后台 PID、RPC socket 和资源锁。
|
||||
- `clean-stable` 只能在后台未运行且 RPC socket 不可连接时清理 `.part`、`.tmp`、失效 PID、失效 socket 和失效锁,不删除正式资源文件。
|
||||
- `doctor` 检查输出目录、后台状态目录、curl/unzip、后台 PID、RPC socket、资源锁和 daemon 控制锁。
|
||||
- `clean-stable` 只能在后台未运行且 RPC socket 不可连接时清理 `.part`、`.tmp`、失效或损坏的 PID、socket、资源锁和 daemon 控制锁,不删除正式资源文件。
|
||||
|
||||
如需调整间隔:
|
||||
|
||||
@@ -258,7 +258,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
默认平台是 `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,包括自动发现、server-info、marker、catalog、audit、download 和 snapshot 阶段;命令结果默认以人类可读摘要写到 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 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录。
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const DAEMON_PID_FILE: &str = "bat.pid";
|
||||
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
||||
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
||||
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
||||
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
||||
const DAEMON_STATUS_VERSION: u32 = 1;
|
||||
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
||||
@@ -82,10 +83,15 @@ fn run() -> anyhow::Result<i32> {
|
||||
match options.command {
|
||||
CliCommand::Run => {
|
||||
if options.daemon {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_start(options)?;
|
||||
} else if options.watch {
|
||||
if !options.daemon_child {
|
||||
assert_no_live_daemon_output_conflict(&options, "watch")?;
|
||||
}
|
||||
run_watch(options)?;
|
||||
} else {
|
||||
assert_no_live_daemon_output_conflict(&options, "run")?;
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let report =
|
||||
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
|
||||
@@ -98,18 +104,22 @@ fn run() -> anyhow::Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Status => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
print_daemon_status(&options.state_dir, options.output_format)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Stop => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
stop_daemon(&options.state_dir, options.output_format)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Restart => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_restart(&options, "restart")?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Reload => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_restart(&options, "reload")?;
|
||||
Ok(0)
|
||||
}
|
||||
@@ -126,6 +136,7 @@ fn run() -> anyhow::Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Logs => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_logs_command(&options)?;
|
||||
Ok(0)
|
||||
}
|
||||
@@ -557,6 +568,224 @@ impl Drop for DaemonRpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DaemonControlLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl DaemonControlLock {
|
||||
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(state_dir)?;
|
||||
let path = daemon_control_lock_path(state_dir);
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..=1 {
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(pid.to_string().as_bytes())?;
|
||||
return Ok(Self { path, pid });
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理",
|
||||
path.display(),
|
||||
describe_pid_lock_owner(&path)?
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"获取后台控制锁失败 {}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonControlLock {
|
||||
fn drop(&mut self) {
|
||||
let expected = self.pid.to_string();
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PidLockState {
|
||||
Missing,
|
||||
Active(u32),
|
||||
StalePid(u32),
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(PidLockState::Missing);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(pid) = parse_pid_value(&contents) else {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
};
|
||||
if process_exists(pid) {
|
||||
Ok(PidLockState::Active(pid))
|
||||
} else {
|
||||
Ok(PidLockState::StalePid(pid))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
match classify_pid_lock_file(path)? {
|
||||
PidLockState::Missing => Ok(false),
|
||||
PidLockState::Active(_) => Ok(false),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(path)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_pid_lock_owner(path: &Path) -> anyhow::Result<String> {
|
||||
Ok(match classify_pid_lock_file(path)? {
|
||||
PidLockState::Missing => "锁文件已不存在".to_string(),
|
||||
PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"),
|
||||
PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"),
|
||||
PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_pid_value(contents: &str) -> Option<u32> {
|
||||
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DaemonResourceConflict {
|
||||
pid: Option<u32>,
|
||||
daemon_root: Option<PathBuf>,
|
||||
target_root: PathBuf,
|
||||
}
|
||||
|
||||
fn assert_no_live_daemon_output_conflict(
|
||||
options: &CliOptions,
|
||||
command_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if options.daemon || options.daemon_child || options.config.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(conflict) =
|
||||
live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let daemon_root = conflict
|
||||
.daemon_root
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
let pid = conflict
|
||||
.pid
|
||||
.map(|pid| pid.to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
Err(anyhow::anyhow!(
|
||||
"后台同步进程正在管理同一资源目录 (locked):command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源",
|
||||
daemon_root,
|
||||
conflict.target_root.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn live_daemon_resource_conflict(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
) -> anyhow::Result<Option<DaemonResourceConflict>> {
|
||||
let pid_path = daemon_pid_path(state_dir);
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let pid_from_file = read_pid_file(&pid_path)?;
|
||||
let rpc_available = daemon_rpc_available(state_dir);
|
||||
let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false);
|
||||
let status_file = match read_daemon_status_file(&status_path) {
|
||||
Ok(status_file) => status_file,
|
||||
Err(error) if pid_file_live || rpc_available => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台同步进程可能仍在运行,但状态文件不可解析 (locked):{}:{error};请先执行 status/stop 或 clean-stable 恢复状态目录",
|
||||
status_path.display()
|
||||
));
|
||||
}
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let status_pid = status_file.as_ref().map(|status| status.pid);
|
||||
let pid = pid_from_file.or(status_pid);
|
||||
let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available;
|
||||
if !live {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target_root = normalized_abs_path(output_root)?;
|
||||
let Some(daemon_root) = status_file
|
||||
.as_ref()
|
||||
.map(|status| status.resource_output_root.clone())
|
||||
else {
|
||||
return Ok(Some(DaemonResourceConflict {
|
||||
pid,
|
||||
daemon_root: None,
|
||||
target_root,
|
||||
}));
|
||||
};
|
||||
|
||||
let normalized_daemon_root = normalized_abs_path(&daemon_root)?;
|
||||
if normalized_daemon_root == target_root {
|
||||
Ok(Some(DaemonResourceConflict {
|
||||
pid,
|
||||
daemon_root: Some(normalized_daemon_root),
|
||||
target_root,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_abs_path(path: &Path) -> anyhow::Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
env::current_dir()?.join(path)
|
||||
};
|
||||
Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute)))
|
||||
}
|
||||
|
||||
fn lexically_normalize_path(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
_ => normalized.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
if normalized.as_os_str().is_empty() {
|
||||
PathBuf::from(".")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn new_daemon_control() -> DaemonControl {
|
||||
Arc::new((Mutex::new(DaemonControlState::default()), Condvar::new()))
|
||||
}
|
||||
@@ -936,6 +1165,8 @@ struct DaemonStatusReport {
|
||||
status_path: PathBuf,
|
||||
socket_path: PathBuf,
|
||||
rpc_available: bool,
|
||||
stale_pid_file: bool,
|
||||
stale_socket: bool,
|
||||
log_path: Option<PathBuf>,
|
||||
started_unix_seconds: Option<u64>,
|
||||
updated_unix_seconds: Option<u64>,
|
||||
@@ -975,7 +1206,7 @@ fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result<DaemonStartReport> {
|
||||
let resource_output_root = options.config.output_root.clone();
|
||||
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
|
||||
let state_dir = options.state_dir.clone();
|
||||
let args = daemon_child_args(options);
|
||||
start_daemon_with_args(state_dir, resource_output_root, args, "后台同步已启动")
|
||||
@@ -993,14 +1224,17 @@ fn start_daemon_with_args(
|
||||
let log_path = daemon_log_path(&state_dir);
|
||||
let socket_path = daemon_socket_path(&state_dir);
|
||||
|
||||
if let Some(existing_pid) = read_pid_file(&pid_path)? {
|
||||
if process_exists(existing_pid) {
|
||||
match classify_pid_lock_file(&pid_path)? {
|
||||
PidLockState::Active(existing_pid) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"官方同步后台进程已经在运行,pid={existing_pid}"
|
||||
));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
PidLockState::Missing => {}
|
||||
}
|
||||
|
||||
let executable = env::current_exe()?;
|
||||
@@ -1118,25 +1352,41 @@ fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<
|
||||
|
||||
fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result<DaemonStopReport> {
|
||||
let pid_path = daemon_pid_path(state_dir);
|
||||
let Some(pid) = read_pid_file(&pid_path)? else {
|
||||
return Ok(DaemonStopReport {
|
||||
status: "not_running",
|
||||
message: "后台进程当前未运行",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
};
|
||||
|
||||
let running = process_exists(pid);
|
||||
if running {
|
||||
terminate_process(pid)?;
|
||||
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
|
||||
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
|
||||
let pid_state = classify_pid_lock_file(&pid_path)?;
|
||||
let (pid, running) = match pid_state {
|
||||
PidLockState::Missing => {
|
||||
return Ok(DaemonStopReport {
|
||||
status: "not_running",
|
||||
message: "后台进程当前未运行",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
PidLockState::Corrupt => {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(daemon_socket_path(state_dir));
|
||||
return Ok(DaemonStopReport {
|
||||
status: "stale_pid_removed",
|
||||
message: "已清理无效的后台 PID 文件",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
}
|
||||
PidLockState::StalePid(pid) => (pid, false),
|
||||
PidLockState::Active(pid) => {
|
||||
terminate_process(pid)?;
|
||||
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
|
||||
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
|
||||
}
|
||||
(pid, true)
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(daemon_socket_path(state_dir));
|
||||
|
||||
@@ -1164,14 +1414,26 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
let status_file = read_daemon_status_file(&status_path)?;
|
||||
let pid = read_pid_file(&pid_path)?.or_else(|| status_file.as_ref().map(|status| status.pid));
|
||||
let pid_file_state = classify_pid_lock_file(&pid_path)?;
|
||||
let pid_file_pid = match pid_file_state {
|
||||
PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid),
|
||||
PidLockState::Missing | PidLockState::Corrupt => None,
|
||||
};
|
||||
let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid));
|
||||
let running = pid.map(process_exists).unwrap_or(false);
|
||||
let rpc_available = daemon_rpc_available(state_dir);
|
||||
let stale_pid_file = matches!(
|
||||
pid_file_state,
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt
|
||||
);
|
||||
let stale_socket = socket_path.exists() && !rpc_available;
|
||||
|
||||
Ok(DaemonStatusReport {
|
||||
status: if running { "running" } else { "stopped" },
|
||||
message: if running {
|
||||
"后台进程正在运行"
|
||||
} else if stale_pid_file || stale_socket {
|
||||
"后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理"
|
||||
} else {
|
||||
"后台进程当前未运行"
|
||||
},
|
||||
@@ -1185,6 +1447,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
status_path,
|
||||
socket_path,
|
||||
rpc_available,
|
||||
stale_pid_file,
|
||||
stale_socket,
|
||||
log_path: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.log_path.clone())
|
||||
@@ -1266,7 +1530,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
||||
start_options.watch = false;
|
||||
(
|
||||
options.state_dir.clone(),
|
||||
options.config.output_root.clone(),
|
||||
normalized_abs_path(&options.config.output_root)?,
|
||||
daemon_child_args(&start_options),
|
||||
"start_with_explicit_options",
|
||||
)
|
||||
@@ -1340,6 +1604,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
if refresh_should_use_daemon_rpc(options, command_name)
|
||||
&& daemon_rpc_available(&options.state_dir)
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(
|
||||
&options.state_dir,
|
||||
RPC_METHOD_REFRESH,
|
||||
@@ -1349,6 +1614,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
assert_no_live_daemon_output_conflict(options, command_name)?;
|
||||
let mut config = options.config.clone();
|
||||
if command_name == "repair" {
|
||||
config.repair = true;
|
||||
@@ -1430,6 +1696,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||
print_json_field(value, "pid", "PID");
|
||||
print_json_field(value, "daemon_state", "后台状态");
|
||||
print_json_field(value, "rpc_available", "RPC 可用");
|
||||
print_json_field(value, "stale_pid_file", "失效 PID");
|
||||
print_json_field(value, "stale_socket", "失效 socket");
|
||||
print_json_field(value, "last_update_status", "上次同步");
|
||||
print_json_field(value, "last_error", "上次错误");
|
||||
print_json_field(value, "next_retry_seconds", "下次重试秒数");
|
||||
@@ -1658,6 +1926,8 @@ impl HumanReport for DaemonStatusReport {
|
||||
print_optional_field("PID", self.pid);
|
||||
print_optional_field("后台状态", self.daemon_state.as_deref());
|
||||
print_field("RPC 可用", format_bool(self.rpc_available));
|
||||
print_field("失效 PID", format_bool(self.stale_pid_file));
|
||||
print_field("失效 socket", format_bool(self.stale_socket));
|
||||
print_optional_field("上次同步", self.last_update_status.as_deref());
|
||||
print_optional_field("上次错误", self.last_error.as_deref());
|
||||
print_optional_field("下次重试秒数", self.next_retry_seconds);
|
||||
@@ -2051,27 +2321,70 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
});
|
||||
|
||||
let lock_path = options.config.lock_path();
|
||||
if lock_path.exists() {
|
||||
let lock_owner = fs::read_to_string(&lock_path)
|
||||
.ok()
|
||||
.and_then(|contents| contents.trim().parse::<u32>().ok());
|
||||
let active = lock_owner.map(process_exists).unwrap_or(true);
|
||||
checks.push(DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: !active,
|
||||
message: if active {
|
||||
format!("资源目录锁正在使用:{}", lock_path.display())
|
||||
} else {
|
||||
format!("发现失效资源锁:{}", lock_path.display())
|
||||
},
|
||||
});
|
||||
} else {
|
||||
checks.push(DoctorCheck {
|
||||
checks.push(match classify_pid_lock_file(&lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: true,
|
||||
message: "资源目录没有活动锁".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
PidLockState::Active(pid) => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"资源目录锁正在使用:{},owner_pid={pid}",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::StalePid(pid) => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现失效资源锁:{},owner_pid={pid};可执行 clean-stable 清理",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::Corrupt => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现损坏资源锁:{};可执行 clean-stable 清理",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
let control_lock_path = daemon_control_lock_path(&options.state_dir);
|
||||
checks.push(match classify_pid_lock_file(&control_lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: true,
|
||||
message: "后台控制锁没有活动锁".to_string(),
|
||||
},
|
||||
PidLockState::Active(pid) => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: true,
|
||||
message: format!(
|
||||
"后台控制命令正在运行:{},owner_pid={pid}",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::StalePid(pid) => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现失效后台控制锁:{},owner_pid={pid};可执行 clean-stable 清理",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::Corrupt => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现损坏后台控制锁:{};可执行 clean-stable 清理",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
let status_path = daemon_status_path(&options.state_dir);
|
||||
checks.push(DoctorCheck {
|
||||
@@ -2167,8 +2480,10 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let pid_path = daemon_pid_path(&options.state_dir);
|
||||
if let Some(pid) = read_pid_file(&pid_path)? {
|
||||
if !process_exists(pid) {
|
||||
match classify_pid_lock_file(&pid_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(_) => skipped_paths.push(pid_path),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&pid_path)?;
|
||||
removed_paths.push(pid_path);
|
||||
}
|
||||
@@ -2185,15 +2500,27 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let lock_path = options.config.lock_path();
|
||||
if lock_path.exists() {
|
||||
let owner = fs::read_to_string(&lock_path)
|
||||
.ok()
|
||||
.and_then(|contents| contents.trim().parse::<u32>().ok());
|
||||
if owner.is_some_and(|pid| !process_exists(pid)) {
|
||||
match classify_pid_lock_file(&lock_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(_) => skipped_paths.push(lock_path),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&lock_path)?;
|
||||
removed_paths.push(lock_path);
|
||||
} else {
|
||||
skipped_paths.push(lock_path);
|
||||
}
|
||||
}
|
||||
|
||||
let control_lock_path = daemon_control_lock_path(&options.state_dir);
|
||||
match classify_pid_lock_file(&control_lock_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(pid) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台控制命令已被锁定 (locked):{};owner_pid={pid} 仍在运行",
|
||||
control_lock_path.display()
|
||||
));
|
||||
}
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&control_lock_path)?;
|
||||
removed_paths.push(control_lock_path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2336,6 +2663,10 @@ fn daemon_socket_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_SOCKET_FILE)
|
||||
}
|
||||
|
||||
fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
||||
}
|
||||
|
||||
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let config = &options.config;
|
||||
@@ -3505,6 +3836,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_control_lock_rejects_active_owner() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
|
||||
let first = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
let second = DaemonControlLock::acquire(&state_dir).unwrap_err();
|
||||
assert!(second.to_string().contains("locked"));
|
||||
drop(first);
|
||||
assert!(DaemonControlLock::acquire(&state_dir).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_control_lock_recovers_stale_and_corrupt_files() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let lock_path = daemon_control_lock_path(&state_dir);
|
||||
|
||||
fs::write(&lock_path, "999999999").unwrap();
|
||||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
drop(lock);
|
||||
assert!(!lock_path.exists());
|
||||
|
||||
fs::write(&lock_path, "not-a-pid").unwrap();
|
||||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&lock_path).unwrap(),
|
||||
std::process::id().to_string()
|
||||
);
|
||||
drop(lock);
|
||||
assert!(!lock_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_daemon_resource_conflict_detects_same_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &output_root),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
|
||||
let conflict = live_daemon_resource_conflict(&state_dir, &output_root)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(conflict.pid, Some(std::process::id()));
|
||||
assert_eq!(
|
||||
conflict.target_root,
|
||||
normalized_abs_path(&output_root).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_daemon_resource_conflict_allows_different_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let daemon_output = temp.path().join("daemon-resources");
|
||||
let manual_output = temp.path().join("manual-resources");
|
||||
fs::create_dir_all(&daemon_output).unwrap();
|
||||
fs::create_dir_all(&manual_output).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &daemon_output),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
|
||||
assert!(live_daemon_resource_conflict(&state_dir, &manual_output)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_write_rejects_live_daemon_for_same_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &output_root),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
let options = CliOptions {
|
||||
state_dir,
|
||||
config: OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
},
|
||||
..CliOptions::default()
|
||||
};
|
||||
|
||||
let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err();
|
||||
assert!(error.to_string().contains("同一资源目录"));
|
||||
assert!(error.to_string().contains("locked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_stable_removes_corrupt_pid_and_lock_files() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output_root = temp.path().join("resources");
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let resource_lock = output_root.join(".official-sync.lock");
|
||||
let control_lock = daemon_control_lock_path(&state_dir);
|
||||
let pid_path = daemon_pid_path(&state_dir);
|
||||
fs::write(&resource_lock, "not-a-pid").unwrap();
|
||||
fs::write(&control_lock, "not-a-pid").unwrap();
|
||||
fs::write(&pid_path, "not-a-pid").unwrap();
|
||||
|
||||
let options = CliOptions {
|
||||
command: CliCommand::CleanStable,
|
||||
output_format: OutputFormat::Json,
|
||||
state_dir,
|
||||
config: OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
},
|
||||
..CliOptions::default()
|
||||
};
|
||||
run_clean_stable_command(&options).unwrap();
|
||||
|
||||
assert!(!resource_lock.exists());
|
||||
assert!(!control_lock.exists());
|
||||
assert!(!pid_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_stop_wait_reports_already_stopped_without_signal() {
|
||||
assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap());
|
||||
@@ -3646,4 +4111,23 @@ mod tests {
|
||||
.saturating_add(second),
|
||||
)
|
||||
}
|
||||
|
||||
fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile {
|
||||
DaemonStatusFile {
|
||||
version: DAEMON_STATUS_VERSION,
|
||||
pid: std::process::id(),
|
||||
state: "sleeping".to_string(),
|
||||
resource_output_root: output_root.to_path_buf(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
log_path: daemon_log_path(state_dir),
|
||||
started_unix_seconds: unix_seconds_now(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
last_update_status: Some("up_to_date".to_string()),
|
||||
last_error: None,
|
||||
next_retry_seconds: Some(60),
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_unix_seconds: None,
|
||||
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1459,8 +1459,9 @@ impl OfficialUpdateLock {
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"官方资源目录已被锁定 (locked):{}",
|
||||
path.display()
|
||||
"官方资源目录已被锁定 (locked):{};{}",
|
||||
path.display(),
|
||||
describe_lock_owner(&path)
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -1481,14 +1482,21 @@ impl OfficialUpdateLock {
|
||||
|
||||
impl Drop for OfficialUpdateLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
let expected = std::process::id().to_string();
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
let Some(pid) = parse_lock_pid(&contents) else {
|
||||
return Ok(false);
|
||||
fs::remove_file(path)?;
|
||||
return Ok(true);
|
||||
};
|
||||
if process_exists(pid) {
|
||||
return Ok(false);
|
||||
@@ -1498,6 +1506,15 @@ fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn describe_lock_owner(path: &Path) -> String {
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
match parse_lock_pid(&contents) {
|
||||
Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"),
|
||||
Some(pid) => format!("owner_pid={pid} 已失效,可重新执行或 clean-stable 清理"),
|
||||
None => "锁文件内容不是有效 PID,可执行 clean-stable 清理".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_lock_pid(contents: &str) -> Option<u32> {
|
||||
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
|
||||
}
|
||||
@@ -1716,6 +1733,25 @@ mod tests {
|
||||
assert!(!config.lock_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dry_run_lock_removes_corrupt_pid_file() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: temp.path().to_path_buf(),
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
fs::create_dir_all(&config.output_root).unwrap();
|
||||
fs::write(config.lock_path(), "not-a-pid").unwrap();
|
||||
|
||||
let lock = OfficialUpdateLock::acquire(&config).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(config.lock_path()).unwrap(),
|
||||
std::process::id().to_string()
|
||||
);
|
||||
drop(lock);
|
||||
assert!(!config.lock_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_run_can_be_cancelled_before_network_work() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user