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/ChildProcessTests.cs b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs new file mode 100644 index 0000000..1110797 --- /dev/null +++ b/src/Particular.PlatformSample.Tests/ChildProcessTests.cs @@ -0,0 +1,73 @@ +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 exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Multiple(() => + { + 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 + { + File.Delete(runtimeConfig); + } + } + + [TestCase(false)] + [TestCase(true)] + 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 exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Multiple(() => + { + 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.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 fe2b2d3..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"); @@ -46,7 +65,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 +77,39 @@ 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(); + 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(); + } + } } [Test, CancelAfter(30_000)] diff --git a/src/Particular.PlatformSample/AppLauncher.cs b/src/Particular.PlatformSample/AppLauncher.cs index 8d96453..366e009 100644 --- a/src/Particular.PlatformSample/AppLauncher.cs +++ b/src/Particular.PlatformSample/AppLauncher.cs @@ -4,17 +4,19 @@ 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(); + Task firstExitTask; public async Task RavenDB(string logsPath, string dataDirectory, CancellationToken cancellationToken = default) { @@ -33,14 +35,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 +84,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 +182,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 +194,17 @@ void StartProcess(string assemblyPath, Dictionary environmentVar } } - if (hideConsoleOutput) - { - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - } + var process = new ChildProcess(Path.GetFileNameWithoutExtension(assemblyPath), startInfo, showPlatformToolConsoleOutput); + processes.Add(process); + } - var process = Process.Start(startInfo); + public Task WaitForFirstExit(CancellationToken cancellationToken = default) => + (firstExitTask ??= ObserveFirstExit(CancellationToken.None)).WaitAsync(cancellationToken); - // without this in certain occasions the start takes waaaaay longer! - if (hideConsoleOutput) - { - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - } + async Task ObserveFirstExit(CancellationToken cancellationToken) + { + var exitedTask = await Task.WhenAny(processes.Select(process => process.Exited)).WaitAsync(cancellationToken).ConfigureAwait(false); + return await exitedTask.ConfigureAwait(false); } static string GetResource(string resourceName) @@ -211,10 +219,25 @@ static string GetResource(string resourceName) public async ValueTask DisposeAsync() { - while (cleanupActions.Count > 0) + try + { + // Give all tools the same grace period, before deleting any of their configuration files. + 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 { - var action = cleanupActions.Pop(); - await action().ConfigureAwait(false); + 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..e999bbb --- /dev/null +++ b/src/Particular.PlatformSample/ChildProcess.cs @@ -0,0 +1,97 @@ +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); + } + + 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 ProcessExit(name, process.ExitCode, capturedOutput[0], capturedOutput[1]); + } + catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + throw 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 process.WaitForExitAsync(CancellationToken.None).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/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 dfa2d30..5498c90 100644 --- a/src/Particular.PlatformSample/PlatformLauncher.cs +++ b/src/Particular.PlatformSample/PlatformLauncher.cs @@ -26,11 +26,33 @@ 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 + /// + /// 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); - Console.CancelKeyPress += (sender, args) => + ConsoleCancelEventHandler cancelHandler = (sender, args) => { args.Cancel = true; try @@ -78,56 +100,103 @@ 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); + Task startup = Task.CompletedTask; + Console.CancelKeyPress += cancelHandler; + try + { + Console.WriteLine("Launching RavenDB"); + 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); + Console.WriteLine("Launching ServiceControl"); + launcher.ServiceControl(controlPort, maintenancePort, controlLogs, transportPath, auditPort, serverUri); - Console.WriteLine("Launching ServiceControl Audit"); - launcher.ServiceControlAudit(auditPort, auditMaintenancePort, auditLogs, transportPath, serverUri); + Console.WriteLine("Launching ServiceControl Audit"); + launcher.ServiceControlAudit(auditPort, auditMaintenancePort, auditLogs, transportPath, serverUri); - Console.WriteLine("Launching ServiceControl Monitoring"); - launcher.Monitoring(monitoringPort, monitoringLogs, transportPath); + Console.WriteLine("Launching ServiceControl Monitoring"); + launcher.Monitoring(monitoringPort, monitoringLogs, transportPath); - Console.WriteLine("Launching ServicePulse"); - launcher.ServicePulse(pulsePort, controlPort, monitoringPort, servicePulseDefaultRoute); + Console.WriteLine("Launching ServicePulse"); + launcher.ServicePulse(pulsePort, controlPort, monitoringPort, servicePulseDefaultRoute); - Console.Write("Waiting for ServiceControl to be available"); - await Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token) - .ConfigureAwait(false); + Console.Write("Waiting for ServiceControl to be available"); + var unexpectedExitTask = launcher.WaitForFirstExit(CancellationToken.None); + startup = Network.WaitForHttpOk($"http://localhost:{controlPort}/api", cancellationToken: tokenSource.Token); + var unexpectedExit = await WaitForTaskOrUnexpectedExit(startup, unexpectedExitTask, tokenSource.Token).ConfigureAwait(false); - if (!tokenSource.IsCancellationRequested) - { - var serviceControlApiUrl = $"http://localhost:{controlPort}/api"; - Console.WriteLine(); - Console.WriteLine($"ServiceControl API can now be accessed via: {serviceControlApiUrl}"); + if (unexpectedExit == null && !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 }); + 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."); + Console.WriteLine(); + Console.WriteLine("Press Ctrl+C stop Particular Service Platform tools."); - try + unexpectedExit = await WaitForTaskOrUnexpectedExit(Task.Delay(Timeout.InfiniteTimeSpan, CancellationToken.None), unexpectedExitTask, tokenSource.Token) + .ConfigureAwait(false); + } + + if (unexpectedExit != null) { - await Task.Delay(Timeout.InfiniteTimeSpan, tokenSource.Token) + 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 + { + Console.CancelKeyPress -= cancelHandler; + await tokenSource.CancelAsync().ConfigureAwait(false); + try + { + await startup.ConfigureAwait(false); + } catch (OperationCanceledException) when (tokenSource.Token.IsCancellationRequested) { - // ignore + // The readiness request is cancelled when a child exits or the launcher stops. } } + } + + internal static async Task WaitForTaskOrUnexpectedExit(Task operationTask, Task unexpectedExitTask, CancellationToken cancellationToken = default) + { + 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) + { + return await unexpectedExitTask.ConfigureAwait(false); + } - Console.WriteLine(); - Console.WriteLine("Waiting for external processes to shut down..."); + await operationTask.ConfigureAwait(false); + return null; } } } diff --git a/src/Particular.PlatformSample/ProcessExit.cs b/src/Particular.PlatformSample/ProcessExit.cs new file mode 100644 index 0000000..146aabd --- /dev/null +++ b/src/Particular.PlatformSample/ProcessExit.cs @@ -0,0 +1,28 @@ +namespace Particular +{ + /// + /// 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; +}