From a6f0572d92f881adf0b91250797bd3be51c7fd2b Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Tue, 5 May 2026 22:35:43 -0400 Subject: [PATCH] Make GetFlagsV1 async to avoid blocking the UI thread GetFlagsV1() was called synchronously on the UI thread at startup, when navigating to the Settings tab, and when opening the Configure Flags dialog. This caused a brief UI freeze each time FSO was spawned to read flag data. Also fixes a potential pipe deadlock: the original code read stdout to completion before reading stderr. If FSO wrote more than ~4 KB to stderr the process would block waiting for the pipe to drain, which Knossos would never do until stdout finished -- a classic deadlock. The fix reads both streams concurrently with Task.WhenAll before calling WaitForExitAsync. Co-Authored-By: Claude Sonnet 4.6 --- Knossos.NET/Classes/FsoBuild.cs | 17 +++++++++----- .../ViewModels/GlobalSettingsViewModel.cs | 23 +++++++++++-------- .../Templates/DevModFsoSettingsViewModel.cs | 11 +++++---- .../ViewModels/Windows/MainWindowViewModel.cs | 4 ++-- .../Windows/ModSettingsViewModel.cs | 18 +++++++-------- 5 files changed, 41 insertions(+), 32 deletions(-) diff --git a/Knossos.NET/Classes/FsoBuild.cs b/Knossos.NET/Classes/FsoBuild.cs index 3ea0e9f8..09ea8b48 100644 --- a/Knossos.NET/Classes/FsoBuild.cs +++ b/Knossos.NET/Classes/FsoBuild.cs @@ -320,7 +320,7 @@ public async Task RunFSO(FsoExecType executableType, string cmdline, /// Get FSO flags structure of this build using the JSON format V1 /// /// A FlagsJsonV1 structure or null if failed - public FlagsJsonV1? GetFlagsV1() + public async Task GetFlagsV1Async() { var executable = GetExecutable(FsoExecType.Flags); var fullpath = GetExecutablePath(executable); @@ -374,10 +374,15 @@ public async Task RunFSO(FsoExecType executableType, string cmdline, } cmd.Start(); - string result = cmd.StandardOutput.ReadToEnd(); - stderr = cmd.StandardError.ReadToEnd(); + // Read stdout and stderr concurrently to prevent pipe deadlock. + // Sequential reads deadlock if stderr output exceeds the pipe buffer (~4 KB). + var stdoutTask = cmd.StandardOutput.ReadToEndAsync(); + var stderrTask = cmd.StandardError.ReadToEndAsync(); + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + string result = stdoutTask.Result; + stderr = stderrTask.Result; output = result; - cmd.WaitForExit(); + await cmd.WaitForExitAsync().ConfigureAwait(false); if (KnUtils.IsLinux && !string.IsNullOrEmpty(stderr)) { @@ -395,7 +400,7 @@ public async Task RunFSO(FsoExecType executableType, string cmdline, if (!_flagErrorOneWarn) { _flagErrorOneWarn = true; - Dispatcher.UIThread.Invoke(async () => { await MessageBox.Show(MainWindow.instance, libfuseError, "Unable to run FSO", MessageBox.MessageBoxButtons.OK); }); + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance, libfuseError, "Unable to run FSO", MessageBox.MessageBoxButtons.OK); }); } } else @@ -404,7 +409,7 @@ public async Task RunFSO(FsoExecType executableType, string cmdline, if (!_flagErrorOneWarn) { _flagErrorOneWarn = true; - Dispatcher.UIThread.Invoke(async ()=> { await MessageBox.Show(MainWindow.instance, stderr, "Unable to run FSO", MessageBox.MessageBoxButtons.OK); }); + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance, stderr, "Unable to run FSO", MessageBox.MessageBoxButtons.OK); }); } } return null; diff --git a/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs b/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs index 04460e40..8f3ea01f 100644 --- a/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs +++ b/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs @@ -588,10 +588,13 @@ public void CommitPendingChanges() /// Loads data from the GlobalSettings.cs class into this one to display it in the UI /// Also loads flag data from a FSO build, if one is installed /// - public void LoadData() + public async Task LoadDataAsync() { var old_path = KnUtils.GetFSODataFolderPath(); - var flagData = GetFlagData(); + // Fetch flag data on a background thread so the UI thread stays responsive. + // await resumes on Avalonia's UI synchronization context, so all ObservableProperty + // assignments below are safe. + var flagData = await Task.Run(GetFlagDataAsync); // reset the ini info if we have gotten an updated preferred path from FSO. if (old_path != KnUtils.GetFSODataFolderPath()){ @@ -1090,7 +1093,7 @@ public void LoadData() UnCommitedChanges = false; } - private FlagsJsonV1? GetFlagData() + private async Task GetFlagDataAsync() { FlagDataLoaded = false; var builds = Knossos.GetInstalledBuildsList(); @@ -1103,7 +1106,7 @@ public void LoadData() stables.Sort(FsoBuild.CompareVersion); foreach (var stable in stables) { - var flags = stable.GetFlagsV1(); + var flags = await stable.GetFlagsV1Async().ConfigureAwait(false); if (flags != null) { FlagDataLoaded = true; @@ -1120,7 +1123,7 @@ public void LoadData() { foreach (var other in others) { - var flags = other.GetFlagsV1(); + var flags = await other.GetFlagsV1Async().ConfigureAwait(false); if (flags != null) { FlagDataLoaded = true; @@ -1168,7 +1171,7 @@ internal async void BrowseFolderCommand() Knossos.globalSettings.basePath = result[0].Path.LocalPath.ToString(); Knossos.globalSettings.Save(); Knossos.ResetBasePath(); - LoadData(); + await LoadDataAsync(); } } catch (Exception ex) @@ -1184,12 +1187,12 @@ await Dispatcher.UIThread.Invoke(async () => { /// /// Reload data from json /// - internal void ResetCommand() + internal async void ResetCommand() { var pxoUser = Knossos.globalSettings.pxoLogin; var pxoPassword = Knossos.globalSettings.pxoPassword; Knossos.globalSettings = new GlobalSettings(); - LoadData(); + await LoadDataAsync(); Knossos.globalSettings.pxoPassword = pxoPassword; Knossos.globalSettings.pxoLogin = pxoUser; SaveCommand(); @@ -1465,10 +1468,10 @@ internal void GlobalCmdHelp() /// /// Reloads configuration and FSO flag data /// - internal void ReloadFlagData() + internal async void ReloadFlagData() { Knossos.globalSettings.Load(); - LoadData(); + await LoadDataAsync(); } /// diff --git a/Knossos.NET/ViewModels/Templates/DevModFsoSettingsViewModel.cs b/Knossos.NET/ViewModels/Templates/DevModFsoSettingsViewModel.cs index 0df97e23..bf79ddb9 100644 --- a/Knossos.NET/ViewModels/Templates/DevModFsoSettingsViewModel.cs +++ b/Knossos.NET/ViewModels/Templates/DevModFsoSettingsViewModel.cs @@ -3,6 +3,7 @@ using Knossos.NET.Models; using Knossos.NET.Views; using System.Linq; +using System.Threading.Tasks; namespace Knossos.NET.ViewModels { @@ -70,7 +71,7 @@ private void LoadFsoPicker() } } - internal void ConfigureBuild() + internal async void ConfigureBuild() { if (editor == null || FsoPicker == null) return; @@ -102,24 +103,24 @@ internal void ConfigureBuild() if(fsoBuild != null) { - var flagsV1=fsoBuild.GetFlagsV1(); + var flagsV1 = await Task.Run(fsoBuild.GetFlagsV1Async); if(flagsV1 != null) { FsoFlags = new FsoFlagsViewModel(flagsV1, CmdLine); } else { - Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance!, "Unable to get flag data from build " + fsoBuild + " It might be below the minimal version supported (3.8.1) or some other error ocurred.", "Invalid flag data", MessageBox.MessageBoxButtons.OK); }); - + } } else { /* No valid build found, send message */ - Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance!, "Unable to resolve FSO build dependency, download the correct one or manually select a FSO version. ", "Not engine build found", MessageBox.MessageBoxButtons.OK); }); diff --git a/Knossos.NET/ViewModels/Windows/MainWindowViewModel.cs b/Knossos.NET/ViewModels/Windows/MainWindowViewModel.cs index 59ccac34..fa699d80 100644 --- a/Knossos.NET/ViewModels/Windows/MainWindowViewModel.cs +++ b/Knossos.NET/ViewModels/Windows/MainWindowViewModel.cs @@ -354,7 +354,7 @@ partial void OnSelectedMenuItemChanged(MainViewMenuItem? value) GlobalSettingsView?.CheckDisplaySettingsWarning(); } Knossos.globalSettings.Load(); - GlobalSettingsView?.LoadData(); + _ = GlobalSettingsView?.LoadDataAsync(); GlobalSettingsView?.UpdateImgCacheSize(); } @@ -498,7 +498,7 @@ public void RemoveInstalledModVersion(Mod mod) /// public void GlobalSettingsLoadData() { - GlobalSettingsView?.LoadData(); + _ = GlobalSettingsView?.LoadDataAsync(); } internal void ApplySettings() diff --git a/Knossos.NET/ViewModels/Windows/ModSettingsViewModel.cs b/Knossos.NET/ViewModels/Windows/ModSettingsViewModel.cs index b9e00cc9..f3e929cb 100644 --- a/Knossos.NET/ViewModels/Windows/ModSettingsViewModel.cs +++ b/Knossos.NET/ViewModels/Windows/ModSettingsViewModel.cs @@ -377,9 +377,9 @@ internal void SaveSettingsCommand() } } - internal void ConfigureBuildCommand() + internal async void ConfigureBuildCommand() { - ConfigureBuild(false); + await ConfigureBuild(false); } /// @@ -387,7 +387,7 @@ internal void ConfigureBuildCommand() /// Also allows to edit the mod cmdline /// /// - private void ConfigureBuild(bool ignoreUserSettings) + private async Task ConfigureBuild(bool ignoreUserSettings) { if (modJson == null) return; @@ -411,24 +411,24 @@ private void ConfigureBuild(bool ignoreUserSettings) if(fsoBuild != null) { - var flagsV1=fsoBuild.GetFlagsV1(); + var flagsV1 = await Task.Run(fsoBuild.GetFlagsV1Async); if(flagsV1 != null) { FsoFlags = new FsoFlagsViewModel(flagsV1,modJson.GetModCmdLine(ignoreUserSettings)); } else { - Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance!, "Unable to get flag data from build " + fsoBuild + " It might be below the minimal version supported (3.8.1) or some other error ocurred.", "Invalid flag data", MessageBox.MessageBoxButtons.OK); }); - + } } else { /* No valid build found, send message */ - Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(MainWindow.instance!, "Unable to resolve FSO build dependency, download the correct one or manually select a FSO version. ", "Not engine build found", MessageBox.MessageBoxButtons.OK); }); @@ -438,13 +438,13 @@ private void ConfigureBuild(bool ignoreUserSettings) /// /// Reset settings to DEFAULT on UI /// - internal void ResetSettingsCommand() + internal async void ResetSettingsCommand() { CustomDependencies = false; BuildMissingWarning = string.Empty; FsoPicker = new FsoBuildPickerViewModel(null, window); FsoFlags = null; - ConfigureBuild(true); + await ConfigureBuild(true); DepItems.Clear(); CreateDependencyItems(true); IgnoreGlobalCmd = false;