From 07bbe72770bd933d843e15e783bad60ee8027c8f Mon Sep 17 00:00:00 2001 From: Bob Langley Date: Fri, 23 Oct 2026 08:29:19 -0700 Subject: [PATCH 1/4] Report platform tool exits and allow newer runtime patches --- .../ChildProcessTests.cs | 72 +++++++++++++ src/Particular.PlatformSample/AppLauncher.cs | 62 ++++++----- src/Particular.PlatformSample/ChildProcess.cs | 98 +++++++++++++++++ .../PlatformLauncher.cs | 100 ++++++++++++------ 4 files changed, 273 insertions(+), 59 deletions(-) create mode 100644 src/Particular.PlatformSample.Tests/ChildProcessTests.cs create mode 100644 src/Particular.PlatformSample/ChildProcess.cs diff --git a/src/Particular.PlatformSample.Tests/ChildProcessTests.cs b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs new file mode 100644 index 0000000..a4baec8 --- /dev/null +++ b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs @@ -0,0 +1,72 @@ +namespace Particular.PlatformSample.Tests +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Threading.Tasks; + using NUnit.Framework; + + [TestFixture] + public class ChildProcessTests + { + [TestCase(false)] + [TestCase(true)] + public async Task ReportsMissingRuntimeEvenWhenConsoleOutputIsHidden(bool showConsoleOutput) + { + var runtimeConfig = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.runtimeconfig.json"); + try + { + await File.WriteAllTextAsync(runtimeConfig, """ + { + "runtimeOptions": { + "tfm": "net10.0", + "rollForward": "Disable", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "999.0.0" + } + } + } + """); + + var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"); + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add("--runtimeconfig"); + startInfo.ArgumentList.Add(runtimeConfig); + startInfo.ArgumentList.Add(typeof(ChildProcessTests).Assembly.Location); + startInfo.Environment.Remove("DOTNET_ROLL_FORWARD"); + + await using var child = new ChildProcess("ServiceControl", startInfo, showConsoleOutput); + var failure = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.TypeOf()); + Assert.That(failure.Message, Does.Contain("ServiceControl").And.Contain("exit code")); + Assert.That(failure.Message, Does.Contain("Microsoft.NETCore.App").And.Contain("999.0.0")); + }); + } + finally + { + File.Delete(runtimeConfig); + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReportsUnexpectedSuccessfulExitAndCapturesStandardOutput(bool showConsoleOutput) + { + var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"); + startInfo.ArgumentList.Add("--list-runtimes"); + + await using var child = new ChildProcess("ServiceControl.Monitoring", startInfo, showConsoleOutput); + var failure = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Multiple(() => + { + Assert.That(failure.Message, Does.Contain("ServiceControl.Monitoring").And.Contain("exit code 0")); + Assert.That(failure.Message, Does.Contain("Microsoft.NETCore.App")); + }); + } + } +} diff --git a/src/Particular.PlatformSample/AppLauncher.cs b/src/Particular.PlatformSample/AppLauncher.cs index 8d96453..641b5ec 100644 --- a/src/Particular.PlatformSample/AppLauncher.cs +++ b/src/Particular.PlatformSample/AppLauncher.cs @@ -4,16 +4,17 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; + using System.Linq; using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; using Raven.Embedded; - sealed class AppLauncher(bool showPlatformToolConsoleOutput) : IAsyncDisposable + sealed class AppLauncher(bool showPlatformToolConsoleOutput, CancellationToken shutdownCancellationToken = default) : IAsyncDisposable { readonly string platformPath = Path.Combine(AppContext.BaseDirectory, "platform"); - readonly bool hideConsoleOutput = !showPlatformToolConsoleOutput; + readonly List processes = []; readonly Stack> cleanupActions = new(); public async Task RavenDB(string logsPath, string dataDirectory, CancellationToken cancellationToken = default) @@ -33,14 +34,17 @@ public async Task RavenDB(string logsPath, string dataDirectory, Cancellati // Make RavenDB roll forward to the next available higher major version if the one it was built against is not installed Environment.SetEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); - EmbeddedServer.Instance.StartServer(options); - cleanupActions.Push(() => StopRavenDB(cancellationToken)); - - var result = await EmbeddedServer.Instance.GetServerUriAsync(cancellationToken).ConfigureAwait(false); - - Environment.SetEnvironmentVariable("DOTNET_ROLL_FORWARD", currentRollforward); + try + { + EmbeddedServer.Instance.StartServer(options); + cleanupActions.Push(() => StopRavenDB(shutdownCancellationToken)); - return result; + return await EmbeddedServer.Instance.GetServerUriAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ROLL_FORWARD", currentRollforward); + } } static async ValueTask StopRavenDB(CancellationToken cancellationToken) @@ -79,6 +83,8 @@ await Console.Error.WriteLineAsync($"Failed to kill RavenDB server PID {processI .ConfigureAwait(false); } } + + await disposeTask.ConfigureAwait(false); } public void ServiceControl(int port, int maintenancePort, string logPath, string transportPath, int auditPort, Uri connectionString) @@ -175,6 +181,10 @@ void StartProcess(string assemblyPath, Dictionary environmentVar // by the ProcessStartInfo constructor by spaces. startInfo.ArgumentList.Add(assemblyPath); + // Portable tools can use newer patches without requiring the exact runtime they were built against. + // Preserve an explicit override supplied by the caller. + startInfo.Environment.TryAdd("DOTNET_ROLL_FORWARD", "LatestPatch"); + if (environmentVariables is not null) { foreach (var item in environmentVariables) @@ -183,20 +193,14 @@ void StartProcess(string assemblyPath, Dictionary environmentVar } } - if (hideConsoleOutput) - { - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - } - - var process = Process.Start(startInfo); + var process = new ChildProcess(Path.GetFileNameWithoutExtension(assemblyPath), startInfo, showPlatformToolConsoleOutput); + processes.Add(process); + } - // without this in certain occasions the start takes waaaaay longer! - if (hideConsoleOutput) - { - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - } + public async Task WaitForExit(CancellationToken cancellationToken = default) + { + var exited = await Task.WhenAny(processes.Select(process => process.Exited)).WaitAsync(cancellationToken).ConfigureAwait(false); + return await exited.ConfigureAwait(false); } static string GetResource(string resourceName) @@ -211,10 +215,18 @@ static string GetResource(string resourceName) public async ValueTask DisposeAsync() { - while (cleanupActions.Count > 0) + try { - var action = cleanupActions.Pop(); - await action().ConfigureAwait(false); + // Give all tools the same grace period, before deleting any of their configuration files. + await Task.WhenAll(processes.Select(process => process.DisposeAsync().AsTask())).ConfigureAwait(false); + } + finally + { + while (cleanupActions.Count > 0) + { + var action = cleanupActions.Pop(); + await action().ConfigureAwait(false); + } } } } diff --git a/src/Particular.PlatformSample/ChildProcess.cs b/src/Particular.PlatformSample/ChildProcess.cs new file mode 100644 index 0000000..e99bd01 --- /dev/null +++ b/src/Particular.PlatformSample/ChildProcess.cs @@ -0,0 +1,98 @@ +namespace Particular +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + sealed class ChildProcess : IAsyncDisposable + { + const int MaximumOutputLength = 8192; + readonly Process process; + + public ChildProcess(string name, ProcessStartInfo startInfo, bool showConsoleOutput) + { + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + + process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start platform tool '{name}'."); + + var output = ReadOutput(process.StandardOutput, showConsoleOutput ? Console.Out : null, CancellationToken.None); + var error = ReadOutput(process.StandardError, showConsoleOutput ? Console.Error : null, CancellationToken.None); + Exited = ObserveExit(name, output, error, CancellationToken.None); + } + + // Return the failure so exits from several tools can be monitored without unobserved task exceptions. + public Task Exited { get; } + + async Task ObserveExit(string name, Task output, Task error, CancellationToken cancellationToken) + { + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var capturedOutput = await Task.WhenAll(output, error).ConfigureAwait(false); + + return new InvalidOperationException($"Platform tool '{name}' exited unexpectedly with exit code {process.ExitCode}.{Environment.NewLine}{string.Join(Environment.NewLine, capturedOutput)}"); + } + catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + return new InvalidOperationException($"Could not monitor platform tool '{name}'.", exception); + } + } + + static async Task ReadOutput(StreamReader reader, TextWriter console, CancellationToken cancellationToken) + { + var output = new StringBuilder(); + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + output.AppendLine(line); + if (output.Length > MaximumOutputLength) + { + output.Remove(0, output.Length - MaximumOutputLength); + } + + if (console != null) + { + await console.WriteLineAsync(line.AsMemory(), cancellationToken).ConfigureAwait(false); + } + } + + return output.ToString(); + } + + public async ValueTask DisposeAsync() + { + try + { + try + { + // Ctrl+C also reaches the tools. Let them finish shutting down before forcing termination. + await Exited.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (TimeoutException) + { + if (!process.HasExited) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) when (process.HasExited) + { + // The tool can exit between checking HasExited and requesting termination. + } + } + } + + await Exited.ConfigureAwait(false); + } + finally + { + process.Dispose(); + } + } + } +} diff --git a/src/Particular.PlatformSample/PlatformLauncher.cs b/src/Particular.PlatformSample/PlatformLauncher.cs index dfa2d30..42244f9 100644 --- a/src/Particular.PlatformSample/PlatformLauncher.cs +++ b/src/Particular.PlatformSample/PlatformLauncher.cs @@ -26,11 +26,12 @@ public static class PlatformLauncher /// By default, the ServicePulse dashboard (/dashboard) is displayed. Set the default route to any valid ServicePulse route such as (/monitoring). /// Overrides the root folder used to store all the platform data. By default the root folder is determined by searching for a solution file. /// Cancellation token + /// A platform tool exits unexpectedly. Includes its exit code and recent console output. public static async Task Launch(bool showPlatformToolConsoleOutput = false, string servicePulseDefaultRoute = null, string rootFolder = null, CancellationToken cancellationToken = default) { using var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Console.CancelKeyPress += (sender, args) => + ConsoleCancelEventHandler cancelHandler = (sender, args) => { args.Cancel = true; try @@ -78,56 +79,87 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri Console.WriteLine("Creating transport folder"); var transportPath = finder.GetDirectory(".learningtransport"); - var launcher = new AppLauncher(showPlatformToolConsoleOutput); + var launcher = new AppLauncher(showPlatformToolConsoleOutput, shutdownCancellationToken: cancellationToken); await using var _ = launcher.ConfigureAwait(false); - Console.WriteLine("Launching RavenDB"); - var serverUri = await launcher.RavenDB(ravenLogs, ravenDB, cancellationToken).ConfigureAwait(false); - - Console.WriteLine("Launching ServiceControl"); - launcher.ServiceControl(controlPort, maintenancePort, controlLogs, transportPath, auditPort, serverUri); + Task startup = Task.CompletedTask; + Console.CancelKeyPress += cancelHandler; + try + { + Console.WriteLine("Launching RavenDB"); + var serverUri = await launcher.RavenDB(ravenLogs, ravenDB, tokenSource.Token).ConfigureAwait(false); - Console.WriteLine("Launching ServiceControl Audit"); - launcher.ServiceControlAudit(auditPort, auditMaintenancePort, auditLogs, transportPath, serverUri); + Console.WriteLine("Launching ServiceControl"); + launcher.ServiceControl(controlPort, maintenancePort, controlLogs, transportPath, auditPort, serverUri); - Console.WriteLine("Launching ServiceControl Monitoring"); - launcher.Monitoring(monitoringPort, monitoringLogs, transportPath); + Console.WriteLine("Launching ServiceControl Audit"); + launcher.ServiceControlAudit(auditPort, auditMaintenancePort, auditLogs, transportPath, serverUri); - Console.WriteLine("Launching ServicePulse"); - launcher.ServicePulse(pulsePort, controlPort, monitoringPort, servicePulseDefaultRoute); + Console.WriteLine("Launching ServiceControl Monitoring"); + launcher.Monitoring(monitoringPort, monitoringLogs, transportPath); - Console.Write("Waiting for ServiceControl to be available"); - await Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token) - .ConfigureAwait(false); + Console.WriteLine("Launching ServicePulse"); + launcher.ServicePulse(pulsePort, controlPort, monitoringPort, servicePulseDefaultRoute); - if (!tokenSource.IsCancellationRequested) - { - var serviceControlApiUrl = $"http://localhost:{controlPort}/api"; - Console.WriteLine(); - Console.WriteLine($"ServiceControl API can now be accessed via: {serviceControlApiUrl}"); + Console.Write("Waiting for ServiceControl to be available"); + var processExit = launcher.WaitForExit(CancellationToken.None); + startup = Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token); + await WaitForTaskOrExit(startup, processExit, tokenSource.Token).ConfigureAwait(false); - var servicePulseUrl = $"http://localhost:{pulsePort}"; - Console.WriteLine(); - Console.WriteLine($"ServicePulse can now be accessed via: {servicePulseUrl}"); - Console.WriteLine("Attempting to launch ServicePulse in a browser window..."); - Process.Start(new ProcessStartInfo(servicePulseUrl) { UseShellExecute = true }); + if (!tokenSource.IsCancellationRequested) + { + var serviceControlApiUrl = $"http://localhost:{controlPort}/api"; + Console.WriteLine(); + Console.WriteLine($"ServiceControl API can now be accessed via: {serviceControlApiUrl}"); + + var servicePulseUrl = $"http://localhost:{pulsePort}"; + Console.WriteLine(); + Console.WriteLine($"ServicePulse can now be accessed via: {servicePulseUrl}"); + Console.WriteLine("Attempting to launch ServicePulse in a browser window..."); + Process.Start(new ProcessStartInfo(servicePulseUrl) { UseShellExecute = true }); + + Console.WriteLine(); + Console.WriteLine("Press Ctrl+C stop Particular Service Platform tools."); + + try + { + await WaitForTaskOrExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token), processExit, tokenSource.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) + { + // ignore + } + } Console.WriteLine(); - Console.WriteLine("Press Ctrl+C stop Particular Service Platform tools."); - + Console.WriteLine("Waiting for external processes to shut down..."); + } + finally + { + Console.CancelKeyPress -= cancelHandler; + await tokenSource.CancelAsync().ConfigureAwait(false); try { - await Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token) - .ConfigureAwait(false); + await startup.ConfigureAwait(false); } - catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) + catch (OperationCanceledException) when (tokenSource.IsCancellationRequested) { - // ignore + // The readiness request is cancelled when a child exits or the launcher stops. } } + } + + static async Task WaitForTaskOrExit(Task task, Task processExit, CancellationToken cancellationToken) + { + var completed = await Task.WhenAny(task, processExit).WaitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (completed == processExit) + { + throw await processExit.ConfigureAwait(false); + } - Console.WriteLine(); - Console.WriteLine("Waiting for external processes to shut down..."); + await task.ConfigureAwait(false); } } } From dfb84002e69c04b0a81992fb0274f8bc6dfa2c2f Mon Sep 17 00:00:00 2001 From: Bob Langley Date: Fri, 23 Oct 2026 08:41:39 -0700 Subject: [PATCH 2/4] Harden browser fixture startup and cleanup --- .../VisualTests.cs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Particular.PlatformSample.Tests/VisualTests.cs b/src/Particular.PlatformSample.Tests/VisualTests.cs index fe2b2d3..258162f 100644 --- a/src/Particular.PlatformSample.Tests/VisualTests.cs +++ b/src/Particular.PlatformSample.Tests/VisualTests.cs @@ -46,7 +46,7 @@ public async Task Setup() chromeOpts.AddArgument("--disk-cache-size=1"); chromeOpts.AddArgument("--media-cache-size=1"); chromeOpts.AddArgument("--incognito"); - chromeOpts.AddArgument("--remote-debugging-port=9222"); + chromeOpts.AddArgument("--remote-debugging-pipe"); chromeOpts.AddArgument("--aggressive-cache-discard"); if (Environment.GetEnvironmentVariable("CI") == "true") @@ -58,17 +58,33 @@ public async Task Setup() } var chromeService = ChromeDriverService.CreateDefaultService(); - driver = new ChromeDriver(chromeService, chromeOpts, TimeSpan.FromSeconds(90)); + try + { + driver = new ChromeDriver(chromeService, chromeOpts, TimeSpan.FromSeconds(90)); + } + catch + { + chromeService.Dispose(); + throw; + } } [OneTimeTearDown] public async Task TearDown() { - await closePlatformTokenSource.CancelAsync(); - await launcherTask; - closePlatformTokenSource.Dispose(); - driver.Close(); - await driver.DisposeAsync(); + try + { + await closePlatformTokenSource.CancelAsync(); + await launcherTask; + } + finally + { + closePlatformTokenSource.Dispose(); + if (driver != null) + { + await driver.DisposeAsync(); + } + } } [Test, CancelAfter(30_000)] From 2aa5cd2e222f4f7f2e501b6dbb9196646f90d76f Mon Sep 17 00:00:00 2001 From: Bob Langley Date: Fri, 23 Oct 2026 12:31:14 -0700 Subject: [PATCH 3/4] Represent child exits as data and report failure at launcher boundary --- .../ChildProcessTests.cs | 17 +++++++++-------- src/Particular.PlatformSample/AppLauncher.cs | 19 +++++++++++++++---- src/Particular.PlatformSample/ChildProcess.cs | 11 +++++------ .../PlatformLauncher.cs | 17 +++++++++-------- src/Particular.PlatformSample/ProcessExit.cs | 4 ++++ 5 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 src/Particular.PlatformSample/ProcessExit.cs diff --git a/src/Particular.PlatformSample.Tests/ChildProcessTests.cs b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs index a4baec8..1110797 100644 --- a/src/Particular.PlatformSample.Tests/ChildProcessTests.cs +++ b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs @@ -37,13 +37,13 @@ await File.WriteAllTextAsync(runtimeConfig, """ startInfo.Environment.Remove("DOTNET_ROLL_FORWARD"); await using var child = new ChildProcess("ServiceControl", startInfo, showConsoleOutput); - var failure = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + var exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); Assert.Multiple(() => { - Assert.That(failure, Is.TypeOf()); - Assert.That(failure.Message, Does.Contain("ServiceControl").And.Contain("exit code")); - Assert.That(failure.Message, Does.Contain("Microsoft.NETCore.App").And.Contain("999.0.0")); + Assert.That(exit.ProcessName, Is.EqualTo("ServiceControl")); + Assert.That(exit.ExitCode, Is.Not.Zero); + Assert.That(exit.StandardError, Does.Contain("Microsoft.NETCore.App").And.Contain("999.0.0")); }); } finally @@ -54,18 +54,19 @@ await File.WriteAllTextAsync(runtimeConfig, """ [TestCase(false)] [TestCase(true)] - public async Task ReportsUnexpectedSuccessfulExitAndCapturesStandardOutput(bool showConsoleOutput) + public async Task ReportsSuccessfulExitAndCapturesStandardOutput(bool showConsoleOutput) { var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"); startInfo.ArgumentList.Add("--list-runtimes"); await using var child = new ChildProcess("ServiceControl.Monitoring", startInfo, showConsoleOutput); - var failure = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + var exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); Assert.Multiple(() => { - Assert.That(failure.Message, Does.Contain("ServiceControl.Monitoring").And.Contain("exit code 0")); - Assert.That(failure.Message, Does.Contain("Microsoft.NETCore.App")); + Assert.That(exit.ProcessName, Is.EqualTo("ServiceControl.Monitoring")); + Assert.That(exit.ExitCode, Is.Zero); + Assert.That(exit.StandardOutput, Does.Contain("Microsoft.NETCore.App")); }); } } diff --git a/src/Particular.PlatformSample/AppLauncher.cs b/src/Particular.PlatformSample/AppLauncher.cs index 641b5ec..366e009 100644 --- a/src/Particular.PlatformSample/AppLauncher.cs +++ b/src/Particular.PlatformSample/AppLauncher.cs @@ -16,6 +16,7 @@ sealed class AppLauncher(bool showPlatformToolConsoleOutput, CancellationToken s readonly string platformPath = Path.Combine(AppContext.BaseDirectory, "platform"); readonly List processes = []; readonly Stack> cleanupActions = new(); + Task firstExitTask; public async Task RavenDB(string logsPath, string dataDirectory, CancellationToken cancellationToken = default) { @@ -197,10 +198,13 @@ void StartProcess(string assemblyPath, Dictionary environmentVar processes.Add(process); } - public async Task WaitForExit(CancellationToken cancellationToken = default) + public Task WaitForFirstExit(CancellationToken cancellationToken = default) => + (firstExitTask ??= ObserveFirstExit(CancellationToken.None)).WaitAsync(cancellationToken); + + async Task ObserveFirstExit(CancellationToken cancellationToken) { - var exited = await Task.WhenAny(processes.Select(process => process.Exited)).WaitAsync(cancellationToken).ConfigureAwait(false); - return await exited.ConfigureAwait(false); + var exitedTask = await Task.WhenAny(processes.Select(process => process.Exited)).WaitAsync(cancellationToken).ConfigureAwait(false); + return await exitedTask.ConfigureAwait(false); } static string GetResource(string resourceName) @@ -218,7 +222,14 @@ public async ValueTask DisposeAsync() try { // Give all tools the same grace period, before deleting any of their configuration files. - await Task.WhenAll(processes.Select(process => process.DisposeAsync().AsTask())).ConfigureAwait(false); + var shutdownTasks = processes.Select(process => process.DisposeAsync().AsTask()).ToList(); + if (firstExitTask != null) + { + // Observe monitoring failures even when shutdown wins the race with a process exit. + shutdownTasks.Add(firstExitTask); + } + + await Task.WhenAll(shutdownTasks).ConfigureAwait(false); } finally { diff --git a/src/Particular.PlatformSample/ChildProcess.cs b/src/Particular.PlatformSample/ChildProcess.cs index e99bd01..e999bbb 100644 --- a/src/Particular.PlatformSample/ChildProcess.cs +++ b/src/Particular.PlatformSample/ChildProcess.cs @@ -25,21 +25,20 @@ public ChildProcess(string name, ProcessStartInfo startInfo, bool showConsoleOut Exited = ObserveExit(name, output, error, CancellationToken.None); } - // Return the failure so exits from several tools can be monitored without unobserved task exceptions. - public Task Exited { get; } + public Task Exited { get; } - async Task ObserveExit(string name, Task output, Task error, CancellationToken cancellationToken) + async Task ObserveExit(string name, Task output, Task error, CancellationToken cancellationToken) { try { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); var capturedOutput = await Task.WhenAll(output, error).ConfigureAwait(false); - return new InvalidOperationException($"Platform tool '{name}' exited unexpectedly with exit code {process.ExitCode}.{Environment.NewLine}{string.Join(Environment.NewLine, capturedOutput)}"); + return new ProcessExit(name, process.ExitCode, capturedOutput[0], capturedOutput[1]); } catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { - return new InvalidOperationException($"Could not monitor platform tool '{name}'.", exception); + throw new InvalidOperationException($"Could not monitor platform tool '{name}'.", exception); } } @@ -70,7 +69,7 @@ public async ValueTask DisposeAsync() try { // Ctrl+C also reaches the tools. Let them finish shutting down before forcing termination. - await Exited.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch (TimeoutException) { diff --git a/src/Particular.PlatformSample/PlatformLauncher.cs b/src/Particular.PlatformSample/PlatformLauncher.cs index 42244f9..fff0191 100644 --- a/src/Particular.PlatformSample/PlatformLauncher.cs +++ b/src/Particular.PlatformSample/PlatformLauncher.cs @@ -102,9 +102,9 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri launcher.ServicePulse(pulsePort, controlPort, monitoringPort, servicePulseDefaultRoute); Console.Write("Waiting for ServiceControl to be available"); - var processExit = launcher.WaitForExit(CancellationToken.None); + var unexpectedExitTask = launcher.WaitForFirstExit(CancellationToken.None); startup = Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token); - await WaitForTaskOrExit(startup, processExit, tokenSource.Token).ConfigureAwait(false); + await WaitForTaskOrUnexpectedExit(startup, unexpectedExitTask, tokenSource.Token).ConfigureAwait(false); if (!tokenSource.IsCancellationRequested) { @@ -123,7 +123,7 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri try { - await WaitForTaskOrExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token), processExit, tokenSource.Token) + await WaitForTaskOrUnexpectedExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token), unexpectedExitTask, tokenSource.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) @@ -150,16 +150,17 @@ await WaitForTaskOrExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token), } } - static async Task WaitForTaskOrExit(Task task, Task processExit, CancellationToken cancellationToken) + static async Task WaitForTaskOrUnexpectedExit(Task operationTask, Task unexpectedExitTask, CancellationToken cancellationToken) { - var completed = await Task.WhenAny(task, processExit).WaitAsync(cancellationToken).ConfigureAwait(false); + var completedTask = await Task.WhenAny(operationTask, unexpectedExitTask).WaitAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - if (completed == processExit) + if (completedTask == unexpectedExitTask) { - throw await processExit.ConfigureAwait(false); + var exit = await unexpectedExitTask.ConfigureAwait(false); + throw new InvalidOperationException($"Platform tool '{exit.ProcessName}' exited unexpectedly with exit code {exit.ExitCode}.{Environment.NewLine}Standard output:{Environment.NewLine}{exit.StandardOutput}{Environment.NewLine}Standard error:{Environment.NewLine}{exit.StandardError}"); } - await task.ConfigureAwait(false); + await operationTask.ConfigureAwait(false); } } } diff --git a/src/Particular.PlatformSample/ProcessExit.cs b/src/Particular.PlatformSample/ProcessExit.cs new file mode 100644 index 0000000..dc34156 --- /dev/null +++ b/src/Particular.PlatformSample/ProcessExit.cs @@ -0,0 +1,4 @@ +namespace Particular +{ + sealed record ProcessExit(string ProcessName, int ExitCode, string StandardOutput, string StandardError); +} From b8a31487c75b2a8ca13882583ae9e408bb7f8a4b Mon Sep 17 00:00:00 2001 From: Bob Langley Date: Fri, 23 Oct 2026 13:38:57 -0700 Subject: [PATCH 4/4] Return platform launch outcomes without throwing for tool exits Keep the existing Launch signature and add LaunchWithResult so hosts can inspect an unexpected exit after cleanup. Report child exits through normal control flow and let callers choose their exit code. Cover cancellation, readiness, process exits, and genuine task faults. --- README.md | 11 +++ .../PlatformLauncherTests.cs | 87 +++++++++++++++++++ .../VisualTests.cs | 43 +++++++-- src/Particular.PlatformSample/LaunchResult.cs | 15 ++++ .../PlatformLauncher.cs | 74 ++++++++++++---- src/Particular.PlatformSample/ProcessExit.cs | 26 +++++- src/SmokeTest/Program.cs | 6 +- 7 files changed, 231 insertions(+), 31 deletions(-) create mode 100644 src/Particular.PlatformSample.Tests/PlatformLauncherTests.cs create mode 100644 src/Particular.PlatformSample/LaunchResult.cs diff --git a/README.md b/README.md index a80d7ea..774a69e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ This can be seen directly in this repository by launching the **SmokeTest.exe**: ![SmokeTest Output](output.png) +### Inspecting the launch outcome + +`Launch` reports an unexpected tool exit to standard error, stops the remaining tools, and returns after cleanup. To distinguish a tool exit from requested shutdown in code, use `LaunchWithResult` with the same optional parameters: + +```csharp +var result = await Particular.PlatformLauncher.LaunchWithResult(); +return result.UnexpectedExit == null ? 0 : 1; +``` + +`UnexpectedExit` contains the tool name, exit code, and recent standard output and standard error. An exit code of zero is still unexpected while the launcher is running. The property is null after shutdown requested by Ctrl+C or the cancellation token, including cancellation before startup completes. Setup, monitoring, and cleanup errors can still fault the returned task. + ## Options The `PlatformLauncher.Launch()` API has very limited options available as optional parameters, which can be mixed as needed. diff --git a/src/Particular.PlatformSample.Tests/PlatformLauncherTests.cs b/src/Particular.PlatformSample.Tests/PlatformLauncherTests.cs new file mode 100644 index 0000000..9b69f86 --- /dev/null +++ b/src/Particular.PlatformSample.Tests/PlatformLauncherTests.cs @@ -0,0 +1,87 @@ +namespace Particular.PlatformSample.Tests +{ + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using NUnit.Framework; + + [TestFixture] + public class PlatformLauncherTests + { + [TestCase(0)] + [TestCase(1)] + public async Task ReturnsUnexpectedExitWithoutFaulting(int exitCode) + { + var exit = new ProcessExit("ServiceControl", exitCode, "output", "error"); + var operation = new TaskCompletionSource(); + + var result = await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, Task.FromResult(exit), CancellationToken.None); + + Assert.That(result, Is.SameAs(exit)); + } + + [Test] + public async Task ContinuesMonitoringAfterReadinessCompletes() + { + var exitSource = new TaskCompletionSource(); + var startupResult = await PlatformLauncher.WaitForTaskOrUnexpectedExit(Task.CompletedTask, exitSource.Task, CancellationToken.None); + Assert.That(startupResult, Is.Null); + + var operation = new TaskCompletionSource(); + var running = PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitSource.Task, CancellationToken.None); + var exit = new ProcessExit("ServiceControl.Audit", 1, "", "stopped"); + exitSource.SetResult(exit); + + Assert.That(await running, Is.SameAs(exit)); + } + + [TestCase(false)] + [TestCase(true)] + public async Task RequestedShutdownTakesPrecedenceOverToolExit(bool toolAlreadyExited) + { + using var shutdown = new CancellationTokenSource(); + shutdown.Cancel(); + var operation = new TaskCompletionSource(); + var exitSource = new TaskCompletionSource(); + if (toolAlreadyExited) + { + exitSource.SetResult(new ProcessExit("ServiceControl", 0, "", "")); + } + + var result = await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitSource.Task, shutdown.Token); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task PropagatesOperationErrors() + { + var exitSource = new TaskCompletionSource(); + var operation = Task.FromException(new IOException("Readiness failed.")); + + await Assert.ThrowsAsync(async () => + await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation, exitSource.Task, CancellationToken.None)); + } + + [Test] + public async Task PropagatesMonitoringErrors() + { + var operation = new TaskCompletionSource(); + var exitTask = Task.FromException(new IOException("Monitoring failed.")); + + await Assert.ThrowsAsync(async () => + await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitTask, CancellationToken.None)); + } + + [Test] + public async Task CancellationBeforeLaunchReturnsRequestedShutdown() + { + using var shutdown = new CancellationTokenSource(); + shutdown.Cancel(); + + var result = await PlatformLauncher.LaunchWithResult(cancellationToken: shutdown.Token); + + Assert.That(result.UnexpectedExit, Is.Null); + } + } +} diff --git a/src/Particular.PlatformSample.Tests/VisualTests.cs b/src/Particular.PlatformSample.Tests/VisualTests.cs index 258162f..1cfc067 100644 --- a/src/Particular.PlatformSample.Tests/VisualTests.cs +++ b/src/Particular.PlatformSample.Tests/VisualTests.cs @@ -13,7 +13,7 @@ namespace Particular.PlatformSample.Tests; [Parallelizable(ParallelScope.None)] public class VisualTests { - Task launcherTask; + Task launcherTask; CancellationTokenSource closePlatformTokenSource; ChromeDriver driver; @@ -22,19 +22,38 @@ public async Task Setup() { closePlatformTokenSource = new CancellationTokenSource(); - launcherTask = Task.Run(async () => - { - await PlatformLauncher.Launch(cancellationToken: closePlatformTokenSource.Token); - }); + launcherTask = Task.Run(() => PlatformLauncher.LaunchWithResult(cancellationToken: closePlatformTokenSource.Token)); using var timeoutTokenSource = new CancellationTokenSource(60_000); - while (TestPortsInternal.ServicePulse == 0) + while (TestPortsInternal.ServicePulse == 0 && !launcherTask.IsCompleted) { - await Task.Delay(1000, closePlatformTokenSource.Token); + await Task.Delay(1000, timeoutTokenSource.Token); } - await Network.WaitForHttpOk($"http://localhost:{TestPortsInternal.ServicePulse}", cancellationToken: timeoutTokenSource.Token); + var readinessTask = Network.WaitForHttpOk($"http://localhost:{TestPortsInternal.ServicePulse}", cancellationToken: timeoutTokenSource.Token); + try + { + if (await Task.WhenAny(readinessTask, launcherTask) == launcherTask) + { + var result = await launcherTask; + Assert.Fail($"The launcher stopped before ServicePulse was ready. Tool: {result.UnexpectedExit?.ProcessName}, exit code: {result.UnexpectedExit?.ExitCode}."); + } + + await readinessTask; + } + finally + { + await timeoutTokenSource.CancelAsync(); + try + { + await readinessTask; + } + catch (OperationCanceledException) when (timeoutTokenSource.Token.IsCancellationRequested) + { + // Observe the pending readiness request when the launcher stops first. + } + } var chromeOpts = new ChromeOptions(); chromeOpts.AddArgument("--headless=new"); @@ -75,11 +94,17 @@ public async Task TearDown() try { await closePlatformTokenSource.CancelAsync(); - await launcherTask; + var result = await launcherTask; + Assert.That(result.UnexpectedExit, Is.Null, "A platform tool exited unexpectedly during the visual tests."); } finally { closePlatformTokenSource.Dispose(); + if (launcherTask?.IsCompleted == true) + { + launcherTask.Dispose(); + } + if (driver != null) { await driver.DisposeAsync(); diff --git a/src/Particular.PlatformSample/LaunchResult.cs b/src/Particular.PlatformSample/LaunchResult.cs new file mode 100644 index 0000000..ccc05ae --- /dev/null +++ b/src/Particular.PlatformSample/LaunchResult.cs @@ -0,0 +1,15 @@ +namespace Particular +{ + /// + /// Describes why the platform launcher stopped. + /// + public sealed class LaunchResult + { + internal LaunchResult(ProcessExit unexpectedExit = null) => UnexpectedExit = unexpectedExit; + + /// + /// The tool that exited unexpectedly, or null when shutdown was requested through Ctrl+C or the cancellation token. + /// + public ProcessExit UnexpectedExit { get; } + } +} diff --git a/src/Particular.PlatformSample/PlatformLauncher.cs b/src/Particular.PlatformSample/PlatformLauncher.cs index fff0191..5498c90 100644 --- a/src/Particular.PlatformSample/PlatformLauncher.cs +++ b/src/Particular.PlatformSample/PlatformLauncher.cs @@ -26,9 +26,30 @@ public static class PlatformLauncher /// By default, the ServicePulse dashboard (/dashboard) is displayed. Set the default route to any valid ServicePulse route such as (/monitoring). /// Overrides the root folder used to store all the platform data. By default the root folder is determined by searching for a solution file. /// Cancellation token - /// A platform tool exits unexpectedly. Includes its exit code and recent console output. + /// + /// Unexpected tool exits are reported to standard error. Use to inspect the outcome. + /// public static async Task Launch(bool showPlatformToolConsoleOutput = false, string servicePulseDefaultRoute = null, string rootFolder = null, CancellationToken cancellationToken = default) { + await LaunchWithResult(showPlatformToolConsoleOutput, servicePulseDefaultRoute, rootFolder, cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the platform tools until shutdown is requested or a tool exits, and returns the outcome after cleanup. + /// Unexpected tool exits are also reported to standard error. + /// + /// Whether to show tool console output while the tools are running. + /// The ServicePulse route to open. Defaults to the dashboard. + /// Overrides the root folder used for platform data. + /// Requests shutdown. + /// A result identifying an unexpected tool exit, or a result with no unexpected exit when shutdown was requested. + public static async Task LaunchWithResult(bool showPlatformToolConsoleOutput = false, string servicePulseDefaultRoute = null, string rootFolder = null, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return new LaunchResult(); + } + using var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); ConsoleCancelEventHandler cancelHandler = (sender, args) => @@ -87,7 +108,15 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri try { Console.WriteLine("Launching RavenDB"); - var serverUri = await launcher.RavenDB(ravenLogs, ravenDB, tokenSource.Token).ConfigureAwait(false); + Uri serverUri; + try + { + serverUri = await launcher.RavenDB(ravenLogs, ravenDB, tokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) + { + return new LaunchResult(); + } Console.WriteLine("Launching ServiceControl"); launcher.ServiceControl(controlPort, maintenancePort, controlLogs, transportPath, auditPort, serverUri); @@ -104,9 +133,9 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri Console.Write("Waiting for ServiceControl to be available"); var unexpectedExitTask = launcher.WaitForFirstExit(CancellationToken.None); startup = Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token); - await WaitForTaskOrUnexpectedExit(startup, unexpectedExitTask, tokenSource.Token).ConfigureAwait(false); + var unexpectedExit = await WaitForTaskOrUnexpectedExit(startup, unexpectedExitTask, tokenSource.Token).ConfigureAwait(false); - if (!tokenSource.IsCancellationRequested) + if (unexpectedExit == null && !tokenSource.IsCancellationRequested) { var serviceControlApiUrl = $"http://localhost:{controlPort}/api"; Console.WriteLine(); @@ -121,19 +150,20 @@ public static async Task Launch(bool showPlatformToolConsoleOutput = false, stri Console.WriteLine(); Console.WriteLine("Press Ctrl+C stop Particular Service Platform tools."); - try - { - await WaitForTaskOrUnexpectedExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token), unexpectedExitTask, tokenSource.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) - { - // ignore - } + unexpectedExit = await WaitForTaskOrUnexpectedExit(Task.Delay(Timeout.InfiniteTimeSpan, CancellationToken.None), unexpectedExitTask, tokenSource.Token) + .ConfigureAwait(false); + } + + if (unexpectedExit != null) + { + await Console.Error.WriteLineAsync().ConfigureAwait(false); + await Console.Error.WriteLineAsync($"Platform tool '{unexpectedExit.ProcessName}' exited unexpectedly with exit code {unexpectedExit.ExitCode}.{Environment.NewLine}Standard output:{Environment.NewLine}{unexpectedExit.StandardOutput}{Environment.NewLine}Standard error:{Environment.NewLine}{unexpectedExit.StandardError}") + .ConfigureAwait(false); } Console.WriteLine(); Console.WriteLine("Waiting for external processes to shut down..."); + return new LaunchResult(unexpectedExit); } finally { @@ -143,24 +173,30 @@ await WaitForTaskOrUnexpectedExit(Task.Delay(Timeout.InfiniteTimeSpan, tokenSour { await startup.ConfigureAwait(false); } - catch (OperationCanceledException) when (tokenSource.IsCancellationRequested) + catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) { // The readiness request is cancelled when a child exits or the launcher stops. } } } - static async Task WaitForTaskOrUnexpectedExit(Task operationTask, Task unexpectedExitTask, CancellationToken cancellationToken) + internal static async Task WaitForTaskOrUnexpectedExit(Task operationTask, Task unexpectedExitTask, CancellationToken cancellationToken = default) { - var completedTask = await Task.WhenAny(operationTask, unexpectedExitTask).WaitAsync(cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); + var shutdownRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(() => shutdownRequested.TrySetResult()); + var completedTask = await Task.WhenAny(operationTask, unexpectedExitTask, shutdownRequested.Task).ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + return null; + } + if (completedTask == unexpectedExitTask) { - var exit = await unexpectedExitTask.ConfigureAwait(false); - throw new InvalidOperationException($"Platform tool '{exit.ProcessName}' exited unexpectedly with exit code {exit.ExitCode}.{Environment.NewLine}Standard output:{Environment.NewLine}{exit.StandardOutput}{Environment.NewLine}Standard error:{Environment.NewLine}{exit.StandardError}"); + return await unexpectedExitTask.ConfigureAwait(false); } await operationTask.ConfigureAwait(false); + return null; } } } diff --git a/src/Particular.PlatformSample/ProcessExit.cs b/src/Particular.PlatformSample/ProcessExit.cs index dc34156..146aabd 100644 --- a/src/Particular.PlatformSample/ProcessExit.cs +++ b/src/Particular.PlatformSample/ProcessExit.cs @@ -1,4 +1,28 @@ namespace Particular { - sealed record ProcessExit(string ProcessName, int ExitCode, string StandardOutput, string StandardError); + /// + /// The exit status and recent console output of a platform tool. + /// + public sealed class ProcessExit + { + internal ProcessExit(string processName, int exitCode, string standardOutput, string standardError) + { + ProcessName = processName; + ExitCode = exitCode; + StandardOutput = standardOutput; + StandardError = standardError; + } + + /// The name of the platform tool. + public string ProcessName { get; } + + /// The process exit code. Even zero is unexpected while the launcher is running. + public int ExitCode { get; } + + /// The most recent standard output, truncated to the last 8192 characters. + public string StandardOutput { get; } + + /// The most recent standard error output, truncated to the last 8192 characters. + public string StandardError { get; } + } } diff --git a/src/SmokeTest/Program.cs b/src/SmokeTest/Program.cs index 348d25b..f986217 100644 --- a/src/SmokeTest/Program.cs +++ b/src/SmokeTest/Program.cs @@ -1,9 +1,11 @@ try { - await Particular.PlatformLauncher.Launch(showPlatformToolConsoleOutput: true); + var result = await Particular.PlatformLauncher.LaunchWithResult(showPlatformToolConsoleOutput: true); + return result.UnexpectedExit == null ? 0 : 1; } catch (Exception e) { Console.WriteLine(e); Console.ReadLine(); -} \ No newline at end of file + return 1; +}