From 260c4f744eefbe86fc99c34e05f5ed25d11f5ba1 Mon Sep 17 00:00:00 2001 From: Bat0oo Date: Fri, 9 Oct 2026 00:39:10 +0200 Subject: [PATCH 1/2] Fix launcher crash when selecting an already-exited process (#959) --- .../Application/ReloadedAppViewModel.cs | 16 ++++++++++++++-- .../Pages/BaseSubpages/ApplicationPage.xaml.cs | 12 +++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Application/ReloadedAppViewModel.cs b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Application/ReloadedAppViewModel.cs index 90088a2c..6908e546 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Application/ReloadedAppViewModel.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Application/ReloadedAppViewModel.cs @@ -38,8 +38,20 @@ public class ReloadedAppViewModel : ObservableObject, IDisposable public ReloadedAppViewModel(ApplicationViewModel applicationViewModel) { ApplicationViewModel = applicationViewModel; - ApplicationViewModel.SelectedProcess!.EnableRaisingEvents = true; - ApplicationViewModel.SelectedProcess.Exited += SelectedProcessOnExited; + var process = ApplicationViewModel.SelectedProcess!; + + process.Exited += SelectedProcessOnExited; + try + { + process.EnableRaisingEvents = true; + } + catch (InvalidOperationException) + { + process.Exited -= SelectedProcessOnExited; + ActionWrappers.ExecuteWithApplicationDispatcherAsync(() => + ApplicationViewModel.ChangeApplicationPage(ApplicationSubPage.ApplicationSummary)); + return; + } /* Try establish connection. */ if (!TryGetPort(true, out _port)) diff --git a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs index fd3c60be..9429c1f8 100644 --- a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs +++ b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs @@ -58,13 +58,23 @@ public void Dispose() private void ReloadedMod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { - if (sender is not FrameworkElement { DataContext: Process process }) + if (sender is not FrameworkElement { DataContext: Process process }) + return; + + if (HasExitedSafe(process)) return; ViewModel.SelectedProcess = process; ViewModel.ChangeApplicationPage(ApplicationSubPage.ReloadedProcess); } + private static bool HasExitedSafe(Process process) + { + try { return process.HasExited; } + catch (InvalidOperationException) { return true; } + catch (System.ComponentModel.Win32Exception) { return false; } + } + private void NonReloadedMod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is not FrameworkElement { DataContext: Process process }) From 8352cd98a6dfce0040e613dd8c8cf10d109a6d12 Mon Sep 17 00:00:00 2001 From: Bat0oo Date: Fri, 9 Oct 2026 00:48:03 +0200 Subject: [PATCH 2/2] Added test for my change also --- .../Launcher/ReloadedAppViewModelTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 source/Reloaded.Mod.Loader.Tests/Launcher/ReloadedAppViewModelTests.cs diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/ReloadedAppViewModelTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/ReloadedAppViewModelTests.cs new file mode 100644 index 00000000..c2ab37c0 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/ReloadedAppViewModelTests.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Reloaded.Mod.Launcher.Lib.Models.Model.Pages; +using Reloaded.Mod.Launcher.Lib.Models.ViewModel; +using Reloaded.Mod.Launcher.Lib.Models.ViewModel.Application; +using Reloaded.Mod.Launcher.Lib.Static; + +namespace Reloaded.Mod.Loader.Tests.Launcher; + +/// +/// Regression tests for https://github.com/Reloaded-Project/Reloaded-II/issues/959 +/// (selecting a Reloaded process that has already exited crashed the launcher). +/// +public class ReloadedAppViewModelTests : IDisposable +{ + private readonly SynchronizationContext _originalContext = Actions.SynchronizationContext; + private readonly ImmediateSynchronizationContext _context = new(); + + public ReloadedAppViewModelTests() => Actions.SynchronizationContext = _context; + + public void Dispose() => Actions.SynchronizationContext = _originalContext; + + [Fact] + public void Constructor_WithExitedProcess_DoesNotThrow_AndReturnsToSummary() + { + using var exitedProcess = CreateExitedProcess(); + var appViewModel = CreateApplicationViewModel(exitedProcess); + + var exception = Record.Exception(() => + { + var viewModel = new ReloadedAppViewModel(appViewModel); + GC.SuppressFinalize(viewModel); + }); + + Assert.Null(exception); + Assert.Equal(1, _context.PostCount); + Assert.Equal(ApplicationSubPage.ApplicationSummary, appViewModel.Page); + } + + /// + /// Returns a for a process that has exited, obtained by PID + /// (without an owned handle), the same way the launcher's process list obtains them. + /// + private static Process CreateExitedProcess() + { + // cmd.exe blocks on redirected stdin, so it stays alive until we kill it. + using var owner = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + RedirectStandardInput = true, + CreateNoWindow = true + })!; + + var stale = Process.GetProcessById(owner.Id); + owner.Kill(); + owner.WaitForExit(); + return stale; + } + + /// + /// The real constructor needs configs, services and a process tracker. + /// only uses + /// and on this path, so skip it. + /// + private static ApplicationViewModel CreateApplicationViewModel(Process process) + { + var viewModel = (ApplicationViewModel)RuntimeHelpers.GetUninitializedObject(typeof(ApplicationViewModel)); + GC.SuppressFinalize(viewModel); // Finalizer calls Dispose(), which needs constructor state. + viewModel.SelectedProcess = process; + return viewModel; + } + + /// + /// Runs posted callbacks immediately and counts them, standing in for the WPF dispatcher. + /// + private sealed class ImmediateSynchronizationContext : SynchronizationContext + { + public int PostCount { get; private set; } + + public override void Post(SendOrPostCallback d, object state) + { + PostCount++; + d(state); + } + + public override void Send(SendOrPostCallback d, object state) => d(state); + } +} \ No newline at end of file