From 8d3e9f3bb312817a19a24dcf13d25ffd4a84e0e8 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Mon, 1 Jun 2026 21:42:06 +0800 Subject: [PATCH 1/5] fix: OSS update flow crash & infinite loop in ClientTest/UpgradeTest OssStrategy.cs: - Replace Console.WriteLine with GeneralTracer for proper logging in Process.Start context - Add diagnostic trace logs at every critical step (download, JSON parse, version compare, file resolution) - List *.exe files in upgrade directory before File.Exists check for easier debugging - Add try-catch around JSON deserialization with explicit error logging - Set _configInfo.LastVersion after building asset list so HookContext.TargetVersion is populated ClientTest/Program.cs: - Add RunOssClientAsync() for OSS update flow testing (AppType.OssClient) - Set UpdatePath='update' to match subdirectory deployment layout - Read .current_version marker file to prevent infinite update loops UpgradeTest/Program.cs: - Add RunOssUpgradeAsync() for OSS upgrade flow testing (AppType.OssUpgrade) - Use Path.GetFullPath(Path.Combine(baseDir, '..')) to resolve InstallPath to parent dir - Write .current_version marker in OnAfterUpdateAsync hook Closes #485 --- .../Strategy/OssStrategy.cs | 62 ++++++++++- tests/ClientTest/Program.cs | 103 ++++++++++++++++-- tests/UpgradeTest/Program.cs | 101 ++++++++++++++++- 3 files changed, 243 insertions(+), 23 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs index 0d6d31db..d70faa37 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs @@ -189,35 +189,63 @@ private async Task ExecuteClientAsync() var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.UpdateAppName}_versions.json"; var versionsFilePath = Path.Combine(installPath, versionFileName); + GeneralTracer.Info($"[OssClient] InstallPath={installPath}"); + GeneralTracer.Info($"[OssClient] VersionFileName={versionFileName}"); + GeneralTracer.Info($"[OssClient] ClientVersion={_configInfo.ClientVersion}"); + GeneralTracer.Info($"[OssClient] MainAppName={_configInfo.MainAppName}"); + GeneralTracer.Info($"[OssClient] UpdateAppName={_configInfo.UpdateAppName}"); + if (!string.IsNullOrEmpty(_configInfo.UpdateUrl)) { + GeneralTracer.Info($"[OssClient] Downloading from {_configInfo.UpdateUrl} ..."); await DownloadVersionConfig(_configInfo.UpdateUrl, versionsFilePath).ConfigureAwait(false); + GeneralTracer.Info($"[OssClient] Downloaded -> {versionsFilePath}"); } if (!File.Exists(versionsFilePath)) { - GeneralTracer.Info("OssStrategy: version config download failed, aborting."); + GeneralTracer.Info("[OssClient] FAIL: version config file not found after download!"); return; } - var versions = JsonSerializer.Deserialize( - File.ReadAllText(versionsFilePath), - JsonContext.OssVersionRecordJsonContext.Default.ListOssVersionRecord); + var jsonText = File.ReadAllText(versionsFilePath); + GeneralTracer.Info($"[OssClient] JSON downloaded ({jsonText.Length} chars)"); + + List versions; + try + { + versions = JsonSerializer.Deserialize( + jsonText, + JsonContext.OssVersionRecordJsonContext.Default.ListOssVersionRecord); + } + catch (Exception ex) + { + GeneralTracer.Error($"[OssClient] JSON deserialize error: {ex.GetType().Name}: {ex.Message}", ex); + throw; + } + if (versions == null || versions.Count == 0) { - GeneralTracer.Info("OssStrategy: no versions found, aborting."); + GeneralTracer.Info($"[OssClient] FAIL: no versions found (count={versions?.Count.ToString() ?? "null"})"); return; } + GeneralTracer.Info($"[OssClient] Deserialized {versions.Count} version(s)"); + foreach (var v in versions) + GeneralTracer.Info($" - {v.PacketName} v{v.Version} PubTime={v.PubTime:O}"); + versions = versions.OrderByDescending(x => x.PubTime).ToList(); var latest = versions.First(); + GeneralTracer.Info($"[OssClient] Latest: {latest.Version} (PubTime={latest.PubTime:O})"); if (!IsOssUpgrade(_configInfo.ClientVersion, latest.Version)) { - GeneralTracer.Info("OssStrategy: no upgrade needed."); + GeneralTracer.Info($"[OssClient] No upgrade needed: {_configInfo.ClientVersion} >= {latest.Version}"); return; } + GeneralTracer.Info($"[OssClient] Upgrade needed: {_configInfo.ClientVersion} -> {latest.Version}"); + // Resolve upgrade exe: prefer UpdatePath, fall back to InstallPath var upgradeDir = !string.IsNullOrWhiteSpace(_configInfo.UpdatePath) ? (Path.IsPathRooted(_configInfo.UpdatePath) @@ -228,10 +256,28 @@ private async Task ExecuteClientAsync() ? _configInfo.UpdateAppName : "GeneralUpdate.Upgrade.exe"; var appPath = Path.Combine(upgradeDir, upgradeAppName); + GeneralTracer.Info($"[OssClient] Resolved upgrade path: {appPath}"); + + // List exe files in the directory to help diagnose missing file issues + try + { + var dirFiles = Directory.GetFiles(upgradeDir, "*.exe").Select(f => Path.GetFileName(f)); + GeneralTracer.Info($"[OssClient] *.exe files in {upgradeDir}: [{string.Join(", ", dirFiles)}]"); + } + catch (Exception ex) + { + GeneralTracer.Warn($"[OssClient] Could not list directory {upgradeDir}: {ex.Message}"); + } + if (!File.Exists(appPath)) + { + GeneralTracer.Error($"[OssClient] FAIL: Upgrade app NOT FOUND at {appPath}"); throw new FileNotFoundException($"Upgrade application not found: {appPath}"); + } + GeneralTracer.Info($"[OssClient] Launching upgrade: {appPath}"); Process.Start(appPath); + GeneralTracer.Info("[OssClient] Upgrade launched, exiting."); await GracefulExit.CurrentProcessAsync().ConfigureAwait(false); } @@ -314,6 +360,10 @@ private async Task ExecuteUpgradeAsync() if (assets.Count == 0) throw new InvalidOperationException("No assets to download."); + // Update LastVersion so hooks (OnAfterUpdate, etc.) know the target version. + _configInfo.LastVersion = assets[assets.Count - 1].Version; + ctx = BuildUpdateContext(); + GeneralTracer.Debug($"OssStrategy (upgrade): downloading {assets.Count} asset(s)."); await DownloadAssetsAsync(assets, installPath).ConfigureAwait(false); diff --git a/tests/ClientTest/Program.cs b/tests/ClientTest/Program.cs index a68ec96f..2a985495 100644 --- a/tests/ClientTest/Program.cs +++ b/tests/ClientTest/Program.cs @@ -6,6 +6,88 @@ using GeneralUpdate.Core.Hooks; try +{ + await RunOssClientAsync(); + /*var isOssMode = args.Length > 0 && args[0] == "--oss"; + + if (isOssMode) + { + await RunOssClientAsync(); + } + else + { + await RunStandardClientAsync(); + }*/ +} +catch (Exception ex) +{ + Console.WriteLine($"FATAL: {ex}"); + Console.WriteLine("Press Enter to exit..."); + Console.ReadLine(); + Environment.Exit(1); +} + +Console.WriteLine("Press Enter to exit..."); +Console.ReadLine(); + +// ═══════════════════════════════════════════════════════════════════ +// OSS Client mode — version JSON download → version compare → launch upgrade +// ═══════════════════════════════════════════════════════════════════ +static async Task RunOssClientAsync() +{ + Console.WriteLine("=== GeneralUpdate OSS Client Test ==="); + Console.WriteLine($"Started at {DateTime.Now}"); + Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); + + var updateUrl = "http://localhost:5000/packages/versions.json"; + + // Read current version from marker file (written by UpgradeTest after each successful update). + // This prevents infinite update loops when the package doesn't change the client binary. + var markerPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".current_version"); + var clientVersion = "1.0.0"; + if (File.Exists(markerPath)) + { + clientVersion = File.ReadAllText(markerPath).Trim(); + Console.WriteLine($"Marker file found: current version = {clientVersion}"); + } + + Console.WriteLine($"UpdateUrl (versions.json): {updateUrl}"); + Console.WriteLine($"Current version: {clientVersion}"); + Console.WriteLine(); + + // OssClient flow: + // 1. Download versions.json from UpdateUrl to InstallPath + // 2. Deserialize as List, sort by PubTime desc + // 3. Compare latest.Version > ClientVersion + // 4. If newer: launch UpdateAppName from InstallPath, then exit + await new GeneralUpdateBootstrap() + .SetConfig(new UpdateRequest + { + UpdateUrl = updateUrl, + InstallPath = AppDomain.CurrentDomain.BaseDirectory, + ClientVersion = clientVersion, + MainAppName = "ClientTest.exe", + UpdateAppName = "UpgradeTest.exe", + UpdatePath = "update", + AppSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6" + }) + .SetOption(Option.AppType, AppType.OssClient) + .Hooks() + .AddListenerMultiDownloadStatistics(OnDownloadStatistics) + .AddListenerMultiDownloadCompleted(OnDownloadCompleted) + .AddListenerMultiAllDownloadCompleted(OnAllDownloadCompleted) + .AddListenerMultiDownloadError(OnDownloadError) + .AddListenerException(OnException) + .AddListenerUpdateInfo(OnUpdateInfo) + .LaunchAsync(); + + Console.WriteLine("OSS Client test completed."); +} + +// ═══════════════════════════════════════════════════════════════════ +// Standard Client mode — silent poll with IPC handoff to Upgrade +// ═══════════════════════════════════════════════════════════════════ +static async Task RunStandardClientAsync() { Console.WriteLine("=== GeneralUpdate Client Test (Silent Mode) ==="); Console.WriteLine($"Started at {DateTime.Now}"); @@ -47,14 +129,12 @@ Console.WriteLine(); // Keep the process alive so the background poll loop can work. - // When the user presses Ctrl+C or Enter, the process exits and - // ProcessExit fires, which triggers the upgrade launch. var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { Console.WriteLine(); Console.WriteLine("[Shutdown] Ctrl+C pressed. Exiting..."); - e.Cancel = true; // Prevent immediate kill — let ProcessExit fire + e.Cancel = true; cts.Cancel(); }; @@ -67,10 +147,6 @@ // Expected on Ctrl+C — graceful shutdown } - // Explicitly launch the upgrade process before exiting. - // ProcessExit may not fire reliably in all scenarios (e.g. console Ctrl+C), - // so we call TryLaunchUpgrade() directly as the primary launch path. - // If ProcessExit also fires later, the _updaterStarted guard prevents a double-launch. Console.WriteLine("[Shutdown] Launching upgrade process..."); if (orchestrator != null && orchestrator.HasPreparedUpdate) { @@ -86,11 +162,10 @@ Console.WriteLine("[Shutdown] Client test exiting gracefully."); } -catch (Exception ex) -{ - Console.WriteLine($"FATAL: {ex}"); - Environment.Exit(1); -} + +// ═══════════════════════════════════════════════════════════════════ +// Event handlers (shared across both modes) +// ═══════════════════════════════════════════════════════════════════ static void OnDownloadStatistics(object sender, MultiDownloadStatisticsEventArgs e) { @@ -136,6 +211,10 @@ static void OnUpdateInfo(object sender, UpdateInfoEventArgs e) } } +// ═══════════════════════════════════════════════════════════════════ +// Hooks (shared across both modes) +// ═══════════════════════════════════════════════════════════════════ + sealed class ClientTestHooks : IUpdateHooks { public async Task OnBeforeUpdateAsync(HookContext ctx) diff --git a/tests/UpgradeTest/Program.cs b/tests/UpgradeTest/Program.cs index 2f510234..35e03086 100644 --- a/tests/UpgradeTest/Program.cs +++ b/tests/UpgradeTest/Program.cs @@ -5,6 +5,84 @@ using GeneralUpdate.Core.Hooks; try +{ + await RunOssUpgradeAsync(); + /*var isOssMode = args.Length > 0 && args[0] == "--oss"; + + if (isOssMode) + { + await RunOssUpgradeAsync(); + } + else + { + await RunStandardUpgradeAsync(); + }*/ +} +catch (Exception ex) +{ + Console.WriteLine($"FATAL: {ex}"); + Environment.Exit(1); +} + +// ═══════════════════════════════════════════════════════════════════ +// OSS Upgrade mode — read versions.json → download packages → decompress → launch main app +// ═══════════════════════════════════════════════════════════════════ +static async Task RunOssUpgradeAsync() +{ + Console.WriteLine("=== GeneralUpdate OSS Upgrade Test ==="); + Console.WriteLine($"Started at {DateTime.Now}"); + Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); + + // OssUpgrade flow: + // 1. Read {MainAppName}_versions.json from InstallPath (downloaded by OssClient) + // 2. Filter versions > ClientVersion, sorted by PubTime asc + // 3. Download each asset's ZIP from the URL in the version record + // 4. Decompress all ZIPs to InstallPath, delete archives + // 5. Launch MainAppName, then exit + // + // NOTE: Unlike the standard Upgrade path (which reads config from IPC), + // OssUpgrade requires explicit SetConfig() so it knows where to find + // the version JSON and which version to compare against. + // + // InstallPath must point to the SAME directory as the OssClient's InstallPath, + // because the versions.json was downloaded there. When the upgrade runs from + // a subdirectory (e.g. update/), we resolve up to the parent. + + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + // baseDir ends with '\' (e.g. "...\update\"), so Path.Combine with ".." goes up one level. + var installPath = Path.GetFullPath(Path.Combine(baseDir, "..")); + + Console.WriteLine($"[OssUpgrade] BaseDir={baseDir}"); + Console.WriteLine($"[OssUpgrade] InstallPath={installPath}"); + GeneralUpdate.Core.GeneralTracer.Info($"[OssUpgrade] BaseDir={baseDir}"); + GeneralUpdate.Core.GeneralTracer.Info($"[OssUpgrade] InstallPath={installPath}"); + + await new GeneralUpdateBootstrap() + .SetConfig(new UpdateRequest + { + UpdateUrl = "http://localhost:5000/packages/versions.json", + InstallPath = installPath, + ClientVersion = "1.0.0", + MainAppName = "ClientTest.exe", + UpdateAppName = "UpgradeTest.exe", + AppSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6" + }) + .SetOption(Option.AppType, AppType.OssUpgrade) + .Hooks() + .AddListenerMultiDownloadStatistics(OnDownloadStatistics) + .AddListenerMultiDownloadCompleted(OnDownloadCompleted) + .AddListenerMultiAllDownloadCompleted(OnAllDownloadCompleted) + .AddListenerMultiDownloadError(OnDownloadError) + .AddListenerException(OnException) + .LaunchAsync(); + + Console.WriteLine("OSS Upgrade test completed."); +} + +// ═══════════════════════════════════════════════════════════════════ +// Standard Upgrade mode — read IPC config → apply patches → launch main app +// ═══════════════════════════════════════════════════════════════════ +static async Task RunStandardUpgradeAsync() { Console.WriteLine("=== GeneralUpdate Upgrade Test ==="); Console.WriteLine($"Started at {DateTime.Now}"); @@ -26,11 +104,10 @@ Console.WriteLine("Upgrade test completed."); } -catch (Exception ex) -{ - Console.WriteLine($"FATAL: {ex}"); - Environment.Exit(1); -} + +// ═══════════════════════════════════════════════════════════════════ +// Event handlers (shared across both modes) +// ═══════════════════════════════════════════════════════════════════ static void OnDownloadStatistics(object sender, MultiDownloadStatisticsEventArgs e) { @@ -62,6 +139,10 @@ static void OnException(object sender, ExceptionEventArgs e) Console.WriteLine($"[Error] {e.Exception}"); } +// ═══════════════════════════════════════════════════════════════════ +// Hooks (shared across both modes) +// ═══════════════════════════════════════════════════════════════════ + sealed class UpgradeTestHooks : IUpdateHooks { public async Task OnBeforeUpdateAsync(HookContext ctx) @@ -79,6 +160,16 @@ public async Task OnDownloadCompletedAsync(DownloadContext ctx) public async Task OnAfterUpdateAsync(HookContext ctx) { Console.WriteLine($"[Hook] OnAfterUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); + + // Write version marker to prevent infinite update loops. + // The OssClient reads this file on startup to know its current version. + if (!string.IsNullOrWhiteSpace(ctx.TargetVersion) && !string.IsNullOrWhiteSpace(ctx.InstallPath)) + { + var markerPath = Path.Combine(ctx.InstallPath, ".current_version"); + File.WriteAllText(markerPath, ctx.TargetVersion); + Console.WriteLine($"[Hook] Version marker written: {markerPath} = {ctx.TargetVersion}"); + } + await Task.CompletedTask; } From 0735936f218a6585b40f60227ee1816b8c299b73 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Mon, 1 Jun 2026 22:20:37 +0800 Subject: [PATCH 2/5] refactor: integrate manifest reading into OssStrategy, simplify Validate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OssStrategy.ExecuteAsync() now calls AppMetadataDiscoverer.Discover() to fill identity fields from generalupdate.manifest.json, same as the standard ClientStrategy flow. - OssStrategy.ExecuteUpgradeAsync() calls ManifestInfo.TryUpdateVersion() after decompression to persist the applied version back to manifest, preventing infinite update loops. - UpdateRequest.Validate() no longer requires MainAppName, UpdateAppName, ClientVersion — these are filled from manifest by strategies at runtime. This also aligns with SetSource() which never set these fields. - Updated UpdateRequestTests to reflect the relaxed validation rules. - Simplified ClientTest/UpgradeTest: only secrets in SetConfig(), identity fields come from manifest automatically. --- .../Configuration/UpdateRequest.cs | 9 ---- .../Strategy/OssStrategy.cs | 13 +++++- tests/ClientTest/Program.cs | 28 +++--------- .../Configuration/UpdateRequestTests.cs | 18 +++++--- tests/UpgradeTest/Program.cs | 43 +++++-------------- 5 files changed, 40 insertions(+), 71 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs index d3a675ac..b3f9ea30 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs @@ -62,17 +62,8 @@ public void Validate() if (!string.IsNullOrWhiteSpace(UpdateLogUrl) && !Uri.IsWellFormedUriString(UpdateLogUrl, UriKind.Absolute)) throw new ArgumentException("Invalid UpdateLogUrl"); - if (string.IsNullOrWhiteSpace(UpdateAppName)) - throw new ArgumentException("UpdateAppName cannot be empty"); - - if (string.IsNullOrWhiteSpace(MainAppName)) - throw new ArgumentException("MainAppName cannot be empty"); - if (string.IsNullOrWhiteSpace(AppSecretKey)) throw new ArgumentException("AppSecretKey cannot be empty"); - - if (string.IsNullOrWhiteSpace(ClientVersion)) - throw new ArgumentException("ClientVersion cannot be empty"); } } } diff --git a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs index d70faa37..3307c978 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs @@ -150,7 +150,12 @@ public async Task ExecuteAsync() if (_configInfo == null) throw new InvalidOperationException("OssStrategy not configured. Call Create() first."); - // Dispatch by role �?no env-var detection needed. + // Fill missing identity fields from generalupdate.manifest.json, + // making the manifest the single source of configuration across + // all update flows (standard ClientStrategy and OssStrategy). + Configuration.AppMetadataDiscoverer.Discover(_configInfo); + + // Dispatch by role — no env-var detection needed. if (_role == AppType.OssUpgrade) { await ExecuteUpgradeAsync(); @@ -371,6 +376,12 @@ private async Task ExecuteUpgradeAsync() var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage); DecompressAssets(assets, installPath, encoding); + // Update generalupdate.manifest.json ClientVersion so the client + // reads the correct version on next startup, preventing infinite loops. + Configuration.ManifestInfo.TryUpdateVersion( + installPath, + clientVersion: _configInfo.LastVersion); + await SafeOnDownloadCompletedAsync(ctx).ConfigureAwait(false); await SafeOnAfterUpdateAsync(ctx).ConfigureAwait(false); await SafeReportUpdateAppliedAsync(ctx).ConfigureAwait(false); diff --git a/tests/ClientTest/Program.cs b/tests/ClientTest/Program.cs index 2a985495..43d47652 100644 --- a/tests/ClientTest/Program.cs +++ b/tests/ClientTest/Program.cs @@ -39,37 +39,21 @@ static async Task RunOssClientAsync() Console.WriteLine($"Started at {DateTime.Now}"); Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); + // Secrets come from code. Identity fields (MainAppName, ClientVersion, + // UpdateAppName, UpdatePath) are read from generalupdate.manifest.json + // by OssStrategy via AppMetadataDiscoverer.Discover() — same as standard flow. var updateUrl = "http://localhost:5000/packages/versions.json"; + var appSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6"; - // Read current version from marker file (written by UpgradeTest after each successful update). - // This prevents infinite update loops when the package doesn't change the client binary. - var markerPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".current_version"); - var clientVersion = "1.0.0"; - if (File.Exists(markerPath)) - { - clientVersion = File.ReadAllText(markerPath).Trim(); - Console.WriteLine($"Marker file found: current version = {clientVersion}"); - } - - Console.WriteLine($"UpdateUrl (versions.json): {updateUrl}"); - Console.WriteLine($"Current version: {clientVersion}"); + Console.WriteLine($"UpdateUrl: {updateUrl}"); Console.WriteLine(); - // OssClient flow: - // 1. Download versions.json from UpdateUrl to InstallPath - // 2. Deserialize as List, sort by PubTime desc - // 3. Compare latest.Version > ClientVersion - // 4. If newer: launch UpdateAppName from InstallPath, then exit await new GeneralUpdateBootstrap() .SetConfig(new UpdateRequest { UpdateUrl = updateUrl, InstallPath = AppDomain.CurrentDomain.BaseDirectory, - ClientVersion = clientVersion, - MainAppName = "ClientTest.exe", - UpdateAppName = "UpgradeTest.exe", - UpdatePath = "update", - AppSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6" + AppSecretKey = appSecretKey }) .SetOption(Option.AppType, AppType.OssClient) .Hooks() diff --git a/tests/CoreTest/Configuration/UpdateRequestTests.cs b/tests/CoreTest/Configuration/UpdateRequestTests.cs index bc0c14c2..c11a784c 100644 --- a/tests/CoreTest/Configuration/UpdateRequestTests.cs +++ b/tests/CoreTest/Configuration/UpdateRequestTests.cs @@ -95,8 +95,9 @@ public void Validate_UpdateLogUrlInvalid_ThrowsArgumentException() [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Validate_UpgradeAppNameNullOrWhitespace_ThrowsArgumentException(string appName) + public void Validate_UpgradeAppNameNullOrWhitespace_NoException(string appName) { + // UpdateAppName is optional — filled from manifest by strategies. var config = new UpdateRequest { UpdateUrl = "https://api.example.com", @@ -106,15 +107,17 @@ public void Validate_UpgradeAppNameNullOrWhitespace_ThrowsArgumentException(stri ClientVersion = "1.0.0", InstallPath = "C:\\app" }; - Assert.Throws(() => config.Validate()); + var ex = Record.Exception(() => config.Validate()); + Assert.Null(ex); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Validate_MainAppNameNullOrWhitespace_ThrowsArgumentException(string mainUpgradeAppName) + public void Validate_MainAppNameNullOrWhitespace_NoException(string mainUpgradeAppName) { + // MainAppName is optional — filled from manifest by strategies. var config = new UpdateRequest { UpdateUrl = "https://api.example.com", @@ -124,7 +127,8 @@ public void Validate_MainAppNameNullOrWhitespace_ThrowsArgumentException(string ClientVersion = "1.0.0", InstallPath = "C:\\app" }; - Assert.Throws(() => config.Validate()); + var ex = Record.Exception(() => config.Validate()); + Assert.Null(ex); } [Theory] @@ -149,8 +153,9 @@ public void Validate_AppSecretKeyNullOrWhitespace_ThrowsArgumentException(string [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Validate_ClientVersionNullOrWhitespace_ThrowsArgumentException(string clientVersion) + public void Validate_ClientVersionNullOrWhitespace_NoException(string clientVersion) { + // ClientVersion is optional — filled from manifest by strategies. var config = new UpdateRequest { UpdateUrl = "https://api.example.com", @@ -160,7 +165,8 @@ public void Validate_ClientVersionNullOrWhitespace_ThrowsArgumentException(strin ClientVersion = clientVersion, InstallPath = "C:\\app" }; - Assert.Throws(() => config.Validate()); + var ex = Record.Exception(() => config.Validate()); + Assert.Null(ex); } [Theory] diff --git a/tests/UpgradeTest/Program.cs b/tests/UpgradeTest/Program.cs index 35e03086..a40141c3 100644 --- a/tests/UpgradeTest/Program.cs +++ b/tests/UpgradeTest/Program.cs @@ -33,39 +33,24 @@ static async Task RunOssUpgradeAsync() Console.WriteLine($"Started at {DateTime.Now}"); Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); - // OssUpgrade flow: - // 1. Read {MainAppName}_versions.json from InstallPath (downloaded by OssClient) - // 2. Filter versions > ClientVersion, sorted by PubTime asc - // 3. Download each asset's ZIP from the URL in the version record - // 4. Decompress all ZIPs to InstallPath, delete archives - // 5. Launch MainAppName, then exit - // - // NOTE: Unlike the standard Upgrade path (which reads config from IPC), - // OssUpgrade requires explicit SetConfig() so it knows where to find - // the version JSON and which version to compare against. - // - // InstallPath must point to the SAME directory as the OssClient's InstallPath, - // because the versions.json was downloaded there. When the upgrade runs from - // a subdirectory (e.g. update/), we resolve up to the parent. - + // OssUpgrade runs from a subdirectory (e.g. update/), but the manifest + // and versions.json are in the parent directory (same as OssClient). + // InstallPath resolves to the parent so OssStrategy reads the correct + // manifest and versions.json. var baseDir = AppDomain.CurrentDomain.BaseDirectory; - // baseDir ends with '\' (e.g. "...\update\"), so Path.Combine with ".." goes up one level. var installPath = Path.GetFullPath(Path.Combine(baseDir, "..")); - Console.WriteLine($"[OssUpgrade] BaseDir={baseDir}"); - Console.WriteLine($"[OssUpgrade] InstallPath={installPath}"); - GeneralUpdate.Core.GeneralTracer.Info($"[OssUpgrade] BaseDir={baseDir}"); - GeneralUpdate.Core.GeneralTracer.Info($"[OssUpgrade] InstallPath={installPath}"); + Console.WriteLine($"BaseDir={baseDir}"); + Console.WriteLine($"InstallPath={installPath} (parent, where manifest + versions.json live)"); + Console.WriteLine(); await new GeneralUpdateBootstrap() .SetConfig(new UpdateRequest { UpdateUrl = "http://localhost:5000/packages/versions.json", InstallPath = installPath, - ClientVersion = "1.0.0", - MainAppName = "ClientTest.exe", - UpdateAppName = "UpgradeTest.exe", AppSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6" + // Identity fields from generalupdate.manifest.json via Discover(). }) .SetOption(Option.AppType, AppType.OssUpgrade) .Hooks() @@ -160,16 +145,8 @@ public async Task OnDownloadCompletedAsync(DownloadContext ctx) public async Task OnAfterUpdateAsync(HookContext ctx) { Console.WriteLine($"[Hook] OnAfterUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); - - // Write version marker to prevent infinite update loops. - // The OssClient reads this file on startup to know its current version. - if (!string.IsNullOrWhiteSpace(ctx.TargetVersion) && !string.IsNullOrWhiteSpace(ctx.InstallPath)) - { - var markerPath = Path.Combine(ctx.InstallPath, ".current_version"); - File.WriteAllText(markerPath, ctx.TargetVersion); - Console.WriteLine($"[Hook] Version marker written: {markerPath} = {ctx.TargetVersion}"); - } - + // Manifest ClientVersion is updated by OssStrategy itself via + // ManifestInfo.TryUpdateVersion() after decompression. await Task.CompletedTask; } From 4b50eb5c815db83d87ae9237f508e45e2c948488 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Mon, 1 Jun 2026 22:24:01 +0800 Subject: [PATCH 3/5] fix: update ParameterMatrixAndEventTests for relaxed Validate() --- .../Bootstrap/ParameterMatrixAndEventTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs b/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs index cad3684a..2626e4c5 100644 --- a/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs +++ b/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs @@ -190,8 +190,9 @@ public void Configinfo_Validate_MissingUpdateUrl_Throws() } [Fact] - public void Configinfo_Validate_MissingMainAppName_Throws() + public void Configinfo_Validate_MissingMainAppName_DoesNotThrow() { + // MainAppName is optional — filled from manifest by strategies. var config = new UpdateRequest { UpdateUrl = "https://api.example.com", @@ -200,12 +201,14 @@ public void Configinfo_Validate_MissingMainAppName_Throws() MainAppName = null! }; - Assert.Throws(() => config.Validate()); + var ex = Record.Exception(() => config.Validate()); + Assert.Null(ex); } [Fact] - public void Configinfo_Validate_MissingClientVersion_Throws() + public void Configinfo_Validate_MissingClientVersion_DoesNotThrow() { + // ClientVersion is optional — filled from manifest by strategies. var config = new UpdateRequest { UpdateUrl = "https://api.example.com", @@ -214,7 +217,8 @@ public void Configinfo_Validate_MissingClientVersion_Throws() ClientVersion = null! }; - Assert.Throws(() => config.Validate()); + var ex = Record.Exception(() => config.Validate()); + Assert.Null(ex); } [Theory] From 750fe1569c44f30a55c59c548cb4b9471a5fe020 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Mon, 1 Jun 2026 23:49:13 +0800 Subject: [PATCH 4/5] feat: add installPath to SetSource(), simplify OSS test code to use SetSource() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetSource() now accepts optional installPath parameter for scenarios where the current process runs from a subdirectory (e.g. OSS upgrade) and generalupdate.manifest.json lives in the parent directory. - Update SetSource() XML doc to mention OssStrategy and all AppType modes. - Update class-level example to show both standard and OSS usage patterns. - Simplify ClientTest and UpgradeTest OSS paths to use SetSource() instead of SetConfig(new UpdateRequest{...}) — matching the standard flow pattern where only secrets are passed in code and identity fields are read from generalupdate.manifest.json. Co-Authored-By: Claude Opus 4.8 --- .../Bootstrap/GeneralUpdateBootstrap.cs | 47 +++++++++++++++---- tests/ClientTest/Program.cs | 14 ++---- tests/UpgradeTest/Program.cs | 15 +++--- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index a071a6db..0c486a6f 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -53,18 +53,33 @@ namespace GeneralUpdate.Core; /// components, network policies, and OS strategy. /// /// +/// Standard flow (Client): /// /// var result = await new GeneralUpdateBootstrap() -/// .SetConfig(new UpdateRequest { -/// UpdateUrl = "https://api.example.com", -/// ClientVersion = "1.0.0", -/// InstallPath = @"C:\MyApp", -/// AppSecretKey = "my-key" -/// }) +/// .SetSource("https://api.example.com", "my-key") /// .SetOption(Option.AppType, AppType.Client) /// .Hooks<MyCustomHooks>() /// .LaunchAsync(); /// +/// OSS flow (Object Storage Service): +/// +/// // OssClient — only secrets in code; identity from generalupdate.manifest.json +/// var result = await new GeneralUpdateBootstrap() +/// .SetSource("https://oss.example.com/versions.json", "my-key") +/// .SetOption(Option.AppType, AppType.OssClient) +/// .Hooks<MyCustomHooks>() +/// .LaunchAsync(); +/// +/// // OssUpgrade — running from a subdirectory; pass installPath to locate +/// // generalupdate.manifest.json in the parent directory +/// var installPath = Path.GetFullPath(Path.Combine(baseDir, "..")); +/// var result = await new GeneralUpdateBootstrap() +/// .SetSource("https://oss.example.com/versions.json", "my-key", +/// installPath: installPath) +/// .SetOption(Option.AppType, AppType.OssUpgrade) +/// .Hooks<MyCustomHooks>() +/// .LaunchAsync(); +/// /// public class GeneralUpdateBootstrap : AbstractBootstrap { @@ -232,15 +247,29 @@ public GeneralUpdateBootstrap SetConfig(string filePath) /// /// Zero-config entry-point: supply only secrets. Provides sensible identity defaults /// that pass ; the real identity metadata is - /// discovered later by from generalupdate.manifest.json. + /// discovered later by or + /// from generalupdate.manifest.json. Works with all + /// modes (Client, Upgrade, OssClient, OssUpgrade). /// + /// The remote endpoint for update validation or version config retrieval. + /// The application secret key used for authentication. + /// Optional URL for reporting update status back to the server. + /// Optional URL scheme override (e.g. "https"). + /// Optional authentication token for API requests. + /// + /// Optional override for the installation root directory. When null, + /// defaults to . Set this when + /// the current process runs from a subdirectory (e.g. the OSS upgrade process in + /// update/) and generalupdate.manifest.json lives in the parent. + /// /// This bootstrap instance for chaining. public GeneralUpdateBootstrap SetSource( string updateUrl, string appSecretKey, string? reportUrl = null, string? scheme = null, - string? token = null) + string? token = null, + string? installPath = null) { var config = new UpdateRequest { @@ -250,6 +279,8 @@ public GeneralUpdateBootstrap SetSource( Scheme = scheme, ReportUrl = reportUrl }; + if (installPath != null) + config.InstallPath = installPath; return SetConfig(config); } diff --git a/tests/ClientTest/Program.cs b/tests/ClientTest/Program.cs index 43d47652..e0e9668c 100644 --- a/tests/ClientTest/Program.cs +++ b/tests/ClientTest/Program.cs @@ -39,9 +39,10 @@ static async Task RunOssClientAsync() Console.WriteLine($"Started at {DateTime.Now}"); Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); - // Secrets come from code. Identity fields (MainAppName, ClientVersion, - // UpdateAppName, UpdatePath) are read from generalupdate.manifest.json - // by OssStrategy via AppMetadataDiscoverer.Discover() — same as standard flow. + // Only secrets are supplied in code. Identity fields (MainAppName, + // ClientVersion, UpdateAppName, UpdatePath) are read from + // generalupdate.manifest.json by OssStrategy via + // AppMetadataDiscoverer.Discover() — same as the standard flow. var updateUrl = "http://localhost:5000/packages/versions.json"; var appSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6"; @@ -49,12 +50,7 @@ static async Task RunOssClientAsync() Console.WriteLine(); await new GeneralUpdateBootstrap() - .SetConfig(new UpdateRequest - { - UpdateUrl = updateUrl, - InstallPath = AppDomain.CurrentDomain.BaseDirectory, - AppSecretKey = appSecretKey - }) + .SetSource(updateUrl, appSecretKey) .SetOption(Option.AppType, AppType.OssClient) .Hooks() .AddListenerMultiDownloadStatistics(OnDownloadStatistics) diff --git a/tests/UpgradeTest/Program.cs b/tests/UpgradeTest/Program.cs index a40141c3..ef728de2 100644 --- a/tests/UpgradeTest/Program.cs +++ b/tests/UpgradeTest/Program.cs @@ -44,14 +44,15 @@ static async Task RunOssUpgradeAsync() Console.WriteLine($"InstallPath={installPath} (parent, where manifest + versions.json live)"); Console.WriteLine(); + // Only secrets and InstallPath are supplied in code. Identity fields + // (MainAppName, ClientVersion, UpdateAppName) are read from + // generalupdate.manifest.json by OssStrategy via + // AppMetadataDiscoverer.Discover() — same as the standard OSS client flow. await new GeneralUpdateBootstrap() - .SetConfig(new UpdateRequest - { - UpdateUrl = "http://localhost:5000/packages/versions.json", - InstallPath = installPath, - AppSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6" - // Identity fields from generalupdate.manifest.json via Discover(). - }) + .SetSource( + "http://localhost:5000/packages/versions.json", + "dfeb5833-975e-4afb-88f1-6278ee9aeff6", + installPath: installPath) .SetOption(Option.AppType, AppType.OssUpgrade) .Hooks() .AddListenerMultiDownloadStatistics(OnDownloadStatistics) From f6bc3187d5d5f1d5d5a2499622588547fbbd276c Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Tue, 2 Jun 2026 00:01:01 +0800 Subject: [PATCH 5/5] fix: reorder hooks after asset build, compute LastVersion deterministically - Move SafeOnBeforeUpdateAsync and SafeReportUpdateStartedAsync to after asset list construction so hooks see the real TargetVersion instead of an empty value (fixes Copilot comment 5). - Compute LastVersion via Max(new Version(...)) instead of relying on array ordering (assets[^1].Version). This is correct for both the local JSON path and custom IDownloadSource implementations whose ListAsync() may not return sorted results (fixes Copilot comment 4). Co-Authored-By: Claude Opus 4.8 --- .../Strategy/OssStrategy.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs index 3307c978..65270a2b 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs @@ -324,15 +324,6 @@ private async Task ExecuteUpgradeAsync() if (!File.Exists(jsonPath) && DownloadSource == null) throw new FileNotFoundException($"Version config not found: {jsonPath}"); - // Hooks: allow cancellation before download - if (!await SafeOnBeforeUpdateAsync(ctx).ConfigureAwait(false)) - { - GeneralTracer.Info("OssStrategy (upgrade): cancelled by hook."); - return; - } - - await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false); - // Build download assets from version config or injected source List assets; if (DownloadSource != null) @@ -365,10 +356,25 @@ private async Task ExecuteUpgradeAsync() if (assets.Count == 0) throw new InvalidOperationException("No assets to download."); - // Update LastVersion so hooks (OnAfterUpdate, etc.) know the target version. - _configInfo.LastVersion = assets[assets.Count - 1].Version; + // Compute LastVersion deterministically via Version comparison + // so hooks see the correct TargetVersion regardless of source ordering. + _configInfo.LastVersion = assets + .Select(a => new Version(a.Version)) + .Max()! + .ToString(); ctx = BuildUpdateContext(); + // Hooks: allow cancellation before download. Called after assets are + // built and LastVersion is set so OnBeforeUpdateAsync sees the real + // TargetVersion for decision-making. + if (!await SafeOnBeforeUpdateAsync(ctx).ConfigureAwait(false)) + { + GeneralTracer.Info("OssStrategy (upgrade): cancelled by hook."); + return; + } + + await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false); + GeneralTracer.Debug($"OssStrategy (upgrade): downloading {assets.Count} asset(s)."); await DownloadAssetsAsync(assets, installPath).ConfigureAwait(false);