feat: add diagnostic source for process lifecycle
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:
parent
cce841a06e
commit
50a92291dc
3 changed files with 273 additions and 34 deletions
165
src/process.tests/DiagnosticListenerTests.cs
Normal file
165
src/process.tests/DiagnosticListenerTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,44 +1,20 @@
|
||||||
// Copyright (c) The Geekeey Authors
|
// Copyright (c) The Geekeey Authors
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Collections;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Geekeey.Process;
|
namespace Geekeey.Process;
|
||||||
|
|
||||||
internal sealed partial class Process : IDisposable
|
internal sealed partial class Process : IDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly DiagnosticListener Diagnostics = new("Geekeey.Process");
|
||||||
|
|
||||||
private readonly TaskCompletionSource _exit = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource _exit = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private readonly System.Diagnostics.Process _process = new();
|
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 int Id => _process.Id;
|
||||||
|
|
||||||
public string FileName
|
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
|
// we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of
|
||||||
// writing and reading to PipeSource/PipeTarget at the higher level.
|
// 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
|
// we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits
|
||||||
public DateTimeOffset StartTime { get; private set; }
|
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
|
// we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits
|
||||||
public DateTimeOffset ExitTime { get; private set; }
|
public DateTimeOffset ExitTime { get; private set; }
|
||||||
|
|
||||||
public int ExitCode => _process.ExitCode;
|
public int ExitCode { get; private set; }
|
||||||
|
|
||||||
public bool Start(out Exception? exception)
|
public bool Start(out Exception? exception)
|
||||||
{
|
{
|
||||||
|
var context = new Hashtable
|
||||||
|
{
|
||||||
|
["process"] = _process
|
||||||
|
};
|
||||||
|
|
||||||
exception = null;
|
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.EnableRaisingEvents = true;
|
||||||
_process.Exited += OnProcessExited;
|
_process.Exited += OnProcessExited;
|
||||||
|
|
||||||
|
|
@ -91,7 +122,22 @@ internal sealed partial class Process : IDisposable
|
||||||
return false;
|
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)
|
catch (Win32Exception value)
|
||||||
{
|
{
|
||||||
|
|
@ -104,7 +150,15 @@ internal sealed partial class Process : IDisposable
|
||||||
void OnProcessExited(object? _, EventArgs args)
|
void OnProcessExited(object? _, EventArgs args)
|
||||||
{
|
{
|
||||||
_process.Exited -= OnProcessExited;
|
_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();
|
_exit.TrySetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,23 @@ public static async Task<int> Main()
|
||||||
return 0;
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue