Skip to content

GH-4191: unwrap before testing for a faulted receiver, and stop restacking the interceptor - #4192

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4191-unwrap-fault-detection
Aug 30, 2026
Merged

GH-4191: unwrap before testing for a faulted receiver, and stop restacking the interceptor#4192
jeremydmiller merged 1 commit into
mainfrom
gh-4191-unwrap-fault-detection

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Aug 30, 2026

Copy link
Copy Markdown
Member

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-_receiver fault tests

public bool ReceiverHasFaulted => _receiver is IFaultTrackingReceiver { HasFaulted: true };   // ~:166
if (_receiver is IFaultTrackingReceiver { HasFaulted: true } faulted)                          // ~:425

IFaultTrackingReceiver is implemented by BufferedReceiver, DurableReceiver and NativeAckReceiver — and by neither wrapper. ReceiverWithRules is installed for any incoming envelope rule, which includes a bare endpoint-level MessageType or TenantId, 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: ReceiverHasFaulted is read only by BackPressureAgent, which does not run for Inline or NativeAck at 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. The StartAsync guard 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() to Inner, but they are IDisposable only — so faulted is IAsyncDisposable ? DisposeAsync() : Dispose() applied to the outer silently takes the sync branch and skips DurableReceiver.DisposeAsync entirely. Nulling the field drops the wrappers; the rebuild re-applies both.

3. StartAsync restacked the interceptor on every back-pressure resume

Not an Unwrap() problem. The receiver rebuild is guarded by ??=; the wrap next to it was not:

_receiver ??= Endpoint.MaybeWrapReceiver(await buildReceiverAsync());   // guarded
...
_receiver = new GlobalPartitionedInterceptor(_receiver, _runtime);      // NOT guarded

StartAsync is also the back-pressure resume path. MarkAsTooBusyAndStopReceivingAsync deliberately keeps _receiver alive — the queue it holds is precisely what has to drain before the listener resumes — and only drops the Listener. 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/DrainAsync forward 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 on Unwrap(), 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 from EnqueueDirectlyAsync regardless 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:

test pre-fix failure
faulted receiver behind a rule is reported agent.ReceiverHasFaulted should be True but was False
faulted receiver is rebuilt on resume should not be same as BufferedReceiver (39097574) but was BufferedReceiver (39097574)
resume does not stack an interceptor after.Inner should be of type BufferedReceiver but was GlobalPartitionedInterceptor

Two things about how they are written:

They fault a real receiver, not a fake. The receivers assign their own onBlockError onto the public settable IBlock.OnError, and that handler sets HasFaulted on 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__BackingField or standing in a stub.

The rebuild test drives MarkAsTooBusy, not a stop/start. StopAndDrainCoreAsync nulls _receiver before it drains, so RestartAsync never reaches the guard — a stop-then-start test passes on unfixed code and proves nothing. MarkAsTooBusyAndStopReceivingAsyncStartAsync is both the path that reaches the guard and the literal CritterWatch#942 scenario, since a faulted receiver's frozen QueueCount is 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.0 clean.

🤖 Generated with Claude Code

…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>
@jeremydmiller
jeremydmiller merged commit 42eba7e into main Aug 30, 2026
39 checks passed
@jeremydmiller jeremydmiller mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A wrapped receiver's terminal fault is invisible: ReceiverHasFaulted and the restart rebuild both type-test the raw _receiver

1 participant