You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
keep failed single-topic consumer mailboxes draining requests so calls made after shutdown complete instead of hanging
propagate multi-topic poller failures to the parent consumer and fail pending receives
drain failed multi-topic mailbox requests and close child consumers when DisposeAsync is called
add integration coverage for single-topic and multi-topic mailbox failures
Problem
When a consumer mailbox faulted, its reader stopped. Requests posted afterwards were accepted by the unbounded channel but never received a response. This could leave operations such as GetStats, ReceiveAsync, and DisposeAsync waiting indefinitely. A multi-topic poller failure was only logged, leaving the parent consumer apparently ready even though it could no longer deliver messages.
Implementation
After a mailbox exits, a lightweight drain continues reading its channel. Requests that can no longer be performed fail with AlreadyClosedException; close requests complete after the necessary local or child-consumer cleanup. Multi-topic poller failures are posted to the parent mailbox, which moves the consumer to a failed state and completes pending receives.
Verification
Unit tests: 216 passed, 2 skipped
Mailbox integration tests against Apache Pulsar 3.3.0: 3 passed
Mailbox failure drops messages without disposing payloads
src/Pulsar.Client/Internal/ConsumerImpl.fs:891
The catch-all also drops queued MessageReceived values without disposing their RawMessage.Payload. These payloads come from MemoryStreamManager, and every normal discard path explicitly disposes them (for example, ConsumerImpl.fs:1002-1052), so messages racing with a mailbox failure retain pooled buffers. Add a stopped-mailbox case that disposes the payload before discarding the message.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
A partitioned producer request can still hang when that request triggers the mailbox failure, and the multi-topic test does not exercise waiter cleanup.
The copilot review comments assume that a ReadAsync which never completes keeps the drain task, and everything it references, in memory forever. The .NET garbage collector doesn't work that way. A pending task is not a garbage collection root; it stays alive only while something reachable references it.
In this case the drain's waiting operation is held by the mailbox channel. The channel is held only by the consumer or producer that owns it, and the drain's closure points back to that same object. Once the object is disposed, nothing outside that group references it:
The client removes it from its consumers or producers set.
The connection (ClientCnx) removes its registration.
Its timers are stopped.
The connection handler's loop exits.
What's left is objects that only reference each other, and the garbage collector frees groups like that.
The suggested fix would also cause a regression. Completing the channel writer makes later TryWrite calls return false. postAndAsyncReply ignores that result, so every public method that still posts after a stop would hang. Those methods include GetStats and IsConnected on the multi-topic consumer and the partitioned producer, which is exactly the hang this PR fixes. Avoiding that would mean adding state checks to every public method and losing the agreed stopped-mailbox replies: IsConnected returning false and Close returning Ok.
This was tested empirically by faulting and disposing an instance of all the implementations and checking that they are all collected by the GC under a forced collection while the drain is still pending. Also verified that completing the writer causes postAndAsyncReply hangs because it ignores TryWrite
Thanks for the updates—the in-flight request handling and regression tests address the earlier concern. I also agree that a pending ReadAsync does not, by itself, permanently root the object graph, so I don’t think the drain needs to be removed or the writer completed on that basis.
There is one remaining ordering issue: all four mailbox continuations call drainStoppedMailbox() before performing failure handling. The drain can therefore process a queued Close before cleanup finishes. For single producers/consumers, that can complete DisposeAsync before the local cleanup assumed by the stopped-mailbox handler has happened. For wrappers, the drain’s close handler can also race with the failure continuation’s state updates.
Could you start the drain after the failure-state transition, cleanup, and in-flight-request handling, while ensuring it still starts if cleanup throws—for example, using try/finally? Please also add a deterministic regression test that queues a close around mailbox failure and verifies it cannot complete before required local cleanup.
This should be a focused ordering fix, not a mailbox redesign. Once that is addressed and CI is green, I’m happy to approve.
A queued Close could be completed by the drain before the Failed transition, in-flight reply and stop ran. Each mailbox continuation now starts the drain in a finally after that cleanup, and deterministic tests queue a Close behind a mailbox failure to check the ordering.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use TaskCreationOptions for asynchronously run reply continuations
src/Pulsar.Client/Internal/ConsumerImpl.fs:907
This drain can still deadlock on a reentrant caller continuation. postAndAsyncReply constructs its TCS with TaskContinuationOptions.RunContinuationsAsynchronously (Common/Tools.fs:162-165), which F# binds to the object-state constructor, so completing a direct reply task here can run an ExecuteSynchronously continuation inline. If that continuation synchronously waits on another consumer call, the sole drain reader cannot process the new request. Create reply TCS instances with TaskCreationOptions.RunContinuationsAsynchronously so stopped-mailbox replies cannot be blocked this way.
This issue also appears on line 1447 of the same file.
Use TaskCreationOptions for asynchronously run reply continuations
This drain can still deadlock on a reentrant caller continuation. postAndAsyncReply constructs its TCS with TaskContinuationOptions.RunContinuationsAsynchronously (Common/Tools.fs:162-165), which F# binds to the object-state constructor, so completing a direct reply task here can run an ExecuteSynchronously continuation inline. If that continuation synchronously waits on another multi-topic consumer call, the sole drain reader cannot process the new request. Create reply TCS instances with TaskCreationOptions.RunContinuationsAsynchronously.
Use TaskCreationOptions for asynchronously run reply continuations
This drain can still deadlock on a reentrant caller continuation. postAndAsyncReply constructs its TCS with TaskContinuationOptions.RunContinuationsAsynchronously (Common/Tools.fs:162-165), which F# binds to the object-state constructor, so completing a direct reply task here can run an ExecuteSynchronously continuation inline. If that continuation synchronously waits on another partitioned-producer call, the sole drain reader cannot process the new request. Create reply TCS instances with TaskCreationOptions.RunContinuationsAsynchronously.
Use TaskCreationOptions for asynchronously run reply continuations
src/Pulsar.Client/Internal/ProducerImpl.fs:576
This drain can still deadlock on a reentrant caller continuation. postAndAsyncReply constructs its TCS with TaskContinuationOptions.RunContinuationsAsynchronously (Common/Tools.fs:162-165), which F# binds to the object-state constructor, so completing a direct reply task here can run an ExecuteSynchronously continuation inline. If that continuation synchronously waits on another producer call, the sole drain reader cannot process the new request. Create reply TCS instances with TaskCreationOptions.RunContinuationsAsynchronously.
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
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.
Summary
DisposeAsyncis calledProblem
When a consumer mailbox faulted, its reader stopped. Requests posted afterwards were accepted by the unbounded channel but never received a response. This could leave operations such as
GetStats,ReceiveAsync, andDisposeAsyncwaiting indefinitely. A multi-topic poller failure was only logged, leaving the parent consumer apparently ready even though it could no longer deliver messages.Implementation
After a mailbox exits, a lightweight drain continues reading its channel. Requests that can no longer be performed fail with
AlreadyClosedException; close requests complete after the necessary local or child-consumer cleanup. Multi-topic poller failures are posted to the parent mailbox, which moves the consumer to a failed state and completes pending receives.Verification