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