Skip to content

Fix consumer hangs after mailbox failure - #374

Merged
Lanayx merged 9 commits into
fsprojects:developfrom
bartoszs321:fix/consumer-mailbox-failure-recovery
Oct 9, 2026
Merged

Lanayx merged 9 commits into
fsprojects:developfrom
bartoszs321:fix/consumer-mailbox-failure-recovery

Conversation

@bartoszs321

Copy link
Copy Markdown
Contributor

Summary

  • 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

bartoszs321 and others added 2 commits October 5, 2026 14:07
Co-authored-by: Cursor <cursoragent@cursor.com>
…lbox-failure-recovery

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	tests/IntegrationTests/Basic.fs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Faulted single-topic consumers remain in the wrong lifecycle state after disposal, and pending-receive coverage is incomplete.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Improves consumer failure handling so mailbox failures no longer leave requests hanging.

Changes:

  • Drains stopped consumer mailboxes and completes requests.
  • Propagates multi-topic poller failures.
  • Adds mailbox-failure integration coverage.
File Description
ConsumerImpl.fs Drains requests after mailbox termination.
MultiTopicsConsumerImpl.fs Handles poller failures and child cleanup.
Basic.fs Tests mailbox-failure behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/IntegrationTests/Basic.fs Resolved
let mb = Channel.CreateUnbounded<ConsumerMessage<'T>>(UnboundedChannelOptions(SingleReader = true, AllowSynchronousContinuations = true))

let replyFromStoppedMailbox (msg: ConsumerMessage<'T>) =
let ex = AlreadyClosedException "Consumer is already closed"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be NotConnectedException

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 29edfc2

@Lanayx

Lanayx commented Oct 5, 2026

Copy link
Copy Markdown
Member

Thank you for the PR, would you be able to implement similar behavior for Producer, so it will be single PR for both

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Stopped consumers can retain pooled payloads, and failed partition cleanup is incorrectly reported as successful.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Drains can complete disposal before cleanup and remain permanently rooted after shutdown.

4 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment on lines +903 to +905
backgroundTask {
while true do
let! msg = mb.Reader.ReadAsync()
Comment on lines +693 to +695
backgroundTask {
while true do
let! msg = mb.Reader.ReadAsync()
Comment on lines +179 to +181
backgroundTask {
while true do
let! msg = mb.Reader.ReadAsync()
Comment on lines +572 to +574
backgroundTask {
while true do
let! msg = mb.Reader.ReadAsync()
@bartoszs321

Copy link
Copy Markdown
Contributor Author

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

@Lanayx

Lanayx commented Oct 8, 2026

Copy link
Copy Markdown
Member

Astra review

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Reply continuations can still block mailbox drains, and consumer-close responses are not registered.

4 open findings
Previously missed (4)

In code that hasn't changed since last review

Medium severity 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.

Medium severity Use TaskCreationOptions for asynchronously run reply continuations

src/​Pulsar.Client/​Internal/​MultiTopicsConsumerImpl.fs:697

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.

Medium severity Use TaskCreationOptions for asynchronously run reply continuations

src/​Pulsar.Client/​Internal/​PartitionedProducerImpl.fs:183

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.

Medium severity 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.

🧠 Review effort: Balanced

@Lanayx
Lanayx merged commit df6c728 into fsprojects:develop Oct 9, 2026
2 of 3 checks passed
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.

3 participants