bfcf82975c
The windev full-suite wedge — every test reported, then the x64 testhost sitting at ~0 CPU forever while `dotnet test` never returns — was one test blocked on a pipe write, not a leaked thread or an undisposed fixture. `dotnet-stack report` on the wedged host showed no thread running test code: xUnit's RunTestsInAssembly was parked on WaitHandle.WaitOne() waiting for the assembly-finished event, so the wait lived in a suspended async state machine. `dotnet-dump analyze -c dumpasync` named the frame — WorkerClientTests .StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout awaiting WorkerFrameWriter.WriteAsync on a 63-byte frame, with <extra>5__15 = 6, i.e. the seventh of the twelve events the test pushes past the client's staging bound. That test faults the worker client on purpose, and a faulted client stops its read loop by design. The test-side pipe came from the NamedPipeServerStream overload without buffer arguments, which passes inBufferSize: 0 / outBufferSize: 0 to CreateNamedPipe; on Windows that reserves no buffer at all, so a write completes only once the peer reads it. Measured on windev, that pipe absorbed 0 bytes against a non-reading peer where the same pipe declared with 64 KiB buffers absorbed 65 520. On macOS and Linux .NET backs named pipes with Unix domain sockets whose socket buffer swallows the writes regardless, which is why the identical test never hung there and the bug read as environmental. Test-owned server pipes now go through TestSupport/TestNamedPipe.CreateServer in both test projects, declaring explicit 64 KiB buffers so those tests exercise the gateway's own staging/queue backpressure rather than the OS pipe's flow control. Separately, every fake-worker write in WorkerClientTests now goes through PipePair.WriteAsync, bounded by the class's five-second TestTimeout. That is where the severity came from: a test method that never returns keeps xUnit from raising ITestAssemblyFinished, so one unbounded await cost the whole suite its result. A blocked write is now a named test failure instead of a silent wedge. The fix also retires a wrong belief the wedge had created. windev reported 855 where macOS reported 879, and that gap was recorded in GatewayTesting.md as Unix-gated test cases; it was really the results lost when the wedged host was torn down. The same clone now reports 879 passed, matching macOS exactly. Product code is unaffected. SessionWorkerClientFactory.CreatePipe keeps the unbuffered declaration deliberately: both ends run continuous read loops and every gateway write is bounded by the worker client's _stopCts, so a stalled peer cancels the write rather than blocking on it. Verified on windev at this SHA: gateway suite x64 three times (879 passed, exit 0, no surviving testhost each time) and Worker.Tests x86 twice (400 passed, 11 skipped, exit 0, clean), plus the macOS gateway suite once (879 passed). Docs: GatewayTesting.md replaces the --blame-hang workaround section with the root cause and corrects the baseline to 879, CLAUDE.md's Source Update Workflow no longer tells readers the windev suite wedges, and ToolchainLinks.md records dotnet-stack and dotnet-dump as installed on windev.
605 lines
26 KiB
C#
605 lines
26 KiB
C#
using System.Buffers.Binary;
|
|
using System.IO.Pipes;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes;
|
|
|
|
public sealed class FakeWorkerHarness : IAsyncDisposable
|
|
{
|
|
public const string DefaultSessionId = "session-fake-worker";
|
|
public const string DefaultNonce = "nonce-fake-worker";
|
|
public const int DefaultWorkerProcessId = 9321;
|
|
|
|
private readonly NamedPipeServerStream? _gatewayStream;
|
|
private readonly NamedPipeClientStream _workerStream;
|
|
private readonly WorkerFrameProtocolOptions _frameOptions;
|
|
private readonly WorkerFrameReader _reader;
|
|
private readonly WorkerFrameWriter _writer;
|
|
private bool _workerSideDisposed;
|
|
|
|
private FakeWorkerHarness(
|
|
string sessionId,
|
|
string nonce,
|
|
NamedPipeServerStream? gatewayStream,
|
|
NamedPipeClientStream workerStream,
|
|
WorkerFrameProtocolOptions frameOptions)
|
|
{
|
|
SessionId = sessionId;
|
|
Nonce = nonce;
|
|
_gatewayStream = gatewayStream;
|
|
_workerStream = workerStream;
|
|
_frameOptions = frameOptions;
|
|
_reader = new WorkerFrameReader(_workerStream, frameOptions);
|
|
_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>
|
|
/// <returns>The connected fake worker harness.</returns>
|
|
public static async Task<FakeWorkerHarness> CreateConnectedPairAsync(
|
|
string sessionId = DefaultSessionId,
|
|
string nonce = DefaultNonce,
|
|
uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
|
int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
string pipeName = $"mxgw-fw-{Guid.NewGuid():N}";
|
|
NamedPipeServerStream gatewayStream = TestNamedPipe.CreateServer(pipeName);
|
|
NamedPipeClientStream workerStream = CreateWorkerStream(pipeName);
|
|
|
|
Task waitForConnectionTask = gatewayStream.WaitForConnectionAsync(cancellationToken);
|
|
await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
await waitForConnectionTask.ConfigureAwait(false);
|
|
|
|
return new FakeWorkerHarness(
|
|
sessionId,
|
|
nonce,
|
|
gatewayStream,
|
|
workerStream,
|
|
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>
|
|
/// <returns>The connected fake worker harness.</returns>
|
|
public static async Task<FakeWorkerHarness> ConnectToGatewayPipeAsync(
|
|
string sessionId,
|
|
string nonce,
|
|
string pipeName,
|
|
uint protocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
|
int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
NamedPipeClientStream workerStream = CreateWorkerStream(pipeName);
|
|
await workerStream.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return new FakeWorkerHarness(
|
|
sessionId,
|
|
nonce,
|
|
gatewayStream: null,
|
|
workerStream,
|
|
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,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
if (_gatewayStream is null)
|
|
{
|
|
throw new InvalidOperationException("This fake worker is connected to a gateway-owned pipe.");
|
|
}
|
|
|
|
WorkerClientConnection connection = new(
|
|
SessionId,
|
|
Nonce,
|
|
_gatewayStream,
|
|
_frameOptions);
|
|
|
|
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",
|
|
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
|
|
string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
WorkerEnvelope gatewayHello = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
|
|
if (gatewayHello.BodyCase != WorkerEnvelope.BodyOneofCase.GatewayHello)
|
|
{
|
|
throw new InvalidOperationException($"Expected GatewayHello but received {gatewayHello.BodyCase}.");
|
|
}
|
|
|
|
await SendWorkerHelloAsync(
|
|
workerProcessId,
|
|
workerVersion,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
await SendWorkerReadyAsync(
|
|
workerProcessId,
|
|
mxaccessProgid,
|
|
mxaccessClsid,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
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);
|
|
if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
|
{
|
|
throw new InvalidOperationException($"Expected WorkerCommand but received {envelope.BodyCase}.");
|
|
}
|
|
|
|
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);
|
|
if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerShutdown)
|
|
{
|
|
throw new InvalidOperationException($"Expected WorkerShutdown but received {envelope.BodyCase}.");
|
|
}
|
|
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task SendWorkerHelloAsync(
|
|
int workerProcessId = DefaultWorkerProcessId,
|
|
string workerVersion = "fake-worker",
|
|
uint? workerProtocolVersion = null,
|
|
string? nonce = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerHello = new WorkerHello
|
|
{
|
|
ProtocolVersion = workerProtocolVersion ?? _frameOptions.ProtocolVersion,
|
|
Nonce = nonce ?? Nonce,
|
|
WorkerProcessId = workerProcessId,
|
|
WorkerVersion = workerVersion,
|
|
}),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task SendWorkerReadyAsync(
|
|
int workerProcessId = DefaultWorkerProcessId,
|
|
string mxaccessProgid = "LMXProxy.LMXProxyServer.1",
|
|
string mxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerReady = new WorkerReady
|
|
{
|
|
WorkerProcessId = workerProcessId,
|
|
MxaccessProgid = mxaccessProgid,
|
|
MxaccessClsid = mxaccessClsid,
|
|
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
}),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task ReplyToCommandAsync(
|
|
WorkerEnvelope commandEnvelope,
|
|
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
|
string statusMessage = "OK",
|
|
Action<MxCommandReply>? configureReply = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
|
{
|
|
throw new ArgumentException("Command envelope must contain WorkerCommand.", nameof(commandEnvelope));
|
|
}
|
|
|
|
MxCommandKind kind = commandEnvelope.WorkerCommand.Command?.Kind ?? MxCommandKind.Unspecified;
|
|
MxCommandReply reply = new()
|
|
{
|
|
SessionId = SessionId,
|
|
CorrelationId = commandEnvelope.CorrelationId,
|
|
Kind = kind,
|
|
ProtocolStatus = new ProtocolStatus
|
|
{
|
|
Code = statusCode,
|
|
Message = statusMessage,
|
|
},
|
|
};
|
|
configureReply?.Invoke(reply);
|
|
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
commandEnvelope.CorrelationId,
|
|
envelope => envelope.WorkerCommandReply = new WorkerCommandReply
|
|
{
|
|
Reply = reply,
|
|
CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
}),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task EmitEventAsync(
|
|
MxEventFamily family,
|
|
CancellationToken cancellationToken = default,
|
|
Action<MxEvent>? configureEvent = null)
|
|
{
|
|
ulong sequence = NextWorkerSequence + 1;
|
|
MxEvent mxEvent = new()
|
|
{
|
|
SessionId = SessionId,
|
|
Family = family,
|
|
WorkerSequence = sequence,
|
|
WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
};
|
|
configureEvent?.Invoke(mxEvent);
|
|
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerEvent = new WorkerEvent
|
|
{
|
|
Event = mxEvent,
|
|
}),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task EmitFaultAsync(
|
|
WorkerFaultCategory category,
|
|
string diagnosticMessage,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerFault = new WorkerFault
|
|
{
|
|
Category = category,
|
|
DiagnosticMessage = diagnosticMessage,
|
|
ProtocolStatus = new ProtocolStatus
|
|
{
|
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
|
Message = diagnosticMessage,
|
|
},
|
|
}),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task SendHeartbeatAsync(
|
|
WorkerState state = WorkerState.Ready,
|
|
CancellationToken cancellationToken = default,
|
|
Action<WorkerHeartbeat>? configureHeartbeat = null)
|
|
{
|
|
WorkerHeartbeat heartbeat = new()
|
|
{
|
|
WorkerProcessId = DefaultWorkerProcessId,
|
|
State = state,
|
|
LastStaActivityTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
};
|
|
configureHeartbeat?.Invoke(heartbeat);
|
|
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerHeartbeat = heartbeat),
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task SendShutdownAckAsync(
|
|
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await _writer.WriteAsync(
|
|
CreateEnvelope(
|
|
correlationId: string.Empty,
|
|
envelope => envelope.WorkerShutdownAck = new WorkerShutdownAck
|
|
{
|
|
Status = new ProtocolStatus
|
|
{
|
|
Code = statusCode,
|
|
Message = statusCode.ToString(),
|
|
},
|
|
}),
|
|
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);
|
|
return await RespondToControlCommandAsync(commandEnvelope, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts an already-read 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.
|
|
/// Use this overload when the caller has already consumed the envelope from the pipe
|
|
/// (e.g., to inspect the kind before routing) to avoid re-reading.
|
|
/// </summary>
|
|
/// <param name="commandEnvelope">The already-read command envelope to respond to.</param>
|
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
/// <returns>The command envelope that was handled.</returns>
|
|
/// <exception cref="ArgumentException">
|
|
/// Thrown when <paramref name="commandEnvelope"/> does not contain a <c>WorkerCommand</c>.
|
|
/// </exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Thrown when the command kind is not one of the five control command kinds.
|
|
/// </exception>
|
|
public async Task<WorkerEnvelope> RespondToControlCommandAsync(
|
|
WorkerEnvelope commandEnvelope,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Expected WorkerCommand envelope but received {commandEnvelope.BodyCase}.",
|
|
nameof(commandEnvelope));
|
|
}
|
|
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task WriteMalformedPayloadAsync(
|
|
ReadOnlyMemory<byte> payload,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (payload.IsEmpty)
|
|
{
|
|
throw new ArgumentException("Malformed payload must include at least one byte.", nameof(payload));
|
|
}
|
|
|
|
byte[] lengthPrefix = new byte[sizeof(uint)];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, (uint)payload.Length);
|
|
await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false);
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task WriteOversizedFrameHeaderAsync(
|
|
uint payloadLength,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (payloadLength <= _frameOptions.MaxMessageBytes)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(payloadLength),
|
|
payloadLength,
|
|
"Payload length must exceed the configured maximum.");
|
|
}
|
|
|
|
byte[] lengthPrefix = new byte[sizeof(uint)];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(lengthPrefix, payloadLength);
|
|
await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Disposes the worker-side stream.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeWorkerSideAsync()
|
|
{
|
|
if (_workerSideDisposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await _workerStream.DisposeAsync().ConfigureAwait(false);
|
|
_workerSideDisposed = true;
|
|
}
|
|
|
|
/// <summary>Disposes the worker-side and (if owned) gateway-side pipe streams.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await DisposeWorkerSideAsync().ConfigureAwait(false);
|
|
if (_gatewayStream is not null)
|
|
{
|
|
await _gatewayStream.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private WorkerEnvelope CreateEnvelope(
|
|
string correlationId,
|
|
Action<WorkerEnvelope> setBody)
|
|
{
|
|
WorkerEnvelope envelope = new()
|
|
{
|
|
ProtocolVersion = _frameOptions.ProtocolVersion,
|
|
SessionId = SessionId,
|
|
Sequence = AdvanceSequence(),
|
|
CorrelationId = correlationId,
|
|
};
|
|
setBody(envelope);
|
|
|
|
return envelope;
|
|
}
|
|
|
|
private ulong AdvanceSequence()
|
|
{
|
|
return ++NextWorkerSequence;
|
|
}
|
|
|
|
private static NamedPipeClientStream CreateWorkerStream(string pipeName)
|
|
{
|
|
return new NamedPipeClientStream(
|
|
".",
|
|
pipeName,
|
|
PipeDirection.InOut,
|
|
PipeOptions.Asynchronous);
|
|
}
|
|
}
|