UI: LoadGuestApplication asynchronous cancellation

Fixed LoadGuestApplication hanging when cancelled.
Since startup procedure has technically changed, we should consider testing this with a variety of game formats to ensure regressions do not occur.
This commit is contained in:
Max
2026-05-16 19:45:45 -05:00
committed by KeatonTheBot
parent ad8ab179d2
commit 93d1eea58d
6 changed files with 112 additions and 44 deletions
+4 -1
View File
@@ -1,3 +1,4 @@
using Ryujinx.Common.Logging;
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Threading; using System.Threading;
@@ -114,7 +115,7 @@ namespace Ryujinx.Graphics.Vulkan
cbs.AddDependant(this); cbs.AddDependant(this);
// We need to add a dependency on the command buffer to all objects this object // We need to add a dependency on the command buffer to all objects this object
// references aswell. // references as well.
if (_referencedObjs != null) if (_referencedObjs != null)
{ {
for (int i = 0; i < _referencedObjs.Length; i++) for (int i = 0; i < _referencedObjs.Length; i++)
@@ -176,6 +177,8 @@ namespace Ryujinx.Graphics.Vulkan
} }
} }
// This can somehow become -1.
// Logger.Info?.PrintMsg(LogClass.Gpu, $"_referenceCount: {_referenceCount}");
Debug.Assert(_referenceCount >= 0); Debug.Assert(_referenceCount >= 0);
} }
@@ -52,6 +52,7 @@ namespace Ryujinx.HLE.Loaders.Processes
if (metaLoader is not null) if (metaLoader is not null)
{ {
Logger.Info?.Print(LogClass.Application,$"metaLoader: {metaLoader}");
ulong programId = metaLoader.GetProgramId(); ulong programId = metaLoader.GetProgramId();
Name = ApplicationControlProperties.Title[(int)titleLanguage].NameString.ToString(); Name = ApplicationControlProperties.Title[(int)titleLanguage].NameString.ToString();
@@ -70,7 +71,14 @@ namespace Ryujinx.HLE.Loaders.Processes
DisplayVersion = ApplicationControlProperties.DisplayVersionString.ToString(); DisplayVersion = ApplicationControlProperties.DisplayVersionString.ToString();
ProgramId = programId; ProgramId = programId;
ProgramIdText = $"{programId:x16}"; ProgramIdText = $"{programId:x16}";
Is64Bit = metaLoader.IsProgram64Bit(); Is64Bit = metaLoader.IsProgram64Bit;
}
else
{
Logger.Error?.Print(LogClass.Application,$"metaLoader is null !!!");
ProcessId = 0;
return;
} }
DiskCacheEnabled = diskCacheEnabled; DiskCacheEnabled = diskCacheEnabled;
@@ -1115,8 +1115,14 @@ namespace Ryujinx.UI.App.Common
ApplicationCountUpdated?.Invoke(null, e); ApplicationCountUpdated?.Invoke(null, e);
} }
public static ApplicationMetadata LoadAndSaveMetaData(string titleId, Action<ApplicationMetadata> modifyFunction = null) public static Gommon.Optional<ApplicationMetadata> LoadAndSaveMetaData(string titleId, Action<ApplicationMetadata> modifyFunction = null)
{ {
if (titleId is null)
{
Logger.Warning?.PrintMsg(LogClass.Application, "Cannot save metadata because title ID is invalid.");
return null;
}
string metadataFolder = Path.Combine(AppDataManager.GamesDirPath, titleId, "gui"); string metadataFolder = Path.Combine(AppDataManager.GamesDirPath, titleId, "gui");
string metadataFile = Path.Combine(metadataFolder, "metadata.json"); string metadataFile = Path.Combine(metadataFolder, "metadata.json");
@@ -1124,6 +1130,7 @@ namespace Ryujinx.UI.App.Common
if (!File.Exists(metadataFile)) if (!File.Exists(metadataFile))
{ {
Logger.Info?.Print(LogClass.Application, $"Metadata file does not exist. Creating metadata for {titleId}...");
Directory.CreateDirectory(metadataFolder); Directory.CreateDirectory(metadataFolder);
appMetadata = new ApplicationMetadata(); appMetadata = new ApplicationMetadata();
@@ -1133,12 +1140,12 @@ namespace Ryujinx.UI.App.Common
try try
{ {
Logger.Debug?.Print(LogClass.Application, $"Deserializing metadata for {titleId}...");
appMetadata = JsonHelper.DeserializeFromFile(metadataFile, _serializerContext.ApplicationMetadata); appMetadata = JsonHelper.DeserializeFromFile(metadataFile, _serializerContext.ApplicationMetadata);
} }
catch (JsonException) catch (JsonException)
{ {
Logger.Warning?.Print(LogClass.Application, $"Failed to parse metadata json for {titleId}. Loading defaults."); Logger.Warning?.Print(LogClass.Application, $"Failed to parse metadata json for {titleId}. Loading defaults.");
appMetadata = new ApplicationMetadata(); appMetadata = new ApplicationMetadata();
} }
+63 -32
View File
@@ -3,6 +3,8 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LibHac.Common;
using LibHac.Ns;
using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem;
using Ryujinx.Audio.Backends.Dummy; using Ryujinx.Audio.Backends.Dummy;
using Ryujinx.Audio.Backends.OpenAL; using Ryujinx.Audio.Backends.OpenAL;
@@ -62,7 +64,7 @@ using VSyncMode = Ryujinx.Common.Configuration.VSyncMode;
namespace Ryujinx.Ava namespace Ryujinx.Ava
{ {
internal class AppHost internal class AppHost : IDisposable
{ {
private const int CursorHideIdleTime = 5; // Hide Cursor seconds. private const int CursorHideIdleTime = 5; // Hide Cursor seconds.
private const float MaxResolutionScale = 4.0f; // Max resolution hotkeys can scale to before wrapping. private const float MaxResolutionScale = 4.0f; // Max resolution hotkeys can scale to before wrapping.
@@ -447,7 +449,7 @@ namespace Ryujinx.Ava
SaveBitmapAsPng(bitmapToSave, path); SaveBitmapAsPng(bitmapToSave, path);
Logger.Notice.Print(LogClass.Application, $"Screenshot saved to {path}", "Screenshot"); Logger.Notice.Print(LogClass.Application, $"Screenshot saved to '{path}'.", "Screenshot");
} }
}); });
} }
@@ -578,27 +580,37 @@ namespace Ryujinx.Ava
_isActive = false; _isActive = false;
// NOTE: The render loop is allowed to stay alive until the renderer itself is disposed, as it may handle resource dispose.
// We only need to wait for all commands submitted during the main gpu loop to be processed.
_gpuDoneEvent.WaitOne();
_gpuDoneEvent.Dispose();
DisplaySleep.Restore(); DisplaySleep.Restore();
NpadManager.Dispose(); NpadManager.Dispose();
TouchScreenManager.Dispose(); TouchScreenManager.Dispose();
Device.Dispose(); Device.Dispose();
DisposeGpu(); // NOTE: The render loop is allowed to stay alive until the renderer itself is disposed, as it may handle resource dispose.
// We only need to wait for all commands submitted during the main gpu loop to be processed.
// If the GPU has no work and is cancelled, we need to handle that as well.
WaitHandle.WaitAny(new[] { _gpuDoneEvent, _gpuCancellationTokenSource.Token.WaitHandle });
_gpuCancellationTokenSource.Dispose();
// Waiting for work to be finished before we dispose.
if (_renderingStarted)
{
Device.Gpu.WaitUntilGpuReady();
}
_gpuDoneEvent.Dispose();
DisposeGpu();
AppExit?.Invoke(this, EventArgs.Empty); AppExit?.Invoke(this, EventArgs.Empty);
} }
private void Dispose() // MUST be public to inherit from IDisposable
public void Dispose()
{ {
if (Device.Processes != null) if (Device.Processes != null)
{ {
MainWindowViewModel.UpdateGameMetadata(Device.Processes.ActiveApplication.ProgramIdText); MainWindowViewModel.UpdateGameMetadata(Device.Processes.ActiveApplication?.ProgramIdText);
} }
ConfigurationState.Instance.System.IgnoreMissingServices.Event -= UpdateIgnoreMissingServicesState; ConfigurationState.Instance.System.IgnoreMissingServices.Event -= UpdateIgnoreMissingServicesState;
@@ -615,7 +627,6 @@ namespace Ryujinx.Ava
_topLevel.PointerExited -= TopLevel_PointerExited; _topLevel.PointerExited -= TopLevel_PointerExited;
_gpuCancellationTokenSource.Cancel(); _gpuCancellationTokenSource.Cancel();
_gpuCancellationTokenSource.Dispose();
_chrono.Stop(); _chrono.Stop();
} }
@@ -640,6 +651,12 @@ namespace Ryujinx.Ava
} }
else else
{ {
// No use waiting on something that never started work
if (_renderingStarted)
{
Device.Gpu.WaitUntilGpuReady();
}
Device.DisposeGpu(); Device.DisposeGpu();
} }
} }
@@ -654,7 +671,7 @@ namespace Ryujinx.Ava
_cursorState = CursorStates.ForceChangeCursor; _cursorState = CursorStates.ForceChangeCursor;
} }
public async Task<bool> LoadGuestApplication() public async Task LoadGuestApplication(CancellationTokenSource cts, BlitStruct<ApplicationControlProperty>? customNacpData = null)
{ {
InitializeSwitchInstance(); InitializeSwitchInstance();
MainWindow.UpdateGraphicsConfig(); MainWindow.UpdateGraphicsConfig();
@@ -682,7 +699,8 @@ namespace Ryujinx.Ava
await UserErrorDialog.ShowUserErrorDialog(userError); await UserErrorDialog.ShowUserErrorDialog(userError);
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
} }
@@ -691,10 +709,11 @@ namespace Ryujinx.Ava
await UserErrorDialog.ShowUserErrorDialog(userError); await UserErrorDialog.ShowUserErrorDialog(userError);
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
// Tell the user that we installed a firmware for them. // Tell the user that we installed firmware for them.
if (userError == UserError.NoFirmware) if (userError == UserError.NoFirmware)
{ {
firmwareVersion = ContentManager.GetCurrentFirmwareVersion(); firmwareVersion = ContentManager.GetCurrentFirmwareVersion();
@@ -714,7 +733,8 @@ namespace Ryujinx.Ava
await UserErrorDialog.ShowUserErrorDialog(userError); await UserErrorDialog.ShowUserErrorDialog(userError);
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
} }
} }
@@ -730,7 +750,8 @@ namespace Ryujinx.Ava
{ {
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
} }
else if (Directory.Exists(ApplicationPath)) else if (Directory.Exists(ApplicationPath))
@@ -748,20 +769,24 @@ namespace Ryujinx.Ava
if (!Device.LoadCart(ApplicationPath, romFsFiles[0])) if (!Device.LoadCart(ApplicationPath, romFsFiles[0]))
{ {
await ContentDialogHelper.CreateErrorDialog(
"Please specify an unpacked game directory with a valid exefs or NSO/NRO.");
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
} }
else else
{ {
Logger.Info?.Print(LogClass.Application, "Loading as cart WITHOUT RomFS."); Logger.Info?.Print(LogClass.Application, "Loading as cart WITHOUT RomFS.");
if (!Device.LoadCart(ApplicationPath)) if (!Device.LoadCart(ApplicationPath))
{ {
await ContentDialogHelper.CreateErrorDialog(
"Please specify an unpacked game directory with a valid exefs or NSO/NRO.");
Device.Dispose(); Device.Dispose();
cts.Cancel();
return false; throw new OperationCanceledException(cts.Token);
} }
} }
} }
@@ -777,7 +802,8 @@ namespace Ryujinx.Ava
{ {
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
break; break;
@@ -790,7 +816,8 @@ namespace Ryujinx.Ava
{ {
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
break; break;
@@ -804,7 +831,8 @@ namespace Ryujinx.Ava
{ {
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
break; break;
@@ -819,7 +847,8 @@ namespace Ryujinx.Ava
{ {
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
} }
catch (ArgumentOutOfRangeException) catch (ArgumentOutOfRangeException)
@@ -828,7 +857,8 @@ namespace Ryujinx.Ava
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
break; break;
@@ -837,11 +867,12 @@ namespace Ryujinx.Ava
} }
else else
{ {
Logger.Warning?.Print(LogClass.Application, "Please specify a valid XCI/NCA/NSP/PFS0/NRO file."); Logger.Warning?.Print(LogClass.Application, "Please specify a valid XCI/NCA/NSP/PFS0/NSO/NRO file.");
Device.Dispose(); Device.Dispose();
return false; cts.Cancel();
throw new OperationCanceledException(cts.Token);
} }
ApplicationMetadata appMeta = ApplicationLibrary.LoadAndSaveMetaData(Device.Processes.ActiveApplication.ProgramIdText, ApplicationMetadata appMeta = ApplicationLibrary.LoadAndSaveMetaData(Device.Processes.ActiveApplication.ProgramIdText,
@@ -849,8 +880,6 @@ namespace Ryujinx.Ava
); );
DiscordIntegrationModule.SwitchToPlayingState(appMeta, Device.Processes.ActiveApplication); DiscordIntegrationModule.SwitchToPlayingState(appMeta, Device.Processes.ActiveApplication);
return true;
} }
internal void Resume() internal void Resume()
@@ -859,7 +888,7 @@ namespace Ryujinx.Ava
_viewModel.IsPaused = false; _viewModel.IsPaused = false;
_viewModel.Title = TitleHelper.ActiveApplicationTitle(Device?.Processes.ActiveApplication, Program.Version); _viewModel.Title = TitleHelper.ActiveApplicationTitle(Device?.Processes.ActiveApplication, Program.Version);
Logger.Info?.Print(LogClass.Emulation, "Emulation was resumed"); Logger.Info?.Print(LogClass.Emulation, "Emulation was resumed.");
} }
internal void Pause() internal void Pause()
@@ -868,7 +897,7 @@ namespace Ryujinx.Ava
_viewModel.IsPaused = true; _viewModel.IsPaused = true;
_viewModel.Title = TitleHelper.ActiveApplicationTitle(Device?.Processes.ActiveApplication, Program.Version, LocaleManager.Instance[LocaleKeys.Paused]); _viewModel.Title = TitleHelper.ActiveApplicationTitle(Device?.Processes.ActiveApplication, Program.Version, LocaleManager.Instance[LocaleKeys.Paused]);
Logger.Info?.Print(LogClass.Emulation, "Emulation was paused"); Logger.Info?.Print(LogClass.Emulation, "Emulation was paused.");
} }
private void InitializeSwitchInstance() private void InitializeSwitchInstance()
@@ -1095,7 +1124,9 @@ namespace Ryujinx.Ava
// Make sure all commands in the run loop are fully executed before leaving the loop. // Make sure all commands in the run loop are fully executed before leaving the loop.
if (Device.Gpu.Renderer is ThreadedRenderer threaded) if (Device.Gpu.Renderer is ThreadedRenderer threaded)
{ {
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushing threaded commands...");
threaded.FlushThreadedCommands(); threaded.FlushThreadedCommands();
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushed!");
} }
_gpuDoneEvent.Set(); _gpuDoneEvent.Set();
+9 -2
View File
@@ -57,8 +57,15 @@ namespace Ryujinx.Ava.UI.Models
} }
else else
{ {
var appMetadata = ApplicationLibrary.LoadAndSaveMetaData(TitleIdString); Gommon.Optional<ApplicationMetadata> appMetadata = ApplicationLibrary.LoadAndSaveMetaData(TitleIdString);
Title = appMetadata.Title ?? TitleIdString; if (appMetadata != null)
{
Title = appMetadata.Value.Title ?? TitleIdString;
}
else
{
Title = "<INVALID>";
}
} }
Task.Run(() => Task.Run(() =>
@@ -2067,10 +2067,6 @@ namespace Ryujinx.Ava.UI.ViewModels
Logger.RestartTime(); Logger.RestartTime();
SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(application.Path, ConfigurationState.Instance.System.Language, application.Id);
PrepareLoadScreen();
RendererHostControl = new RendererHost(); RendererHostControl = new RendererHost();
AppHost = new AppHost( AppHost = new AppHost(
@@ -2085,18 +2081,34 @@ namespace Ryujinx.Ava.UI.ViewModels
this, this,
TopLevel); TopLevel);
if (!await AppHost.LoadGuestApplication()) CancellationTokenSource cts = new CancellationTokenSource();
try
{ {
await AppHost.LoadGuestApplication(cts);
}
catch (OperationCanceledException exception)
{
Logger.Info?.Print(LogClass.Application,
"LoadGuestApplication was interrupted !!! " + exception.Message);
AppHost.DisposeContext(); AppHost.DisposeContext();
AppHost = null; AppHost = null;
return; return;
} }
finally
{
cts.Dispose();
}
CanUpdate = false; CanUpdate = false;
LoadHeading = application.Name; LoadHeading = application.Name;
SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(application.Path,
ConfigurationState.Instance.System.Language, application.Id);
PrepareLoadScreen();
if (string.IsNullOrWhiteSpace(application.Name)) if (string.IsNullOrWhiteSpace(application.Name))
{ {
LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, AppHost.Device.Processes.ActiveApplication.Name); LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, AppHost.Device.Processes.ActiveApplication.Name);