feat(worker): implement Ping/GetSessionState/GetWorkerInfo/DrainEvents/ShutdownWorker control commands
Answer the five worker control/lifecycle commands at the WorkerPipeSession message-loop layer instead of the STA-bound MxAccessCommandExecutor. These replies are built from process-level state (worker pid, assembly version, worker lifecycle, the runtime session's event queue) the executor cannot see, and ShutdownWorker must emit its OK reply before the graceful shutdown joins the STA thread - dispatching it onto the STA would deadlock. - Ping: OK reply, echoes message into diagnostic_message. - GetSessionState: maps WorkerState to proto SessionState. - GetWorkerInfo: pid, worker version, MXAccess ProgID/CLSID. - DrainEvents: drains the runtime event queue into DrainEventsReply. - ShutdownWorker: OK reply, then graceful shutdown, then stops the loop. Tests added in WorkerPipeSessionTests; FakeRuntimeSession gains a batch-size drain suppressor so DrainEvents does not race the background drain loop.
This commit is contained in:
@@ -288,6 +288,212 @@ public sealed class WorkerPipeSessionTests
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a Ping control command is answered on the worker side
|
||||
/// (not dispatched to the STA) with an OK reply that echoes the ping
|
||||
/// message into the reply's diagnostic field.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PingControlCommand_RepliesOkAndEchoesMessage()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new();
|
||||
WorkerPipeSession session = CreatePipeSession(pipePair.WorkerStream, runtime);
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(CreatePingCommandEnvelope("ping-1", "hello-worker"), cancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerEnvelope replyEnvelope = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||
cancellation.Token);
|
||||
|
||||
MxCommandReply reply = replyEnvelope.WorkerCommandReply.Reply;
|
||||
Assert.Equal("ping-1", reply.CorrelationId);
|
||||
Assert.Equal(MxCommandKind.Ping, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal("hello-worker", reply.DiagnosticMessage);
|
||||
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetSessionState reports the worker's lifecycle as the
|
||||
/// proto SessionState — READY while the message loop is serving.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_GetSessionStateControlCommand_RepliesReady()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new();
|
||||
WorkerPipeSession session = CreatePipeSession(pipePair.WorkerStream, runtime);
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(
|
||||
CreateControlCommandEnvelope(
|
||||
"state-1",
|
||||
MxCommandKind.GetSessionState,
|
||||
command => command.GetSessionState = new GetSessionStateCommand()),
|
||||
cancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerEnvelope replyEnvelope = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||
cancellation.Token);
|
||||
|
||||
MxCommandReply reply = replyEnvelope.WorkerCommandReply.Reply;
|
||||
Assert.Equal(MxCommandKind.GetSessionState, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(SessionState.Ready, reply.SessionState.State);
|
||||
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetWorkerInfo populates the worker process id, version,
|
||||
/// and MXAccess ProgID/CLSID from the worker's own metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_GetWorkerInfoControlCommand_PopulatesWorkerInfoFields()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new();
|
||||
WorkerPipeSession session = CreatePipeSession(pipePair.WorkerStream, runtime);
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(
|
||||
CreateControlCommandEnvelope(
|
||||
"info-1",
|
||||
MxCommandKind.GetWorkerInfo,
|
||||
command => command.GetWorkerInfo = new GetWorkerInfoCommand()),
|
||||
cancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerEnvelope replyEnvelope = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||
cancellation.Token);
|
||||
|
||||
MxCommandReply reply = replyEnvelope.WorkerCommandReply.Reply;
|
||||
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
WorkerInfoReply info = reply.WorkerInfo;
|
||||
Assert.Equal(1234, info.WorkerProcessId);
|
||||
Assert.False(string.IsNullOrEmpty(info.WorkerVersion));
|
||||
Assert.Equal(MxAccessInteropInfo.ProgId, info.MxaccessProgid);
|
||||
Assert.Equal(MxAccessInteropInfo.Clsid, info.MxaccessClsid);
|
||||
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that DrainEvents drains the runtime session's queued events
|
||||
/// into the reply rather than streaming them as WorkerEvent envelopes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DrainEventsControlCommand_ReturnsQueuedEvents()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
// Suppress the background drain loop's fixed-batch drains so the
|
||||
// queued events survive for the explicit DrainEvents command (which
|
||||
// drains all via max_events == 0). 128 mirrors
|
||||
// WorkerPipeSession.EventDrainBatchSize.
|
||||
FakeRuntimeSession runtime = new() { SuppressDrainForBatchSize = 128 };
|
||||
WorkerPipeSession session = CreatePipeSession(pipePair.WorkerStream, runtime);
|
||||
runtime.EnqueueEvent(CreateWorkerEvent(sequence: 11));
|
||||
runtime.EnqueueEvent(CreateWorkerEvent(sequence: 12));
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(
|
||||
CreateControlCommandEnvelope(
|
||||
"drain-1",
|
||||
MxCommandKind.DrainEvents,
|
||||
command => command.DrainEvents = new DrainEventsCommand { MaxEvents = 0 }),
|
||||
cancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerEnvelope replyEnvelope = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||
cancellation.Token);
|
||||
|
||||
MxCommandReply reply = replyEnvelope.WorkerCommandReply.Reply;
|
||||
Assert.Equal(MxCommandKind.DrainEvents, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(2, reply.DrainEvents.Events.Count);
|
||||
Assert.Contains(reply.DrainEvents.Events, e => e.WorkerSequence == 11UL);
|
||||
Assert.Contains(reply.DrainEvents.Events, e => e.WorkerSequence == 12UL);
|
||||
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ShutdownWorker returns its OK reply BEFORE the graceful
|
||||
/// shutdown runs and disposes the runtime session, and that the message
|
||||
/// loop then stops.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ShutdownWorkerControlCommand_RepliesOkThenShutsDown()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new();
|
||||
WorkerPipeSession session = CreatePipeSession(pipePair.WorkerStream, runtime);
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(
|
||||
CreateControlCommandEnvelope(
|
||||
"shutdown-1",
|
||||
MxCommandKind.ShutdownWorker,
|
||||
command => command.ShutdownWorker = new ShutdownWorkerCommand
|
||||
{
|
||||
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)),
|
||||
}),
|
||||
cancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerEnvelope replyEnvelope = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||
cancellation.Token);
|
||||
|
||||
MxCommandReply reply = replyEnvelope.WorkerCommandReply.Reply;
|
||||
Assert.Equal("shutdown-1", reply.CorrelationId);
|
||||
Assert.Equal(MxCommandKind.ShutdownWorker, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
|
||||
// The OK reply is followed by a shutdown ack, then the loop stops and
|
||||
// the runtime session is disposed.
|
||||
WorkerEnvelope ack = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerShutdownAck,
|
||||
cancellation.Token);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, ack.WorkerShutdownAck.Status.Code);
|
||||
|
||||
Task completedTask = await Task
|
||||
.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5), cancellation.Token))
|
||||
.ConfigureAwait(false);
|
||||
Assert.Same(runTask, completedTask);
|
||||
await runTask.ConfigureAwait(false);
|
||||
Assert.True(runtime.Disposed, "ShutdownWorker must dispose the runtime session.");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that stale STA activity with no command in flight triggers
|
||||
@@ -939,6 +1145,40 @@ public sealed class WorkerPipeSessionTests
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreatePingCommandEnvelope(
|
||||
string correlationId,
|
||||
string message,
|
||||
ulong sequence = 2)
|
||||
{
|
||||
return CreateControlCommandEnvelope(
|
||||
correlationId,
|
||||
MxCommandKind.Ping,
|
||||
command => command.Ping = new PingCommand { Message = message },
|
||||
sequence);
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreateControlCommandEnvelope(
|
||||
string correlationId,
|
||||
MxCommandKind kind,
|
||||
Action<MxCommand> configurePayload,
|
||||
ulong sequence = 2)
|
||||
{
|
||||
MxCommand command = new() { Kind = kind };
|
||||
configurePayload(command);
|
||||
return new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = SessionId,
|
||||
Sequence = sequence,
|
||||
CorrelationId = correlationId,
|
||||
WorkerCommand = new WorkerCommand
|
||||
{
|
||||
Command = command,
|
||||
EnqueueTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreateCancelEnvelope(string correlationId, ulong sequence = 2)
|
||||
{
|
||||
return new WorkerEnvelope
|
||||
|
||||
@@ -122,11 +122,26 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="DrainEvents"/> returns no events for the
|
||||
/// WorkerPipeSession background drain loop's fixed batch size, so an
|
||||
/// explicit DrainEvents control command (which drains all via
|
||||
/// <c>maxEvents == 0</c>) can claim the queued events deterministically
|
||||
/// without racing the 25 ms background loop. Mirrors
|
||||
/// <c>WorkerPipeSession.EventDrainBatchSize</c>.
|
||||
/// </summary>
|
||||
public uint? SuppressDrainForBatchSize { get; set; }
|
||||
|
||||
/// <summary>Drains queued events up to the specified limit.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain; 0 drains all.</param>
|
||||
/// <returns>The drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
if (SuppressDrainForBatchSize is uint suppressed && maxEvents == suppressed)
|
||||
{
|
||||
return Array.Empty<WorkerEvent>();
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
|
||||
Reference in New Issue
Block a user