automatically clear out any corrupted files (#708)

Removes non-empty all-NUL files from the game directory before content is materialized, so installations bricked by corrupted files recover on the next launch.
This commit is contained in:
Julian Chang 2026-09-05 19:47:32 -04:00 • committed by GitHub
parent 3d0c880096
commit 275abd13b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 147 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -2,6 +2,7 @@ mod analytics;
mod error;
#[cfg(any(target_os = "linux", test))]
mod gpu;
mod heal;
mod launch;
mod log_replay;
mod process;
@ -23,6 +24,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,

View file

@ -51,6 +51,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;
import_manual_content(services, cluster, game_dir).await;
// Before the folder is built not after handing the game several enabled