feat: add diagnostic source for process lifecycle
Some checks failed
default / dotnet-default-workflow (pull_request) Failing after 56s

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 a2a8b548d7
3 changed files with 272 additions and 34 deletions

View file

@ -0,0 +1,164 @@
// 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();
}
}
}