Skip to content

fix(transit/delta-message): ReceiveAllAsync survives transient transport disconnects via _receiveEof#331

Merged
Kiryuumaru merged 3 commits into
masterfrom
fix/delta-message-receive-all-eof
May 22, 2026
Merged

fix(transit/delta-message): ReceiveAllAsync survives transient transport disconnects via _receiveEof#331
Kiryuumaru merged 3 commits into
masterfrom
fix/delta-message-receive-all-eof

Conversation

@Kiryuumaru
Copy link
Copy Markdown
Owner

Fixes #297.

Problem

DeltaMessageTransit<T>.ReceiveAllAsync used IsConnected as a termination condition:

while (!cancellationToken.IsCancellationRequested && IsConnected)

IsConnected flips to false during every transient transport disconnect — TCP RST, WebSocket reconnect, keepalive miss, etc. — even when auto-reconnect is configured to recover within milliseconds. The enumerable terminated mid-session with no exception, indistinguishable from a clean peer close. The fallback else if (!IsConnected) break; after a null return additionally conflated mid-stream control frames (resync requests, type 0x02; delta-before-baseline triggering an outbound resync) with real end-of-stream.

MessageTransit.ReceiveAllAsync solved this in #177 by using a _receiveEof flag set only on real EOF. The same pattern was not applied to DeltaMessageTransit.

Fix

  1. Added private volatile bool _receiveEof; field.
  2. ReadMessageAsync sets _receiveEof = true when the underlying read returns 0 bytes (real EOF) — both in the length-prefix loop and the payload loop.
  3. Rewrote ReceiveAllAsync to mirror MessageTransit.ReceiveAllAsync:
    • Loop condition is !cancellationToken.IsCancellationRequested && !_disposed (no IsConnected).
    • OperationCanceledException and ObjectDisposedException from ReceiveAsync terminate via yield break.
    • _receiveEof is the sole real-EOF terminator.
    • Null returns are now non-terminal — they represent legitimate mid-stream control traffic (resync request received via 0x02, or delta-before-baseline sending an outbound resync request) and the loop continues to wait for the next data frame.

Regression test

DeltaMessageTransitReceiveAllReconnectTests:

  • ReceiveAllAsync_TransportDisconnectMidStream_ContinuesAfterReconnect — uses a ScriptedReadChannel test double whose IsConnected toggles and whose ReadAsync parks until frames are enqueued. The consumer's foreach body is gated on a TaskCompletionSource so the iterator's while-check fires after IsConnected flips to false (deterministic — no race timing). Pre-fix: enumerable terminates after 1 frame. Post-fix: enumerable continues, second frame is delivered.
  • ReceiveAllAsync_ReadReturnsZero_TerminatesViaEof — confirms real EOF still terminates the enumerable (via _receiveEof).

Verification

  • Pre-fix (source stashed): ReceiveAllAsync_TransportDisconnectMidStream_ContinuesAfterReconnect FAILS with Expected: 2, Actual: 1 — proving the bug.
  • Post-fix: dotnet test tests/NetConduit.Transit.DeltaMessage.UnitTests → 80/80 pass.
  • Full solution build: 0 warnings, 0 errors across net8.0/net9.0/net10.0.

Scope

Stays within NetConduit's stated scope (stream multiplexer). The fix only affects the in-process iteration contract of an existing optional transit; no new auth, encryption, routing, persistence, or hostile-peer concerns are introduced.

@Kiryuumaru
Copy link
Copy Markdown
Owner Author

CI is failing on NetConduit.UnitTests.PublicApiTests.OnChannelOpened_DoesNotFire_BeforeRemoteAccept — a known-flaky test unrelated to this PR's scope (DeltaMessage ReceiveAllAsync EOF handling). The same test caused unrelated CI failures on #275 and #334.

Cannot approve while CI is red. Suggest re-running the Test NetConduit job; if it passes on rerun, this PR is good to merge from a checks standpoint. If the flake keeps reproducing, it should be addressed in its own PR before this can be approved.

@Kiryuumaru Kiryuumaru merged commit 2c5b546 into master May 22, 2026
35 checks passed
@Kiryuumaru Kiryuumaru deleted the fix/delta-message-receive-all-eof branch May 22, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant