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
@@ -192,6 +192,139 @@ public sealed class FakeWorkerHarnessTests
Assert.Equal(WorkerClientState.Closed, client.State);
}
/// <summary>
/// Verifies that RespondToControlCommandAsync echoes the Ping message back
/// in the DiagnosticMessage field, matching the real worker's ping reply shape.
/// </summary>
[Fact]
public async Task RespondToControlCommandAsync_Ping_EchoesMessageInDiagnostic()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.Ping, cmd => cmd.Ping = new PingCommand { Message = "hello-ping" }),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync();
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.Equal("hello-ping", reply.Reply.DiagnosticMessage);
}
/// <summary>
/// Verifies that RespondToControlCommandAsync returns a SessionStateReply
/// with state Ready for a GetSessionState command.
/// </summary>
[Fact]
public async Task RespondToControlCommandAsync_GetSessionState_ReturnsReadyState()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.GetSessionState),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync();
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.GetSessionState, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.SessionState);
Assert.Equal(SessionState.Ready, reply.Reply.SessionState.State);
}
/// <summary>
/// Verifies that RespondToControlCommandAsync returns a WorkerInfoReply
/// with the fake worker's process ID, version, and MXAccess identifiers.
/// </summary>
[Fact]
public async Task RespondToControlCommandAsync_GetWorkerInfo_ReturnsFakeWorkerInfo()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.GetWorkerInfo),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync();
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.WorkerInfo);
Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, reply.Reply.WorkerInfo.WorkerProcessId);
Assert.Equal("LMXProxy.LMXProxyServer.1", reply.Reply.WorkerInfo.MxaccessProgid);
Assert.False(string.IsNullOrEmpty(reply.Reply.WorkerInfo.MxaccessClsid));
}
/// <summary>
/// Verifies that RespondToControlCommandAsync returns an empty DrainEventsReply
/// for a DrainEvents command (the fake harness has no queued events).
/// </summary>
[Fact]
public async Task RespondToControlCommandAsync_DrainEvents_ReturnsEmptyReply()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.DrainEvents, cmd => cmd.DrainEvents = new DrainEventsCommand { MaxEvents = 32 }),
TestTimeout,
CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync();
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.DrainEvents, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
Assert.NotNull(reply.Reply.DrainEvents);
Assert.Empty(reply.Reply.DrainEvents.Events);
}
/// <summary>
/// Verifies that RespondToControlCommandAsync for ShutdownWorker sends an OK
/// reply followed by a WorkerShutdownAck, which closes the client.
/// </summary>
[Fact]
public async Task RespondToControlCommandAsync_ShutdownWorker_SendsReplyThenAck()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
await using WorkerClient client = fakeWorker.CreateClient();
await StartClientAsync(fakeWorker, client);
// ShutdownAsync triggers a WorkerShutdown envelope (not WorkerCommand),
// so we directly invoke ShutdownWorker as a control command via InvokeAsync.
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
CreateCommand(MxCommandKind.ShutdownWorker, cmd => cmd.ShutdownWorker = new ShutdownWorkerCommand()),
TestTimeout,
CancellationToken.None);
// The harness reads the ShutdownWorker WorkerCommand and replies with
// OK + ShutdownAck — the WorkerClient's read loop processes the ack and
// transitions to Closed.
await fakeWorker.RespondToControlCommandAsync();
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.ShutdownWorker, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
await WaitUntilAsync(() => client.State == WorkerClientState.Closed, TestTimeout);
Assert.Equal(WorkerClientState.Closed, client.State);
}
private static async Task StartClientAsync(
FakeWorkerHarness fakeWorker,
WorkerClient client)
@@ -201,15 +334,13 @@ public sealed class FakeWorkerHarnessTests
await startTask.WaitAsync(TestTimeout).ConfigureAwait(false);
}
private static WorkerCommand CreateCommand(MxCommandKind kind)
private static WorkerCommand CreateCommand(
MxCommandKind kind,
Action<MxCommand>? configure = null)
{
return new WorkerCommand
{
Command = new MxCommand
{
Kind = kind,
},
};
MxCommand command = new() { Kind = kind };
configure?.Invoke(command);
return new WorkerCommand { Command = command };
}
private static async Task WaitUntilAsync(
@@ -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>