Merge branch 'fix/gwc-28-29-30-polish'
ci / java (push) Successful in 2m44s
ci / windows-x86 (push) Successful in 1m3s
ci / nightly-windev (push) Has been skipped
ci / portable (push) Successful in 17m13s

# Conflicts:
#	archreview/2026-07-12/remediation/00-tracking.md
This commit is contained in:
Joseph Doherty
2026-08-07 06:20:14 -04:00
11 changed files with 217 additions and 20 deletions
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Grpc;
@@ -38,6 +39,48 @@ public sealed class MxAccessGrpcMapperTests
Assert.NotNull(workerCommand.EnqueueTimestamp);
}
/// <summary>
/// The command-only overload exists so <c>Invoke</c> does not deep-clone the whole request just to
/// overwrite and discard its command (GWC-29). It must still perform the one clone that keeps the
/// worker-bound graph unaliased from the caller-owned gRPC command, and must produce the same
/// <see cref="WorkerCommand"/> as the request overload.
/// </summary>
[Fact]
public void MapCommandFromCommandClonesPayload()
{
FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 8, 7, 12, 0, 0, TimeSpan.Zero));
MxAccessGrpcMapper mapper = new(timeProvider);
MxCommand command = new()
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = 10,
ItemHandle = 20,
UserId = 30,
Value = new MxValue
{
DataType = MxDataType.String,
StringValue = "value",
},
},
};
MxCommandRequest request = new()
{
SessionId = "session-1",
Command = command.Clone(),
};
WorkerCommand fromCommand = mapper.MapCommand(command);
WorkerCommand fromRequest = mapper.MapCommand(request);
command.Write.Value.StringValue = "changed";
Assert.Equal(MxCommandKind.Write, fromCommand.Command.Kind);
Assert.Equal("value", fromCommand.Command.Write.Value.StringValue);
Assert.NotNull(fromCommand.EnqueueTimestamp);
Assert.Equal(fromRequest, fromCommand);
}
/// <summary>Verifies that command reply mapping preserves HRESULT and status information.</summary>
[Fact]
public void MapCommandReply_PreservesHresultStatusesAndPayload()
@@ -4,6 +4,7 @@ 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.Gateway.Workers.Fakes;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
@@ -29,6 +30,33 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerProcessId, client.ProcessId);
}
/// <summary>
/// The <c>GatewayHello</c> carries the negotiated worker-frame maximum so the worker adopts the
/// configured limit instead of its own default (IPC-02); a regression to sending <c>0</c> means
/// "older gateway, use default" to the worker and would silently downgrade the negotiated limit.
/// The adoption half is asserted only in the Windows-only worker suite, so the gateway half is
/// pinned here, in the portable suite (TST-28). Both the default and an override are covered so
/// the assertion tracks configuration rather than a constant.
/// </summary>
/// <param name="maxMessageBytes">Configured worker-frame maximum to negotiate.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(WorkerFrameProtocolOptions.DefaultMaxMessageBytes)]
[InlineData(2 * 1024 * 1024)]
public async Task StartAsync_SendsGatewayHelloWithConfiguredMaxFrameBytes(int maxMessageBytes)
{
await using FakeWorkerHarness harness =
await FakeWorkerHarness.CreateConnectedPairAsync(maxMessageBytes: maxMessageBytes);
await using WorkerClient client = harness.CreateClient();
Task startTask = client.StartAsync(CancellationToken.None);
WorkerEnvelope gatewayHello = await harness.CompleteStartupAsync().WaitAsync(TestTimeout);
await startTask.WaitAsync(TestTimeout);
Assert.NotEqual(0u, gatewayHello.GatewayHello.MaxFrameBytes);
Assert.Equal((uint)maxMessageBytes, gatewayHello.GatewayHello.MaxFrameBytes);
}
/// <summary>Verifies that InvokeAsync completes a pending command when a matching reply arrives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -141,6 +169,48 @@ public sealed class WorkerClientTests
Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind);
}
/// <summary>
/// The envelope <c>sequence</c> is a monotonic per-sender counter (gateway.md), so the values
/// observed on the pipe must be strictly increasing in wire order. Stamping the sequence when
/// the envelope is constructed lets two concurrent invokes stamp 1 and 2 but enqueue 2 then 1
/// (GWC-28); stamping on the single-consumer write loop makes wire order and sequence order the
/// same thing by construction.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire()
{
const int commandCount = 32;
await using PipePair pipePair = await PipePair.CreateAsync();
await using WorkerClient client = CreateClient(pipePair);
await CompleteHandshakeAsync(client, pipePair);
Task<WorkerCommandReply>[] invokeTasks = Enumerable.Range(0, commandCount)
.Select(_ => Task.Run(async () => await client.InvokeAsync(
CreateCommand(MxCommandKind.Ping),
TestTimeout,
CancellationToken.None)))
.ToArray();
ulong previousSequence = 0;
for (int index = 0; index < commandCount; index++)
{
WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase);
Assert.True(
commandEnvelope.Sequence > previousSequence,
$"Command {index} arrived with sequence {commandEnvelope.Sequence} after {previousSequence}; "
+ "envelope sequences must be strictly increasing in wire order.");
previousSequence = commandEnvelope.Sequence;
await pipePair.WorkerWriter.WriteAsync(
CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping));
}
await Task.WhenAll(invokeTasks).WaitAsync(TestTimeout);
Assert.Equal(WorkerClientState.Ready, client.State);
}
/// <summary>Verifies that ReadEventsAsync yields events in pipe order from the worker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -55,6 +55,41 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(original, parsed);
}
/// <summary>
/// One reader instance reads many frames in sequence. The reader reuses a single length-prefix
/// scratch buffer across calls (GWC-30), so varying the payload length frame to frame proves the
/// reused buffer is fully overwritten each time rather than carrying a stale prefix forward.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadAsync_WithMultipleFramesOnOneReader_ParsesEveryFrame()
{
const int frameCount = 5;
WorkerFrameProtocolOptions options = new(SessionId);
await using MemoryStream stream = new();
WorkerFrameWriter writer = new(stream, options);
List<WorkerEnvelope> originals = [];
for (int index = 1; index <= frameCount; index++)
{
WorkerEnvelope envelope = CreateEnvelope();
envelope.Sequence = (ulong)index;
// Differing payload lengths so a stale length prefix would misparse rather than pass.
envelope.WorkerHello.WorkerVersion = new string('v', index * 37);
originals.Add(envelope);
await writer.WriteAsync(envelope);
}
stream.Position = 0;
WorkerFrameReader reader = new(stream, options);
foreach (WorkerEnvelope original in originals)
{
WorkerEnvelope parsed = await reader.ReadAsync();
Assert.Equal(original, parsed);
}
}
/// <summary>Verifies that reading a frame with partial reads reassembles the frame correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]