diff --git a/src/process.tests/DiagnosticListenerTests.cs b/src/process.tests/DiagnosticListenerTests.cs new file mode 100644 index 0000000..78c626f --- /dev/null +++ b/src/process.tests/DiagnosticListenerTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections; +using System.Diagnostics; +using System.Text; + +namespace Geekeey.Process.Tests; + +internal sealed class DiagnosticListenerTests +{ + [Test] + public async Task I_can_observe_the_process_lifecycle_and_context_values() + { + // Arrange + using var observer = new ListenerObserver("diagnostic-lifecycle"); + using var subscription = DiagnosticListener.AllListeners.Subscribe(observer); + + // Act + await new Command(Testing.Fixture.Program.FilePath) + .WithArguments(["echo", "diagnostic-lifecycle"]) + .ExecuteAsync(); + + // Assert + await Assert.That(observer.Events.Select(kvp => kvp.Key)).IsEquivalentTo(["starting", "started", "finished"]); + await Assert.That(observer.Events[0].Value["process"]).IsTypeOf(); + await Assert.That(observer.Events[0].Value.ContainsKey("startTime")).IsFalse(); + await Assert.That(observer.Events[1].Value["startTime"]).IsTypeOf(); + await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf(); + await Assert.That(observer.Events[2].Value["exitCode"]).IsEqualTo(0); + } + + [Test] + public async Task I_can_observe_the_process_lifecycle_when_validation_fails() + { + // Arrange + using var observer = new ListenerObserver("diagnostic-validation"); + using var subscription = DiagnosticListener.AllListeners.Subscribe(observer); + + // Act & Assert + await Assert.That(async () => await new Command(Testing.Fixture.Program.FilePath) + .WithArguments(["exit", "1", "diagnostic-validation"]) + .ExecuteAsync()).Throws(); + + // Assert + await Assert.That(observer.Events.Select(kvp => kvp.Key)).IsEquivalentTo(["starting", "started", "finished"]); + await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf(); + await Assert.That(observer.Events[2].Value["exitCode"]).IsEqualTo(1); + } + + [Test] + public async Task I_can_add_and_remove_environment_variables_from_the_starting_context() + { + // Arrange + using var observer = new ListenerObserver("GEEKEEY_ENV_"); + using var subscription = DiagnosticListener.AllListeners.Subscribe(observer); + var output = new StringBuilder(); + observer.ContextChanged = (info, context) => + { + if (info is not "starting" || context["process"] is not System.Diagnostics.Process process) + { + return; + } + + process.StartInfo.Environment["GEEKEEY_ENV_ADD"] = "added-value"; + process.StartInfo.Environment.Remove("GEEKEEY_ENV_REMOVED"); + }; + + // Act + await new Command(Testing.Fixture.Program.FilePath) + .WithArguments(["env", "GEEKEEY_ENV_ADD", "GEEKEEY_ENV_REMOVED"]) + .WithEnvironment(environment => environment.Set("GEEKEEY_ENV_REMOVED", "removed-value")) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(output)) + .ExecuteAsync(); + + // Assert + await Assert.That(output.ToString()).IsEqualTo($"added-value{Environment.NewLine}{Environment.NewLine}"); + } + + [Test] + public async Task I_can_replace_standard_streams_from_the_started_context() + { + // Arrange + using var observer = new ListenerObserver("diagnostic-streams"); + using var subscription = DiagnosticListener.AllListeners.Subscribe(observer); + var input = new MemoryStream(); + var output = new MemoryStream([.. "some-other-value"u8]); + var error = new MemoryStream(); + var capturedOutput = new StringBuilder(); + observer.ContextChanged = (info, context) => + { + if (info == "started") + { + context["stdin"] = input; + context["stdout"] = output; + context["stderr"] = error; + } + }; + + // Act + await new Command(Testing.Fixture.Program.FilePath) + .WithArguments(["echo", "diagnostic-streams"]) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(capturedOutput)) + .ExecuteAsync(); + + // Assert + var context = observer.Events.Single(key => key.Key == "finished").Value; + await Assert.That(context["stdin"]).IsSameReferenceAs(input); + await Assert.That(context["stdout"]).IsSameReferenceAs(output); + await Assert.That(context["stderr"]).IsSameReferenceAs(error); + await Assert.That(capturedOutput.ToString()).IsEqualTo("some-other-value"); + } + + private sealed class ListenerObserver : IObserver, IObserver>, IDisposable + { + private readonly string _name; + private readonly List> _events = []; + private IDisposable? _subscription; + + public ListenerObserver(string name) + { + _name = name; + } + + public IReadOnlyList> Events => [.. _events]; + + public Action? ContextChanged { get; set; } + + public void OnNext(DiagnosticListener value) + { + if (value.Name == "Geekeey.Process") + { + _subscription = value.Subscribe(this); + } + } + + public void OnNext(KeyValuePair value) + { + if (value.Value is not Hashtable context) + { + return; + } + + if (context["process"] is not System.Diagnostics.Process process) + { + return; + } + + if (!process.StartInfo.Arguments.Contains(_name, StringComparison.Ordinal)) + { + return; + } + + ContextChanged?.Invoke(value.Key, context); + _events.Add(new KeyValuePair(value.Key, new Hashtable(context))); + } + + public void OnError(Exception error) { } + public void OnCompleted() { } + public void Dispose() + { + _subscription?.Dispose(); + } + } +} diff --git a/src/process/Process.cs b/src/process/Process.cs index 6cf9b77..fc0555d 100644 --- a/src/process/Process.cs +++ b/src/process/Process.cs @@ -1,44 +1,20 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Collections; using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace Geekeey.Process; internal sealed partial class Process : IDisposable { + private static readonly DiagnosticListener Diagnostics = new("Geekeey.Process"); + private readonly TaskCompletionSource _exit = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly System.Diagnostics.Process _process = new(); - public Process() - { - // Redirect all standard streams - _process.StartInfo.RedirectStandardInput = true; - _process.StartInfo.RedirectStandardOutput = true; - _process.StartInfo.RedirectStandardError = true; - // Do not use the system shell to start the process - _process.StartInfo.UseShellExecute = false; - // This option only works on Windows and is required there to prevent the - // child processes from attaching to the parent console window if one exists. - // We need this to be able to send signals to one specific child process, - // without affecting any others that may also be running in parallel. - _process.StartInfo.CreateNoWindow = true; - - // Only create a new process group on windows to allow sending ctrl-c/ctrl-break signals - // without affecting ourselves. This has the implication that the spawned process might not handle - // the ctrl-c/ctrl-break signals any more because the process is launched with the CREATE_NEW_PROCESS_GROUP flag. - // This is because it disables the default ctrl-c handling for the process. - // The process must reenable this behavior itself with a call to `SetConsoleCtrlHandler(null, false)`. - // > "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input, - // > and a FALSE value restores normal processing of CTRL+C input. - // > This attribute of ignoring or processing CTRL+C is inherited by child processes." - if (OperatingSystem.IsWindows()) - { - _process.StartInfo.CreateNewProcessGroup = true; - } - } - public int Id => _process.Id; public string FileName @@ -63,11 +39,26 @@ internal sealed partial class Process : IDisposable // we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of // writing and reading to PipeSource/PipeTarget at the higher level. - public Stream StandardInput => _process.StartInfo.RedirectStandardInput ? _process.StandardInput.BaseStream : Stream.Null; + [field: MaybeNull] + public Stream StandardInput + { + get => field ?? throw new InvalidOperationException("The process is not"); + private set; + } - public Stream StandardOutput => _process.StartInfo.RedirectStandardOutput ? _process.StandardOutput.BaseStream : Stream.Null; + [field: MaybeNull] + public Stream StandardOutput + { + get => field ?? throw new NotSupportedException(); + private set; + } - public Stream StandardError => _process.StartInfo.RedirectStandardError ? _process.StandardError.BaseStream : Stream.Null; + [field: MaybeNull] + public Stream StandardError + { + get => field ?? throw new NotSupportedException(); + private set; + } // we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits public DateTimeOffset StartTime { get; private set; } @@ -75,12 +66,52 @@ internal sealed partial class Process : IDisposable // we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits public DateTimeOffset ExitTime { get; private set; } - public int ExitCode => _process.ExitCode; + public int ExitCode { get; private set; } public bool Start(out Exception? exception) { + var context = new Hashtable + { + ["process"] = _process + }; + exception = null; + // Do not use the system shell to start the process + _process.StartInfo.UseShellExecute = false; + // This option only works on Windows and is required there to prevent the + // child processes from attaching to the parent console window if one exists. + // We need this to be able to send signals to one specific child process, + // without affecting any others that may also be running in parallel. + _process.StartInfo.CreateNoWindow = true; + + if (Diagnostics.IsEnabled("starting")) + { + Diagnostics.Write("starting", context); + } + + // some properties on the process start info are set after the diagnostic event + // to not allow the event to change these as they are critical for the correct + // functionally of the library. + + // Redirect all standard streams + _process.StartInfo.RedirectStandardInput = true; + _process.StartInfo.RedirectStandardOutput = true; + _process.StartInfo.RedirectStandardError = true; + + // Only create a new process group on windows to allow sending ctrl-c/ctrl-break signals + // without affecting ourselves. This has the implication that the spawned process might not handle + // the ctrl-c/ctrl-break signals any more because the process is launched with the CREATE_NEW_PROCESS_GROUP flag. + // This is because it disables the default ctrl-c handling for the process. + // The process must reenable this behavior itself with a call to `SetConsoleCtrlHandler(null, false)`. + // > "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input, + // > and a FALSE value restores normal processing of CTRL+C input. + // > This attribute of ignoring or processing CTRL+C is inherited by child processes." + if (OperatingSystem.IsWindows()) + { + _process.StartInfo.CreateNewProcessGroup = true; + } + _process.EnableRaisingEvents = true; _process.Exited += OnProcessExited; @@ -91,7 +122,22 @@ internal sealed partial class Process : IDisposable return false; } - StartTime = DateTimeOffset.Now; + context["startTime"] = StartTime = DateTimeOffset.Now; + + if (Diagnostics.IsEnabled("started")) + { + Diagnostics.Write("started", context); + } + + StandardInput = _process.StartInfo.RedirectStandardInput + ? context["stdin"] as Stream ?? _process.StandardInput.BaseStream + : Stream.Null; + StandardOutput = _process.StartInfo.RedirectStandardOutput + ? context["stdout"] as Stream ?? _process.StandardOutput.BaseStream + : Stream.Null; + StandardError = _process.StartInfo.RedirectStandardError + ? context["stderr"] as Stream ?? _process.StandardError.BaseStream + : Stream.Null; } catch (Win32Exception value) { @@ -104,7 +150,15 @@ internal sealed partial class Process : IDisposable void OnProcessExited(object? _, EventArgs args) { _process.Exited -= OnProcessExited; - ExitTime = DateTimeOffset.Now; + + context["exitTime"] = ExitTime = DateTimeOffset.Now; + context["exitCode"] = ExitCode = _process.ExitCode; + + if (Diagnostics.IsEnabled("finished")) + { + Diagnostics.Write("finished", context); + } + _exit.TrySetResult(); } } diff --git a/src/process/package-readme.md b/src/process/package-readme.md index ae832ea..3fdd7ac 100644 --- a/src/process/package-readme.md +++ b/src/process/package-readme.md @@ -66,3 +66,23 @@ public static async Task Main() return 0; } ``` + +### Diagnostics + +The library publishes process lifecycle events through a `DiagnosticListener` named +`Geekeey.Process`. Subscribe through `DiagnosticListener.AllListeners` and handle the +`starting`, `started`, and `finished` events. Each event value is a mutable `Hashtable` +that is shared by the lifecycle, so values added by a listener remain available to later +stages. + +| Event | Context entries available before listeners run | +|------------|-------------------------------------------------------------------------------| +| `starting` | `process`: the underlying `System.Diagnostics.Process` | +| `started` | All `starting` entries, plus `startTime`: `DateTimeOffset` | +| `finished` | All previous entries, plus `exitTime`: `DateTimeOffset` and `exitCode`: `int` | + +At the `started` event, a listener may replace any redirected stream by adding a `Stream` +under the corresponding key: `stdin`, `stdout`, or `stderr`. The replacement is used by +the command instead of the process's native stream. This is useful for adapters such as +buffering, tracing, throttling, or encryption streams; the stream must support the +operations required by the consuming pipe.