From 599ead8391b560228548809e8deb15ba25c727d4 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 13 Jul 2026 22:02:11 +0800 Subject: [PATCH] fix: serialize daemon control commands Fixes #10 --- CHANGELOG.md | 1 + CURRENT_STATUS.md | 4 +- README.md | 6 +- docs/architecture/README.md | 2 +- .../architecture/official-resource-backend.md | 4 +- docs/guides/deployment.md | 4 +- docs/guides/official-resource-test-pull.md | 12 +- infrastructure/src/bin/bat_official_sync.rs | 588 ++++++++++++++++-- infrastructure/src/official_update.rs | 44 +- 9 files changed, 593 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe771f..cfaa359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 接口 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index a85b503..9648013 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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 尚未记录在仓库文档中。 diff --git a/README.md b/README.md index 2200d3a..148da53 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 56106d6..4aa0f80 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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。 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index b43a2f0..0936883 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -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 猜测 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index ac73fcd..88b0bed 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -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 示例 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index ff1c9a7..83de1e8 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -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`,但生产同步默认应保持开启。 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index e5e8d96..bbb7885 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -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 { 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 { 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 { 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 { + 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 { + 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 { + 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 { + 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 { + contents.trim().parse::().ok().filter(|pid| *pid > 0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DaemonResourceConflict { + pid: Option, + daemon_root: Option, + 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> { + 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 { + 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, started_unix_seconds: Option, updated_unix_seconds: Option, @@ -975,7 +1206,7 @@ fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { } fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result { - 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 { 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 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 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 { }); 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::().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::().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 { 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()], + } + } } diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 16877e2..ee542a6 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -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 { 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 { 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 { contents.trim().parse::().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();