Summary
FixpClientSession.SendApplicationFrameAsync — and every other outbound send path in the class (Negotiate, Establish, heartbeats, etc.) — writes directly to the underlying Stream with no lock/semaphore/queue serializing concurrent callers:
// FixpClientSession.cs:411-420
public async Task SendApplicationFrameAsync(byte[] buffer, int length, CancellationToken ct)
{
if (_machine.State != FixpClientState.Established)
throw new InvalidOperationException(...);
...
await _stream.WriteAsync(buffer.AsMemory(0, length), ct).ConfigureAwait(false);
if (_options.AutoFlushOutboundFrames)
await _stream.FlushAsync(ct).ConfigureAwait(false);
}
NextOutboundSeqNum() (line 441) is race-free — it uses Interlocked.Increment to hand out sequence numbers atomically. But sequence assignment and sequence wire order are two different guarantees. In EntryPointClient.CancelAsync/SubmitAsync/ReplaceAsync/MassActionAsync/SubmitCrossAsync:
var seq = _session!.NextOutboundSeqNum(); // (1) atomic reservation
var len = OrderEntryEncoder.Encode...(buffer, ..., seq); // (2) sync encode
await _session.SendApplicationFrameAsync(buffer, len, ct); // (3) async write — NOT serialized
If two callers race between (1)/(2) and (3), the caller that reserved the higher seq number can win the race to actually complete _stream.WriteAsync first. Depending on the transport:
- Plain
NetworkStream: concurrent WriteAsync calls from multiple threads are unsupported and can interleave/corrupt bytes on the wire with no exception.
SslStream: concurrent writes are explicitly documented as unsupported and can throw NotSupportedException/InvalidOperationException, or hang, depending on the frame overlap.
Either way, the peer (venue/matching gateway) can end up seeing a corrupted frame or the wrong wire order for a given MsgSeqNum — which is exactly the kind of gap/NotApplied class of bug this SDK has already hit once (#208/#209).
Why this matters concretely
B3TradingPlatform's trading-host wraps a single EntryPointClient/session per firm and calls into it from B3EntryPointClientGateway.CancelAsync/SubmitAsync with no additional serialization either (confirmed — only _reconnectLock exists there, guarding reconnects, not sends). The frontend's "Cancel All" panic action (frontend/js/app.js, CANCEL_ALL_CONCURRENCY = 8) fires up to 8 concurrent cancel requests against the same firm session — this is the most likely real-world trigger for hitting this race.
Live symptom observed on pedrosakuma/b3deploy's prod stack (2026-07-07 ~16:09 UTC): a trader used "Cancel All"; the blotter showed Canceled for one order (client-side ack received), but the depth-of-book snapshot did not change — consistent with another concurrently-cancelled order's frame losing the write race and never reaching matching correctly, silently leaving a resting order the trader believed was cancelled. (Could not get further live confirmation — trading-host/matching only log at Info level, which doesn't cover per-order lifecycle detail, so this report is based on the code-level race identified above plus the observed symptom, not a captured wire-level repro yet.)
Suggested fix
Serialize the "reserve seq → encode → write to stream" critical section end-to-end, e.g. a SemaphoreSlim(1,1) (or single-writer Channel<T> drained by one background loop) wrapping every outbound application-frame send (and ideally every outbound frame, including heartbeats/control frames, since those hit the same unsynchronized _stream.WriteAsync call sites). This guarantees wire order matches seq-number assignment order and that no two callers ever call WriteAsync on _stream concurrently.
Suggested test coverage
An integration/conformance test that fires N concurrent CancelAsync/SubmitAsync calls against a real (or TestPeer) venue connection and asserts:
- All N frames arrive at the peer with strictly increasing, gap-free MsgSeqNum in the order they were encoded.
- No decode/corruption errors on the peer side.
- (Regression guard) Repeat with
CANCEL_ALL_CONCURRENCY-equivalent concurrency (8) to mirror the real trigger in B3TradingPlatform.
Related: #208/#209 (a different but adjacent class of outbound-sequencing bug in this same session/write path).
Summary
FixpClientSession.SendApplicationFrameAsync— and every other outbound send path in the class (Negotiate, Establish, heartbeats, etc.) — writes directly to the underlyingStreamwith no lock/semaphore/queue serializing concurrent callers:NextOutboundSeqNum()(line 441) is race-free — it usesInterlocked.Incrementto hand out sequence numbers atomically. But sequence assignment and sequence wire order are two different guarantees. InEntryPointClient.CancelAsync/SubmitAsync/ReplaceAsync/MassActionAsync/SubmitCrossAsync:If two callers race between (1)/(2) and (3), the caller that reserved the higher seq number can win the race to actually complete
_stream.WriteAsyncfirst. Depending on the transport:NetworkStream: concurrentWriteAsynccalls from multiple threads are unsupported and can interleave/corrupt bytes on the wire with no exception.SslStream: concurrent writes are explicitly documented as unsupported and can throwNotSupportedException/InvalidOperationException, or hang, depending on the frame overlap.Either way, the peer (venue/matching gateway) can end up seeing a corrupted frame or the wrong wire order for a given MsgSeqNum — which is exactly the kind of gap/
NotAppliedclass of bug this SDK has already hit once (#208/#209).Why this matters concretely
B3TradingPlatform's trading-host wraps a singleEntryPointClient/session per firm and calls into it fromB3EntryPointClientGateway.CancelAsync/SubmitAsyncwith no additional serialization either (confirmed — only_reconnectLockexists there, guarding reconnects, not sends). The frontend's "Cancel All" panic action (frontend/js/app.js,CANCEL_ALL_CONCURRENCY = 8) fires up to 8 concurrent cancel requests against the same firm session — this is the most likely real-world trigger for hitting this race.Live symptom observed on
pedrosakuma/b3deploy's prod stack (2026-07-07 ~16:09 UTC): a trader used "Cancel All"; the blotter showedCanceledfor one order (client-side ack received), but the depth-of-book snapshot did not change — consistent with another concurrently-cancelled order's frame losing the write race and never reaching matching correctly, silently leaving a resting order the trader believed was cancelled. (Could not get further live confirmation — trading-host/matching only log at Info level, which doesn't cover per-order lifecycle detail, so this report is based on the code-level race identified above plus the observed symptom, not a captured wire-level repro yet.)Suggested fix
Serialize the "reserve seq → encode → write to stream" critical section end-to-end, e.g. a
SemaphoreSlim(1,1)(or single-writerChannel<T>drained by one background loop) wrapping every outbound application-frame send (and ideally every outbound frame, including heartbeats/control frames, since those hit the same unsynchronized_stream.WriteAsynccall sites). This guarantees wire order matches seq-number assignment order and that no two callers ever callWriteAsyncon_streamconcurrently.Suggested test coverage
An integration/conformance test that fires N concurrent
CancelAsync/SubmitAsynccalls against a real (or TestPeer) venue connection and asserts:CANCEL_ALL_CONCURRENCY-equivalent concurrency (8) to mirror the real trigger inB3TradingPlatform.Related: #208/#209 (a different but adjacent class of outbound-sequencing bug in this same session/write path).