Skip to content

Catch exceptions in EventHubs client destructors#7116

Open
j7nw4r wants to merge 2 commits into
Azure:mainfrom
j7nw4r:fix/6957-eventhubs-destructor-noexcept
Open

Catch exceptions in EventHubs client destructors#7116
j7nw4r wants to merge 2 commits into
Azure:mainfrom
j7nw4r:fix/6957-eventhubs-destructor-noexcept

Conversation

@j7nw4r
Copy link
Copy Markdown
Member

@j7nw4r j7nw4r commented May 13, 2026

Summary

Two related fixes for cleanup behavior in the EventHubs clients.

1. Catch exceptions in destructors (Fixes #6957)

ProducerClient::~ProducerClient, ConsumerClient::~ConsumerClient, and PartitionClient::~PartitionClient each call Close() (or
m_receiver.Close()) without exception handling. Destructors are implicitly noexcept in C++11+, so a throw from cleanup invokes std::terminate
and kills the program with no chance to recover.

  • Wrap the Close() call in each of the three destructors with try / catch (std::exception const&) / catch (...); log at Warning and never
    rethrow.
  • Mark each destructor explicitly noexcept to document the contract and let clang-tidy bugprone-exception-escape flag future regressions.
  • Move ~ProducerClient out-of-line into producer_client.cpp so the public header no longer pulls in the internal logging header.
  • Each Log::Stream call inside the catch handlers is wrapped in its own inner try/catch (...). Log::Stream allocates a std::stringstream
    and its destructor invokes the user-installed log listener, so it can throw and would otherwise re-trigger std::terminate from inside the
    noexcept destructor frame.

2. Best-effort teardown in Close()

Previously, if the first step in ProducerClient::Close / ConsumerClient::Close threw, every subsequent step was skipped — leaking senders,
sessions, and connections.

  • Refactored both Close() methods so each teardown step (properties client, sender/receiver loop, session loop, connection loop) runs in its own
    try/catch.
  • The first exception encountered is captured via std::exception_ptr and rethrown to the caller after every step has been attempted; later
    exceptions are logged and swallowed.
  • This is the safety net that makes (1) meaningful: a single bad sender no longer prevents the rest of the client from tearing down.
  • Doxygen on Close() updated to document the best-effort contract.

Drive-by cleanup

  • Fixed two copy-paste typos in ConsumerClient::Close log messages: "Close producer client.""Close consumer client.", "Closing message senders.""Closing message receivers.".
  • Replaced the equivalent-but-cryptic while (!m_sessions.empty()) m_sessions.erase(begin) drain pattern in ConsumerClient::Close with
    clear(), matching ProducerClient.

Test plan

  • Pre-fix reproduction (destructor): on a scratch branch off main, insert throw std::runtime_error("forced"); at the top of
    ProducerClient::Close, construct + destroy a ProducerClient. Expected: process aborts with terminate called after throwing....
  • Post-fix validation (destructor): apply this PR with the same scratch throw in place. Expected: clean exit; Warning-level log emitted
    (with a registered listener).
  • Pre-fix reproduction (Close): on a scratch branch off main, populate m_senders then make m_propertiesClient->Close() throw.
    Expected: senders never get .Close() called, leaking AMQP state.
  • Post-fix validation (Close): same setup with this PR. Expected: every sender gets .Close(), the original properties-client exception is
    rethrown to the caller, intermediate failures are logged.
  • Repeat for ConsumerClient.
  • CI: azure-messaging-eventhubs-test non-live unit tests pass — they exercise both Close paths through happy-path destruction and verify no
    regression.

No automated regression test added: ProducerClient/ConsumerClient/PartitionClient hold concrete
Azure::Core::Amqp::_internal::MessageSender/MessageReceiver/Session/Connection instances by value with no mockable seam. Forcing Close()
to throw deterministically from a test would require introducing a mock AMQP interface — a much larger change than this fix.

Breaking changes

None for callers who don't call Close() explicitly. For callers who do: if the first step of Close() throws, the same exception is still
rethrown, but subsequent steps now also run (and may log additional warnings) before the rethrow. This is a strict improvement in cleanup
guarantees; no API or signature change.

Wrap the Close() calls in ProducerClient, ConsumerClient, and
PartitionClient destructors with try/catch so a throw from Close()
no longer terminates the program via std::terminate. Caught
exceptions are logged at Warning level. Each Log::Stream call is
itself wrapped so a logger allocation failure or a throwing user
listener cannot escape the noexcept destructor.

Fixes Azure#6957
Copilot AI review requested due to automatic review settings May 13, 2026 15:39
@j7nw4r j7nw4r requested review from axisc, hmlam and sjkwak as code owners May 13, 2026 15:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens Azure Event Hubs client cleanup by ensuring that exceptions thrown during Close() (or m_receiver.Close()) do not escape noexcept destructors and trigger std::terminate, addressing the scenario described in #6957.

Changes:

  • Wrap Close()/m_receiver.Close() in ProducerClient, ConsumerClient, and PartitionClient destructors with try/catch and log failures at Warning level.
  • Explicitly mark the three destructors as noexcept to document/lock in the non-throwing contract.
  • Move ProducerClient’s destructor definition out-of-line into producer_client.cpp, removing the inline Close() call from the public header.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
sdk/eventhubs/azure-messaging-eventhubs/src/producer_client.cpp Adds a noexcept destructor that catches and logs exceptions from Close().
sdk/eventhubs/azure-messaging-eventhubs/src/consumer_client.cpp Makes destructor noexcept and guards Close({}) with exception handling + warning logs.
sdk/eventhubs/azure-messaging-eventhubs/src/partition_client.cpp Makes destructor noexcept and guards receiver close + logging against exceptions.
sdk/eventhubs/azure-messaging-eventhubs/inc/azure/messaging/eventhubs/producer_client.hpp Replaces inline destructor with an out-of-line noexcept declaration.
sdk/eventhubs/azure-messaging-eventhubs/inc/azure/messaging/eventhubs/consumer_client.hpp Marks destructor noexcept (declaration change).
sdk/eventhubs/azure-messaging-eventhubs/inc/azure/messaging/eventhubs/partition_client.hpp Marks virtual destructor noexcept (declaration change).
sdk/eventhubs/azure-messaging-eventhubs/CHANGELOG.md Documents the bug fix under “Bugs Fixed” with issue link.

Comment on lines +38 to +62
try
{
Close();
}
catch (std::exception const& e)
{
try
{
Log::Stream(Logger::Level::Warning)
<< "~ProducerClient(): exception thrown during Close(): " << e.what();
}
catch (...)
{
}
}
catch (...)
{
try
{
Log::Stream(Logger::Level::Warning)
<< "~ProducerClient(): unknown exception thrown during Close().";
}
catch (...)
{
}
Comment on lines +45 to +55
catch (std::exception const& e)
{
try
{
Log::Stream(Logger::Level::Warning)
<< "~ConsumerClient(): exception thrown during Close(): " << e.what();
}
catch (...)
{
}
}
Comment on lines +226 to +236
catch (std::exception const& e)
{
try
{
Log::Stream(Logger::Level::Warning)
<< "~PartitionClient(): exception thrown during m_receiver.Close(): " << e.what();
}
catch (...)
{
}
}
Refactor ProducerClient::Close and ConsumerClient::Close so a throw
from one teardown step no longer skips the rest. Each step (properties
client, sender/receiver loop, session loop, connection loop) runs in
its own try/catch. The first exception is captured and rethrown to the
caller after every step has been attempted; subsequent throws are
logged and swallowed.

Also fix two copy-paste log message typos in ConsumerClient::Close
("Close producer client." and "Closing message senders.") and replace
the equivalent-but-cryptic `while(!empty) erase(begin)` drain pattern
with `clear()` to match ProducerClient.
@antkmsft
Copy link
Copy Markdown
Member

Thank you @j7nw4r, we will fix this in #7118 and will release a 1.0.0-beta.11.
We started working on ##7118 before we noticed your PR, but also there we intentionally do not catch (...) (if need for that happens, everything is in a deeper trouble, catching ... would suppress that). And we scoped it to a smaller area of 5 destructors, so that the change is easier to follow and hopefully it is enough to address the initial concern plus a few other possible ones. If it comes in the future that there are more contexts where we might need to do that, we may return to a broader set of areas that you are touching in this PR, as well as we might consider adding a "bool noThrow" parameter to all the functions that end up being called from these destructors (we decided to not do it this time because it would propagate a lot throughout the entire SDK, including the azure-core-amqp-cpp library, so it would require us to do a more complex release containing two libraries, for what we assume would be practically the same result).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants