From e45cf6b95eb85509c2aeccd129ba624aa856d91b Mon Sep 17 00:00:00 2001 From: diaverso Date: Fri, 15 May 2026 18:48:13 +0200 Subject: [PATCH 1/3] fix: resolve critical bugs in symlinks, async deadlock, NullRef, serialization (#115, #131, #167, #238, #242, #251, #254, #255, #259) FASTER/Models/ArmaMod.cs, FASTER/Models/SteamWebApi.cs - #167: Show one-time warning after 3 failed retries if Steam API Key is invalid; check response.IsSuccessStatusCode in ApiCall() so errors propagate to retry logic with a meaningful reason FASTER/Models/BasicCfg.cs - #251/#115: PerfPreset getter always returns "Custom" so JSON deserialization called the setter and reset MaxMsgSend to 256 on every profile clone; fixed with [Newtonsoft.Json.JsonIgnore] FASTER/ViewModel/DeploymentViewModel.cs - #254: LinkMod/DeleteLink called Directory.Delete(path, true) on symlinks, which destroyed the source mod folder; now checks FileAttributes.ReparsePoint - #131: DeployAll() crashed when InstallPath didn't exist; added guard + user-facing error message; added UnauthorizedAccessException handler with Developer Mode / run-as-Admin guidance FASTER/ViewModel/SteamUpdaterViewModel.cs - #242: mod.Status was set to NotComplete in the early cancellation path before any download started - #259: Task.Factory.StartNew(async ()=>) returns Task; missing .Unwrap() caused ContinueWith to fire instantly instead of after async work; converted lambda to async + await, added .Unwrap() - #238: NullReferenceException accessing SteamClient.Credentials.Username after SteamClient was nulled; save username before disposal Co-Authored-By: Claude Sonnet 4.6 --- FASTER/Models/ArmaMod.cs | 8 + FASTER/Models/BasicCfg.cs | 41 ++-- FASTER/Models/SteamWebApi.cs | 10 +- FASTER/ViewModel/DeploymentViewModel.cs | 45 ++++- FASTER/ViewModel/SteamUpdaterViewModel.cs | 216 +++++++++++++++------- 5 files changed, 236 insertions(+), 84 deletions(-) diff --git a/FASTER/Models/ArmaMod.cs b/FASTER/Models/ArmaMod.cs index d24a20f0..0a8e8675 100644 --- a/FASTER/Models/ArmaMod.cs +++ b/FASTER/Models/ArmaMod.cs @@ -105,6 +105,7 @@ public class ArmaMod : INotifyPropertyChanged private long _size; private bool _isLoading; private bool _isSelected; + private static bool _apiKeyWarningShown = false; public uint WorkshopId @@ -337,6 +338,13 @@ internal void UpdateInfos(bool checkFileSize = true) success = true; } while (failNum < 3 && !success); + if (!success && !_apiKeyWarningShown) + { + _apiKeyWarningShown = true; + MainWindow.Instance?.Dispatcher.Invoke(() => + MainWindow.Instance.DisplayMessage("Could not fetch mod info. Please check your Steam API Key in Settings.")); + } + if (checkFileSize) CheckModSize(); diff --git a/FASTER/Models/BasicCfg.cs b/FASTER/Models/BasicCfg.cs index fa3cf7f2..dbdd699b 100644 --- a/FASTER/Models/BasicCfg.cs +++ b/FASTER/Models/BasicCfg.cs @@ -9,6 +9,7 @@ public static class BasicCfgArrays { public static string[] PerfPresets { get; } = {"Custom", "Arma3 Defaults", "1Mb Preset", "250Mb Preset", "1Gb Preset"}; public static double[] TerrainGrids { get; } = { 50, 25, 12.5, 6.25, 3.125 }; + public static string[] Languages { get; } = { "English", "Czech", "French", "German", "Italian", "Polish", "Portuguese", "Russian", "Spanish", "Turkish", "Hungarian" }; } [Serializable] @@ -27,15 +28,26 @@ public class BasicCfg : INotifyPropertyChanged private ushort maxCustomFileSize = 1024; private ushort maxPacketSize = 1400; + private string _language = "English"; private string basicContent; + public string Language + { + get => _language; + set + { + _language = value; + RaisePropertyChanged(nameof(Language)); + } + } + public string BasicContent { get => basicContent; set { basicContent = value; - RaisePropertyChanged("BasicContent"); + RaisePropertyChanged(nameof(BasicContent)); } } @@ -45,7 +57,7 @@ public uint ViewDistance set { viewDistance = value; - RaisePropertyChanged("ViewDistance"); + RaisePropertyChanged(nameof(ViewDistance)); } } @@ -55,7 +67,7 @@ public double TerrainGrid set { terrainGrid = value; - RaisePropertyChanged("TerrainGrid"); + RaisePropertyChanged(nameof(TerrainGrid)); } } @@ -65,7 +77,7 @@ public ushort MaxSizeGuaranteed set { maxSizeGuaranteed = value; - RaisePropertyChanged("MaxSizeGuaranteed"); + RaisePropertyChanged(nameof(MaxSizeGuaranteed)); } } @@ -75,7 +87,7 @@ public ushort MaxSizeNonGuaranteed set { maxSizeNonguaranteed = value; - RaisePropertyChanged("MaxSizeNonGuaranteed"); + RaisePropertyChanged(nameof(MaxSizeNonGuaranteed)); } } @@ -85,7 +97,7 @@ public ushort MaxMsgSend set { maxMsgSend = value; - RaisePropertyChanged("MaxMsgSend"); + RaisePropertyChanged(nameof(MaxMsgSend)); } } @@ -95,7 +107,7 @@ public ulong MinBandwidth set { minBandwidth = value; - RaisePropertyChanged("MinBandwidth"); + RaisePropertyChanged(nameof(MinBandwidth)); } } @@ -105,7 +117,7 @@ public ulong MaxBandwidth set { maxBandwidth = value; - RaisePropertyChanged("MaxBandwidth"); + RaisePropertyChanged(nameof(MaxBandwidth)); } } @@ -115,7 +127,7 @@ public ushort MaxPacketSize set { maxPacketSize = value; - RaisePropertyChanged("MaxPacketSize"); + RaisePropertyChanged(nameof(MaxPacketSize)); } } @@ -125,7 +137,7 @@ public double MinErrorToSend set { minErrorToSend = value; - RaisePropertyChanged("MinErrorToSend"); + RaisePropertyChanged(nameof(MinErrorToSend)); } } @@ -135,7 +147,7 @@ public double MinErrorToSendNear set { minErrorToSendNear = value; - RaisePropertyChanged("MinErrorToSendNear"); + RaisePropertyChanged(nameof(MinErrorToSendNear)); } } @@ -145,11 +157,12 @@ public ushort MaxCustomFileSize set { maxCustomFileSize = value; - RaisePropertyChanged("MaxCustomFileSize"); + RaisePropertyChanged(nameof(MaxCustomFileSize)); } } [XmlIgnore] + [Newtonsoft.Json.JsonIgnore] public string PerfPreset { get => "Custom"; @@ -181,7 +194,7 @@ public string PerfPreset MinBandwidth = 1000000000; break; } - RaisePropertyChanged("PerfPreset"); + RaisePropertyChanged(nameof(PerfPreset)); } } @@ -191,7 +204,7 @@ public BasicCfg() public string ProcessFile() { string output = "// These options are created by default\r\n" - + "language=\"English\";\r\n" + + $"language=\"{_language}\";\r\n" + "adapter=-1;\r\n" + "3D_Performance=1.000000;\r\n" + "Resolution_W=800;\r\n" diff --git a/FASTER/Models/SteamWebApi.cs b/FASTER/Models/SteamWebApi.cs index 66b823fd..45c6e21e 100644 --- a/FASTER/Models/SteamWebApi.cs +++ b/FASTER/Models/SteamWebApi.cs @@ -84,9 +84,13 @@ private static JObject ApiCall(string uri) // Display the status. Console.WriteLine((response)?.StatusCode); - return response == null - ? null - : JObject.Parse(response.Content.ReadAsStringAsync().Result); + if (response == null) + return null; + + if (!response.IsSuccessStatusCode) + throw new Exception($"Steam API returned HTTP {(int)response.StatusCode} {response.StatusCode}. Please check your Steam API Key in Settings."); + + return JObject.Parse(response.Content.ReadAsStringAsync().Result); // Return the response } diff --git a/FASTER/ViewModel/DeploymentViewModel.cs b/FASTER/ViewModel/DeploymentViewModel.cs index 2a38f414..465d856e 100644 --- a/FASTER/ViewModel/DeploymentViewModel.cs +++ b/FASTER/ViewModel/DeploymentViewModel.cs @@ -108,12 +108,25 @@ public void DeployAll() {"Name", Settings.Default.steamUserName} }); + Logger.Log($"DeployAll: installPath={Deployment.InstallPath}, mods={Deployment.DeployMods.Count}"); + + if (!Directory.Exists(Deployment.InstallPath)) + { + Logger.Log("DeployAll: install path not found, aborting."); + DisplayMessage("Arma Install Path is empty.\nMake sure you have entered a valid path before deploying mods."); + return; + } + foreach (var mod in Deployment.DeployMods) { var linkPath = Path.Combine(Deployment.InstallPath, $"@{Functions.SafeName(mod.Name)}"); mod.Marked = true; + Logger.Log($" Linking {mod.Name}: {mod.Path} -> {linkPath}"); LinkMod(mod, linkPath); } + Settings.Default.Deployments = Deployment; + Settings.Default.Save(); + Logger.Log("DeployAll: done."); } /// @@ -204,15 +217,36 @@ public void OpenModPage(DeploymentMod mod) /// private void LinkMod(DeploymentMod mod, string linkPath) { + Logger.Log($"LinkMod: {mod.Name} ({mod.WorkshopId}) -> {linkPath}"); try { if(Directory.Exists(linkPath)) - Directory.Delete(linkPath, true); + { + if (new DirectoryInfo(linkPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + Logger.Log($" Removing existing symlink: {linkPath}"); + Directory.Delete(linkPath); + } + else + { + Logger.Log($" Removing existing real dir: {linkPath}"); + Directory.Delete(linkPath, true); + } + } Directory.CreateSymbolicLink(linkPath ?? throw new ArgumentNullException(nameof(linkPath)), mod.Path); + Logger.Log($" Symlink created OK."); + } + catch (UnauthorizedAccessException) + { + Logger.Log($" ERROR: UnauthorizedAccessException creating symlink."); + DisplayMessage("Could not create symlink: Access denied.\n\nTo deploy mods, enable Windows Developer Mode in Settings → Update & Security → For Developers, or run FASTER as Administrator."); } catch (Exception ex) - { DisplayMessage("An exception occurred: \n\n" + ex.Message); } + { + Logger.Log($" ERROR: {ex.Message}"); + DisplayMessage("An exception occurred: \n\n" + ex.Message); + } } /// @@ -224,7 +258,12 @@ private void DeleteLink(string linkPath) try { if (Directory.Exists(linkPath)) - Directory.Delete(linkPath, true); + { + if (new DirectoryInfo(linkPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) + Directory.Delete(linkPath); + else + Directory.Delete(linkPath, true); + } } catch (Exception ex) { DisplayMessage("An exception occurred: \n\n" + ex.Message); } diff --git a/FASTER/ViewModel/SteamUpdaterViewModel.cs b/FASTER/ViewModel/SteamUpdaterViewModel.cs index ee740d8b..e2d451e3 100644 --- a/FASTER/ViewModel/SteamUpdaterViewModel.cs +++ b/FASTER/ViewModel/SteamUpdaterViewModel.cs @@ -415,13 +415,22 @@ public async Task RunModUpdater(ulong modId, string path) public async Task RunModsUpdater(ObservableCollection mods) { + Logger.Log($"RunModsUpdater: starting, {mods.Count} mods total"); tokenSource = new CancellationTokenSource(); - if(!await SteamLogin()) - { + + Logger.Log("RunModsUpdater: calling SteamLogin..."); + bool loginOk = false; + try { loginOk = await SteamLogin(); } + catch (Exception ex) { Logger.Log($"RunModsUpdater: SteamLogin threw exception: {ex}"); } + + if (!loginOk) + { + Logger.Log("RunModsUpdater: SteamLogin failed, aborting."); IsLoggingIn = false; return UpdateState.LoginFailed; } + Logger.Log("RunModsUpdater: SteamLogin OK"); Parameters.Output += "\nAdding mods to download list..."; @@ -429,91 +438,122 @@ public async Task RunModsUpdater(ObservableCollection mods) var ml = mods.Where(m => !m.IsLocal).ToList(); uint finished = 0; IsDlOverride = true; + Logger.Log($"RunModsUpdater: {ml.Count} non-local mods to update"); foreach (ArmaMod mod in ml) { + Logger.Log($"RunModsUpdater: waiting semaphore for mod {mod.WorkshopId} ({mod.Name})"); await maxThread.WaitAsync(); - _ = Task.Factory.StartNew(() => + _ = Task.Factory.StartNew(async () => { - if (!Directory.Exists(mod.Path)) - Directory.CreateDirectory(mod.Path); - - if (tokenSource.Token.IsCancellationRequested) - { - mod.Status = ArmaModStatus.NotComplete; - return; - } - Parameters.Output += $"\n Starting {mod.WorkshopId}"; - - Stopwatch sw = Stopwatch.StartNew(); + Logger.Log($" Task started: {mod.WorkshopId} ({mod.Name}), path={mod.Path}"); try { - ManifestId manifestId = default; - - if(mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) + if (!Directory.Exists(mod.Path)) { - mod.Status = ArmaModStatus.UpToDate; - Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; - return; + Logger.Log($" Creating dir: {mod.Path}"); + Directory.CreateDirectory(mod.Path); } - if (!SteamClient.Credentials.IsAnonymous) //IS SYNC NEABLED + if (tokenSource.Token.IsCancellationRequested) { - Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; - manifestId = SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId).Result.hcontent_file; - Manifest manifest = SteamContentClient.GetManifestAsync(107410, 107410, manifestId).Result; - Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; - SyncDeleteRemovedFiles(mod.Path, manifest); + Logger.Log($" Cancellation requested before {mod.WorkshopId}, skipping."); + return; } + Parameters.Output += $"\n Starting {mod.WorkshopId}"; - Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; - - var downloadHandler = SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); - DownloadForMultiple(downloadHandler.Result, mod.Path).Wait(); + Stopwatch sw = Stopwatch.StartNew(); + try + { + ManifestId manifestId = default; + + if(mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) + { + mod.Status = ArmaModStatus.UpToDate; + Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; + Logger.Log($" {mod.WorkshopId} already up to date, skipping."); + return; + } + + if (!SteamClient.Credentials.IsAnonymous) + { + Logger.Log($" Getting manifest for {mod.WorkshopId}"); + Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; + manifestId = (await SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId)).hcontent_file; + Manifest manifest = await SteamContentClient.GetManifestAsync(107410, 107410, manifestId); + Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; + Logger.Log($" Manifest retrieved for {mod.WorkshopId}, syncing deleted files..."); + SyncDeleteRemovedFiles(mod.Path, manifest); + } + + Logger.Log($" Requesting download handler for {mod.WorkshopId}"); + Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; + + var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); + Logger.Log($" Download handler obtained for {mod.WorkshopId}, starting download..."); + await DownloadForMultiple(downloadHandler, mod.Path); + Logger.Log($" Download complete for {mod.WorkshopId}"); - mod.Status = ArmaModStatus.UpToDate; - var nx = DateTime.UnixEpoch; - var ts = DateTime.UtcNow - nx; - mod.LocalLastUpdated = (ulong)ts.TotalSeconds; - } - catch (TaskCanceledException) - { + mod.Status = ArmaModStatus.UpToDate; + var nx = DateTime.UnixEpoch; + var ts = DateTime.UtcNow - nx; + mod.LocalLastUpdated = (ulong)ts.TotalSeconds; + } + catch (TaskCanceledException) + { + Logger.Log($" {mod.WorkshopId} task cancelled."); + sw.Stop(); + mod.Status = ArmaModStatus.NotComplete; + } + catch (Exception ex) + { + Logger.Log($" ERROR downloading {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}{(ex.InnerException != null ? $" | Inner: {ex.InnerException.Message}" : "")}\n StackTrace: {ex.StackTrace}"); + sw.Stop(); + mod.Status = ArmaModStatus.NotComplete; + Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; + } sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; + + mod.CheckModSize(); + Parameters.Output += $"\n Download {mod.WorkshopId} completed, it took {sw.Elapsed.Minutes + sw.Elapsed.Hours*60}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; } catch (Exception ex) { - sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; + Logger.Log($" UNHANDLED ERROR in task for {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); } - sw.Stop(); - - mod.CheckModSize(); - - Parameters.Output += $"\n Download {mod.WorkshopId} completed, it took {sw.Elapsed.Minutes + sw.Elapsed.Hours*60}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; - }, TaskCreationOptions.LongRunning).ContinueWith((_) => + }, TaskCreationOptions.LongRunning).Unwrap().ContinueWith((t) => { + if (t.IsFaulted) + Logger.Log($" ContinueWith: task for {mod.WorkshopId} faulted: {t.Exception}"); finished += 1; Parameters.Output += $"\n Thread {mod.WorkshopId} complete ({finished} / {ml.Count})"; Parameters.Progress = finished * ml.Count / 100.00; + Logger.Log($" ContinueWith: mod {mod.WorkshopId} done ({finished}/{ml.Count}), releasing semaphore."); maxThread.Release(); }); } + Logger.Log("RunModsUpdater: all tasks queued, waiting for last semaphore..."); Parameters.Output += "\nAlmost there..."; - await maxThread.WaitAsync(); - + try + { + await maxThread.WaitAsync(); + } + finally + { + IsDlOverride = false; + } + Logger.Log("RunModsUpdater: all done."); Parameters.Output += "\nMods updated !"; - IsDlOverride = false; return UpdateState.Success; } internal async Task SteamLogin() { + Logger.Log("SteamLogin: start"); if (tokenSource.IsCancellationRequested) tokenSource = new CancellationTokenSource(); IsLoggingIn = true; @@ -538,14 +578,17 @@ internal async Task SteamLogin() try { await SteamClient.ConnectAsync(tokenSource.Token); } catch (SteamClientAlreadyRunningException) - { - Parameters.Output += $"\nClient already logged in."; + { + Logger.Log("SteamLogin: SteamClientAlreadyRunningException - client already running"); + Parameters.Output += $"\nClient already logged in."; IsLoggingIn = false; return false; } catch (Exception ex) { + Logger.Log($"SteamLogin: ConnectAsync failed: {ex.GetType().Name}: {ex.Message}\nStackTrace: {ex.StackTrace}"); Parameters.Output += $"\nFailed! Error: {ex.Message}"; + var savedUsername = SteamClient?.Credentials.Username; SteamClient.Shutdown(); SteamClient.Dispose(); SteamClient = null; @@ -553,7 +596,7 @@ internal async Task SteamLogin() if (ex.GetBaseException() is SteamAuthenticationException) { Parameters.Output += "\nWarning: The logon may have failed due to expired sentry-data." - + $"\nIf you are sure that the provided username and password are correct, consider deleting the token file for the user \"{SteamClient?.Credentials.Username}\" in the sentries directory." + + $"\nIf you are sure that the provided username and password are correct, consider deleting the token file for the user \"{savedUsername}\" in the sentries directory." + $"{path}"; } IsLoggingIn = false; @@ -561,8 +604,10 @@ internal async Task SteamLogin() } } + Logger.Log($"SteamLogin: creating SteamContentClient with {Properties.Settings.Default.CliWorkers} workers"); SteamContentClient = new SteamContentClient(SteamClient, Properties.Settings.Default.CliWorkers); Parameters.Output += "\nConnected !"; + Logger.Log("SteamLogin: connected OK"); IsLoggingIn = false; return SteamClient.IsConnected; } @@ -685,28 +730,64 @@ private async Task Download(IDownloadHandler downloadHandler, string targetDir) private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string targetDir) { + Logger.Log($"DownloadForMultiple: targetDir={targetDir}"); if (targetDir == null) + { + Logger.Log("DownloadForMultiple: targetDir is null, aborting."); return; + } if (!Directory.Exists(targetDir)) + { + Logger.Log($"DownloadForMultiple: creating dir {targetDir}"); Directory.CreateDirectory(targetDir); + } tokenSource.Token.ThrowIfCancellationRequested(); ulong downloadedSize = 0; - downloadHandler.FileVerified += (_, args) => Parameters.Output += $"{(args.RequiresDownload ? $"\n File verified : {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})" : "")}"; - downloadHandler.VerificationCompleted += (_, args) => Parameters.Output += $"\n Verification completed, {args.QueuedFiles.Count} files queued for download. ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"; + downloadHandler.FileVerified += (_, args) => + { + if (args.RequiresDownload) + { + Logger.Log($" File verified (needs download): {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})"); + Parameters.Output += $"\n File verified : {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})"; + } + }; + downloadHandler.VerificationCompleted += (_, args) => + { + Logger.Log($" Verification completed: {args.QueuedFiles.Count} files queued ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"); + Parameters.Output += $"\n Verification completed, {args.QueuedFiles.Count} files queued for download. ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"; + }; downloadHandler.FileDownloaded += (_, args) => - { - downloadedSize += args.TotalSize; - Parameters.Output += $"\n Progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)} / {Functions.ParseFileSize(downloadHandler.TotalFileSize)})"; - }; - downloadHandler.DownloadComplete += (_, _) => Parameters.Output += "\n Download completed"; + { + downloadedSize += args.TotalSize; + Logger.Log($" File downloaded: progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)}/{Functions.ParseFileSize(downloadHandler.TotalFileSize)})"); + Parameters.Output += $"\n Progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)} / {Functions.ParseFileSize(downloadHandler.TotalFileSize)})"; + }; + downloadHandler.DownloadComplete += (_, _) => + { + Logger.Log(" DownloadComplete event fired."); + Parameters.Output += "\n Download completed"; + }; + Logger.Log($"DownloadForMultiple: starting Task.Run (Setup/Verify/Download), totalFiles={downloadHandler.TotalFileCount}, totalSize={Functions.ParseFileSize(downloadHandler.TotalFileSize)}"); Task downloadTask = Task.Run(async () => { - await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); - await downloadHandler.VerifyAsync(tokenSource.Token); - await downloadHandler.DownloadAsync(tokenSource.Token); + try + { + Logger.Log(" SetupAsync starting..."); + await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); + Logger.Log(" SetupAsync done. VerifyAsync starting..."); + await downloadHandler.VerifyAsync(tokenSource.Token); + Logger.Log(" VerifyAsync done. DownloadAsync starting..."); + await downloadHandler.DownloadAsync(tokenSource.Token); + Logger.Log(" DownloadAsync done."); + } + catch (Exception ex) + { + Logger.Log($" ERROR inside download Task.Run: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); + throw; + } }); Parameters.Output += "\n OK."; @@ -717,7 +798,6 @@ private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string Parameters.Progress = 0; while (!downloadTask.IsCompleted && !downloadTask.IsCanceled && !tokenSource.IsCancellationRequested) { - var delayTask = Task.Delay(500, tokenSource.Token); await Task.WhenAny(delayTask, downloadTask); @@ -727,6 +807,7 @@ private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string if (downloadTask.IsCanceled) { + Logger.Log("DownloadForMultiple: task was cancelled."); Parameters.Output += "\n Task Cancelled"; DownloadTasks.Remove(downloadTask); await downloadHandler.DisposeAsync(); @@ -737,11 +818,18 @@ private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string { await downloadTask; } catch (TaskCanceledException) { + Logger.Log("DownloadForMultiple: TaskCanceledException caught on await."); Parameters.Output += "\n Task Cancelled"; throw; } + catch (Exception ex) + { + Logger.Log($"DownloadForMultiple: exception on await downloadTask: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); + throw; + } finally { + Logger.Log("DownloadForMultiple: finalizing, disposing handler."); DownloadTasks.Remove(downloadTask); await downloadHandler.DisposeAsync(); downloadTask.Dispose(); From 926b77b99ec9e8fd0f17cc51899233eda06f7045 Mon Sep 17 00:00:00 2001 From: diaverso Date: Fri, 15 May 2026 19:06:19 +0200 Subject: [PATCH 2/3] fix: extract static field write to static method to resolve S2696 SonarCloud rule S2696 flags writing to static fields from instance methods. Extracted _apiKeyWarningShown write into TryShowApiKeyWarning() static method so the field is only accessed from static context. Co-Authored-By: Claude Sonnet 4.6 --- FASTER/Models/ArmaMod.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/FASTER/Models/ArmaMod.cs b/FASTER/Models/ArmaMod.cs index 0a8e8675..e710bece 100644 --- a/FASTER/Models/ArmaMod.cs +++ b/FASTER/Models/ArmaMod.cs @@ -107,6 +107,13 @@ public class ArmaMod : INotifyPropertyChanged private bool _isSelected; private static bool _apiKeyWarningShown = false; + private static bool TryShowApiKeyWarning() + { + if (_apiKeyWarningShown) return false; + _apiKeyWarningShown = true; + return true; + } + public uint WorkshopId { @@ -338,9 +345,8 @@ internal void UpdateInfos(bool checkFileSize = true) success = true; } while (failNum < 3 && !success); - if (!success && !_apiKeyWarningShown) + if (!success && TryShowApiKeyWarning()) { - _apiKeyWarningShown = true; MainWindow.Instance?.Dispatcher.Invoke(() => MainWindow.Instance.DisplayMessage("Could not fetch mod info. Please check your Steam API Key in Settings.")); } From e047703ca0ee205de5910ecc6272b8b8ec4b3fc6 Mon Sep 17 00:00:00 2001 From: diaverso Date: Mon, 18 May 2026 14:23:49 +0200 Subject: [PATCH 3/3] fix: SonarCloud S2699/S3237, reduce RunModsUpdater complexity, add crash logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FASTER/Models/SteamWebApi.cs - S2699: Replace throw new Exception() with throw new HttpRequestException() so callers can catch a specific type instead of base Exception FASTER/ViewModel/SteamUpdaterViewModel.cs - S3776: Extract inner async lambda from RunModsUpdater into ProcessModDownloadAsync(ArmaMod mod) private method, reducing cognitive complexity from 36 to well below the 25 limit FASTER/App.xaml.cs - Add AppDomain.UnhandledException, DispatcherUnhandledException and TaskScheduler.UnobservedTaskException handlers that write to the debug log file — captures silent fatal crashes (e.g. native exceptions from BytexDigital.Steam during DownloadAsync) that bypass try/catch Co-Authored-By: Claude Sonnet 4.6 --- FASTER/App.xaml.cs | 18 +++ FASTER/Models/SteamWebApi.cs | 2 +- FASTER/ViewModel/SteamUpdaterViewModel.cs | 158 +++++++++++----------- 3 files changed, 98 insertions(+), 80 deletions(-) diff --git a/FASTER/App.xaml.cs b/FASTER/App.xaml.cs index 37a0d6d3..f5ac7d72 100644 --- a/FASTER/App.xaml.cs +++ b/FASTER/App.xaml.cs @@ -1,10 +1,13 @@  +using FASTER.Models; + using Microsoft.AppCenter; using Microsoft.AppCenter.Analytics; using Microsoft.AppCenter.Crashes; using System; using System.Globalization; +using System.Threading.Tasks; using System.Windows; using ControlzEx.Theming; @@ -19,6 +22,21 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + AppDomain.CurrentDomain.UnhandledException += (_, args) => + Logger.Log($"[FATAL] Unhandled exception (CLR): {args.ExceptionObject}"); + + DispatcherUnhandledException += (_, args) => + { + Logger.Log($"[FATAL] Unhandled dispatcher exception: {args.Exception}"); + args.Handled = true; + }; + + TaskScheduler.UnobservedTaskException += (_, args) => + { + Logger.Log($"[FATAL] Unobserved task exception: {args.Exception}"); + args.SetObserved(); + }; + var countryCode = RegionInfo.CurrentRegion.TwoLetterISORegionName; var userID = AppCenter.GetInstallIdAsync(); diff --git a/FASTER/Models/SteamWebApi.cs b/FASTER/Models/SteamWebApi.cs index 45c6e21e..a23a38cd 100644 --- a/FASTER/Models/SteamWebApi.cs +++ b/FASTER/Models/SteamWebApi.cs @@ -88,7 +88,7 @@ private static JObject ApiCall(string uri) return null; if (!response.IsSuccessStatusCode) - throw new Exception($"Steam API returned HTTP {(int)response.StatusCode} {response.StatusCode}. Please check your Steam API Key in Settings."); + throw new HttpRequestException($"Steam API returned HTTP {(int)response.StatusCode} {response.StatusCode}. Please check your Steam API Key in Settings."); return JObject.Parse(response.Content.ReadAsStringAsync().Result); diff --git a/FASTER/ViewModel/SteamUpdaterViewModel.cs b/FASTER/ViewModel/SteamUpdaterViewModel.cs index e2d451e3..a4a97c09 100644 --- a/FASTER/ViewModel/SteamUpdaterViewModel.cs +++ b/FASTER/ViewModel/SteamUpdaterViewModel.cs @@ -445,85 +445,9 @@ public async Task RunModsUpdater(ObservableCollection mods) Logger.Log($"RunModsUpdater: waiting semaphore for mod {mod.WorkshopId} ({mod.Name})"); await maxThread.WaitAsync(); - _ = Task.Factory.StartNew(async () => - { - Logger.Log($" Task started: {mod.WorkshopId} ({mod.Name}), path={mod.Path}"); - try - { - if (!Directory.Exists(mod.Path)) - { - Logger.Log($" Creating dir: {mod.Path}"); - Directory.CreateDirectory(mod.Path); - } - - if (tokenSource.Token.IsCancellationRequested) - { - Logger.Log($" Cancellation requested before {mod.WorkshopId}, skipping."); - return; - } - Parameters.Output += $"\n Starting {mod.WorkshopId}"; - - Stopwatch sw = Stopwatch.StartNew(); - try - { - ManifestId manifestId = default; - - if(mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) - { - mod.Status = ArmaModStatus.UpToDate; - Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; - Logger.Log($" {mod.WorkshopId} already up to date, skipping."); - return; - } - - if (!SteamClient.Credentials.IsAnonymous) - { - Logger.Log($" Getting manifest for {mod.WorkshopId}"); - Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; - manifestId = (await SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId)).hcontent_file; - Manifest manifest = await SteamContentClient.GetManifestAsync(107410, 107410, manifestId); - Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; - Logger.Log($" Manifest retrieved for {mod.WorkshopId}, syncing deleted files..."); - SyncDeleteRemovedFiles(mod.Path, manifest); - } - - Logger.Log($" Requesting download handler for {mod.WorkshopId}"); - Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; - - var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); - Logger.Log($" Download handler obtained for {mod.WorkshopId}, starting download..."); - await DownloadForMultiple(downloadHandler, mod.Path); - Logger.Log($" Download complete for {mod.WorkshopId}"); - - mod.Status = ArmaModStatus.UpToDate; - var nx = DateTime.UnixEpoch; - var ts = DateTime.UtcNow - nx; - mod.LocalLastUpdated = (ulong)ts.TotalSeconds; - } - catch (TaskCanceledException) - { - Logger.Log($" {mod.WorkshopId} task cancelled."); - sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; - } - catch (Exception ex) - { - Logger.Log($" ERROR downloading {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}{(ex.InnerException != null ? $" | Inner: {ex.InnerException.Message}" : "")}\n StackTrace: {ex.StackTrace}"); - sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; - } - sw.Stop(); - - mod.CheckModSize(); - Parameters.Output += $"\n Download {mod.WorkshopId} completed, it took {sw.Elapsed.Minutes + sw.Elapsed.Hours*60}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; - } - catch (Exception ex) - { - Logger.Log($" UNHANDLED ERROR in task for {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); - } - - }, TaskCreationOptions.LongRunning).Unwrap().ContinueWith((t) => + _ = Task.Factory.StartNew( + () => ProcessModDownloadAsync(mod), + TaskCreationOptions.LongRunning).Unwrap().ContinueWith((t) => { if (t.IsFaulted) Logger.Log($" ContinueWith: task for {mod.WorkshopId} faulted: {t.Exception}"); @@ -551,6 +475,82 @@ public async Task RunModsUpdater(ObservableCollection mods) return UpdateState.Success; } + private async Task ProcessModDownloadAsync(ArmaMod mod) + { + Logger.Log($" Task started: {mod.WorkshopId} ({mod.Name}), path={mod.Path}"); + try + { + if (!Directory.Exists(mod.Path)) + { + Logger.Log($" Creating dir: {mod.Path}"); + Directory.CreateDirectory(mod.Path); + } + + if (tokenSource.Token.IsCancellationRequested) + { + Logger.Log($" Cancellation requested before {mod.WorkshopId}, skipping."); + return; + } + Parameters.Output += $"\n Starting {mod.WorkshopId}"; + + Stopwatch sw = Stopwatch.StartNew(); + try + { + ManifestId manifestId = default; + + if (mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) + { + mod.Status = ArmaModStatus.UpToDate; + Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; + Logger.Log($" {mod.WorkshopId} already up to date, skipping."); + return; + } + + if (!SteamClient.Credentials.IsAnonymous) + { + Logger.Log($" Getting manifest for {mod.WorkshopId}"); + Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; + manifestId = (await SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId)).hcontent_file; + Manifest manifest = await SteamContentClient.GetManifestAsync(107410, 107410, manifestId); + Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; + Logger.Log($" Manifest retrieved for {mod.WorkshopId}, syncing deleted files..."); + SyncDeleteRemovedFiles(mod.Path, manifest); + } + + Logger.Log($" Requesting download handler for {mod.WorkshopId}"); + Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; + + var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); + Logger.Log($" Download handler obtained for {mod.WorkshopId}, starting download..."); + await DownloadForMultiple(downloadHandler, mod.Path); + Logger.Log($" Download complete for {mod.WorkshopId}"); + + mod.Status = ArmaModStatus.UpToDate; + mod.LocalLastUpdated = (ulong)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds; + } + catch (TaskCanceledException) + { + Logger.Log($" {mod.WorkshopId} task cancelled."); + sw.Stop(); + mod.Status = ArmaModStatus.NotComplete; + } + catch (Exception ex) + { + Logger.Log($" ERROR downloading {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}{(ex.InnerException != null ? $" | Inner: {ex.InnerException.Message}" : "")}\n StackTrace: {ex.StackTrace}"); + sw.Stop(); + mod.Status = ArmaModStatus.NotComplete; + Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; + } + sw.Stop(); + mod.CheckModSize(); + Parameters.Output += $"\n Download {mod.WorkshopId} completed in {sw.Elapsed.Hours * 60 + sw.Elapsed.Minutes}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; + } + catch (Exception ex) + { + Logger.Log($" UNHANDLED ERROR in task for {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); + } + } + internal async Task SteamLogin() { Logger.Log("SteamLogin: start");