Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions packages/oneclient_core/src/game/heal.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> = 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);
}
}
2 changes: 2 additions & 0 deletions packages/oneclient_core/src/game/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod analytics;
mod error;
mod heal;
mod launch;
mod log_replay;
mod process;
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/oneclient_core/src/game/shared_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down