diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 4e9eb96..ddf47cb 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -20,7 +20,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。 6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。 -7. `bat-official-sync --watch` 可常驻运行,默认每 1 小时检查一次;远端和本地一致时默认静默。 +7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试。 8. 非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一状态目录。 仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。 @@ -241,7 +241,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \ 2. 不要指向已有游戏客户端目录。 3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。 4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。 -5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部。 +5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部;正常检查默认 1 小时,失败重试默认 60 秒。 详细运行说明见 `docs/guides/official-resource-test-pull.md`。 diff --git a/README.md b/README.md index 82e4ec9..cf5506c 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,15 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \ --dry-run ``` -常驻自动更新,默认每 1 小时检查一次;远端和本地一致时静默: +常驻自动更新,正常情况下默认每 1 小时检查一次;远端和本地一致时静默。下载或发现失败时默认 60 秒后重试,可用 `--error-retry 60s` 调整: ```bash cargo run -p bat-infrastructure --bin bat-official-sync -- \ --auto-discover \ --platforms Windows,Android \ --output /tmp/ba-official-update \ - --watch + --watch \ + --error-retry 60s ``` 生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。 diff --git a/adapters/src/official/inventory.rs b/adapters/src/official/inventory.rs index 04080ef..3ae2db8 100644 --- a/adapters/src/official/inventory.rs +++ b/adapters/src/official/inventory.rs @@ -1,7 +1,7 @@ //! Official JP download inventory extraction. use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot}; -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; /// Download inventory extracted from the official JP catalog bytes. #[derive(Debug, Clone, PartialEq, Eq)] @@ -79,7 +79,7 @@ impl YostarJpPlatformDownloadInventory { platform_catalogs: Vec, ) -> Self { Self { - table_file_names: extract_file_names(table_catalog, &["db", "zip"]), + table_file_names: extract_table_file_names(table_catalog), platform_catalogs: merge_platform_catalogs(platform_catalogs), } } @@ -171,7 +171,7 @@ impl YostarJpDownloadInventory { ) -> Self { Self { bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), - table_file_names: extract_file_names(table_catalog, &["db", "zip"]), + table_file_names: extract_table_file_names(table_catalog), media_file_names: extract_file_names( media_catalog, &["zip", "mp4", "png", "ogg", "wav"], @@ -297,6 +297,24 @@ fn extract_full_patch_pack_names(data: &[u8]) -> Vec { .collect() } +fn extract_table_file_names(data: &[u8]) -> Vec { + let mut counts = BTreeMap::::new(); + + // Real TableCatalog bytes list top-level downloadable table files twice, + // while package-internal stage/resource .zip names normally appear once + // next to rawdata paths and are not direct TableBundles URLs. + for string in extract_printable_strings(data, 4) { + for name in candidate_file_names(&string, &["db", "zip"]) { + *counts.entry(name).or_default() += 1; + } + } + + counts + .into_iter() + .filter_map(|(name, count)| (count >= 2).then_some(name)) + .collect() +} + fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec { let mut names = BTreeSet::new(); @@ -422,7 +440,7 @@ mod tests { fn extracts_download_names_from_synthetic_bytes() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix", - b"GameData\\Table\\ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip8", + b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8", b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip", ); @@ -443,11 +461,27 @@ mod tests { ); } + #[test] + fn extracts_only_top_level_table_files_from_table_catalog() { + let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( + b"ExcelDB.db\0ExcelDB.db\0TablePatchPack_Prologue_GroundStage_1.zip\0TablePatchPack_Prologue_GroundStage_1.zip|1011101_01_s1_01_mainstreet_p01_d.zip\0rawdata/ground/stage/bytes/1011101_01_s1_01_mainstreet_p01_d.bytes", + Vec::new(), + ); + + assert_eq!( + inventory.table_file_names, + vec![ + "ExcelDB.db".to_string(), + "TablePatchPack_Prologue_GroundStage_1.zip".to_string() + ] + ); + } + #[test] fn builds_direct_download_urls() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip FullPatch_001.zip", - b"ExcelDB.db Battle.zip", + b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip", b"JP_Airi.zip JP_Akane.zip", ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); @@ -469,7 +503,7 @@ mod tests { fn builds_direct_download_urls_for_verified_platforms() { let inventory = YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip", - b"ExcelDB.db", + b"ExcelDB.db ExcelDB.db", b"JP_Airi.zip", ); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); @@ -499,7 +533,7 @@ mod tests { #[test] fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() { let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( - b"ExcelDB.db", + b"ExcelDB.db ExcelDB.db", vec![ YostarJpPlatformCatalogInventory::from_catalog_bytes( PatchPlatform::Windows, @@ -556,7 +590,7 @@ mod tests { ); assert_eq!(inventory.bundle_patch_pack_names.len(), 142); - assert_eq!(inventory.table_file_names.len(), 6351); + assert!(inventory.table_file_names.len() < 1000); assert_eq!(inventory.media_file_names.len(), 1887); assert!(inventory .bundle_patch_pack_names diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ca5195e..fff1979 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -116,7 +116,7 @@ official-sync-snapshot.json + official-download-manifest.json - 不安装、不执行官方 launcher。 - 默认平台 `Windows + Android`。 - `--auto-discover` 自动获取 `app-version`、`connection-group` 和 `server-info`。 -- `--watch` 常驻检查,默认 1 小时。 +- `--watch` 常驻检查,正常检查默认 1 小时,失败重试默认 60 秒。 - 远端 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 d59ec94..840ad83 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -190,7 +190,7 @@ 7. 远端变化或本地 audit 发现 repair_needed 时生成 pull plan,下载并校验官方 URL。 8. 下载成功后写回新的 snapshot。 -该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary 支持 `--watch` 常驻模式,默认每 1 小时执行一次检查;远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 +该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary 支持 `--watch` 常驻模式,正常情况下默认每 1 小时执行一次检查;远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 对应实现主要在: @@ -228,7 +228,7 @@ Linux 生产路径: - pull plan 会同时包含 discovery URLs 和 content URLs - 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 -- `bat-official-sync` 输出稳定 JSON report,支持 `--watch --interval 1h` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录 +- `bat-official-sync` 输出稳定 JSON report,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录 - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 - 下载层能在本地文件 size/BLAKE3/path 或 manifest 不匹配时重新下载 - 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 2ab091c..ccd53ea 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -157,7 +157,7 @@ target/release/bat-official-sync --watch ``` -`--watch` 是 Rust 内部持久检查模式,默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。 +`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s` 或 `--error-retry-seconds 60` 调整。 ### systemd service 示例 @@ -173,7 +173,7 @@ Wants=network-online.target Type=simple User=bat Group=bat -ExecStart=/opt/bluearchive-toolkit/bin/bat-official-sync --auto-discover --platforms Windows,Android --output /var/lib/bluearchive-toolkit/official --watch +ExecStart=/opt/bluearchive-toolkit/bin/bat-official-sync --auto-discover --platforms Windows,Android --output /var/lib/bluearchive-toolkit/official --watch --error-retry 60s Restart=on-failure RestartSec=30 StateDirectory=bluearchive-toolkit diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 639ae4c..1fdce90 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -225,10 +225,11 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \ --platforms Windows,Android \ --output /tmp/ba-official-update \ --watch \ - --interval 30m + --interval 30m \ + --error-retry 60s ``` -命令成功时 stdout 输出稳定 JSON report;错误时 stderr 输出 JSON error。普通错误 exit `1`,状态目录锁冲突 exit `75`。 +`--interval` 是正常检查周期,默认 `1h`;`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。命令成功时 stdout 输出稳定 JSON report;错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期。普通错误 exit `1`,状态目录锁冲突 exit `75`。 生产可以直接运行 `--watch`,也可以用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一状态目录。 diff --git a/docs/reports/current-status-handoff.md b/docs/reports/current-status-handoff.md index fe00aae..ccf7463 100644 --- a/docs/reports/current-status-handoff.md +++ b/docs/reports/current-status-handoff.md @@ -18,7 +18,7 @@ - `--auto-discover` 通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`。 - 默认平台是 `Windows + Android`。 - 真实下载会维护 `official-download-manifest.json`,并使用 size + BLAKE3、本地 audit/repair 和官方 seed `.hash` 校验。 -- `--watch` 是 Rust 内部常驻检查模式,默认 1 小时;外部 systemd/container 只负责守护进程。 +- `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。 ## 2. 现在不要误解的点 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 50157e4..9921ec4 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -11,6 +11,7 @@ use std::time::Duration; const EXIT_ERROR: i32 = 1; const EXIT_LOCKED: i32 = 75; const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60; +const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60; fn main() { match run() { @@ -64,6 +65,7 @@ struct CliOptions { config: OfficialUpdateConfig, watch: bool, interval: Duration, + error_retry_interval: Duration, quiet_up_to_date: bool, quiet_up_to_date_explicit: bool, } @@ -74,6 +76,7 @@ impl Default for CliOptions { config: OfficialUpdateConfig::default(), watch: false, interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS), + error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS), quiet_up_to_date: false, quiet_up_to_date_explicit: false, } @@ -87,8 +90,14 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { if options.interval.is_zero() { return Err(anyhow::anyhow!("watch interval must be greater than zero")); } + if options.error_retry_interval.is_zero() { + return Err(anyhow::anyhow!( + "watch error retry interval must be greater than zero" + )); + } let service = OfficialUpdateService::new(); loop { + let mut sleep_for = options.interval; match service.run(&options.config) { Ok(report) => { if should_print_status(report.update_status, options.quiet_up_to_date) { @@ -96,17 +105,18 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { } } Err(error) => { + sleep_for = options.error_retry_interval; let payload = ErrorReport { status: "error", exit_code: EXIT_ERROR, error: error.to_string(), - next_retry_seconds: Some(options.interval.as_secs()), + next_retry_seconds: Some(sleep_for.as_secs()), }; eprintln!("{}", serde_json::to_string_pretty(&payload)?); } } - thread::sleep(options.interval); + thread::sleep(sleep_for); } } @@ -205,6 +215,16 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; options.interval = Duration::from_secs(seconds); } + "--error-retry" => { + options.error_retry_interval = + parse_duration(&next_option_value(&mut args, &flag)?)?; + } + "--error-retry-seconds" => { + let seconds = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; + options.error_retry_interval = Duration::from_secs(seconds); + } "--quiet-up-to-date" => { options.quiet_up_to_date = true; options.quiet_up_to_date_explicit = true; @@ -227,6 +247,11 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result if options.interval.is_zero() { return Err(anyhow::anyhow!("watch interval must be greater than zero")); } + if options.error_retry_interval.is_zero() { + return Err(anyhow::anyhow!( + "watch error retry interval must be greater than zero" + )); + } if options.watch && !options.quiet_up_to_date_explicit { options.quiet_up_to_date = true; } @@ -248,7 +273,7 @@ fn print_usage(binary: &str) { [--app-version 1.70.0] [--connection-group ] [--launcher-version 1.7.2] \ [--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \ [--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \ - [--watch] [--interval 1h|60m|3600s] [--quiet-up-to-date|--no-quiet-up-to-date]" + [--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date]" ); } @@ -337,6 +362,10 @@ mod tests { options.interval, Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS) ); + assert_eq!( + options.error_retry_interval, + Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) + ); assert!(!options.quiet_up_to_date); assert!(!options.quiet_up_to_date_explicit); } @@ -385,10 +414,38 @@ mod tests { assert!(options.watch); assert_eq!(options.interval, Duration::from_secs(30 * 60)); + assert_eq!( + options.error_retry_interval, + Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) + ); assert!(options.quiet_up_to_date); assert!(!options.quiet_up_to_date_explicit); } + #[test] + fn parses_explicit_watch_error_retry_interval() { + let options = parse(&[ + "bat-official-sync", + "--watch", + "--interval", + "1h", + "--error-retry", + "45s", + ]) + .unwrap(); + assert_eq!(options.interval, Duration::from_secs(3600)); + assert_eq!(options.error_retry_interval, Duration::from_secs(45)); + + let options = parse(&[ + "bat-official-sync", + "--watch", + "--error-retry-seconds", + "30", + ]) + .unwrap(); + assert_eq!(options.error_retry_interval, Duration::from_secs(30)); + } + #[test] fn explicit_no_quiet_up_to_date_overrides_watch_default() { let options = parse(&[ @@ -413,6 +470,10 @@ mod tests { let error = parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err(); assert!(error.to_string().contains("greater than zero")); + + let error = + parse(&["bat-official-sync", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); + assert!(error.to_string().contains("error retry interval")); } #[test] diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index bee1ef1..40eba45 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -329,6 +329,21 @@ impl OfficialResourcePullService { return Err(format!("Refusing to fetch non-official URL: {url}")); } + let attempts = self.retry_attempts.max(1); + let mut last_error = None; + for attempt in 1..=attempts { + match self.fetch_bytes_once(url) { + Ok(bytes) => return Ok(bytes), + Err(error) => { + last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); + } + } + } + + Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) + } + + fn fetch_bytes_once(&self, url: &str) -> Result, String> { let output = Command::new(&self.curl_command) .arg("--fail") .arg("--location") @@ -1078,7 +1093,7 @@ mod tests { fn inventory() -> YostarJpDownloadInventory { YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip", - b"ExcelDB.db", + b"ExcelDB.db ExcelDB.db", b"JP_Airi.zip", ) } @@ -1680,6 +1695,31 @@ fi ); } + #[test] + fn retries_transient_fetch_bytes_failures() { + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_flaky_curl(&curl_path); + + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path) + .with_retry_attempts(2); + let bytes = service + .fetch_bytes( + "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes", + ) + .unwrap(); + + assert_eq!( + String::from_utf8(bytes).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes" + ); + assert_eq!( + fs::read_to_string(curl_path.with_extension("state")).unwrap(), + "2" + ); + } + #[test] fn rejects_non_official_urls() { let service = OfficialResourcePullService::new("/tmp/unused"); diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index 47e50c0..7a2287e 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; +const DEFAULT_BOOTSTRAP_DOWNLOAD_RETRY_ATTEMPTS: usize = 3; + /// Combined official bootstrap data for the latest client package. #[derive(Debug, Clone)] pub struct OfficialGameMainConfigBootstrap { @@ -113,6 +115,21 @@ impl OfficialGameMainConfigBootstrapService { } fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> { + let attempts = DEFAULT_BOOTSTRAP_DOWNLOAD_RETRY_ATTEMPTS; + let mut last_error = None; + for attempt in 1..=attempts { + match self.download_file_once(url, destination) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); + } + } + } + + Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) + } + + fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> { let output = Command::new(&self.curl_command) .arg("--fail") .arg("--location") diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs index 47ac76f..0ab20c8 100644 --- a/infrastructure/src/official_launcher.rs +++ b/infrastructure/src/official_launcher.rs @@ -8,7 +8,7 @@ use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG}; use md5::{Digest, Md5}; use serde::Deserialize; -use std::process::Command; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; /// Official JP launcher API root. @@ -23,6 +23,8 @@ pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-sta /// Official backup JP PC launcher package CDN host. pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com"; +const DEFAULT_LAUNCHER_RETRY_ATTEMPTS: usize = 3; + /// Latest official PC client metadata returned by `/api/launcher/game/config`. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct YostarJpLauncherGameConfig { @@ -77,6 +79,7 @@ pub struct YostarJpLauncherRemoteManifest { pub struct OfficialLauncherBootstrapService { curl_command: String, launcher_version: String, + retry_attempts: usize, } impl OfficialLauncherBootstrapService { @@ -93,9 +96,16 @@ impl OfficialLauncherBootstrapService { Self { curl_command: curl_command.into(), launcher_version: launcher_version.into(), + retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS, } } + /// Sets the number of attempts for each launcher `curl` transfer. + pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self { + self.retry_attempts = retry_attempts.max(1); + self + } + /// Fetches latest official PC game config. pub fn fetch_game_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; @@ -161,28 +171,26 @@ impl OfficialLauncherBootstrapService { )); } - let output = Command::new(&self.curl_command) - .arg("--fail") - .arg("--location") - .arg("--silent") - .arg("--show-error") - .arg("--url") - .arg(manifest_url) - .output() - .map_err(|error| { + let output = self.run_curl_with_retry( + || { + let mut command = Command::new(&self.curl_command); + command + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--url") + .arg(manifest_url); + command + }, + |output| { + let stderr = String::from_utf8_lossy(&output.stderr); format!( - "Failed to launch curl command {}: {error}", - self.curl_command + "curl failed for launcher manifest {manifest_url}: {}", + stderr.trim() ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "curl failed for launcher manifest {manifest_url}: {}", - stderr.trim() - )); - } + }, + )?; let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) .map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?; @@ -220,29 +228,27 @@ impl OfficialLauncherBootstrapService { let url = launcher_api_url(path)?; let authorization = launcher_authorization_header(&self.launcher_version, "", None)?; - let output = Command::new(&self.curl_command) - .arg("--fail") - .arg("--location") - .arg("--silent") - .arg("--show-error") - .arg("--header") - .arg("Content-Type: application/json;charset=UTF-8") - .arg("--header") - .arg(format!("Authorization: {authorization}")) - .arg("--url") - .arg(&url) - .output() - .map_err(|error| { - format!( - "Failed to launch curl command {}: {error}", - self.curl_command - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("curl failed for {url}: {}", stderr.trim())); - } + let output = self.run_curl_with_retry( + || { + let mut command = Command::new(&self.curl_command); + command + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--header") + .arg("Content-Type: application/json;charset=UTF-8") + .arg("--header") + .arg(format!("Authorization: {authorization}")) + .arg("--url") + .arg(&url); + command + }, + |output| { + let stderr = String::from_utf8_lossy(&output.stderr); + format!("curl failed for {url}: {}", stderr.trim()) + }, + )?; let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout) .map_err(|error| format!("Failed to parse official launcher API response: {error}"))?; @@ -261,6 +267,34 @@ impl OfficialLauncherBootstrapService { Ok(envelope) } + + fn run_curl_with_retry( + &self, + mut build_command: impl FnMut() -> Command, + failure_message: impl Fn(&Output) -> String, + ) -> Result { + let attempts = self.retry_attempts.max(1); + let mut last_error = None; + for attempt in 1..=attempts { + match build_command().output() { + Ok(output) if output.status.success() => return Ok(output), + Ok(output) => { + last_error = Some(format!( + "attempt {attempt}/{attempts}: {}", + failure_message(&output) + )); + } + Err(error) => { + last_error = Some(format!( + "attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}", + self.curl_command + )); + } + } + } + + Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command))) + } } /// Generic official launcher API response envelope. @@ -394,6 +428,58 @@ pub fn launcher_authorization_header( #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn write_shell_script(path: &Path, script: &str) { + fs::write(path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } + + fn fake_flaky_launcher_curl_script() -> &'static str { + r#"#!/bin/sh +set -eu +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --url) + url="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +state="$0.state" +count=0 +if [ -f "$state" ]; then + count="$(cat "$state")" +fi +count=$((count + 1)) +printf '%s' "$count" > "$state" +if [ "$count" -eq 1 ]; then + printf '%s\n' "temporary launcher failure" >&2 + exit 35 +fi +case "$url" in + */api/launcher/game/config) + printf '%s' '{"code":200,"data":{"game_latest_version":"1.70.436321","game_latest_file_path":"prod/manifest/BlueArchive_JP_TEMP","game_lowest_version":"1.69.0","game_start_params":[]}}' + ;; + *) + printf '%s\n' "unexpected url: $url" >&2 + exit 22 + ;; +esac +"# + } #[test] fn builds_official_launcher_api_url() { @@ -510,4 +596,25 @@ mod tests { assert_eq!(manifest.files.len(), 1); assert_eq!(manifest.files[0].path, "/BlueArchive.exe"); } + + #[test] + fn retries_transient_launcher_api_failures() { + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_shell_script(&curl_path, fake_flaky_launcher_curl_script()); + + let service = OfficialLauncherBootstrapService::with_curl_command( + "1.7.2", + curl_path.to_string_lossy().to_string(), + ) + .with_retry_attempts(2); + + let config = service.fetch_game_config().unwrap(); + + assert_eq!(config.game_latest_version, "1.70.436321"); + assert_eq!( + fs::read_to_string(curl_path.with_extension("state")).unwrap(), + "2" + ); + } } diff --git a/infrastructure/src/official_pull.rs b/infrastructure/src/official_pull.rs index 1693d95..787d9a2 100644 --- a/infrastructure/src/official_pull.rs +++ b/infrastructure/src/official_pull.rs @@ -185,14 +185,14 @@ mod tests { fn inventory() -> YostarJpDownloadInventory { YostarJpDownloadInventory::from_catalog_bytes( b"FullPatch_000.zip", - b"ExcelDB.db", + b"ExcelDB.db ExcelDB.db", b"JP_Airi.zip", ) } fn platform_inventory() -> YostarJpPlatformDownloadInventory { YostarJpPlatformDownloadInventory::from_catalog_bytes( - b"ExcelDB.db", + b"ExcelDB.db ExcelDB.db", vec![ YostarJpPlatformCatalogInventory::from_catalog_bytes( PatchPlatform::Windows, diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 65a47f4..e8d4abd 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -315,7 +315,7 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.zip" ]] elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then emit_text "catalog-android-hash" elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then - emit_text "ExcelDB.db" + emit_text "ExcelDB.db ExcelDB.db" elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then emit_text "{table_catalog_hash}" elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then @@ -361,7 +361,7 @@ fi server_info_url = TEST_SERVER_INFO_URL, connection_group = TEST_CONNECTION_GROUP, addressables_root = TEST_ADDRESSABLES_ROOT, - table_catalog_hash = xxhash32(b"ExcelDB.db"), + table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"), windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"), windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"), android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),