test(gateway): fake worker responds to control commands (A6)

Add RespondToControlCommandAsync to FakeWorkerHarness so scripted fake
workers can auto-reply to the five control command kinds (Ping,
GetSessionState, GetWorkerInfo, DrainEvents, ShutdownWorker) with canned
replies whose shapes match the real WorkerPipeSession helpers.

Add five unit tests in FakeWorkerHarnessTests covering each control
command kind through the WorkerClient→pipe roundtrip, and one gateway
E2E test (GatewayService_WithFakeWorker_ControlCommandsRoundtripThroughGateway)
that exercises Ping, GetWorkerInfo, and DrainEvents through the full
gRPC→SessionManager→WorkerClient→named-pipe path using a scripted
ControlCommandFakeWorkerProcessLauncher.
This commit is contained in:
Joseph Doherty
2026-06-15 10:56:56 -04:00
parent dde9934e60
commit bb5139fec2
3 changed files with 466 additions and 8 deletions
@@ -391,6 +391,87 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Reads one incoming command envelope and, if it is one of the five
/// control command kinds (Ping, GetSessionState, GetWorkerInfo, DrainEvents,
/// ShutdownWorker), writes a canned reply that mirrors the real worker's
/// reply shape. For ShutdownWorker the method additionally sends a
/// <see cref="WorkerShutdownAck"/> after the OK reply, matching the real
/// worker's shutdown flow.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command envelope that was handled.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the next envelope is not a <c>WorkerCommand</c> or contains a
/// non-control command kind.
/// </exception>
public async Task<WorkerEnvelope> RespondToControlCommandAsync(
CancellationToken cancellationToken = default)
{
WorkerEnvelope commandEnvelope = await ReadCommandAsync(cancellationToken).ConfigureAwait(false);
MxCommand command = commandEnvelope.WorkerCommand.Command;
switch (command.Kind)
{
case MxCommandKind.Ping:
await ReplyToCommandAsync(
commandEnvelope,
configureReply: reply =>
{
string? message = command.Ping?.Message;
if (!string.IsNullOrEmpty(message))
{
reply.DiagnosticMessage = message;
}
},
cancellationToken: cancellationToken).ConfigureAwait(false);
break;
case MxCommandKind.GetSessionState:
await ReplyToCommandAsync(
commandEnvelope,
configureReply: reply => reply.SessionState = new SessionStateReply
{
State = SessionState.Ready,
},
cancellationToken: cancellationToken).ConfigureAwait(false);
break;
case MxCommandKind.GetWorkerInfo:
await ReplyToCommandAsync(
commandEnvelope,
configureReply: reply => reply.WorkerInfo = new WorkerInfoReply
{
WorkerProcessId = DefaultWorkerProcessId,
WorkerVersion = "fake-worker",
MxaccessProgid = "LMXProxy.LMXProxyServer.1",
MxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}",
},
cancellationToken: cancellationToken).ConfigureAwait(false);
break;
case MxCommandKind.DrainEvents:
await ReplyToCommandAsync(
commandEnvelope,
configureReply: reply => reply.DrainEvents = new DrainEventsReply(),
cancellationToken: cancellationToken).ConfigureAwait(false);
break;
case MxCommandKind.ShutdownWorker:
await ReplyToCommandAsync(
commandEnvelope,
cancellationToken: cancellationToken).ConfigureAwait(false);
await SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
break;
default:
throw new InvalidOperationException(
$"RespondToControlCommandAsync only handles control command kinds; received {command.Kind}.");
}
return commandEnvelope;
}
/// <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>