diff --git a/src/process.tests/DiagnosticListenerTests.cs b/src/process.tests/DiagnosticListenerTests.cs deleted file mode 100644 index 78c626f..0000000 --- a/src/process.tests/DiagnosticListenerTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -// 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/PipeSource.cs b/src/process/PipeSource.cs index 096e629..b6cbb29 100644 --- a/src/process/PipeSource.cs +++ b/src/process/PipeSource.cs @@ -81,7 +81,7 @@ public abstract partial class PipeSource /// public static PipeSource FromPipeReader(PipeReader reader) { - return Create(reader.CopyToAsync); + return Create((destination, cancellationToken) => reader.CopyToAsync(destination, cancellationToken)); } /// diff --git a/src/process/Process.cs b/src/process/Process.cs index fc0555d..6cf9b77 100644 --- a/src/process/Process.cs +++ b/src/process/Process.cs @@ -1,20 +1,44 @@ // 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 @@ -39,26 +63,11 @@ 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. - [field: MaybeNull] - public Stream StandardInput - { - get => field ?? throw new InvalidOperationException("The process is not"); - private set; - } + public Stream StandardInput => _process.StartInfo.RedirectStandardInput ? _process.StandardInput.BaseStream : Stream.Null; - [field: MaybeNull] - public Stream StandardOutput - { - get => field ?? throw new NotSupportedException(); - private set; - } + public Stream StandardOutput => _process.StartInfo.RedirectStandardOutput ? _process.StandardOutput.BaseStream : Stream.Null; - [field: MaybeNull] - public Stream StandardError - { - get => field ?? throw new NotSupportedException(); - private set; - } + public Stream StandardError => _process.StartInfo.RedirectStandardError ? _process.StandardError.BaseStream : Stream.Null; // we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits public DateTimeOffset StartTime { get; private set; } @@ -66,52 +75,12 @@ 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 { get; private set; } + public int ExitCode => _process.ExitCode; 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; @@ -122,22 +91,7 @@ internal sealed partial class Process : IDisposable return false; } - 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; + StartTime = DateTimeOffset.Now; } catch (Win32Exception value) { @@ -150,15 +104,7 @@ internal sealed partial class Process : IDisposable void OnProcessExited(object? _, EventArgs args) { _process.Exited -= OnProcessExited; - - context["exitTime"] = ExitTime = DateTimeOffset.Now; - context["exitCode"] = ExitCode = _process.ExitCode; - - if (Diagnostics.IsEnabled("finished")) - { - Diagnostics.Write("finished", context); - } - + ExitTime = DateTimeOffset.Now; _exit.TrySetResult(); } } diff --git a/src/process/package-readme.md b/src/process/package-readme.md index 3fdd7ac..ae832ea 100644 --- a/src/process/package-readme.md +++ b/src/process/package-readme.md @@ -66,23 +66,3 @@ 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.