| id | 0054-rmq-shutdown-and-connection-pool-fixes | ||||
|---|---|---|---|---|---|
| title | Fix RMQ Shutdown Deadlock and Connection Pool Race Condition | ||||
| status | Accepted | ||||
| author |
|
||||
| created | 2026-03-11 | ||||
| summary | Fixes a sync-over-async shutdown deadlock in the RabbitMQ Proactor by adding `IAsyncDisposable` to `IAmAChannelAsync` and awaiting `DisposeAsync()` on MT_QUIT, and resolves a connection pool race condition where stale `ConnectionShutdown` handlers could dispose active connections via `ReferenceEquals` guards and `ConfigureAwait(false)`. | ||||
| tags |
|
Date: 2026-03-11
Accepted
Parent Requirement: specs/0024-rmq_shutdown/requirements.md
Scope: This ADR addresses two related bugs in the RabbitMQ messaging gateway: a sync-over-async deadlock that prevents clean shutdown (#3684), and a connection pool race condition that can dispose active connections (#4024).
When a ServiceActivator using the Proactor (async message pump) receives a shutdown signal (Ctrl+C), the process hangs indefinitely. The root cause is a sync-over-async deadlock in the dispose chain:
- The Proactor's
EventLoopruns insideBrighterAsyncContext.Run(), which provides a single-threadedBrighterTaskSchedulerand a customBrighterSynchronizationContext. - On
MT_QUIT, the Proactor callsChannel.Dispose()(line 218 ofProactor.cs). ChannelAsync.Dispose(bool)calls_messageConsumer.DisposeAsync()fire-and-forget — theValueTaskis never awaited (line 205 ofChannelAsync.cs).- The fire-and-forget
DisposeAsync()posts its continuation to theBrighterSynchronizationContextviaSynchronizationContext.Post(), incrementing_outstandingOperations. RmqMessageConsumer.DisposeAsync()callsDispose(true)onRmqMessageGateway.RmqMessageGateway.Dispose(bool)performs blocking sync-over-async:Channel?.AbortAsync().Wait()andRemoveConnectionAsync().GetAwaiter().GetResult()(lines 206-212 ofRmqMessageGateway.cs).RemoveConnectionAsyncinternallyawaits withoutConfigureAwait(false), so its continuations are posted back to theBrighterSynchronizationContext.- The single
BrighterTaskSchedulerthread is blocked in.GetAwaiter().GetResult()— the continuations can never run — deadlock. BrighterAsyncContext.Run()never returns because_outstandingOperations > 0, soProactor.Run()never returns, the Performer task never completes,Dispatcher._controlTaskhangs inTask.WaitAny(), and the process is stuck.
The Reactor (sync pump) is not affected because it uses Channel (sync) which calls _messageConsumer.Dispose() synchronously, and the sync RmqMessageGateway.Dispose(bool) calls synchronous Channel.Abort() and RemoveConnection() — no async operations, no deadlock.
RmqMessageGatewayConnectionPool uses a static Dictionary<string, PooledConnection> keyed by {user}.{password}.{host}.{port}.{vhost}. All consumers/producers to the same broker share one entry.
The ConnectionShutdown event handler closure captures the connectionId string but does not verify the connection instance. A stale handler from a replaced connection can dispose the current connection:
- Connection A shuts down,
TryRemoveConnectionis called, A is disposed and removed from pool. - Connection B is created for the same endpoint and added to the pool.
- Connection A's shutdown event was already queued before the handler was unsubscribed.
- The stale handler fires and calls
TryRemoveConnection(connectionId), which disposes Connection B.
Additionally, the sync variant's RemoveConnection checks ContainsKey outside the lock (TOCTOU issue).
Role: Channel — responsible for knowing about its consumer and doing proper lifecycle management.
IAmAChannelAsync currently extends only IAmAChannel : IDisposable. We add IAsyncDisposable to the interface:
public interface IAmAChannelAsync : IAmAChannel, IAsyncDisposable
{
// existing async methods...
}ChannelAsync then implements DisposeAsync():
public virtual async ValueTask DisposeAsync()
{
await _messageConsumer.DisposeAsync();
GC.SuppressFinalize(this);
}This replaces the current fire-and-forget Dispose(bool) path for async consumers. The synchronous Dispose() remains for backward compatibility but delegates to DisposeAsync safely using Task.Run to escape any ambient SynchronizationContext.
Role: Proactor — responsible for coordinating the async message pump lifecycle.
The Proactor's Channel property is already typed as IAmAChannelAsync. Since IAmAChannelAsync now extends IAsyncDisposable, the MT_QUIT handler changes from:
// Before
Channel.Dispose();to:
// After
await Channel.DisposeAsync();This ensures the entire dispose chain runs asynchronously within the BrighterAsyncContext event loop — no sync-over-async, no deadlock.
Role: Connection Pool — responsible for knowing about active connections and doing thread-safe lifecycle management.
All await calls in RmqMessageGatewayConnectionPool (async variant) must use ConfigureAwait(false) to prevent capturing ambient SynchronizationContext. This is defence-in-depth — even if a caller accidentally uses sync-over-async, the continuations will run on the thread pool rather than deadlocking.
Affected methods:
GetOrCreateConnectionAsync—await s_lock.WaitAsync().ConfigureAwait(false)RemoveConnectionAsync—await s_lock.WaitAsync().ConfigureAwait(false),await TryRemoveConnectionAsync().ConfigureAwait(false)TryRemoveConnectionAsync—await pooledConnection.Connection.DisposeAsync().ConfigureAwait(false)CreateConnectionAsync—await TryRemoveConnectionAsync().ConfigureAwait(false),await connectionFactory.CreateConnectionAsync().ConfigureAwait(false)
Role: Connection Pool — responsible for deciding whether a shutdown event applies to the current pooled connection.
The ShutdownHandler closure must verify the connection being shut down is the same instance currently in the pool:
// Async variant
async Task ShutdownHandler(object sender, ShutdownEventArgs e)
{
await s_lock.WaitAsync().ConfigureAwait(false);
try
{
if (s_connectionPool.TryGetValue(connectionId, out var pooled)
&& ReferenceEquals(pooled.Connection, sender))
{
await TryRemoveConnectionAsync(connectionId).ConfigureAwait(false);
}
}
finally
{
s_lock.Release();
}
}// Sync variant
void ShutdownHandler(object? sender, ShutdownEventArgs e)
{
lock (s_lock)
{
if (s_connectionPool.TryGetValue(connectionId, out var pooled)
&& ReferenceEquals(pooled.Connection, sender))
{
TryRemoveConnection(connectionId);
}
}
}The sender parameter of the ConnectionShutdown event is the IConnection instance that is shutting down. By comparing it with ReferenceEquals against what's currently in the pool, stale handlers become no-ops.
Role: Connection Pool — responsible for doing thread-safe removal.
Move the ContainsKey check inside the lock:
// Before (TOCTOU)
public void RemoveConnection(ConnectionFactory connectionFactory)
{
var connectionId = GetConnectionId(connectionFactory);
if (s_connectionPool.ContainsKey(connectionId)) // outside lock!
{
lock (s_lock)
{
TryRemoveConnection(connectionId);
}
}
}
// After
public void RemoveConnection(ConnectionFactory connectionFactory)
{
var connectionId = GetConnectionId(connectionFactory);
lock (s_lock)
{
TryRemoveConnection(connectionId); // TryGetValue inside is safe if missing
}
}TryRemoveConnection already uses TryGetValue and returns early if the key is missing, so the outer check is redundant and racy.
Apply the same simplification to the async variant's RemoveConnectionAsync.
Role: Gateway — responsible for doing channel and connection cleanup.
RmqMessageGateway.DisposeAsync() already exists and correctly uses await:
public virtual async ValueTask DisposeAsync()
{
if (Channel != null)
{
await Channel.AbortAsync();
await Channel.DisposeAsync();
Channel = null;
}
await new RmqMessageGatewayConnectionPool(...)
.RemoveConnectionAsync(_connectionFactory);
}The sync Dispose(bool) remains for callers that genuinely need synchronous disposal (e.g. the Reactor path via Channel → sync RmqMessageConsumer.Dispose() → sync RmqMessageGateway.Dispose(bool) which delegates to the sync connection pool). No changes needed to the sync Dispose(bool) — it only deadlocks when called from within BrighterAsyncContext, which the Proactor fix (decision 2) eliminates.
Ctrl+C → ServiceActivatorHostedService.StopAsync()
→ Dispatcher.End()
→ Consumer.Shut() → ChannelAsync.Stop() [enqueues MT_QUIT]
→ Proactor EventLoop (inside BrighterAsyncContext):
receives MT_QUIT
→ await Channel.DisposeAsync() ← Decision 2 (was: Channel.Dispose())
→ await ChannelAsync.DisposeAsync() ← Decision 1 (new method)
→ await RmqMessageConsumer.DisposeAsync()
→ await RmqMessageGateway.DisposeAsync() ← Decision 6 (existing, now reachable)
→ await Channel.AbortAsync()
→ await RemoveConnectionAsync() ← Decision 3 (ConfigureAwait(false))
→ ShutdownHandler validates instance ← Decision 4 (ReferenceEquals guard)
→ Connection closed, background threads stop
→ BrighterAsyncContext.Run() returns
→ Performer task completes
→ Dispatcher._controlTask completes
→ Process exits cleanly
| Component | Change | Package |
|---|---|---|
IAmAChannelAsync |
Add : IAsyncDisposable |
Paramore.Brighter |
ChannelAsync |
Implement DisposeAsync() |
Paramore.Brighter |
Proactor |
await Channel.DisposeAsync() on MT_QUIT |
Paramore.Brighter.ServiceActivator |
RmqMessageGatewayConnectionPool (async) |
ConfigureAwait(false), ReferenceEquals guard, simplify RemoveConnectionAsync |
Paramore.Brighter.MessagingGateway.RMQ.Async |
RmqMessageGatewayConnectionPool (sync) |
ReferenceEquals guard, fix TOCTOU in RemoveConnection |
Paramore.Brighter.MessagingGateway.RMQ.Sync |
Adding IAsyncDisposable to IAmAChannelAsync means all implementations of IAmAChannelAsync must implement DisposeAsync(). A codebase-wide analysis confirms the blast radius is contained:
-
No transport implements
IAmAChannelAsyncdirectly. Every transport's channel factory (RMQ, Kafka, AWS SQS V3/V4, Azure Service Bus, GCP Pub/Sub, Redis, MsSql, PostgreSQL, MQTT, RocketMQ, InMemory) createsnew ChannelAsync(...)— the concrete class. The only subclass is a test double (FailingChannelAsyncin core tests), which will inherit the newDisposeAsync()from its base class. -
All async message consumers already implement
DisposeAsync()becauseIAmAMessageConsumerAsyncalready extendsIAsyncDisposable. Verified for all 13 consumer implementations:- RMQ Async (
RmqMessageConsumer), Kafka (KafkaMessageConsumer), AWS SQS V3 (SqsMessageConsumer), AWS SQS V4 (SqsMessageConsumer), Azure Service Bus (AzureServiceBusConsumer), GCP Pull (GcpPullMessageConsumer), GCP Stream (GcpPubSubStreamMessageConsumer), Redis (RedisMessageConsumer), MsSql (MsSqlMessageConsumer), PostgreSQL (PostgresMessageConsumer), MQTT (MqttMessageConsumer), RocketMQ (RocketMessageConsumer), InMemory (InMemoryMessageConsumer).
- RMQ Async (
-
ChannelAsync.DisposeAsync()delegates toawait _messageConsumer.DisposeAsync(), which is the existing, already-working method on every transport's consumer. No gateway projects require additional changes.
The interface change is technically breaking for any external code that implements IAmAChannelAsync directly (they would need to add DisposeAsync()), but this is unlikely — ChannelAsync is the standard concrete implementation and all known usages go through it.
- The shutdown deadlock is eliminated — the Proactor's dispose path is fully async.
ConfigureAwait(false)provides defence-in-depth against future sync-over-async regressions.- The
ReferenceEqualsguard prevents stale shutdown handlers from disposing active connections, fixing CI test flakiness. - The TOCTOU fix in
RemoveConnectioneliminates a subtle race condition. - The fix is minimal and surgical — no architectural redesign of the connection pool or dispatcher.
- Adding
IAsyncDisposabletoIAmAChannelAsyncis a breaking change for any external code that implements the interface directly (unlikely —ChannelAsyncis the standard implementation). - The sync
Dispose(bool)onRmqMessageGateway(async variant) still contains sync-over-async patterns. This is acceptable because it's only reachable from sync callers that don't run insideBrighterAsyncContext.
- Risk: Other transports'
IAmAMessageConsumerAsyncimplementations may not implementDisposeAsync()correctly.- Mitigation:
IAmAMessageConsumerAsyncalready extendsIAsyncDisposable; all existing implementations already haveDisposeAsync().
- Mitigation:
- Risk: The
ReferenceEqualsguard could prevent legitimate shutdown cleanup if the RabbitMQ client reuses connection objects.- Mitigation: RabbitMQ client creates new
IConnectioninstances perCreateConnection()call; it does not pool or reuse them internally.
- Mitigation: RabbitMQ client creates new
Instead of making the Proactor's dispose path async, wrap the synchronous dispose in Task.Run to move it off the BrighterTaskScheduler thread:
Task.Run(() => Channel.Dispose()).GetAwaiter().GetResult();Rejected because: This is a workaround, not a fix. It hides the fundamental problem (calling sync dispose on an async consumer) and introduces thread pool dependency during shutdown. The proper fix is to use the async dispose path that already exists.
Track each IConnection instance with a reference count, only disposing when all consumers/producers release their reference.
Rejected because: Over-engineered for the current problem. The ReferenceEquals guard in the shutdown handler is sufficient to prevent stale handlers from disposing active connections. Reference counting could be considered as a future enhancement if connection sharing semantics become more complex.
Make each consumer/producer own its own connection.
Rejected because: This would be a significant behavioral change. RabbitMQ best practice is to share connections and use multiple channels. The static pool serves this purpose correctly in production.