feat: add diagnostic source for process lifecycle
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 1m21s
default / dotnet-default-workflow (push) Successful in 1m12s

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.
This commit is contained in:
Louis Seubert 2026-08-25 22:03:09 +02:00
commit 50a92291dc
3 changed files with 273 additions and 34 deletions

View file

@ -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<System.Diagnostics.Process>();
await Assert.That(observer.Events[0].Value.ContainsKey("startTime")).IsFalse();
await Assert.That(observer.Events[1].Value["startTime"]).IsTypeOf<DateTimeOffset>();
await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf<DateTimeOffset>();
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<CommandExecutionException>();
// Assert
await Assert.That(observer.Events.Select(kvp => kvp.Key)).IsEquivalentTo(["starting", "started", "finished"]);
await Assert.That(observer.Events[2].Value["exitTime"]).IsTypeOf<DateTimeOffset>();
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<DiagnosticListener>, IObserver<KeyValuePair<string, object?>>, IDisposable
{
private readonly string _name;
private readonly List<KeyValuePair<string, Hashtable>> _events = [];
private IDisposable? _subscription;
public ListenerObserver(string name)
{
_name = name;
}
public IReadOnlyList<KeyValuePair<string, Hashtable>> Events => [.. _events];
public Action<string, Hashtable>? ContextChanged { get; set; }
public void OnNext(DiagnosticListener value)
{
if (value.Name == "Geekeey.Process")
{
_subscription = value.Subscribe(this);
}
}
public void OnNext(KeyValuePair<string, object?> 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<string, Hashtable>(value.Key, new Hashtable(context)));
}
public void OnError(Exception error) { }
public void OnCompleted() { }
public void Dispose()
{
_subscription?.Dispose();
}
}
}

View file

@ -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();
}
}

View file

@ -66,3 +66,23 @@ public static async Task<int> 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.