From ca85991ea053883572ae84c19b01f07d1849a509 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 8 Aug 2026 11:25:46 +0700 Subject: [PATCH] automatically clear out any corrupted files --- packages/oneclient_core/src/game/heal.rs | 143 ++++++++++++++++++ packages/oneclient_core/src/game/mod.rs | 2 + .../oneclient_core/src/game/shared_dir.rs | 2 + 3 files changed, 147 insertions(+) create mode 100644 packages/oneclient_core/src/game/heal.rs diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs new file mode 100644 index 00000000..5849d1d5 --- /dev/null +++ b/packages/oneclient_core/src/game/heal.rs @@ -0,0 +1,143 @@ +use std::path::{Path, PathBuf}; + +use tokio::io::AsyncReadExt; + +const PROBE_LEN: usize = 8 * 1024; + +#[tracing::instrument(level = "debug")] +pub async fn clear_zeroed_files(game_dir: &Path) -> usize { + let mut cleared = 0usize; + let mut stack: Vec = vec![game_dir.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + let path = entry.path(); + + if file_type.is_dir() { + stack.push(path); + continue; + } + + if !file_type.is_file() || !is_zeroed(&path).await { + continue; + } + + match polyio::remove_file(&path).await { + Ok(()) => { + cleared += 1; + tracing::warn!( + file = %path.display(), + "removed zero-filled file left by an unclean shutdown" + ); + } + Err(err) => tracing::warn!( + file = %path.display(), + error = %err, + "failed to remove zero-filled file" + ), + } + } + } + + if cleared > 0 { + tracing::info!( + cleared, + game_dir = %game_dir.display(), + "cleared zero-filled files; affected mods will regenerate defaults" + ); + } + + cleared +} + +async fn is_zeroed(path: &Path) -> bool { + let Ok(mut file) = tokio::fs::File::open(path).await else { + return false; + }; + + let mut buf = vec![0u8; PROBE_LEN]; + let mut seen_any = false; + + loop { + let read = match file.read(&mut buf).await { + Ok(0) => break, + Ok(n) => n, + Err(_) => return false, + }; + + if buf[..read].iter().any(|&b| b != 0) { + return false; + } + + seen_any = true; + } + + seen_any +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, bytes).unwrap(); + } + + #[tokio::test] + async fn clears_only_non_empty_all_nul_files() { + let dir = polyio::testing::ScratchDir::new("heal-zeroed"); + let root = dir.join("game"); + + let zeroed = root.join("config").join("sodium-options.json"); + let partly = root.join("config").join("half.json"); + let good = root.join("options.txt"); + let empty = root.join(".dedicated_directory"); + let nested = root.join("config").join("deep").join("nested.toml"); + + write(&zeroed, &[0u8; 818]); + write(&partly, &[&[0u8; 4096][..], b"{}"].concat()); + write(&good, b"fov:70\n"); + write(&empty, b""); + write(&nested, &[0u8; 3]); + + assert_eq!(clear_zeroed_files(&root).await, 2); + + assert!(!zeroed.exists()); + assert!(!nested.exists(), "recursion must reach nested directories"); + assert!(partly.exists()); + assert!(good.exists()); + assert!(empty.exists(), "empty marker files are not corruption"); + } + + #[cfg(unix)] + #[tokio::test] + async fn does_not_descend_linked_directories() { + let dir = polyio::testing::ScratchDir::new("heal-symlink"); + let root = dir.join("game"); + let outside = dir.join("launcher-logs"); + + let victim = outside.join("latest.log"); + write(&victim, &[0u8; 64]); + std::fs::create_dir_all(&root).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("logs")).unwrap(); + + assert_eq!(clear_zeroed_files(&root).await, 0); + assert!(victim.exists()); + } + + #[tokio::test] + async fn missing_directory_is_not_an_error() { + let dir = polyio::testing::ScratchDir::new("heal-missing"); + assert_eq!(clear_zeroed_files(&dir.join("nope")).await, 0); + } +} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 39bd11a7..1f614829 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,5 +1,6 @@ mod analytics; mod error; +mod heal; mod launch; mod log_replay; mod process; @@ -21,6 +22,7 @@ pub mod diagnosis; pub use diagnosis::{CrashDiagnosis, diagnose}; pub use error::GameError; +pub use heal::clear_zeroed_files; pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; pub use process::{ GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index eb837337..47757193 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -49,6 +49,8 @@ pub async fn materialize_content( let dedicated = cluster.uses_dedicated_dir(); polyio::create_dir_all(game_dir).await.ok(); + crate::game::heal::clear_zeroed_files(game_dir).await; + // In the shared directory this often belongs to another cluster so every // use of it checks the id let previous = manifest::load(game_dir).await;