GH-4191: unwrap before testing for a faulted receiver, and stop restacking the interceptor - #4192
Merged
Merged
Conversation
…cking the interceptor Three defects in ListeningAgent, all in how it reasons about a receiver that is actually a pass-through wrapper. ReceiverHasFaulted and StartAsync's "never re-attach a listener to a terminally faulted receiver" guard both type-tested IFaultTrackingReceiver against the raw _receiver field. Only BufferedReceiver, DurableReceiver and NativeAckReceiver implement that interface -- neither ReceiverWithRules nor GlobalPartitionedInterceptor does -- so for any endpoint carrying an incoming envelope rule, which includes a bare endpoint-level MessageType or TenantId, a terminally faulted receiver reported healthy forever and was never rebuilt. That is the silently-dead-listener case CritterWatch#942 exists to catch, defeated on exactly the endpoints most likely to be non-trivially configured. Both now detect through GH-4188's Unwrap(). The rebuild disposes the UNWRAPPED receiver rather than the outer one. The wrappers hold nothing of their own and forward Dispose() to Inner, but they are IDisposable only, so disposing the outer would silently take the sync branch and skip DurableReceiver.DisposeAsync entirely. Nulling the field drops the wrappers; the rebuild re-applies them. Third, and not an Unwrap() problem: StartAsync's GlobalPartitionedInterceptor wrap was unconditional while the receiver rebuild beside it is guarded by ??=. StartAsync is also the back-pressure RESUME path -- MarkAsTooBusyAndStopReceivingAsync deliberately keeps _receiver alive, since the queue it holds is what has to drain before the listener resumes, and only drops the Listener. So every latch/resume cycle added another interceptor layer, with nothing ever removing one. Never incorrect, because every layer delegates; just an unbounded chain whose per-message cost grew with the number of cycles the endpoint had been through. Worth its own guard rather than riding on Unwrap(), which is depth-agnostic by construction and so keeps working at any depth while telling you nothing about the depth being wrong. Tests fault a real receiver through the production path -- the receivers assign their own onBlockError onto IBlock.OnError, and that handler sets HasFaulted on a null envelope, the #506 terminal signature -- rather than writing a backing field or standing in a fake. The rebuild test drives MarkAsTooBusy rather than a stop/start, because StopAndDrainCoreAsync nulls _receiver before it drains, so RestartAsync never reaches the guard and a stop-then-start test passes on unfixed code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4191. Follows #4190.
Three defects in
ListeningAgent, all in how it reasons about a receiver that is actually a pass-through wrapper. The first two are the ones #4191 describes; the third turned up while verifying them and is in the same method.1–2. The last two raw-
_receiverfault testsIFaultTrackingReceiveris implemented byBufferedReceiver,DurableReceiverandNativeAckReceiver— and by neither wrapper.ReceiverWithRulesis installed for any incoming envelope rule, which includes a bare endpoint-levelMessageTypeorTenantId, so "behind a wrapper" is the ordinary case. A terminally faulted receiver behind one reported healthy forever and was never rebuilt: the silently-dead-listener case CritterWatch#942 exists to catch, defeated on exactly the endpoints most likely to be non-trivially configured.Both now detect through #4190's
Unwrap(). The two halves do not have the same blast radius, which is worth stating since the issue is the only place it is written down:ReceiverHasFaultedis read only byBackPressureAgent, which does not run forInlineorNativeAckat all (Endpoint.ShouldEnforceBackPressure, GH-3708) — so that half is Buffered and Durable only, and there was never a periodic detector to lose for the other two modes. TheStartAsyncguard is the mode-independent one.The disposal subtlety
The rebuild disposes the unwrapped receiver, not the outer one. The wrappers hold nothing of their own and forward
Dispose()toInner, but they areIDisposableonly — sofaulted is IAsyncDisposable ? DisposeAsync() : Dispose()applied to the outer silently takes the sync branch and skipsDurableReceiver.DisposeAsyncentirely. Nulling the field drops the wrappers; the rebuild re-applies both.3.
StartAsyncrestacked the interceptor on every back-pressure resumeNot an
Unwrap()problem. The receiver rebuild is guarded by??=; the wrap next to it was not:StartAsyncis also the back-pressure resume path.MarkAsTooBusyAndStopReceivingAsyncdeliberately keeps_receiveralive — the queue it holds is precisely what has to drain before the listener resumes — and only drops theListener. So every latch/resume cycle on a Buffered or Durable endpoint in a global-partitioned topology added another interceptor layer, with nothing ever removing one.Never incorrect, because every layer delegates and
Dispose/DrainAsyncforward to inner. Just an unbounded chain whose per-message cost grows with how long the endpoint has been flapping. It wants its own guard rather than riding onUnwrap(), which is depth-agnostic by construction: it keeps working at any depth and so tells you nothing about the depth being wrong. Worth noting that #4190 is what makes this silent rather than fatal — before it, anything behind the interceptor threw fromEnqueueDirectlyAsyncregardless of depth, so nesting could never have been observed there.Evidence
wrapped_receiver_fault_detection_4191, three tests, each verified red first and for its own reason:agent.ReceiverHasFaulted should be True but was Falseshould not be same as BufferedReceiver (39097574) but was BufferedReceiver (39097574)after.Inner should be of type BufferedReceiver but was GlobalPartitionedInterceptorTwo things about how they are written:
They fault a real receiver, not a fake. The receivers assign their own
onBlockErroronto the public settableIBlock.OnError, and that handler setsHasFaultedon exactly one condition — a null envelope, the #506 terminal signature the block itself raises. The test reflects the block field and invokes the handler the block would have invoked, rather than writing<HasFaulted>k__BackingFieldor standing in a stub.The rebuild test drives
MarkAsTooBusy, not a stop/start.StopAndDrainCoreAsyncnulls_receiverbefore it drains, soRestartAsyncnever reaches the guard — a stop-then-start test passes on unfixed code and proves nothing.MarkAsTooBusyAndStopReceivingAsync→StartAsyncis both the path that reaches the guard and the literal CritterWatch#942 scenario, since a faulted receiver's frozenQueueCountis what latches the listener in the first place. Both of these came from a third session that had been started on this same issue from a task chip and stood down when we reconciled — it produced no code, and these two findings were the whole of what it contributed. Without the second one I would have written a stop-then-start test that went vacuously green.CoreTests 2685/2685 with 2 pre-existing skips, on the rebased post-#4190 base.
dotnet build wolverine.slnx -c Release -f net9.0clean.🤖 Generated with Claude Code