diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 24a1165..e21bf69 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,6 +5486,26 @@ fn with_file_in_use_hint(message: String) -> String { ) } +/// MiniInstaller died in native code (again, after the automatic retry in +/// `everest::install_everest_archive_with_steps`). The stack belongs to the +/// runtime, not to the game: `MonoModder.FindType` compares dependency names +/// with a culture-sensitive `String.StartsWith`, .NET routes that through ICU, +/// and dotnet/runtime#132702 is the same crash with the runtime team's verdict +/// that this path is the earliest *victim* of heap corruption written elsewhere. +/// The raw stack makes people think their game or CeleMod is broken, so point at +/// what actually helps and at the manual install as the fallback. +fn with_installer_crash_hint(message: String) -> String { + if !everest::is_installer_native_crash(&message) { + return message; + } + format!( + "{message}\n\n这不是游戏文件坏了:安装程序跑的时候被系统里的东西打断了(CeleMod 已经自动重装过一遍,还是崩)。\ + 这类崩溃常常是偶发的,再点一次安装往往就过了;连着几次都崩,就临时关掉杀毒软件/安全卫士的实时防护、\ + 显卡驱动的游戏内覆盖层再装;还是不行就手动装:去 everestapi.github.io 下载 Everest 的 Main.zip,\ + 解压到游戏目录(和 Celeste.exe 放在一起),再双击运行里面的 MiniInstaller-win64.exe。" + ) +} + #[tauri::command] fn download_and_install_everest(game_path: String, url: String, on_event: Channel) { std::thread::spawn(move || { @@ -5526,7 +5546,7 @@ fn download_and_install_everest(game_path: String, url: String, on_event: Channe ), Err(error) => { let error = logged_error(format!("Failed to install Everest: {error:#}")); - let error = with_file_in_use_hint(error); + let error = with_installer_crash_hint(with_file_in_use_hint(error)); send_event( &on_event, vec![serde_json::json!("Failed"), serde_json::json!(error)], @@ -5720,6 +5740,7 @@ fn install_local_packages( let error = logged_error(format!( "Failed to install local package {file_name}: {error:#}" )); + let error = with_installer_crash_hint(error); LocalPackageInstallResult { file: file_name, package_type, @@ -6342,6 +6363,44 @@ mod keyboard_input_tests { } } +#[cfg(test)] +mod everest_install_hint_tests { + use super::with_installer_crash_hint; + + #[test] + fn appends_a_hint_to_an_installer_native_crash() { + let message = "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected \ + memory. at MonoMod.MonoModder.FindType"; + let hinted = with_installer_crash_hint(message.to_string()); + assert!( + hinted.starts_with(message), + "the install error must stay first" + ); + assert!(hinted.contains("everestapi.github.io")); + assert!(hinted.contains("MiniInstaller-win64.exe")); + } + + #[test] + fn recognizes_the_localized_crash_message() { + let hinted = with_installer_crash_hint( + "System.AccessViolationException: 尝试读取或写入受保护的内存。".to_string(), + ); + assert!(hinted.contains("杀毒软件")); + } + + #[test] + fn leaves_other_install_failures_alone() { + for message in [ + "Failed to install Everest: Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste", + "Failed to install Everest: The zip is not an Everest install package for this platform", + "Failed to install local package mod.zip: unsupported zip", + ] { + assert_eq!(with_installer_crash_hint(message.to_string()), message); + } + } +} + #[cfg(test)] mod legacy_loader_tests { use super::configure_legacy_loader_environment; diff --git a/src-tauri/src/everest.rs b/src-tauri/src/everest.rs index f209364..7643e8b 100644 --- a/src-tauri/src/everest.rs +++ b/src-tauri/src/everest.rs @@ -468,6 +468,40 @@ pub fn is_everest_ultra(game_path: &Path) -> bool { }) } +/// The install has to survive a MiniInstaller process that dies in native code +/// (see [`is_installer_native_crash`]). On Windows that crash keeps landing in +/// the ICU path .NET uses for culture-sensitive string comparison: +/// `String.StartsWith` -> `CompareInfo.IsPrefix` -> `GlobalizationNative_StartsWith`. +/// MonoMod hits it in `MonoModder.FindType` (`CrawlDependencies` checks whether a +/// dependency name starts with `"System.Private"`), and dotnet/runtime#132702 is +/// the same stack: a Chinese (non-`en*`) system locale is what sends these ASCII +/// prefixes through ICU at all, and the runtime team's verdict there was that the +/// path is the earliest *victim* of heap corruption written elsewhere. +/// +/// Asking the runtime for NLS instead of ICU for the installer process only +/// changes how those ASCII prefixes are compared (identical results), so it is a +/// safe default for a child process we own. +#[cfg(target_os = "windows")] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 1] = + [("DOTNET_SYSTEM_GLOBALIZATION_USENLS", "1")]; +#[cfg(not(target_os = "windows"))] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 0] = []; + +/// MiniInstaller sometimes fails with a native fault instead of an install +/// error: the .NET runtime prints `Fatal error. System.AccessViolationException` +/// to stderr and fails the process fast. Those runs are *intermittent* (the same +/// machine and the same steps succeed on a retry), and they leave the game +/// directory half-updated, so the useful reaction is to run the whole install +/// again once instead of telling the user their game is broken. +pub fn is_installer_native_crash(message: &str) -> bool { + const CRASH_MARKERS: [&str; 3] = [ + "System.AccessViolationException", + "Attempted to read or write protected memory", + "尝试读取或写入受保护的内存", + ]; + CRASH_MARKERS.iter().any(|marker| message.contains(marker)) +} + fn run_command( installer_path: PathBuf, step_label: &str, @@ -480,6 +514,7 @@ fn run_command( let mut cmd = Command::new(&installer_path); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.envs(INSTALLER_GLOBALIZATION_ENV); #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; #[cfg(target_os = "windows")] @@ -592,19 +627,12 @@ pub fn is_everest_install_archive(path: &Path) -> anyhow::Result { Ok(has_installer) } -fn install_everest_archive_with_steps( +fn extract_everest_archive( game_path: &Path, archive_path: &Path, extract_step: &str, - installer_step: &str, progress_callback: &mut dyn FnMut(String, f32), ) -> anyhow::Result<()> { - #[cfg(target_os = "android")] - crate::android::can_install()?; - if !is_everest_install_archive(archive_path)? { - bail!("The zip is not an Everest install package for this platform"); - } - progress_callback(extract_step.to_string(), 0.0); let mut archive = zip::ZipArchive::new(std::fs::File::open(archive_path)?)?; @@ -652,12 +680,50 @@ fn install_everest_archive_with_steps( } } + Ok(()) +} + +fn install_everest_archive_with_steps( + game_path: &Path, + archive_path: &Path, + extract_step: &str, + installer_step: &str, + progress_callback: &mut dyn FnMut(String, f32), +) -> anyhow::Result<()> { + #[cfg(target_os = "android")] + crate::android::can_install()?; + if !is_everest_install_archive(archive_path)? { + bail!("The zip is not an Everest install package for this platform"); + } + + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + + let installer_path = game_path.join(installer_name()?); progress_callback(installer_step.to_string(), 0.0); - run_command( - game_path.join(installer_name()?), - installer_step, - progress_callback, - ) + let error = match run_command(installer_path.clone(), installer_step, progress_callback) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + // A native crash in MiniInstaller is intermittent: the same machine installs + // fine on the next attempt, and the failing run left the game directory + // half-updated. Re-extract and run the installer once more before giving up, + // which is exactly what the user would otherwise do by hand. + if !is_installer_native_crash(&format!("{error:#}")) { + return Err(error); + } + + progress_callback( + format!("{installer_step}: 安装器崩溃,正在重新解压并重试一次"), + 0.0, + ); + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + progress_callback(installer_step.to_string(), 0.0); + run_command(installer_path, installer_step, progress_callback).map_err(|second| { + second.context(format!( + "安装器连续两次在原生代码里崩溃(第一次:{error:#})" + )) + }) } pub fn install_everest_archive( @@ -704,6 +770,112 @@ pub fn download_and_install_everest( ) } +#[cfg(test)] +mod installer_retry_tests { + use super::is_installer_native_crash; + use std::fs; + use std::io::Write; + use std::path::Path; + + fn write_everest_archive(path: &Path, installer_name: &str, script: &str) { + let file = fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + for (name, contents) in [ + (format!("main/{installer_name}"), script.as_bytes()), + ("main/Celeste.Mod.mm.dll".to_string(), b"dll".as_slice()), + ] { + writer + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(contents).unwrap(); + } + writer.finish().unwrap(); + } + + #[test] + fn recognizes_the_runtime_native_crash() { + assert!(is_installer_native_crash( + "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected memory." + )); + assert!(is_installer_native_crash( + "System.AccessViolationException: 尝试读取或写入受保护的内存。" + )); + assert!(!is_installer_native_crash( + "Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste and any program using this file" + )); + } + + #[cfg(unix)] + #[test] + fn retries_the_whole_install_once_after_a_native_crash() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + if [ \"$n\" -lt 2 ]; then\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\nfi\n", + ); + + let result = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ); + assert!( + result.is_ok(), + "the second attempt must be used: {result:?}" + ); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn reports_an_installer_that_keeps_crashing_instead_of_looping() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-fail-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\n", + ); + + let error = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("连续两次")); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } +} + #[cfg(test)] mod tests { use super::{CatalogWait, is_everest_ultra, wait_for_catalog};