diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index bb6ec0c..9522a5e 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,11 +1,11 @@ use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ - lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, redact_proxy_url, - resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic, - CurlProxyConfig, CurlProxyMode, OfficialFailedVersionRecord, OfficialServerInfoSource, - OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, - OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, - PRIVATE_FILE_MODE, + gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink, + read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root, + validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode, + OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, + OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, + OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, }; use serde::{Deserialize, Serialize}; use std::env; @@ -2979,6 +2979,11 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { removed_paths.push(proxy_secret_path); } + // 后台已停止,清理未被版本状态引用的孤儿 staging 目录。 + if let Some(state) = read_version_state(&options.config.version_state_path())? { + removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?); + } + let report = CleanStableReport { command: "clean-stable", status: if skipped_paths.is_empty() { diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index d0ee02f..3962cb7 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -53,9 +53,9 @@ pub use official_sync::{ default_official_platforms, OfficialSyncDecision, OfficialSyncPlan, }; pub use official_update::{ - cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache, - read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state, - ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, + cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging, + read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, + write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 0ce9c08..deaa470 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -1439,6 +1439,26 @@ impl OfficialUpdateService { )); } + // 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。 + match read_version_state(&version_state_path) { + Ok(Some(state)) => match gc_orphan_staging(&config.output_root, &state) { + Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new( + "publish", + format!("已清理 {} 个孤儿 staging 目录", removed.len()), + )), + Ok(_) => {} + Err(error) => progress(OfficialUpdateProgress::new( + "publish", + format!("清理孤儿 staging 目录失败(忽略):{error}"), + )), + }, + Ok(None) => {} + Err(error) => progress(OfficialUpdateProgress::new( + "publish", + format!("读取版本状态用于 staging 清理失败(忽略):{error}"), + )), + } + progress(OfficialUpdateProgress::new( "finish", format!( @@ -2334,6 +2354,63 @@ fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionR && left.addressables_root == right.addressables_root } +/// 清理 `/.staging` 下未被版本状态引用的孤儿目录。 +/// +/// 只保留 `in_progress_version` 与 `failed_versions` 引用的 `` 目录(后者供失败 +/// 后复用),删除其余目录——即跨版本失败、或被 trim/去重挤出记录后残留的 staging, +/// 避免长期 daemon 场景下多 GB 孤儿目录累积。返回被删除的目录路径。 +pub fn gc_orphan_staging( + output_root: &Path, + state: &OfficialVersionState, +) -> anyhow::Result> { + gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state) +} + +fn gc_orphan_staging_dirs( + staging_dir: &Path, + state: &OfficialVersionState, +) -> anyhow::Result> { + let read_dir = match fs::read_dir(staging_dir) { + Ok(read_dir) => read_dir, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(anyhow::anyhow!( + "读取官方资源 staging 根目录失败 {}:{error}", + staging_dir.display() + )) + } + }; + + let mut referenced: Vec<&str> = Vec::new(); + if let Some(in_progress) = state.in_progress_version.as_ref() { + referenced.push(in_progress.id.as_str()); + } + for failed in &state.failed_versions { + referenced.push(failed.version.id.as_str()); + } + + let mut removed = Vec::new(); + for entry in read_dir { + let entry = entry?; + // file_type 不跟随 symlink:symlink(即使指向目录)is_dir() 为 false,会被跳过。 + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if referenced.contains(&name) { + continue; + } + fs::remove_dir_all(&path).map_err(|error| { + anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display()) + })?; + removed.push(path); + } + Ok(removed) +} + fn snapshot_path_for_state_path( _snapshot: &OfficialUpdateSnapshot, resource_root: &Path, @@ -2985,6 +3062,66 @@ mod tests { assert!(state.failed_versions[0].error.contains("403")); } + fn version_record_with_id(id: &str) -> OfficialVersionRecord { + OfficialVersionRecord { + id: id.to_string(), + app_version: "1.0.0".to_string(), + bundle_version: None, + addressables_root: "root".to_string(), + resource_root: PathBuf::from("/tmp/x"), + snapshot_path: PathBuf::from("/tmp/x/snap.json"), + staging_path: None, + version_path: None, + started_unix_seconds: None, + completed_unix_seconds: None, + } + } + + #[test] + fn gc_orphan_staging_removes_only_unreferenced_dirs() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + let staging = root.join(OFFICIAL_STAGING_DIR); + for id in ["keep-inprogress", "keep-failed", "orphan-a", "orphan-b"] { + fs::create_dir_all(staging.join(id)).unwrap(); + fs::write(staging.join(id).join("marker"), b"x").unwrap(); + } + // staging 根下的普通文件不应被误删。 + fs::write(staging.join("stray-file"), b"x").unwrap(); + + let state = OfficialVersionState { + in_progress_version: Some(version_record_with_id("keep-inprogress")), + failed_versions: vec![OfficialFailedVersionRecord { + version: version_record_with_id("keep-failed"), + error: "boom".to_string(), + failed_unix_seconds: 1, + }], + ..OfficialVersionState::default() + }; + + let removed = gc_orphan_staging(root, &state).unwrap(); + let mut removed_names: Vec = removed + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str())) + .map(str::to_string) + .collect(); + removed_names.sort(); + assert_eq!(removed_names, vec!["orphan-a", "orphan-b"]); + + assert!(staging.join("keep-inprogress").exists()); + assert!(staging.join("keep-failed").exists()); + assert!(!staging.join("orphan-a").exists()); + assert!(!staging.join("orphan-b").exists()); + assert!(staging.join("stray-file").exists()); + } + + #[test] + fn gc_orphan_staging_missing_dir_is_noop() { + let temp = tempfile::TempDir::new().unwrap(); + let removed = gc_orphan_staging(temp.path(), &OfficialVersionState::default()).unwrap(); + assert!(removed.is_empty()); + } + #[test] fn version_state_deduplicates_repeated_failures_and_clears_after_success() { let temp = tempfile::TempDir::new().unwrap();