diff --git a/CreamInstaller/Forms/SelectForm.cs b/CreamInstaller/Forms/SelectForm.cs index 6d38c07..9efc140 100644 --- a/CreamInstaller/Forms/SelectForm.cs +++ b/CreamInstaller/Forms/SelectForm.cs @@ -177,6 +177,7 @@ internal sealed partial class SelectForm : CustomForm } int steamGamesToCheck; + TaskCompletionSource gameQueriesDone = new(); if (uninstallAll || programsToScan.Any(c => c.platform is Platform.Steam)) { Stopwatch steamLibTimer = Stopwatch.StartNew(); @@ -244,6 +245,8 @@ internal sealed partial class SelectForm : CustomForm return; StoreAppData storeAppData = await SteamStore.QueryStoreAPI(appId); _ = Interlocked.Decrement(ref steamGamesToCheck); + if (Volatile.Read(ref steamGamesToCheck) == 0) + gameQueriesDone.TrySetResult(); CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), 16000); if (storeAppData is null && cmdAppData is null) { @@ -273,9 +276,12 @@ internal sealed partial class SelectForm : CustomForm { if (Program.Canceled) return; - do // give games steam store api limit priority - Thread.Sleep(200); - while (!Program.Canceled && steamGamesToCheck > 0); + while (!Program.Canceled) + { + Task completed = await Task.WhenAny(gameQueriesDone.Task, Task.Delay(250)); + if (completed == gameQueriesDone.Task) + break; + } if (Program.Canceled) return; string fullGameAppId = null; @@ -374,7 +380,7 @@ internal sealed partial class SelectForm : CustomForm await task; } - steamGamesToCheck = 0; + gameQueriesDone.TrySetResult(); if (dlc.IsEmpty) { ProgramData.Log($"[Steam] Skipping {name} ({appId}): no DLCs remained after processing"); @@ -653,7 +659,7 @@ internal sealed partial class SelectForm : CustomForm } gameDlcTimer.Stop(); - steamGamesToCheck = 0; + gameQueriesDone.TrySetResult(); scanTimer.Stop(); ProgramData.Log($"[Scan] Library scan total: {totalLibraryScanSeconds:F1}s across all platforms"); diff --git a/CreamInstaller/Forms/UpdateForm.cs b/CreamInstaller/Forms/UpdateForm.cs index 4b4eb72..ddbfd31 100644 --- a/CreamInstaller/Forms/UpdateForm.cs +++ b/CreamInstaller/Forms/UpdateForm.cs @@ -62,7 +62,7 @@ internal sealed partial class UpdateForm : CustomForm Version currentVersion = new(Program.Version); #endif List releases = null; - string response = + (string response, _) = await HttpClientManager.EnsureGet( $"https://api.github.com/repos/{Program.RepositoryOwner}/{Program.RepositoryName}/releases"); if (response is not null) diff --git a/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs b/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs index e23cc2d..4dc389f 100644 --- a/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs +++ b/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs @@ -21,8 +21,15 @@ internal static partial class SteamCMD bool cachedExists = cacheFile.FileExists(); if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame)) { - string response = + (string response, bool permanentFailure) = await HttpClientManager.EnsureGet($"https://api.steamcmd.net/v1/info/{appId}"); + if (permanentFailure) + { + ProgramData.LogSteam("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + + appId + (isDlc ? " (DLC)" : "") + + ": Permanent failure, aborting retries"); + return null; + } if (response is not null) { try @@ -83,13 +90,14 @@ internal static partial class SteamCMD if (isDlc) break; - if (attempts > 10) + if (attempts > 3) { - ProgramData.LogSteam("[SteamAPI] Failed to query SteamCMD web API after 10 tries: " + appId); + ProgramData.LogSteam("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId); break; } - Thread.Sleep(1000); + int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000); + await Task.Delay(delayMs + Random.Shared.Next(0, 1000)); } return null; diff --git a/CreamInstaller/Platforms/Steam/SteamCMD.cs b/CreamInstaller/Platforms/Steam/SteamCMD.cs index 2f3e600..beccf0d 100644 --- a/CreamInstaller/Platforms/Steam/SteamCMD.cs +++ b/CreamInstaller/Platforms/Steam/SteamCMD.cs @@ -41,7 +41,7 @@ internal static partial class SteamCMD : $"+login anonymous +app_info_print {appId} +quit"; private static async Task Run(string appId) - => await Task.Run(() => + => await Task.Run(async () => { while (true) { @@ -72,10 +72,14 @@ internal static partial class SteamCMD StandardErrorEncoding = Encoding.UTF8 }; Process process = Process.Start(processStartInfo); + // Drain stderr asynchronously to prevent pipe deadlock + process.BeginErrorReadLine(); StringBuilder output = new(); StringBuilder appInfo = new(); bool appInfoStarted = false; DateTime lastOutput = DateTime.UtcNow; + const int bufferSize = 4096; + char[] buffer = new char[bufferSize]; while (process != null) { if (Program.Canceled) @@ -85,21 +89,30 @@ internal static partial class SteamCMD break; } - int c = process.StandardOutput.Read(); - if (c != -1) + // Buffered read: ReadAsync returns up to bufferSize chars per call, + // with Task.WhenAny providing a 5s idle timeout + Task readTask = process.StandardOutput.ReadAsync(buffer, 0, bufferSize); + if (await Task.WhenAny(readTask, Task.Delay(5000)) == readTask) { - lastOutput = DateTime.UtcNow; - char ch = (char)c; - if (ch == '{') - appInfoStarted = true; - _ = appInfoStarted ? appInfo.Append(ch) : output.Append(ch); + int charsRead = await readTask; + if (charsRead > 0) + { + lastOutput = DateTime.UtcNow; + for (int j = 0; j < charsRead; j++) + { + char ch = buffer[j]; + if (ch == '{') + appInfoStarted = true; + _ = appInfoStarted ? appInfo.Append(ch) : output.Append(ch); + } + continue; + } + // charsRead == 0: stream closed, process exited naturally } + // else: timeout — 5 seconds without any output - DateTime now = DateTime.UtcNow; - TimeSpan timeDiff = now - lastOutput; - if (!(timeDiff.TotalSeconds > 0.1)) - continue; - process.Kill(true); + if (!process.HasExited) + process.Kill(true); process.Close(); if (appId != null && output.ToString().Contains($"No app info for AppID {appId} found, requesting...")) @@ -107,6 +120,7 @@ internal static partial class SteamCMD AttemptCount[appId]++; processStartInfo.Arguments = GetArguments(appId); process = Process.Start(processStartInfo); + process.BeginErrorReadLine(); appInfoStarted = false; _ = output.Clear(); _ = appInfo.Clear(); @@ -119,7 +133,7 @@ internal static partial class SteamCMD return appInfo.ToString(); } - Thread.Sleep(200); + await Task.Delay(200); } }); diff --git a/CreamInstaller/Platforms/Steam/SteamStore.cs b/CreamInstaller/Platforms/Steam/SteamStore.cs index 4767630..eba016b 100644 --- a/CreamInstaller/Platforms/Steam/SteamStore.cs +++ b/CreamInstaller/Platforms/Steam/SteamStore.cs @@ -55,8 +55,14 @@ internal static class SteamStore bool cachedExists = cacheFile.FileExists(); if (!cachedExists || ProgramData.CheckCooldown(appId, isDlc ? CooldownDlc : CooldownGame)) { - string response = + (string response, bool permanentFailure) = await HttpClientManager.EnsureGet($"https://store.steampowered.com/api/appdetails?appids={appId}"); + if (permanentFailure) + { + ProgramData.LogSteam( + "[SteamAPI] " + FormatErrorLog(attempts, appId, gameName, isDlc, "Permanent failure, aborting retries", parentGameName, parentGameAppId)); + return null; + } if (response is not null) { Dictionary apps = @@ -133,14 +139,15 @@ internal static class SteamStore if (isDlc) break; - if (attempts > 10) + if (attempts > 3) { ProgramData.LogSteam( - "[SteamAPI] " + FormatErrorLog(attempts, appId, gameName, isDlc, "Maximum retry attempts exceeded (10)", parentGameName, parentGameAppId)); + "[SteamAPI] " + FormatErrorLog(attempts, appId, gameName, isDlc, "Maximum retry attempts exceeded (3)", parentGameName, parentGameAppId)); break; } - Thread.Sleep(1000); + int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000); + await Task.Delay(delayMs + Random.Shared.Next(0, 1000)); } return null; diff --git a/CreamInstaller/Utility/HttpClientManager.cs b/CreamInstaller/Utility/HttpClientManager.cs index 86247c9..9d1293c 100644 --- a/CreamInstaller/Utility/HttpClientManager.cs +++ b/CreamInstaller/Utility/HttpClientManager.cs @@ -66,7 +66,7 @@ internal static class HttpClientManager } } - internal static async Task EnsureGet(string url) + internal static async Task<(string content, bool permanentFailure)> EnsureGet(string url) { try { @@ -75,37 +75,40 @@ internal static class HttpClientManager await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); if (response.StatusCode is HttpStatusCode.NotModified && HttpContentCache.TryGetValue(url, out string content)) - return content; + return (content, false); _ = response.EnsureSuccessStatusCode(); content = await response.Content.ReadAsStringAsync(); HttpContentCache[url] = content; - return content; + return (content, false); } catch (HttpRequestException e) { - if (e.StatusCode != HttpStatusCode.TooManyRequests) + if (e.StatusCode is not null) { - string statusInfo = e.StatusCode.HasValue ? $" (HTTP {(int)e.StatusCode.Value})" : ""; - ProgramData.LogSteam($"[SteamAPI] Get request failed to {url}{statusInfo}: {e.Message}"); - return null; + int code = (int)e.StatusCode.Value; + bool permanent = code is >= 400 and < 500 and not 429; + string label = permanent ? "Permanent failure" : code == 429 ? "Too many requests" : "Get request failed"; + string statusInfo = $" (HTTP {code}{(permanent ? " - Permanent" : code == 429 ? " - Rate Limited" : "")})"; + ProgramData.LogSteam($"[SteamAPI] {label} to {url}{statusInfo}: {e.Message}"); + return (null, permanent); } - ProgramData.LogSteam($"[SteamAPI] Too many requests to {url} (HTTP 429 - Rate Limited)"); - return null; + ProgramData.LogSteam($"[SteamAPI] Get request failed to {url}: {e.Message}"); + return (null, false); } catch (TaskCanceledException) { ProgramData.LogSteam("[SteamAPI] Get request timed out for " + url); - return null; + return (null, false); } catch (OperationCanceledException) { ProgramData.LogSteam("[SteamAPI] Get request was cancelled for " + url); - return null; + return (null, false); } catch (Exception e) { ProgramData.LogSteam("[SteamAPI] Get request failed to " + url + ": " + e.Message); - return null; + return (null, false); } }