fix(GWC-28): stamp gateway envelope sequence at write, not construction
CreateEnvelope stamped Sequence with an interlocked increment when the envelope was built, so two concurrent InvokeAsync callers could take 1 and 2 and then enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's "monotonic per sender" contract. Benign today (neither side validates inbound sequence, old GWC-10 still open) but it would fault healthy sessions the moment worker-side validation lands. WriteLoopAsync now stamps immediately before _writer.WriteAsync. It is the outbound channel's single consumer (SingleReader = true), so wire order and stamp order are the same thing by construction and _nextSequence drops to a plain ulong with no interlocking. This mirrors the worker's WRK-04 fix, which the gateway half never received. Also adds TST-28: a [Theory] pinning that GatewayHello.MaxFrameBytes carries the configured worker-frame maximum (default + 2 MiB override). The adoption half is asserted only in the Windows-only worker suite, so a regression to sending 0 — "older gateway, use default" to the worker — would silently downgrade the negotiated IPC-02 limit with every CI test still green. Mutation-checked (hard-coded 0 fails both cases). Tests: WorkerClientTests.ConcurrentInvokesEmitStrictlyIncreasingSequences OnTheWire (32 parallel invokes; failed 3/3 pre-fix) and .StartAsync_SendsGatewayHelloWithConfiguredMaxFrameBytes.
This commit is contained in:
@@ -40,7 +40,9 @@ public sealed class WorkerClient : IWorkerClient
|
||||
private readonly ConcurrentDictionary<string, PendingCommand> _pendingCommands = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _pendingCommandSlots;
|
||||
private readonly CancellationTokenSource _stopCts = new();
|
||||
private long _nextSequence;
|
||||
// Touched only by WriteLoopAsync — the single consumer of _outboundEnvelopes — so it needs no
|
||||
// interlocking. See WriteLoopAsync for why the stamp happens there rather than at construction.
|
||||
private ulong _nextSequence;
|
||||
private WorkerClientState _state;
|
||||
private DateTimeOffset _lastHeartbeatAt;
|
||||
private int? _processId;
|
||||
@@ -404,6 +406,13 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
|
||||
{
|
||||
// GWC-28: stamp the sequence at the point of writing, not when the envelope is built.
|
||||
// Stamping at construction let two concurrent InvokeAsync callers take 1 and 2 and then
|
||||
// enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's
|
||||
// "monotonic per sender" contract. This loop is the channel's single consumer
|
||||
// (SingleReader = true), so wire order and stamp order are the same thing here and
|
||||
// _nextSequence needs no interlocking. Mirrors the worker's WRK-04 fix.
|
||||
envelope.Sequence = unchecked(++_nextSequence);
|
||||
await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1072,11 +1081,13 @@ public sealed class WorkerClient : IWorkerClient
|
||||
string correlationId,
|
||||
Action<WorkerEnvelope> setBody)
|
||||
{
|
||||
// Sequence is deliberately left unset here: WriteLoopAsync stamps it immediately before the
|
||||
// frame goes out, so the numbers are monotonic in wire order however the callers interleave
|
||||
// between construction and enqueue (GWC-28, mirroring the worker's WRK-04 fix).
|
||||
WorkerEnvelope envelope = new()
|
||||
{
|
||||
ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||||
SessionId = SessionId,
|
||||
Sequence = (ulong)Interlocked.Increment(ref _nextSequence),
|
||||
CorrelationId = correlationId,
|
||||
};
|
||||
setBody(envelope);
|
||||
|
||||
Reference in New Issue
Block a user