diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index 3c5b270e..0f651621 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -158,7 +157,6 @@ private async Task LaunchWithStrategy(IStrategy roleStra var sslPolicy = ResolveExtension(); if (sslPolicy != null) { - Network.VersionService.SetSslValidationPolicy(sslPolicy); Network.HttpClientProvider.SetSslValidationPolicy(sslPolicy); } var authProvider = ResolveExtension(); @@ -166,9 +164,6 @@ private async Task LaunchWithStrategy(IStrategy roleStra ConfigureStrategy(roleStrategy); - if (roleStrategy is ClientStrategy cs) - await CallSmallBowlHomeAsync(_configInfo.Bowl).ConfigureAwait(false); - roleStrategy.Create(_configInfo); await roleStrategy.ExecuteAsync(); @@ -221,6 +216,9 @@ public GeneralUpdateBootstrap SetConfig(UpdateRequest configInfo) var appType = GetOption(Option.AppType); if (appType != AppType.Upgrade) { + // Cleanup temp directories from previous runs (older than 24h) to + // prevent disk accumulation from silent-mode polling or crashes. + StorageManager.CleanupOldTempDirectories(); _configInfo.TempPath = StorageManager.GetTempDirectory("upgrade_temp"); InitBlackPolicy(); } @@ -466,7 +464,6 @@ private async Task LaunchSilentAsync() var sslPolicy = ResolveExtension(); if (sslPolicy != null) { - Network.VersionService.SetSslValidationPolicy(sslPolicy); Network.HttpClientProvider.SetSslValidationPolicy(sslPolicy); } var authProvider = ResolveExtension(); @@ -577,38 +574,12 @@ private static Format ParseFormat(string? compressFormat) private void InitBlackPolicy() { - // Build blacklist matcher from UpdateContext and set on StorageManager. - // The matcher combines user config with system defaults. - var effectiveConfig = new BlackPolicy( - _configInfo.Files?.Count > 0 ? _configInfo.Files : BlackDefaults.DefaultFiles, - _configInfo.Formats?.Count > 0 ? _configInfo.Formats : BlackDefaults.DefaultFormats, - _configInfo.Directories?.Count > 0 - ? _configInfo.Directories - : BlackDefaults.DefaultDirectories - ); - StorageManager.BlackMatcher = new BlackMatcher(effectiveConfig); - } - - private async Task CallSmallBowlHomeAsync(string processName) - { - if (string.IsNullOrWhiteSpace(processName)) - { - GeneralTracer.Warn("CallSmallBowlHomeAsync: Bowl process name is empty or whitespace, skipping shutdown."); - return; - } - try - { - var processes = Process.GetProcessesByName(processName); - foreach (var process in processes) - { - GeneralTracer.Info($"Shutting down process {process.ProcessName} (ID: {process.Id})"); - await GracefulExit.ShutdownAsync(process).ConfigureAwait(false); - } - } - catch (Exception ex) - { - GeneralTracer.Error("CallSmallBowlHomeAsync failed.", ex); - } + StorageManager.BlackMatcher = new BlackMatcher( + BlackDefaults.CreatePolicyWithDefaults( + _configInfo.Files, + _configInfo.Formats, + _configInfo.Directories + )); } // ════════════════════════════════════════════════════════════════ diff --git a/src/c#/GeneralUpdate.Core/Configuration/Option.cs b/src/c#/GeneralUpdate.Core/Configuration/Option.cs index 03539905..62aa5a82 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/Option.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/Option.cs @@ -37,11 +37,6 @@ public class Option /// private static readonly ConcurrentDictionary _registry = new(); - /// - /// The synchronization lock object for thread-safe option creation. - /// - private static readonly object _lock = new(); - /// /// The unique name identifier for this option. /// Remains unique throughout the application lifetime. @@ -74,15 +69,19 @@ protected Option(string name) /// Thrown when is null. public static Option ValueOf(string name, T defaultValue = default!) { - lock (_lock) - { - if (_registry.TryGetValue(name, out var existing) && existing is Option typed) - return typed; - - var option = new Option(name, defaultValue); - _registry[name] = option; - return option; - } + // GetOrAdd is lock-free on ConcurrentDictionary. Under contention the + // factory may run multiple times, but only one instance is stored and + // returned by all racing callers. The factory must be side-effect-free. + var raw = _registry.GetOrAdd(name, _ => new Option(name, defaultValue)); + + // If the existing entry was registered with a different type T, create a new one. + // This is the same "last writer wins" behavior as the original lock-based implementation. + if (raw is Option typed) + return typed; + + var replacement = new Option(name, defaultValue); + _registry[name] = replacement; + return replacement; } /// diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs index b3f9ea30..8660a3a2 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs @@ -37,13 +37,7 @@ public class UpdateRequest : UpdateConfiguration /// /// UpdateLogUrl: If set, must be a valid absolute URI. /// - /// UpdateAppName: Must not be empty. - /// - /// MainAppName: Must not be empty. - /// /// AppSecretKey: Must not be empty. - /// - /// ClientVersion: Must not be empty. /// /// /// This method is typically called at the end of the method diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs index 4fe598c7..c66014d6 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -83,6 +83,20 @@ public static bool HasUpdate( return serverVersions.Max() > local; } + /// Pre-parses versions for a list of assets to avoid repeated Semver.TryParse calls. + private static Dictionary PreParseVersions(IEnumerable assets) + { + var map = new Dictionary(); + foreach (var a in assets) + { + // Custom IDownloadSource implementations could return duplicate records; + // silently accept the first occurrence rather than throwing. + if (!map.ContainsKey(a)) + map[a] = ParseVersion(a.Version); + } + return map; + } + /// /// Builds a download plan with AppType-aware version filtering. /// Client-type assets are compared against . @@ -107,6 +121,16 @@ public static DownloadPlan Build( var parsedUpgrade = ParseVersion(upgradeClientVersion) ?? parsedClient; var uv = parsedUpgrade.Value; + // Pre-parse all asset versions to avoid repeated Semver.TryParse calls. + var versionMap = PreParseVersions(assets); + + // Helper: safe lookup that matches netstandard2.0 (no GetValueOrDefault). + SemVersion? Lookup(DownloadAsset a) + { + versionMap.TryGetValue(a, out var sv); + return sv; + } + // 1. Filter out frozen packages var active = assets .Where(a => !a.IsFreeze) @@ -122,23 +146,22 @@ public static DownloadPlan Build( var candidates = active .Where(a => { - if (!Semver.TryParse(a.Version, out var pv)) return false; + var pv = Lookup(a); + if (pv == null) return false; var localVersion = (a.AppType == (int)AppType.Upgrade) ? uv : cv; - return pv > localVersion; + return pv.Value > localVersion; }) .Where(a => IsCompatible(a.MinClientVersion, clientVersion)) - .OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }) + .OrderBy(a => Lookup(a)) .ToList(); if (candidates.Count == 0) return DownloadPlan.Empty; - // Separate chain vs full packages. - // Treat Unspecified (0) as Chain for backward compatibility with older - // servers that do not set PackageType yet. + // 4. Separate chain vs full packages. var chainCandidates = candidates .Where(a => a.PackageType == (int)Configuration.PackageType.Chain || a.PackageType == (int)Configuration.PackageType.Unspecified) @@ -149,17 +172,12 @@ public static DownloadPlan Build( .ToList(); // ── Chain vs Full size-based decision ── - // If a full replacement package is available and the total chain download - // size approaches or exceeds the full package size, skip chain and use full. if (chainCandidates.Count > 0 && fullCandidates.Count > 0) { - // Pick the latest full package (highest version) across all AppTypes var bestFull = fullCandidates - .OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; }) + .OrderByDescending(a => Lookup(a)) .First(); - // Only compare against chain packages of the same AppType as bestFull. - // Mixing Client and Upgrade sizes together could trigger incorrect switching. long chainTotal = chainCandidates .Where(a => a.AppType == bestFull.AppType) .Sum(a => a.Size); @@ -167,23 +185,18 @@ public static DownloadPlan Build( if (chainTotal >= threshold) { - // Chain is too expensive — use full package instead. - // Supplement with chain packages for other AppTypes not covered by full. GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}"); + var bestFullSv = Lookup(bestFull); var planAssets = new List { bestFull }; planAssets.AddRange(chainCandidates .Where(a => a.AppType != bestFull.AppType - || (Semver.TryParse(a.Version, out var av) - && Semver.TryParse(bestFull.Version, out var fv) - && av > fv)) - .OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; })); + || (Lookup(a) is { } av && bestFullSv != null && av > bestFullSv)) + .OrderBy(a => Lookup(a))); return new DownloadPlan(planAssets, isForcibly); } } // ── Chain plan with fallback fulls ── - // Use chain packages normally. Attach FallbackFull* info to each chain entry - // so that if a chain patch fails, AbstractStrategy can fall back to full. if (fullCandidates.Count > 0) { var fallbackFulls = new List(); @@ -191,20 +204,18 @@ public static DownloadPlan Build( var chainWithFallback = chainCandidates .Select(chain => { - // Find a matching full: same AppType + same Version (or closest) var match = fullCandidates .Where(f => f.AppType == chain.AppType) - .OrderBy(f => { Semver.TryParse(f.Version, out var sv); return sv; }) + .OrderBy(f => Lookup(f)) .FirstOrDefault(f => { - if (!Semver.TryParse(f.Version, out var fv)) return false; - if (!Semver.TryParse(chain.Version, out var cv)) return false; - return fv >= cv; + var fv = Lookup(f); + var cv = Lookup(chain); + return fv != null && cv != null && fv.Value >= cv.Value; }); if (match != null) { - // Add matching full to the fallback list once if (!fallbackFulls.Any(f => f.Url == match.Url)) fallbackFulls.Add(match); @@ -226,7 +237,6 @@ public static DownloadPlan Build( }; } - // No full packages at all: return chain packages as-is return new DownloadPlan(chainCandidates, isForcibly); } @@ -245,11 +255,7 @@ public static DownloadPlan Build(IEnumerable assets, string curre /// /// Checks whether the specified MinClientVersion is compatible with the current client version. - /// If a package's MinClientVersion is higher than the current version, the package is not applicable. /// - /// The minimum client version required by the package. If null or empty, the package is considered compatible. - /// The current client version string. - /// True if the current version meets or exceeds the minimum requirement; otherwise false. internal static bool IsCompatible(string? minClientVersion, string currentVersion) { if (string.IsNullOrEmpty(minClientVersion)) return true; @@ -260,8 +266,6 @@ internal static bool IsCompatible(string? minClientVersion, string currentVersio } /// Parses a version string and returns null if the string cannot be parsed. - /// The version string to parse. - /// A parsed value, or null if parsing fails. internal static SemVersion? ParseVersion(string? version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs index bd15e144..25a264a6 100644 --- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -210,10 +210,10 @@ public async Task ExecuteAsync( var tasks = plan.Assets.Select(async asset => { - var acquired = await sem.WaitAsync(TimeSpan.FromMinutes(5), token).ConfigureAwait(false); + var acquired = await sem.WaitAsync(TimeSpan.FromMinutes(1), token).ConfigureAwait(false); if (!acquired) { - GeneralTracer.Warn("DefaultDownloadOrchestrator: semaphore wait timed out for " + asset.Name + ", skipping."); + GeneralTracer.Warn($"DefaultDownloadOrchestrator: semaphore wait timed out (1 min) for '{asset.Name}'. Concurrency={effectiveConcurrency}, ActiveSlots={effectiveConcurrency - sem.CurrentCount}. Skipping asset."); lock (results) { results.Add(new DownloadResult(asset, null, 0, TimeSpan.Zero, 0, false, "Semaphore wait timed out")); diff --git a/src/c#/GeneralUpdate.Core/FileSystem/BlackDefaults.cs b/src/c#/GeneralUpdate.Core/FileSystem/BlackDefaults.cs index 926b0015..bab9ee80 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/BlackDefaults.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/BlackDefaults.cs @@ -28,4 +28,21 @@ public static class BlackDefaults StorageManager.LegacyDirectoryPrefix, "fail" }; + + /// + /// Creates a using caller-provided lists, falling back to + /// , , and + /// for any list that is null or empty. + /// + public static BlackPolicy CreatePolicyWithDefaults( + IReadOnlyList? files, + IReadOnlyList? formats, + IReadOnlyList? directories) + { + return new BlackPolicy( + files?.Count > 0 ? new List(files) : DefaultFiles, + formats?.Count > 0 ? new List(formats) : DefaultFormats, + directories?.Count > 0 ? new List(directories) : DefaultDirectories + ); + } } diff --git a/src/c#/GeneralUpdate.Core/FileSystem/BlackMatcher.cs b/src/c#/GeneralUpdate.Core/FileSystem/BlackMatcher.cs index 860df087..9e774a72 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/BlackMatcher.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/BlackMatcher.cs @@ -95,9 +95,16 @@ public bool IsBlacklistedFormat(string extension) /// Uses case-insensitive substring containment matching (string.IndexOf) for evaluation. /// The directory is considered skippable if its name contains any string from the Directories list. /// - public bool ShouldSkipDirectory(string directoryName) - => _config.Directories?.Any(d => - directoryName.IndexOf(d, StringComparison.OrdinalIgnoreCase) >= 0) == true; + public bool ShouldSkipDirectory(string directoryOrPath) + { + // Extract the directory name from a possible full path so both + // bare names ("backup-2026") and full paths (".backups/backup-2026") work. + var dirName = Path.GetFileName(directoryOrPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrEmpty(dirName)) return false; + + return _config.Directories?.Any(d => + dirName.StartsWith(d, StringComparison.OrdinalIgnoreCase)) == true; + } /// /// Matches a file name against a simple Glob pattern. diff --git a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs index 9bfebdf6..a824be6f 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs @@ -221,6 +221,58 @@ public static string GetTempDirectory(string name) return tempDir; } + /// + /// Cleans up temporary directories created by previous runs that are older than . + /// These directories match the pattern "generalupdate_*" created by . + /// + /// Maximum age to retain. Defaults to 24 hours. + /// + /// This is safe to call at application startup or update cycle start to prevent disk + /// accumulation from silent-mode polling or crashed update processes. + /// Only directories belonging to OTHER processes (different PID) are cleaned — + /// the current process's temp directory is left untouched. + /// + public static void CleanupOldTempDirectories(TimeSpan? maxAge = null) + { + maxAge ??= TimeSpan.FromHours(24); + var cutoff = DateTime.UtcNow - maxAge.Value; + var currentPid = System.Diagnostics.Process.GetCurrentProcess().Id; + + try + { + foreach (var dir in Directory.GetDirectories(Path.GetTempPath(), "generalupdate_*")) + { + try + { + var dirInfo = new DirectoryInfo(dir); + var dirName = dirInfo.Name; + + // Parse timestamp from "generalupdate_yyyy-MM-dd-HHmmss-fff_PID_name" + // Splitting by '_': [0]=prefix, [1]=timestamp, [2]=PID, [3..]=name. + // If the PID segment matches the current process, skip it. + var parts = dirName.Split('_'); + if (parts.Length >= 3 && int.TryParse(parts[2], out var pid) && pid == currentPid) + continue; + + if (dirInfo.CreationTimeUtc < cutoff) + { + try { DeleteDirectory(dir); } + catch (Exception ex) + { + GeneralTracer.Warn($"StorageManager.CleanupOldTempDirectories: failed to delete old temp '{dir}': {ex.Message}"); + } + } + } + catch (UnauthorizedAccessException) { /* skip */ } + catch (DirectoryNotFoundException) { /* raced away */ } + } + } + catch (Exception ex) + { + GeneralTracer.Warn($"StorageManager.CleanupOldTempDirectories: enumeration failed: {ex.Message}"); + } + } + /// /// Generates a timestamp-based backup directory name in the format "backup-{yyyyMMddHHmmss}". /// @@ -292,18 +344,62 @@ private static IEnumerable GetBackupDirectoryInfos(string path) /// public static void DeleteDirectory(string targetDir) { - foreach (var file in Directory.GetFiles(targetDir)) + // Enumerate then delete with per-item exception handling. + // Between enumeration and deletion, concurrent processes may add/remove + // files — handle these races gracefully instead of crashing. + try { - File.SetAttributes(file, FileAttributes.Normal); - File.Delete(file); - } + foreach (var file in Directory.GetFiles(targetDir)) + { + try + { + File.SetAttributes(file, FileAttributes.Normal); + File.Delete(file); + } + catch (FileNotFoundException) { /* raced away — already deleted */ } + catch (DirectoryNotFoundException) { /* raced away */ } + catch (UnauthorizedAccessException ex) + { + GeneralTracer.Warn($"StorageManager.DeleteDirectory: cannot delete file '{Path.GetFileName(file)}': {ex.Message}"); + } + } - foreach (var dir in Directory.GetDirectories(targetDir)) + try + { + foreach (var dir in Directory.GetDirectories(targetDir)) + { + try + { + DeleteDirectory(dir); + } + catch (DirectoryNotFoundException) { /* raced away */ } + catch (UnauthorizedAccessException ex) + { + GeneralTracer.Warn($"StorageManager.DeleteDirectory: cannot delete directory '{Path.GetFileName(dir)}': {ex.Message}"); + } + } + } + catch (DirectoryNotFoundException) { /* parent raced away */ } + catch (UnauthorizedAccessException ex) + { + GeneralTracer.Warn($"StorageManager.DeleteDirectory: cannot enumerate subdirectories: {ex.Message}"); + } + + try + { + Directory.Delete(targetDir, false); + } + catch (DirectoryNotFoundException) { /* raced away */ } + catch (UnauthorizedAccessException ex) + { + GeneralTracer.Warn($"StorageManager.DeleteDirectory: cannot delete directory '{Path.GetFileName(targetDir)}': {ex.Message}"); + } + } + catch (DirectoryNotFoundException) { /* parent raced away before enumeration */ } + catch (UnauthorizedAccessException ex) { - DeleteDirectory(dir); + GeneralTracer.Warn($"StorageManager.DeleteDirectory: cannot enumerate '{targetDir}': {ex.Message}"); } - - Directory.Delete(targetDir, false); } /// diff --git a/src/c#/GeneralUpdate.Core/GracefulExit.cs b/src/c#/GeneralUpdate.Core/GracefulExit.cs index 66b54515..ec3f75ca 100644 --- a/src/c#/GeneralUpdate.Core/GracefulExit.cs +++ b/src/c#/GeneralUpdate.Core/GracefulExit.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; using System.Threading.Tasks; @@ -39,10 +40,39 @@ public static async Task ShutdownAsync(Process? process, int timeoutMs = 3000) process.Kill(); // Last resort } - /// Shutdown the current process gracefully. - public static async Task CurrentProcessAsync(int timeoutMs = 3000) + /// Exit the current process gracefully. + /// + /// + /// For external processes, sends WM_CLOSE then waits. + /// For self-shutdown (the current process), calling CloseMainWindow + Kill + /// on oneself is harmful — Kill skips finally blocks, CloseMainWindow is a + /// no-op for console apps, and the 3-second wait is wasted. + /// + /// + /// Instead, this method signals the process to exit naturally. + /// Callers must dispose their own resources (tracer, etc.) before calling this method. + /// + /// + public static Task CurrentProcessAsync(int timeoutMs = 3000) { - var p = Process.GetCurrentProcess(); - await ShutdownAsync(p, timeoutMs).ConfigureAwait(false); + try + { + // Signal GUI windows to close. For console/background processes this is + // a no-op, but the process will exit when the async call stack unwinds. + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + using var p = Process.GetCurrentProcess(); + if (!p.HasExited) + p.CloseMainWindow(); + } + } + catch (InvalidOperationException) + { + // Process already exiting — nothing to do. + } + + // The process exits naturally when the async call stack completes. + // Callers should have already disposed critical resources (tracer, etc.). + return Task.CompletedTask; } } diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index 3e75b352..ee7227c2 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Net.Http; -using System.Security.Cryptography.X509Certificates; -using System.Net.Security; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -64,47 +62,17 @@ namespace GeneralUpdate.Core.Network /// public class VersionService { - private static readonly HttpClient _sharedClient; - private static volatile ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy(); - private readonly IHttpAuthProvider _authProvider; private readonly TimeSpan _timeout; private readonly int _maxRetryAttempts; - /// - /// Static constructor: initializes the static members of . - /// - /// - /// - /// Execution flow: - /// - /// Creates an with a custom SSL validation callback. - /// The SSL validation logic is delegated to , - /// which can be replaced globally via . - /// Initializes the static shared instance using the handler. - /// - /// - /// - static VersionService() - { - var handler = new HttpClientHandler(); - handler.ServerCertificateCustomValidationCallback = SharedCertValidation; - _sharedClient = new HttpClient(handler, disposeHandler: false); - } - /// /// Sets the global SSL certificate validation policy. + /// Delegates to so there is + /// only one SSL policy for all HTTP traffic in the framework. /// - /// - /// This policy affects all HTTPS requests made by instances. - /// The default is , i.e., strict mode. - /// Pass a custom implementation to relax or replace - /// the validation logic. - /// - /// The SSL validation policy instance. Must not be null. - /// Thrown when is null. public static void SetSslValidationPolicy(ISslValidationPolicy policy) - => _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy)); + => HttpClientProvider.SetSslValidationPolicy(policy); /// /// Sets the global default HTTP authentication provider. @@ -115,25 +83,10 @@ public static void SetSslValidationPolicy(ISslValidationPolicy policy) /// and other components using /// share the same global authentication. /// - /// - /// When a global authentication provider is set, all requests made via the static APIs - /// ( - /// and ) - /// will preferentially use this provider, overriding the authentication instance - /// created by . - /// - /// - /// Passing null clears the global authentication provider, reverting to the factory method. - /// /// - /// The global authentication provider instance, or null to clear the global configuration. public static void SetDefaultAuthProvider(IHttpAuthProvider? provider) => HttpClientProvider.DefaultAuthProvider = provider; - private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? c, - X509Chain? ch, SslPolicyErrors e) - => _globalSslPolicy.ValidateCertificate(c, ch, e); - /// /// Initializes a new instance of the class. /// @@ -353,7 +306,7 @@ private async Task SendAsync(string url, Dictionary p, using var cts = CancellationTokenSource.CreateLinkedTokenSource(t); cts.CancelAfter(_timeout); - var r = await _sharedClient.SendAsync(req, cts.Token).ConfigureAwait(false); + var r = await HttpClientProvider.Shared.SendAsync(req, cts.Token).ConfigureAwait(false); r.EnsureSuccessStatusCode(); var rj = await r.Content.ReadAsStringAsync().ConfigureAwait(false); var result = JsonSerializer.Deserialize(rj, ti); diff --git a/src/c#/GeneralUpdate.Core/Pipeline/HashMiddleware.cs b/src/c#/GeneralUpdate.Core/Pipeline/HashMiddleware.cs index 6c8615f1..01358f53 100644 --- a/src/c#/GeneralUpdate.Core/Pipeline/HashMiddleware.cs +++ b/src/c#/GeneralUpdate.Core/Pipeline/HashMiddleware.cs @@ -32,6 +32,9 @@ namespace GeneralUpdate.Core.Pipeline; /// public class HashMiddleware : IMiddleware { + // Reusable thread-safe algorithm instance. SHA256.Create is stateless + // once initialized; individual calls compute on their own input stream. + private static readonly Sha256HashAlgorithm HashAlgorithm = new(); /// /// Asynchronously executes the hash verification logic. /// @@ -95,8 +98,7 @@ private Task VerifyFileHash(string path, string hash) { return Task.Run(() => { - var hashAlgorithm = new Sha256HashAlgorithm(); - var hashSha256 = hashAlgorithm.ComputeHash(path); + var hashSha256 = HashAlgorithm.ComputeHash(path); return string.Equals(hash, hashSha256, StringComparison.OrdinalIgnoreCase); }); } diff --git a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs index f5890fad..5f50d87c 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GeneralUpdate.Core.FileSystem; using GeneralUpdate.Core.Event; @@ -47,6 +48,12 @@ public abstract class AbstractStrategy : IStrategy { private const string Patchs = "patchs"; + /// Guard against re-entrant calls. + private int _executing; + + /// Tracks whether at least one version was applied successfully in the current batch. + private bool _appliedAnyVersion; + /// /// Global configuration information containing parameters such as update package path, temporary directory, report URL, and version list. /// Initialized by the method and used by the pipeline execution loop. @@ -141,10 +148,18 @@ public abstract class AbstractStrategy : IStrategy /// public virtual async Task ExecuteAsync() { + if (Interlocked.Exchange(ref _executing, 1) == 1) + { + GeneralTracer.Warn("AbstractStrategy.ExecuteAsync: re-entrant call ignored."); + AllPackagesSucceeded = false; + return; + } + var patchRoot = string.Empty; try { AllPackagesSucceeded = true; + _appliedAnyVersion = false; var status = ReportType.None; patchRoot = StorageManager.GetTempDirectory(Patchs); @@ -185,6 +200,7 @@ public virtual async Task ExecuteAsync() var context = CreatePipelineContext(version, patchPath); var pipelineBuilder = BuildPipeline(context); await pipelineBuilder.Build(); + _appliedAnyVersion = true; status = ReportType.Success; } catch (Exception e) when (version.PackageType == (int)PackageType.Chain @@ -212,6 +228,7 @@ public virtual async Task ExecuteAsync() try { await fallbackBuilder.Build(); + _appliedAnyVersion = true; status = ReportType.Success; // Record the fallback full package version so subsequent // chain packages ≤ this version are skipped. @@ -236,7 +253,12 @@ public virtual async Task ExecuteAsync() status = ReportType.Failure; AllPackagesSucceeded = false; HandleExecuteException(e); - TryRollback(); + // Only rollback when NO version has succeeded yet in this batch. + // If a previous version was already applied successfully, + // rolling back would undo valid work and leave the app in + // a downgraded state across version boundaries. + if (!_appliedAnyVersion) + TryRollback(); } finally { @@ -258,6 +280,7 @@ public virtual async Task ExecuteAsync() } finally { + Interlocked.Exchange(ref _executing, 0); if (!string.IsNullOrEmpty(patchRoot)) Clear(patchRoot); } @@ -523,6 +546,14 @@ private void DeleteVersionZip(VersionEntry version) { if (string.IsNullOrWhiteSpace(_configinfo.TempPath)) return; + // Guard: version.Name must not be null or empty, otherwise the path + // would resolve to TempPath/.zip which could delete an unrelated file. + if (string.IsNullOrWhiteSpace(version.Name)) + { + GeneralTracer.Warn($"AbstractStrategy: cannot delete zip for version {version.Version ?? "null"} — Name is empty."); + return; + } + var zipPath = Path.Combine(_configinfo.TempPath, $"{version.Name}{_configinfo.Format.ToExtension()}"); try { diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs index 44fb76c0..e129c51c 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text.Json; using System.Threading.Tasks; using GeneralUpdate.Core.Configuration; @@ -857,17 +856,7 @@ internal void LaunchUpgradeProcessSync() /// If neither matches, a is thrown. /// private IStrategy ResolveOsStrategy() - { - if (_customOsStrategy != null) - return _customOsStrategy; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return new WindowsStrategy(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - return new LinuxStrategy(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return new MacStrategy(); - throw new PlatformNotSupportedException("The current operating system is not supported!"); - } + => OsStrategyResolver.Resolve(_customOsStrategy); /// /// Initializes the file blacklist configuration. Used to exclude files, formats, and directories that do not need processing @@ -880,14 +869,12 @@ private IStrategy ResolveOsStrategy() /// private void InitBlackPolicy() { - var effectiveConfig = new BlackPolicy( - _configInfo!.Files?.Count > 0 ? _configInfo.Files : BlackDefaults.DefaultFiles, - _configInfo.Formats?.Count > 0 ? _configInfo.Formats : BlackDefaults.DefaultFormats, - _configInfo.Directories?.Count > 0 - ? _configInfo.Directories - : BlackDefaults.DefaultDirectories - ); - StorageManager.BlackMatcher = new BlackMatcher(effectiveConfig); + StorageManager.BlackMatcher = new BlackMatcher( + BlackDefaults.CreatePolicyWithDefaults( + _configInfo!.Files, + _configInfo.Formats, + _configInfo.Directories + )); } /// @@ -951,22 +938,9 @@ private bool CheckFail(string version) return failVersion >= versionParsed; } - /// /// Gets the platform type for the current running OS. - /// - /// The current platform type (, , - /// , or ). - /// - /// Uses RuntimeInformation.IsOSPlatform for runtime detection. - /// The return value is used to inform the server of the client platform when constructing HttpDownloadSource. - /// private static PlatformType GetPlatform() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return PlatformType.Windows; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return PlatformType.Linux; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return PlatformType.MacOS; - return PlatformType.Unknown; - } + => OsStrategyResolver.GetPlatform(); /// /// After upgrade packages have been applied in-place, writes the latest upgrade version diff --git a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs index 724bb0f5..b3829044 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs @@ -76,8 +76,11 @@ public override async Task StartAppAsync() { GeneralTracer.Info($"MacStrategy.StartApp: launching app={mainApp}"); using var process = System.Diagnostics.Process.Start(mainApp); - if (process == null || process.HasExited) + if (process == null) throw new InvalidOperationException($"Failed to start application: {mainApp}"); + // Do NOT check process.HasExited here — a fast-starting app may + // have already exited by the time we check, causing a false failure. + // Process.Start returning non-null confirms the OS created the process. GeneralTracer.Info($"MacStrategy.StartApp: app launched successfully (PID: {process.Id})."); } else diff --git a/src/c#/GeneralUpdate.Core/Strategy/OsStrategyResolver.cs b/src/c#/GeneralUpdate.Core/Strategy/OsStrategyResolver.cs new file mode 100644 index 00000000..9329b9b7 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Strategy/OsStrategyResolver.cs @@ -0,0 +1,50 @@ +using System; +using System.Runtime.InteropServices; + +namespace GeneralUpdate.Core.Strategy; + +/// +/// Shared OS platform strategy resolver. Eliminates the duplicate +/// ResolveOsStrategy() method in +/// and . +/// +internal static class OsStrategyResolver +{ + /// + /// Resolves the platform-specific strategy for the current OS. + /// + /// + /// An optional custom OS strategy. When non-null, returned as-is. + /// + /// + /// Thrown when the current OS is not Windows, Linux, or macOS and no custom strategy was provided. + /// + internal static IStrategy Resolve(IStrategy? customOsStrategy = null) + { + if (customOsStrategy != null) + return customOsStrategy; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return new WindowsStrategy(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return new LinuxStrategy(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return new MacStrategy(); + + throw new PlatformNotSupportedException("The current operating system is not supported!"); + } + + /// + /// Resolves the platform type for the current OS. + /// + internal static Configuration.PlatformType GetPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Configuration.PlatformType.Windows; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return Configuration.PlatformType.Linux; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return Configuration.PlatformType.MacOS; + return Configuration.PlatformType.Unknown; + } +} diff --git a/src/c#/GeneralUpdate.Core/Strategy/UpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/UpdateStrategy.cs index 8352d0c2..eb2124b2 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/UpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/UpdateStrategy.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using GeneralUpdate.Core.Configuration; using GeneralUpdate.Core.Event; @@ -213,17 +212,7 @@ public async Task StartAppAsync() #region Helpers private IStrategy ResolveOsStrategy() - { - if (_customOsStrategy != null) - return _customOsStrategy; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return new WindowsStrategy(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - return new LinuxStrategy(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return new MacStrategy(); - throw new PlatformNotSupportedException("The current operating system is not supported!"); - } + => OsStrategyResolver.Resolve(_customOsStrategy); private Hooks.HookContext BuildUpdateContext() { @@ -239,7 +228,7 @@ private Hooks.HookContext BuildUpdateContext() private async Task SafeOnBeforeUpdateAsync(Hooks.HookContext ctx) { try { return await Hooks.OnBeforeUpdateAsync(ctx).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"OnBeforeUpdateAsync hook failed: {ex.Message}"); return true; } + catch (Exception ex) { GeneralTracer.Warn($"OnBeforeUpdateAsync hook failed: {ex.Message}"); return false; } } private async Task SafeOnAfterUpdateAsync(Hooks.HookContext ctx) diff --git a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs index bbd50e10..55ef18ed 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs @@ -109,8 +109,11 @@ public override async Task StartAppAsync() GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: launching app={appPath}"); using var appProcess = Process.Start(appPath); - if (appProcess == null || appProcess.HasExited) + if (appProcess == null) throw new InvalidOperationException($"Failed to start application: {appPath}"); + // Do NOT check appProcess.HasExited here — a fast-starting app may + // have already exited by the time we check, causing a false failure. + // Process.Start returning non-null confirms the OS created the process. appLaunched = true; GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: app launched successfully (PID: {appProcess.Id}).");