Note: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in
docs/adr/.
Linked Issues: #3684, #4024
As a developer using Brighter's ServiceActivator with RabbitMQ, I would like the service to shut down cleanly when I press Ctrl+C, so that the process exits without requiring a force-kill.
There are two related bugs:
After Ctrl+C the message pump logs "Finished running message loop" but the process never terminates. The root cause is a sync-over-async deadlock inside BrighterAsyncContext's single-threaded scheduler:
- The Proactor's
EventLoopruns insideBrighterAsyncContext.Run()on a single-threadedBrighterTaskScheduler. - When
MT_QUITis received, the Proactor callsChannel.Dispose(). ChannelAsync.Dispose(bool)calls_messageConsumer.DisposeAsync()fire-and-forget — theValueTaskis not awaited (ChannelAsync.cs:205).RmqMessageConsumer.DisposeAsync()eventually callsDispose(true)onRmqMessageGateway.RmqMessageGateway.Dispose(bool)does blocking sync-over-async:Channel?.AbortAsync().Wait()andRemoveConnectionAsync().GetAwaiter().GetResult()(RmqMessageGateway.cs:206-212).RemoveConnectionAsyncinternallyawaits withoutConfigureAwait(false), so continuations are posted back to theBrighterSynchronizationContext.- But the only thread serving that context is blocked in
.GetAwaiter().GetResult()— classic 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.
Confirmed still present in v10.3.0 (reported 2026-03-11).
The static RmqMessageGatewayConnectionPool has a race where a stale ConnectionShutdown event handler can dispose a newer connection sharing the same pool key ({user}.{password}.{host}.{port}.{vhost}), causing OperationInterruptedException ("Connection close forced").
The ShutdownHandler closure captures the connectionId string but does not verify the connection instance. If:
- Connection A shuts down and is replaced by Connection B in the pool (same key)
- 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, RemoveConnection (sync variant) checks ContainsKey outside the lock (TOCTOU issue).
- Eliminate the sync-over-async chain in the Proactor's dispose path. The Proactor should
awaitchannel disposal asynchronously rather than calling synchronousDispose()which triggers blocking.Wait()/.GetAwaiter().GetResult()calls inside theBrighterAsyncContext. - Ensure
ChannelAsyncproperly awaitsDisposeAsync()instead of fire-and-forget. - Add
ConfigureAwait(false)to connection pool async methods so continuations don't captureBrighterSynchronizationContext.
- Guard the
ShutdownHandlerwith aReferenceEqualscheck: only remove/dispose the connection if the instance being shut down is the same one currently in the pool. - Move the
ContainsKeycheck inside the lock in the sync variant'sRemoveConnection.
- Clean shutdown: When
ServiceActivatorHostedService.StopAsync()completes, the RMQ connection must be closed and the process must exit without requiring a force-kill. - No sync-over-async in async pipeline: The Proactor dispose path must not call blocking
.Wait(),.GetAwaiter().GetResult(), orBrighterAsyncContext.Run()on async RMQ operations. The async channel and consumer must be disposed viaawait DisposeAsync(). - ChannelAsync must properly dispose:
ChannelAsyncmust not fire-and-forgetDisposeAsync(). It must either await it (in an async dispose path) or properly synchronize it. - ConfigureAwait(false) on library internals:
RmqMessageGatewayConnectionPoolasync methods must useConfigureAwait(false)to avoid capturing ambientSynchronizationContext. - Connection pool instance safety: The
ConnectionShutdownevent handler must verify viaReferenceEqualsthat the connection being shut down is the same instance currently in the pool before removing/disposing it. - Thread-safe pool removal: The sync variant's
RemoveConnectionmust move theContainsKeycheck inside the lock (fix TOCTOU). - Both Sync and Async variants: Fixes must be applied to both
Paramore.Brighter.MessagingGateway.RMQ.SyncandParamore.Brighter.MessagingGateway.RMQ.Asyncpackages.
- No behavioral regression: Existing RMQ tests must continue to pass. The connection pool must still share connections correctly in production.
- Thread safety: All connection pool mutations must remain properly synchronized.
- Idempotent disposal: Calling Dispose/DisposeAsync multiple times on a consumer must not throw.
- The connection pool is static and shared across the application lifetime — this design is intentional for production use (connection reuse).
- The pool key is
{user}.{password}.{host}.{port}.{vhost}, so all consumers/producers to the same broker share one entry. - The
ConnectionShutdownevent fires asynchronously from the RabbitMQ client library and may fire after a replacement connection has been added to the pool. - The Proactor runs its event loop inside
BrighterAsyncContext.Run()on a single-threaded scheduler — any blocking call on that thread will deadlock if the blocked operation's continuations are posted back to the same context. RmqMessageGateway.Dispose(bool)is called from multiple paths (consumer dispose, producer dispose) — changes must not break the sync variant's disposal.
- Changing the connection pool from static/shared to per-consumer connections.
- Adding connection pool reference counting.
- Changing the Dispatcher's control task architecture.
- Fixing pre-existing flaky
[Trait("Fragile", "CI")]tests beyond what these fixes naturally resolve. - Other transport implementations (Kafka, SQS, Redis, etc.).
- Process exits after Ctrl+C: A ServiceActivator with RMQ subscriptions (using
MessagePumpType.Proactor) shuts down and the process exits within a reasonable timeout after Ctrl+C (reproducer from issue #3684). - No deadlock in async dispose path: The Proactor dispose chain does not call blocking
.Wait()or.GetAwaiter().GetResult()on code that capturesBrighterSynchronizationContext. - No stale handler disposal: When a connection is replaced in the pool, the old connection's shutdown handler does not dispose the new connection.
- Existing RMQ tests pass: All non-fragile RMQ tests pass.
- Idempotent consumer dispose: Disposing an already-disposed consumer does not throw.
Ctrl+C → ServiceActivatorHostedService.StopAsync()
→ Dispatcher.End()
→ Consumer.Shut() → Channel.Stop() [enqueues MT_QUIT]
→ Proactor EventLoop receives MT_QUIT
→ Channel.Dispose() [inside BrighterAsyncContext]
→ ChannelAsync.Dispose() fire-and-forgets DisposeAsync()
→ RmqMessageConsumer.DisposeAsync() → RmqMessageGateway.Dispose(true)
→ AbortAsync().Wait() / RemoveConnectionAsync().GetResult() [BLOCKS single thread]
→ Continuations posted to BrighterSynchronizationContext [DEADLOCK — thread blocked]
→ BrighterAsyncContext.Run() never returns
→ Performer task never completes → Dispatcher._controlTask hangs
✗ Process stuck forever
Ctrl+C → ServiceActivatorHostedService.StopAsync()
→ Dispatcher.End()
→ Consumer.Shut() → Channel.Stop() [enqueues MT_QUIT]
→ Proactor EventLoop receives MT_QUIT
→ await Channel.DisposeAsync() [properly async]
→ await RmqMessageConsumer.DisposeAsync() [no sync-over-async]
→ await AbortAsync(); await RemoveConnectionAsync() [with ConfigureAwait(false)]
→ Connection closed → background threads terminate
→ BrighterAsyncContext.Run() returns
→ Performer task completes → Dispatcher control task completes
→ Process exits cleanly
src/Paramore.Brighter/ChannelAsync.cs— fire-and-forget DisposeAsync (line 205)src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageGateway.cs— blocking sync-over-async in Dispose(bool) (lines 206-212)src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageGatewayConnectionPool.cs— missing ConfigureAwait(false), ShutdownHandler racesrc/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageGatewayConnectionPool.cs— ShutdownHandler race, TOCTOU in RemoveConnectionsrc/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageConsumer.cssrc/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cssrc/Paramore.Brighter.ServiceActivator/Proactor.cs— EventLoop calls Channel.Dispose() (line 218)