Skip to content

GH-3965: stop the ConventionalRouting tests leaking Rabbit consumers - #3967

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3965-conventional-routing-host-leak
Aug 16, 2026
Merged

GH-3965: stop the ConventionalRouting tests leaking Rabbit consumers#3967
jeremydmiller merged 1 commit into
mainfrom
gh-3965-conventional-routing-host-leak

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #3965. Test infrastructure only — no shipped code path depends on Dispose() stopping a host.

The evidence

end_to_end_with_conventional_routing.send_from_one_node_to_another_all_with_conventional_routing failed in a full Wolverine.RabbitMQ.Tests run and passed 3/3 in isolation. The tracked session is what pinned it:

System.TimeoutException : This TrackedSession timed out before all activity completed.
| Service (Node Id)  | Message Id | Message Type | Time (ms) | Event |
| Sender (7db8333a…) | 08defbc1-… | routed       |         2 | Sent  |

Sent, and never Received by anyone. Not "received by the wrong host" — nothing in the session consumed it at all.

The cause

ConventionallyRoutedMessage carries [MessageIdentity("routed")], so every conventionally routed instance lands on one fixed queue named routed. Five classes in this namespace stand up hosts against it, several with conventional discovery on, which gives them a live listener via RoutedMessageHandler.

Every one of them tore down like this:

ValueTask IAsyncDisposable.DisposeAsync() => ValueTask.CompletedTask;
public void Dispose() => _host.Dispose();

IHost.Dispose() does not run IHostedService.StopAsync, so the Rabbit listeners were never cancelled and their consumers stayed attached to routed on the broker after the class finished. A later class's message was delivered to one of those orphans, and the real receiver never saw it.

The trap in the fix

Making ConventionalRoutingContext implement IAsyncDisposable is not sufficient on its own. Its derived classes implement IAsyncLifetime, whose explicit ValueTask IAsyncDisposable.DisposeAsync() shadows the base implementation — so the base would simply never run. Each derived class therefore calls the new protected DisposeHostAsync() explicitly.

Deliberately not touched

discover_with_naming_prefix and Bug_3633_conventional_routing_respects_named_broker leak the same way, but they use prefixed and named-broker queue names that don't collide with plain routed, so they don't contribute to this interference. They also build their hosts in a constructor, so converting them means restructuring to IAsyncLifetime — a larger change than this fix warrants. Worth a follow-up if the suite ever shows symptoms from them.

Verification

  • Wolverine.RabbitMQ.Tests.ConventionalRouting namespace: 33/34 → 34/34
  • Full suite result to follow in a comment

Note on timing

The full suite was recorded at 503/503 on 2026-08-15, so something tipped the ordering or shutdown timing recently — #3796, #3960 and #3961 all touched RabbitMqListener shutdown and are the candidates. The leak itself predates all of them, and is the defect regardless.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC

end_to_end_with_conventional_routing.send_from_one_node_to_another_all_with_conventional_routing
failed in a full Wolverine.RabbitMQ.Tests run and passed 3/3 in isolation. The tracked
session showed the message Sent and never Received by anyone -- something else consumed it.

Every conventionally routed ConventionallyRoutedMessage lands on ONE fixed queue: the type
carries [MessageIdentity("routed")], so the conventional queue is literally "routed", and
five classes in this namespace stand up hosts against it, several with a live listener.

All of them tore down as:

    ValueTask IAsyncDisposable.DisposeAsync() => ValueTask.CompletedTask;
    public void Dispose() => _host.Dispose();

IHost.Dispose() does NOT run IHostedService.StopAsync, so the Rabbit listeners were never
cancelled and their consumers stayed attached to "routed" on the broker. A later class's
message was then delivered to a leaked consumer belonging to an already-finished test, and
the real receiver never saw it.

ConventionalRoutingContext now implements IAsyncDisposable and exposes a protected
DisposeHostAsync(). Derived classes that implement IAsyncLifetime SHADOW the interface
implementation, so each one calls the helper explicitly rather than returning a completed
ValueTask -- that shadowing is the trap that made the base class fix alone insufficient.

Test infrastructure only; no shipped code path depends on Dispose() stopping a host.

Namespace run went 33/34 -> 34/34.

Not touched: discover_with_naming_prefix and Bug_3633_conventional_routing_respects_named_broker
leak the same way, but they use prefixed and named-broker queues that do not collide with
plain "routed", and they build their hosts in a constructor, so converting them is a larger
refactor than this fix needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
@jeremydmiller

Copy link
Copy Markdown
Member Author

Full Wolverine.RabbitMQ.Tests suite: 504 / 504, zero failures.

The progression across this session, all on the same machine and broker:

total failed
main before #3964 503 1
main + #3964 (GH-3950) 504 1
+ this fix 504 0

Namespace-level: Wolverine.RabbitMQ.Tests.ConventionalRouting went 33/34 → 34/34.

That also retires the open question from #3964, where the same test failed on both the branch and the unmodified baseline — it was this leak, not the channel quiesce.

@jeremydmiller
jeremydmiller merged commit 5158301 into main Aug 16, 2026
37 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

1 participant