Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -9,6 +9,7 @@ public sealed class FakeWorkerHarnessTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that completing startup with hello and ready transitions the client to ready state.</summary>
[Fact]
public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady()
{
@@ -25,6 +26,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that a protocol version mismatch during startup fails the client.</summary>
[Fact]
public async Task StartAsync_WithProtocolMismatch_FailsStartup()
{
@@ -43,6 +45,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode);
}
/// <summary>Verifies that a scripted reply completes a pending command invocation.</summary>
[Fact]
public async Task InvokeAsync_WithScriptedReply_CompletesCommand()
{
@@ -64,6 +67,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that scripted events are yielded in order through the event stream.</summary>
[Fact]
public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents()
{
@@ -87,6 +91,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that a scripted fault from the worker faults the client.</summary>
[Fact]
public async Task ReadLoop_WithScriptedFault_FaultsClient()
{
@@ -105,6 +110,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that sending a heartbeat updates the client heartbeat state.</summary>
[Fact]
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
{
@@ -124,6 +130,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that a hung worker times out pending command invocations.</summary>
[Fact]
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
{
@@ -144,6 +151,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode);
}
/// <summary>Verifies that a malformed frame in the read loop faults the client.</summary>
[Fact]
public async Task ReadLoop_WithMalformedFrame_FaultsClient()
{
@@ -160,6 +168,7 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that a shutdown acknowledgment from the worker closes the client.</summary>
[Fact]
public async Task ShutdownAsync_WithShutdownAck_ClosesClient()
{
@@ -37,12 +37,21 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_writer = new WorkerFrameWriter(_workerStream, frameOptions);
}
/// <summary>Gets the session ID for the fake worker harness.</summary>
public string SessionId { get; }
/// <summary>Gets the nonce for the fake worker harness.</summary>
public string Nonce { get; }
/// <summary>Gets or sets the next worker sequence number.</summary>
public ulong NextWorkerSequence { get; private set; }
/// <summary>Creates a connected pair of fake worker harness with gateway and worker pipes.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> CreateConnectedPairAsync(
string sessionId = DefaultSessionId,
string nonce = DefaultNonce,
@@ -71,6 +80,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Connects to an existing gateway pipe as a fake worker harness.</summary>
/// <param name="sessionId">Identifier for the fake session.</param>
/// <param name="nonce">Nonce for session validation.</param>
/// <param name="pipeName">Name of the named pipe to connect to.</param>
/// <param name="protocolVersion">Protocol version for frame communication.</param>
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public static async Task<FakeWorkerHarness> ConnectToGatewayPipeAsync(
string sessionId,
string nonce,
@@ -90,6 +106,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes));
}
/// <summary>Creates a worker client connected to the fake worker harness.</summary>
/// <param name="options">Configuration options for the worker client.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <returns>A configured worker client connected to this harness.</returns>
public WorkerClient CreateClient(
WorkerClientOptions? options = null,
GatewayMetrics? metrics = null,
@@ -109,6 +130,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return new WorkerClient(connection, options, metrics, timeProvider);
}
/// <summary>Completes the worker startup handshake by reading the gateway hello and sending worker hello and ready.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway hello envelope received during startup.</returns>
public async Task<WorkerEnvelope> CompleteStartupAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -135,11 +163,17 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return gatewayHello;
}
/// <summary>Reads the next gateway envelope from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The gateway envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default)
{
return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Reads the next command from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadCommandAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -151,6 +185,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Reads the next shutdown request from the worker stream.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The shutdown envelope read from the stream.</returns>
public async Task<WorkerEnvelope> ReadShutdownAsync(CancellationToken cancellationToken = default)
{
WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
@@ -162,6 +199,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
return envelope;
}
/// <summary>Sends a worker hello message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="workerVersion">Version string of the fake worker.</param>
/// <param name="workerProtocolVersion">Protocol version override.</param>
/// <param name="nonce">Nonce override.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerHelloAsync(
int workerProcessId = DefaultWorkerProcessId,
string workerVersion = "fake-worker",
@@ -182,6 +225,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a worker ready message to the gateway.</summary>
/// <param name="workerProcessId">Process ID of the fake worker.</param>
/// <param name="mxaccessProgid">MXAccess COM ProgID.</param>
/// <param name="mxaccessClsid">MXAccess COM CLSID.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendWorkerReadyAsync(
int workerProcessId = DefaultWorkerProcessId,
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
@@ -201,6 +249,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a reply to a command received from the gateway.</summary>
/// <param name="commandEnvelope">The command envelope to reply to.</param>
/// <param name="statusCode">Protocol status code for the reply.</param>
/// <param name="statusMessage">Human-readable status message.</param>
/// <param name="configureReply">Optional callback to customize the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task ReplyToCommandAsync(
WorkerEnvelope commandEnvelope,
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
@@ -238,6 +292,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits an event to the gateway.</summary>
/// <param name="family">Family of the event to emit.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureEvent">Optional callback to customize the event.</param>
public async Task EmitEventAsync(
MxEventFamily family,
CancellationToken cancellationToken = default,
@@ -263,6 +321,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Emits a fault message to the gateway.</summary>
/// <param name="category">Category of the fault.</param>
/// <param name="diagnosticMessage">Diagnostic message describing the fault.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task EmitFaultAsync(
WorkerFaultCategory category,
string diagnosticMessage,
@@ -284,6 +346,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a heartbeat message to the gateway.</summary>
/// <param name="state">Current worker state.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <param name="configureHeartbeat">Optional callback to customize the heartbeat.</param>
public async Task SendHeartbeatAsync(
WorkerState state = WorkerState.Ready,
CancellationToken cancellationToken = default,
@@ -304,6 +370,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a shutdown acknowledgment message to the gateway.</summary>
/// <param name="statusCode">Protocol status code for the acknowledgment.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task SendShutdownAckAsync(
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
CancellationToken cancellationToken = default)
@@ -322,6 +391,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes a malformed payload directly to the worker stream.</summary>
/// <param name="payload">Malformed payload bytes to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteMalformedPayloadAsync(
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
@@ -337,6 +409,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
}
/// <summary>Writes an oversized frame header to the worker stream for testing frame size limits.</summary>
/// <param name="payloadLength">Length of the oversized payload in bytes.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task WriteOversizedFrameHeaderAsync(
uint payloadLength,
CancellationToken cancellationToken = default)
@@ -354,6 +429,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false);
}
/// <summary>Disposes the worker-side stream.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -365,6 +441,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
_workerSideDisposed = true;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync().ConfigureAwait(false);
@@ -14,6 +14,7 @@ public sealed class WorkerClientTests
private const int WorkerProcessId = 4321;
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Verifies that StartAsync enters ready state after receiving worker hello and ready messages.</summary>
[Fact]
public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState()
{
@@ -26,6 +27,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerProcessId, client.ProcessId);
}
/// <summary>Verifies that InvokeAsync completes a pending command when a matching reply arrives.</summary>
[Fact]
public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand()
{
@@ -51,6 +53,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that InvokeAsync ignores late replies and keeps the client ready.</summary>
[Fact]
public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady()
{
@@ -86,6 +89,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
}
/// <summary>Verifies that ReadEventsAsync yields events in pipe order from the worker.</summary>
[Fact]
public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder()
{
@@ -111,6 +115,7 @@ public sealed class WorkerClientTests
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
}
/// <summary>Verifies that the read loop faults the client when the event queue overflows.</summary>
[Fact]
public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient()
{
@@ -137,6 +142,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop faults the client when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -153,6 +159,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that the read loop stops the running worker metric when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric()
{
@@ -175,6 +182,7 @@ public sealed class WorkerClientTests
Assert.Equal(1, snapshot.WorkerExits);
}
/// <summary>Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked.</summary>
[Fact]
public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout()
{
@@ -191,6 +199,7 @@ public sealed class WorkerClientTests
$"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms.");
}
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
[Fact]
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
{
@@ -209,6 +218,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
[Fact]
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
{
@@ -393,12 +403,16 @@ public sealed class WorkerClientTests
WorkerWriter = new WorkerFrameWriter(_workerStream, new WorkerFrameProtocolOptions(SessionId));
}
/// <summary>The gateway side of the named pipe connection.</summary>
public NamedPipeServerStream GatewayStream { get; }
/// <summary>Frame reader for worker messages.</summary>
public WorkerFrameReader WorkerReader { get; }
/// <summary>Frame writer for worker messages.</summary>
public WorkerFrameWriter WorkerWriter { get; }
/// <summary>Creates a connected pipe pair for testing.</summary>
public static async Task<PipePair> CreateAsync()
{
string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}";
@@ -421,6 +435,7 @@ public sealed class WorkerClientTests
return new PipePair(gatewayStream, workerStream);
}
/// <summary>Disposes the worker side of the pipe.</summary>
public async ValueTask DisposeWorkerSideAsync()
{
if (_workerSideDisposed)
@@ -432,6 +447,7 @@ public sealed class WorkerClientTests
_workerSideDisposed = true;
}
/// <summary>Disposes the duplex stream.</summary>
public async ValueTask DisposeAsync()
{
await DisposeWorkerSideAsync();
@@ -10,6 +10,7 @@ public sealed class WorkerFrameProtocolTests
{
private const string SessionId = "session-1";
/// <summary>Verifies that writing and reading a valid envelope round-trips the frame correctly.</summary>
[Fact]
public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame()
{
@@ -27,6 +28,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(original, parsed);
}
/// <summary>Verifies that reading a frame with partial reads reassembles the frame correctly.</summary>
[Fact]
public async Task ReadAsync_WithPartialReads_ReassemblesFrame()
{
@@ -42,6 +44,7 @@ public sealed class WorkerFrameProtocolTests
Assert.True(stream.ReadCallCount > 2);
}
/// <summary>Verifies that reading a frame with zero length throws a malformed length exception.</summary>
[Fact]
public async Task ReadAsync_WithZeroLengthFrame_ThrowsMalformedLength()
{
@@ -56,6 +59,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with oversized length throws before allocating the payload.</summary>
[Fact]
public async Task ReadAsync_WithOversizedLength_ThrowsBeforePayloadAllocation()
{
@@ -72,6 +76,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong protocol version throws a protocol version mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch()
{
@@ -88,6 +93,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with wrong session ID throws a session mismatch exception.</summary>
[Fact]
public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch()
{
@@ -104,6 +110,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with malformed payload throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
{
@@ -119,6 +126,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that reading a frame with missing envelope body throws an invalid envelope exception.</summary>
[Fact]
public async Task ReadAsync_WithMissingEnvelopeBody_ThrowsInvalidEnvelope()
{
@@ -135,6 +143,7 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>Verifies that writing an oversized envelope throws a message too large exception.</summary>
[Fact]
public async Task WriteAsync_WithOversizedEnvelope_ThrowsMessageTooLarge()
{
@@ -186,6 +195,9 @@ public sealed class WorkerFrameProtocolTests
{
private readonly int _chunkSize;
/// <summary>Initializes a new instance of the <see cref="ChunkedReadStream"/> class with chunked reads.</summary>
/// <param name="buffer">The buffer containing data to read.</param>
/// <param name="chunkSize">The maximum number of bytes to read per operation.</param>
public ChunkedReadStream(
byte[] buffer,
int chunkSize)
@@ -194,8 +206,10 @@ public sealed class WorkerFrameProtocolTests
_chunkSize = chunkSize;
}
/// <summary>Gets the number of read calls made to the stream.</summary>
public int ReadCallCount { get; private set; }
/// <inheritdoc />
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
@@ -13,6 +13,7 @@ public sealed class WorkerProcessLauncherTests
private const string PipeName = "mxaccess-gateway-123-session-1";
private const string Nonce = "super-secret-nonce";
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
[Fact]
public async Task LaunchAsync_WithValidWorker_StartsProcessWithBootstrapArgumentsAndNonceEnvironment()
{
@@ -46,6 +47,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
}
/// <summary>Verifies that a failed startup probe kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFails_KillsAndDisposesWorker()
{
@@ -71,6 +73,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker()
{
@@ -97,6 +100,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]);
}
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
[Fact]
public async Task LaunchAsync_WhenStartupTimesOut_KillsAndDisposesWorker()
{
@@ -121,6 +125,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that a missing worker executable fails before attempting to start the process.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableDoesNotExist_FailsBeforeStartingProcess()
{
@@ -137,6 +142,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker executable with mismatched architecture fails before attempting to start.</summary>
[Fact]
public async Task LaunchAsync_WhenExecutableArchitectureDoesNotMatch_FailsBeforeStartingProcess()
{
@@ -153,6 +159,7 @@ public sealed class WorkerProcessLauncherTests
Assert.Null(processFactory.LastStartInfo);
}
/// <summary>Verifies that a worker that has already exited fails and disposes without additional killing.</summary>
[Fact]
public async Task LaunchAsync_WhenWorkerAlreadyExited_FailsAndDisposesWorkerWithoutKill()
{
@@ -215,12 +222,16 @@ public sealed class WorkerProcessLauncherTests
pipeReservation);
}
/// <summary>Fake worker process factory for testing process launch logic.</summary>
private sealed class FakeWorkerProcessFactory(IWorkerProcess process) : IWorkerProcessFactory
{
/// <summary>Gets the most recent process start information.</summary>
public ProcessStartInfo? LastStartInfo { get; private set; }
/// <summary>Gets the number of times the process factory has started a process.</summary>
public int StartCount { get; private set; }
/// <inheritdoc />
public IWorkerProcess Start(ProcessStartInfo startInfo)
{
StartCount++;
@@ -229,23 +240,31 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake worker process for testing process lifecycle and exit behavior.</summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets or sets a value indicating whether the process has exited.</summary>
public bool HasExited { get; set; }
/// <summary>Gets or sets the process exit code.</summary>
public int? ExitCode { get; set; }
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <summary>Gets a value indicating whether the Kill method was called.</summary>
public bool KillCalled { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
Assert.True(entireProcessTree);
@@ -253,14 +272,17 @@ public sealed class WorkerProcessLauncherTests
HasExited = true;
}
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Fake startup probe that immediately succeeds.</summary>
private sealed class SucceedingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -270,8 +292,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that always fails.</summary>
private sealed class FailingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -281,8 +305,10 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that waits indefinitely to simulate a startup timeout.</summary>
private sealed class WaitingStartupProbe : IWorkerStartupProbe
{
/// <inheritdoc />
public async Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -292,10 +318,12 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake startup probe that fails a configurable number of times before succeeding.</summary>
private sealed class TransientStartupProbe(int failuresBeforeSuccess) : IWorkerStartupProbe
{
private int _attempts;
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -310,16 +338,20 @@ public sealed class WorkerProcessLauncherTests
}
}
/// <summary>Fake pipe reservation for testing pipe lifecycle.</summary>
private sealed class FakePipeReservation : IDisposable
{
/// <summary>Gets a value indicating whether the Dispose method was called.</summary>
public bool DisposeCalled { get; private set; }
/// <inheritdoc />
public void Dispose()
{
DisposeCalled = true;
}
}
/// <summary>Test helper that creates and cleans up a temporary directory for worker executable tests.</summary>
private sealed class TestDirectory : IDisposable
{
private TestDirectory(string path)
@@ -327,8 +359,10 @@ public sealed class WorkerProcessLauncherTests
Path = path;
}
/// <summary>Gets the path to the temporary test directory.</summary>
public string Path { get; }
/// <summary>Creates a new temporary directory for testing.</summary>
public static TestDirectory Create()
{
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"mxgateway-tests-{Guid.NewGuid():N}");
@@ -337,6 +371,9 @@ public sealed class WorkerProcessLauncherTests
return new TestDirectory(path);
}
/// <summary>Creates a fake PE executable with the specified machine architecture for testing.</summary>
/// <param name="machine">PE machine type constant (0x014c for x86, 0x8664 for x64).</param>
/// <returns>Full path to the created executable file.</returns>
public string CreateWorkerExecutable(ushort machine)
{
string path = System.IO.Path.Combine(Path, "MxGateway.Worker.exe");
@@ -354,6 +391,7 @@ public sealed class WorkerProcessLauncherTests
return path;
}
/// <inheritdoc />
public void Dispose()
{
Directory.Delete(Path, recursive: true);