From 1829b0eb36388c03706ebc3b738cc9f91483f053 Mon Sep 17 00:00:00 2001 From: drewmcelhany Date: Wed, 22 Jul 2026 21:47:20 -0700 Subject: [PATCH 1/5] Fixed: Launcher freezing on startup when fetching mod update metadata (#910) --- .../Dialog/ModUpdateDialogViewModel.cs | 15 +++- source/Reloaded.Mod.Launcher.Lib/Update.cs | 7 +- .../Structures/ModUpdateSummary.cs | 80 ++++++++++--------- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs index 21cd96c1..bca20dcd 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs @@ -41,17 +41,26 @@ public class ModUpdateDialogViewModel : ObservableObject /// public bool CanDownload { get; set; } - /// - public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary) + /// + /// Creates the ViewModel using precomputed update info. + /// Prefer this overload; it performs no network I/O and is safe on the UI thread. + /// + public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary, ModUpdate[] updateInfo) { Updater = updater; Summary = summary; - UpdateInfo = Summary.GetUpdateInfo(); + UpdateInfo = updateInfo; TotalSize = UpdateInfo.Sum(x => x.UpdateSize); SelectedUpdate = UpdateInfo[0]; CanDownload = true; } + /// + /// Legacy constructor. Performs blocking network I/O; do not call from the UI thread. + /// + public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary) + : this(updater, summary, summary.GetUpdateInfo()) { } + /// /// Performs an update of all mods. /// diff --git a/source/Reloaded.Mod.Launcher.Lib/Update.cs b/source/Reloaded.Mod.Launcher.Lib/Update.cs index 42021d8a..51a739f5 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Update.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Update.cs @@ -130,9 +130,14 @@ public static async Task CheckForModUpdatesAsync() if (updateDetails.HasUpdates()) { + // Fetch update metadata (download sizes, changelogs) on a background thread. + // Doing this inside the ViewModel constructor blocked the UI thread on + // unbounded network I/O, freezing the launcher on startup. See issue #910. + var updateInfo = await updateDetails.GetUpdateInfoAsync(); + Actions.SynchronizationContext.Send(_ => { - Actions.ShowModUpdateDialog.Invoke(new ModUpdateDialogViewModel(updater, updateDetails)); + Actions.ShowModUpdateDialog.Invoke(new ModUpdateDialogViewModel(updater, updateDetails, updateInfo)); }, null); return true; diff --git a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs index dbd35f34..da76491d 100644 --- a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs +++ b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs @@ -39,61 +39,65 @@ public bool HasUpdates() /// /// Retrieves info about the individual updates. + /// Synchronous wrapper; do not call from the UI thread. Prefer . /// /// - public ModUpdate[] GetUpdateInfo() + public ModUpdate[] GetUpdateInfo() => Task.Run(GetUpdateInfoAsync).GetAwaiter().GetResult(); + + /// + /// Retrieves info about the individual updates without blocking the calling thread. + /// + /// + public async Task GetUpdateInfoAsync() { if (_updates != null) return _updates; - _updates = new ModUpdate[ManagerModResultPairs.Count]; - Task.Run(async () => + var updates = new ModUpdate[ManagerModResultPairs.Count]; + for (var x = 0; x < ManagerModResultPairs.Count; x++) { - for (var x = 0; x < ManagerModResultPairs.Count; x++) + var resultPairs = ManagerModResultPairs[x]; + var modName = resultPairs.ModTuple.Config.ModName; + var modId = resultPairs.ModTuple.Config.ModId; + var oldVersion = resultPairs.ModTuple.Config.ModVersion; + var newVersion = resultPairs.Result.LastVersion; + var resolver = ((IPackageResolverDownloadSize)resultPairs.Manager.Resolver); + var updateSize = (long)0; + string? changelog = null; + + try { - var resultPairs = ManagerModResultPairs[x]; - var modName = resultPairs.ModTuple.Config.ModName; - var modId = resultPairs.ModTuple.Config.ModId; - var oldVersion = resultPairs.ModTuple.Config.ModVersion; - var newVersion = resultPairs.Result.LastVersion; - var resolver = ((IPackageResolverDownloadSize)resultPairs.Manager.Resolver); - var updateSize = (long)0; - string? changelog = null; + updateSize = await resolver.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()); + } + catch (Exception) { /* Ignored */ } + // Get changelog from supported resolver. + if (resolver is IPackageResolverGetLatestReleaseMetadata getMetadata) + { try { - updateSize = await resolver.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()); + var releaseMetadata = await getMetadata.GetReleaseMetadataAsync(default); + var extraData = releaseMetadata?.GetExtraData(); + if (extraData != null) + changelog = extraData.Changelog; } catch (Exception) { /* Ignored */ } + } - // Get changelog from supported resolver. - if (resolver is IPackageResolverGetLatestReleaseMetadata getMetadata) - { - try - { - var releaseMetadata = await getMetadata.GetReleaseMetadataAsync(default); - var extraData = releaseMetadata?.GetExtraData(); - if (extraData != null) - changelog = extraData.Changelog; - } - catch (Exception) { /* Ignored */ } - } - - // NuGet has special case, since it doesn't support release metadata but supports changelogs in nuspec. - if (string.IsNullOrEmpty(changelog) && resolver is NuGetUpdateResolver nugetResolver) - { - var copiedSettings = nugetResolver.GetResolverSettings(); - var repository = NugetRepository.FromSourceUrl(copiedSettings.NugetRepository!.SourceUrl); - var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)); - if (reader != null) - changelog = reader?.GetReleaseNotes(); - } - - _updates[x] = new ModUpdate(modId, NuGetVersion.Parse(oldVersion), newVersion!, updateSize, changelog, modName); + // NuGet has special case, since it doesn't support release metadata but supports changelogs in nuspec. + if (string.IsNullOrEmpty(changelog) && resolver is NuGetUpdateResolver nugetResolver) + { + var copiedSettings = nugetResolver.GetResolverSettings(); + var repository = NugetRepository.FromSourceUrl(copiedSettings.NugetRepository!.SourceUrl); + var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)); + if (reader != null) + changelog = reader?.GetReleaseNotes(); } - }).Wait(); + updates[x] = new ModUpdate(modId, NuGetVersion.Parse(oldVersion), newVersion!, updateSize, changelog, modName); + } + _updates = updates; return _updates; } From 7214a9f5011fb8176b7fa9b3c79b250908000ec1 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 25 Jul 2026 16:55:44 +0100 Subject: [PATCH 2/5] Removed: Legacy unneeded blocking constructor. This is internal code, so no need for a backwards compatible overload" Also cleaned up the doc comment on the remaining constructor (the "Prefer this overload" note was stale after the removal). --- .../Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs index bca20dcd..a6a2794c 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs @@ -43,7 +43,6 @@ public class ModUpdateDialogViewModel : ObservableObject /// /// Creates the ViewModel using precomputed update info. - /// Prefer this overload; it performs no network I/O and is safe on the UI thread. /// public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary, ModUpdate[] updateInfo) { @@ -55,12 +54,6 @@ public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary, ModUp CanDownload = true; } - /// - /// Legacy constructor. Performs blocking network I/O; do not call from the UI thread. - /// - public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary) - : this(updater, summary, summary.GetUpdateInfo()) { } - /// /// Performs an update of all mods. /// From f119075ebbbb0ef6816a2eb6e66dc5c37a538af7 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 25 Jul 2026 16:59:05 +0100 Subject: [PATCH 3/5] Fixed: Guard update-size cast and NuGet changelog fallback in ModUpdateSummary.GetUpdateInfoAsync - Replaced direct IPackageResolverDownloadSize cast with is pattern check so resolvers not implementing it no longer throw InvalidCastException; download size remains default (0) when unsupported or failing, matching the existing safe pattern in UpdateDownloadablePackage. - Wrapped NuGet nuspec-based changelog extraction in its own try/catch so failures in resolver-settings access, repository creation, nuspec download, or release-notes parsing leave changelog unset and do not abort the per-mod iteration. --- .../Structures/ModUpdateSummary.cs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs index da76491d..42a3504c 100644 --- a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs +++ b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs @@ -61,15 +61,18 @@ public async Task GetUpdateInfoAsync() var modId = resultPairs.ModTuple.Config.ModId; var oldVersion = resultPairs.ModTuple.Config.ModVersion; var newVersion = resultPairs.Result.LastVersion; - var resolver = ((IPackageResolverDownloadSize)resultPairs.Manager.Resolver); + var resolver = resultPairs.Manager.Resolver; var updateSize = (long)0; string? changelog = null; - try + if (resolver is IPackageResolverDownloadSize hasDownloadSize) { - updateSize = await resolver.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()); + try + { + updateSize = await hasDownloadSize.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()); + } + catch (Exception) { /* Ignored */ } } - catch (Exception) { /* Ignored */ } // Get changelog from supported resolver. if (resolver is IPackageResolverGetLatestReleaseMetadata getMetadata) @@ -87,11 +90,15 @@ public async Task GetUpdateInfoAsync() // NuGet has special case, since it doesn't support release metadata but supports changelogs in nuspec. if (string.IsNullOrEmpty(changelog) && resolver is NuGetUpdateResolver nugetResolver) { - var copiedSettings = nugetResolver.GetResolverSettings(); - var repository = NugetRepository.FromSourceUrl(copiedSettings.NugetRepository!.SourceUrl); - var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)); - if (reader != null) - changelog = reader?.GetReleaseNotes(); + try + { + var copiedSettings = nugetResolver.GetResolverSettings(); + var repository = NugetRepository.FromSourceUrl(copiedSettings.NugetRepository!.SourceUrl); + var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)); + if (reader != null) + changelog = reader?.GetReleaseNotes(); + } + catch (Exception) { /* Ignored */ } } updates[x] = new ModUpdate(modId, NuGetVersion.Parse(oldVersion), newVersion!, updateSize, changelog, modName); From 872e9c0c8e7850352193ad1186dc26ffe62d2f0d Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 25 Jul 2026 17:00:17 +0100 Subject: [PATCH 4/5] Fixed: Guard ModUpdateDialogViewModel constructor against empty updateInfo array Replace unconditional UpdateInfo[0] access with a length check. Empty arrays now set SelectedUpdate to null and CanDownload to false, preventing an IndexOutOfRangeException when no updates are provided. --- .../ViewModel/Dialog/ModUpdateDialogViewModel.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs index a6a2794c..4767e8ce 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/ViewModel/Dialog/ModUpdateDialogViewModel.cs @@ -50,8 +50,17 @@ public ModUpdateDialogViewModel(Updater updater, ModUpdateSummary summary, ModUp Summary = summary; UpdateInfo = updateInfo; TotalSize = UpdateInfo.Sum(x => x.UpdateSize); - SelectedUpdate = UpdateInfo[0]; - CanDownload = true; + + if (UpdateInfo.Length > 0) + { + SelectedUpdate = UpdateInfo[0]; + CanDownload = true; + } + else + { + SelectedUpdate = null; + CanDownload = false; + } } /// From e5f22c3a805ce4ea7cfa09287c91d16a1fe4e7b5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 25 Jul 2026 17:05:25 +0100 Subject: [PATCH 5/5] Changed: Add ConfigureAwait(false) to all awaits in GetUpdateInfoAsync Library-internal async calls should not capture the synchronization context under the default assumption of library usage. Applied ConfigureAwait(false) to GetDownloadFileSizeAsync, GetReleaseMetadataAsync, and DownloadNuspecReaderAsync consistently within the method. Exception handling (empty catch blocks) and result processing remain unchanged. --- .../Structures/ModUpdateSummary.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs index 42a3504c..9e0cfece 100644 --- a/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs +++ b/source/Reloaded.Mod.Loader.Update/Structures/ModUpdateSummary.cs @@ -69,7 +69,7 @@ public async Task GetUpdateInfoAsync() { try { - updateSize = await hasDownloadSize.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()); + updateSize = await hasDownloadSize.GetDownloadFileSizeAsync(newVersion!, resultPairs.ModTuple.GetVerificationInfo()).ConfigureAwait(false); } catch (Exception) { /* Ignored */ } } @@ -79,7 +79,7 @@ public async Task GetUpdateInfoAsync() { try { - var releaseMetadata = await getMetadata.GetReleaseMetadataAsync(default); + var releaseMetadata = await getMetadata.GetReleaseMetadataAsync(default).ConfigureAwait(false); var extraData = releaseMetadata?.GetExtraData(); if (extraData != null) changelog = extraData.Changelog; @@ -94,7 +94,7 @@ public async Task GetUpdateInfoAsync() { var copiedSettings = nugetResolver.GetResolverSettings(); var repository = NugetRepository.FromSourceUrl(copiedSettings.NugetRepository!.SourceUrl); - var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)); + var reader = await repository.DownloadNuspecReaderAsync(new PackageIdentity(copiedSettings.PackageId, newVersion!)).ConfigureAwait(false); if (reader != null) changelog = reader?.GetReleaseNotes(); }