Make message macros (FAIL, WARN, INFO, etc) thread safe

This builds on the existing work to make assertion thread safe,
by adding an extra synchronization point in the holder of
`ReusableStringStream`'s stream instances, as those are used to
build the messages, and finishing the move of message scope holders
to be thread-local.
This commit is contained in:
Martin Hořeňovský
2025-08-23 00:15:53 +02:00
parent f4e05a67bb
commit 582200a1f8
7 changed files with 165 additions and 54 deletions

View File

@@ -2,7 +2,9 @@
# Thread safety in Catch2 # Thread safety in Catch2
**Contents**<br> **Contents**<br>
[Using assertion macros from multiple threads](#using-assertion-macros-from-multiple-threads)<br> [Using assertion macros from spawned threads](#using-assertion-macros-from-spawned-threads)<br>
[Assertion-like message macros and spawned threads](#assertion-like-message-macros-and-spawned-threads)<br>
[Message macros and spawned threads](#message-macros-and-spawned-threads)<br>
[examples](#examples)<br> [examples](#examples)<br>
[`STATIC_REQUIRE` and `STATIC_CHECK`](#static_require-and-static_check)<br> [`STATIC_REQUIRE` and `STATIC_CHECK`](#static_require-and-static_check)<br>
[Fatal errors and multiple threads](#fatal-errors-and-multiple-threads)<br> [Fatal errors and multiple threads](#fatal-errors-and-multiple-threads)<br>
@@ -10,17 +12,18 @@
> Thread safe assertions were introduced in Catch2 3.9.0 > Thread safe assertions were introduced in Catch2 3.9.0
Thread safety in Catch2 is currently limited to all the assertion macros. Thread safety in Catch2 is currently limited to all the assertion macros,
Interacting with benchmark macros, message macros (e.g. `INFO` or `CAPTURE`), and to message or message-adjacent macros (e.g. `INFO` or `WARN`).
sections macros, generator macros, or test case macros is not thread-safe.
The message macros are likely to be made thread-safe in the future, but Interacting with benchmark macros, sections macros, generator macros, or
the way sections define test runs is incompatible with user being able test case macros is not thread-safe. The way sections define paths through
to spawn threads arbitrarily, thus that limitation is here to stay. the test is incompatible with user spawning threads arbitrarily, so this
limitation is here to stay.
**Important: thread safety in Catch2 is [opt-in](configuration.md#experimental-thread-safety)** **Important: thread safety in Catch2 is [opt-in](configuration.md#experimental-thread-safety)**
## Using assertion macros from multiple threads ## Using assertion macros from spawned threads
The full set of Catch2's runtime assertion macros is thread-safe. However, The full set of Catch2's runtime assertion macros is thread-safe. However,
it is important to keep in mind that their semantics might not support it is important to keep in mind that their semantics might not support
@@ -30,7 +33,7 @@ Specifically, the `REQUIRE` family of assertion macros have semantics
of stopping the test execution on failure. This is done by throwing of stopping the test execution on failure. This is done by throwing
an exception, but since the user-spawned thread will not have the test-level an exception, but since the user-spawned thread will not have the test-level
try-catch block ready to catch the test failure exception, failing a try-catch block ready to catch the test failure exception, failing a
`REQUIRE` assertion inside this thread will terminate the process. `REQUIRE` assertion inside user-spawned thread will terminate the process.
The `CHECK` family of assertions does not have this issue, because it The `CHECK` family of assertions does not have this issue, because it
does not try to stop the test execution. does not try to stop the test execution.
@@ -38,16 +41,32 @@ does not try to stop the test execution.
Note that `CHECKED_IF` and `CHECKED_ELSE` are also thread safe (internally Note that `CHECKED_IF` and `CHECKED_ELSE` are also thread safe (internally
they are assertion macro + an if). they are assertion macro + an if).
**`SKIP()`, `FAIL()`, `SUCCEED()` are not assertion macros, and are not
thread-safe.** ## Assertion-like message macros and spawned threads
Similarly to assertion macros, not all assertion-like message macros can
be used from spawned thread.
`SKIP` and `FAIL` macros stop the test execution. Just like with `REQUIRE`,
this means that they cannot be used inside user-spawned threads. `SUCCEED`,
`FAIL_CHECK` and `WARN` do not attempt to stop the test execution and
thus can be used from any thread.
## Message macros and spawned threads
Macros that add extra messages to following assertion, such as `INFO`
or `CAPTURE`, are all thread safe and can be used in any thread. Note
that these messages are per-thread, and thus `INFO` inside a user-spawned
thread will not be seen by the main thread, and vice versa.
## examples ## examples
### `REQUIRE` from main thread, `CHECK` from spawned threads ### `REQUIRE` from the main thread, `CHECK` from spawned threads
```cpp ```cpp
TEST_CASE( "Failed REQUIRE in main thread is fine" ) { TEST_CASE( "Failed REQUIRE in the main thread is fine" ) {
std::vector<std::jthread> threads; std::vector<std::jthread> threads;
for ( size_t t = 0; t < 16; ++t) { for ( size_t t = 0; t < 16; ++t) {
threads.emplace_back( []() { threads.emplace_back( []() {
@@ -85,7 +104,7 @@ TEST_CASE( "Successful REQUIRE in spawned thread is fine" ) {
This will also work as expected, because the `REQUIRE` is successful. This will also work as expected, because the `REQUIRE` is successful.
```cpp ```cpp
TEST_CASE( "Failed REQUIRE in spawned thread is fine" ) { TEST_CASE( "Failed REQUIRE in spawned thread kills the process" ) {
std::vector<std::jthread> threads; std::vector<std::jthread> threads;
for ( size_t t = 0; t < 16; ++t) { for ( size_t t = 0; t < 16; ++t) {
threads.emplace_back( []() { threads.emplace_back( []() {
@@ -99,12 +118,88 @@ TEST_CASE( "Failed REQUIRE in spawned thread is fine" ) {
This will fail catastrophically and terminate the process. This will fail catastrophically and terminate the process.
### INFO across threads
```cpp
TEST_CASE( "messages don't cross threads" ) {
std::jthread t1( [&]() {
for ( size_t i = 0; i < 100; ++i ) {
INFO( "spawned thread #1" );
CHECK( 1 == 1 );
}
} );
std::thread t2( [&]() {
for (size_t i = 0; i < 100; ++i) {
UNSCOPED_INFO( "spawned thread #2" );
}
} );
for (size_t i = 0; i < 100; ++i) {
CHECK( 1 == 2 );
}
}
```
None of the failed checks will show the "spawned thread #1" message, as
that message is for the `t1` thread. If the reporter shows passing
assertions (e.g. due to the tests being run with `-s`), you will see the
"spawned thread #1" message alongside the passing `CHECK( 1 == 1 )` assertion.
The message "spawned thread #2" will never be shown, because there are no
assertions in `t2`.
### FAIL/SKIP from the main thread
```cpp
TEST_CASE( "FAIL in the main thread is fine" ) {
std::vector<std::jthread> threads;
for ( size_t t = 0; t < 16; ++t) {
threads.emplace_back( []() {
for (size_t i = 0; i < 10; ++i) {
CHECK( true );
CHECK( false );
}
} );
}
FAIL();
}
```
This will work as expected, that is, the process will finish running
normally, the test case will fail and there will be 321 total assertions,
160 passing and 161 failing (`FAIL` counts as failed assertion).
However, when the main thread hits `FAIL`, it will wait for the other
threads to finish due to `std::jthread`'s destructor joining the spawned
thread. Due to this, using `SKIP` is not recommended once more threads
are spawned; while the main thread will bail from the test execution,
the spawned threads will keep running and may fail the test case.
### FAIL/SKIP from spawned threads
```cpp
TEST_CASE( "FAIL/SKIP in spawned thread kills the process" ) {
std::vector<std::jthread> threads;
for ( size_t t = 0; t < 16; ++t) {
threads.emplace_back( []() {
for (size_t i = 0; i < 10'000; ++i) {
FAIL();
}
} );
}
}
```
As with failing `REQUIRE`, both `FAIL` and `SKIP` in spawned threads
terminate the process.
## `STATIC_REQUIRE` and `STATIC_CHECK` ## `STATIC_REQUIRE` and `STATIC_CHECK`
None of `STATIC_REQUIRE`, `STATIC_REQUIRE_FALSE`, `STATIC_CHECK`, and All of `STATIC_REQUIRE`, `STATIC_REQUIRE_FALSE`, `STATIC_CHECK`, and
`STATIC_CHECK_FALSE` are currently thread safe. This might be surprising `STATIC_CHECK_FALSE` are thread safe in the delayed evaluation configuration.
given that they are a compile-time checks, but they also rely on the
message macros to register the result with reporter at runtime.
## Fatal errors and multiple threads ## Fatal errors and multiple threads

View File

@@ -101,8 +101,9 @@ namespace Catch {
} }
Capturer::~Capturer() { Capturer::~Capturer() {
assert( m_captured == m_messages.size() ); assert( m_captured == m_messages.size() );
for ( size_t i = 0; i < m_captured; ++i ) for ( size_t i = 0; i < m_captured; ++i ) {
m_resultCapture.popScopedMessage( m_messages[i] ); m_resultCapture.popScopedMessage( m_messages[i] );
}
} }
void Capturer::captureValue( size_t index, std::string const& value ) { void Capturer::captureValue( size_t index, std::string const& value ) {

View File

@@ -10,16 +10,17 @@
namespace Catch { namespace Catch {
MessageInfo::MessageInfo( StringRef _macroName, MessageInfo::MessageInfo( StringRef _macroName,
SourceLineInfo const& _lineInfo, SourceLineInfo const& _lineInfo,
ResultWas::OfType _type ) ResultWas::OfType _type )
: macroName( _macroName ), : macroName( _macroName ),
lineInfo( _lineInfo ), lineInfo( _lineInfo ),
type( _type ), type( _type ),
sequence( ++globalCount ) sequence( ++globalCount )
{} {}
// This may need protecting if threading support is added // Messages are owned by their individual threads, so the counter should be thread-local as well.
unsigned int MessageInfo::globalCount = 0; // Alternative consideration: atomic, so threads don't share IDs and things are easier to debug.
thread_local unsigned int MessageInfo::globalCount = 0;
} // end namespace Catch } // end namespace Catch

View File

@@ -37,7 +37,7 @@ namespace Catch {
return sequence < other.sequence; return sequence < other.sequence;
} }
private: private:
static unsigned int globalCount; static thread_local unsigned int globalCount;
}; };
} // end namespace Catch } // end namespace Catch

View File

@@ -7,6 +7,7 @@
// SPDX-License-Identifier: BSL-1.0 // SPDX-License-Identifier: BSL-1.0
#include <catch2/internal/catch_reusable_string_stream.hpp> #include <catch2/internal/catch_reusable_string_stream.hpp>
#include <catch2/internal/catch_singletons.hpp> #include <catch2/internal/catch_singletons.hpp>
#include <catch2/internal/catch_thread_support.hpp>
#include <catch2/internal/catch_unique_ptr.hpp> #include <catch2/internal/catch_unique_ptr.hpp>
#include <cstdio> #include <cstdio>
@@ -20,8 +21,10 @@ namespace Catch {
std::vector<Detail::unique_ptr<std::ostringstream>> m_streams; std::vector<Detail::unique_ptr<std::ostringstream>> m_streams;
std::vector<std::size_t> m_unused; std::vector<std::size_t> m_unused;
std::ostringstream m_referenceStream; // Used for copy state/ flags from std::ostringstream m_referenceStream; // Used for copy state/ flags from
Detail::Mutex m_mutex;
auto add() -> std::size_t { auto add() -> std::size_t {
Detail::LockGuard _( m_mutex );
if( m_unused.empty() ) { if( m_unused.empty() ) {
m_streams.push_back( Detail::make_unique<std::ostringstream>() ); m_streams.push_back( Detail::make_unique<std::ostringstream>() );
return m_streams.size()-1; return m_streams.size()-1;
@@ -33,9 +36,13 @@ namespace Catch {
} }
} }
void release( std::size_t index ) { void release( std::size_t index, std::ostream* originalPtr ) {
m_streams[index]->copyfmt( m_referenceStream ); // Restore initial flags and other state assert( originalPtr );
m_unused.push_back(index); originalPtr->copyfmt( m_referenceStream ); // Restore initial flags and other state
Detail::LockGuard _( m_mutex );
assert( originalPtr == m_streams[index].get() && "Mismatch between release index and stream ptr" );
m_unused.push_back( index );
} }
}; };
@@ -47,7 +54,7 @@ namespace Catch {
ReusableStringStream::~ReusableStringStream() { ReusableStringStream::~ReusableStringStream() {
static_cast<std::ostringstream*>( m_oss )->str(""); static_cast<std::ostringstream*>( m_oss )->str("");
m_oss->clear(); m_oss->clear();
Singleton<StringStreams>::getMutable().release( m_index ); Singleton<StringStreams>::getMutable().release( m_index, m_oss );
} }
std::string ReusableStringStream::str() const { std::string ReusableStringStream::str() const {

View File

@@ -172,9 +172,6 @@ namespace Catch {
// This also implies that messages are owned by their respective // This also implies that messages are owned by their respective
// threads, and should not be shared across different threads. // threads, and should not be shared across different threads.
// //
// For simplicity, we disallow messages in multi-threaded contexts,
// but in the future we can enable them under this logic.
//
// This implies that various pieces of metadata referring to last // This implies that various pieces of metadata referring to last
// assertion result/source location/message handling, etc // assertion result/source location/message handling, etc
// should also be thread local. For now we just use naked globals // should also be thread local. For now we just use naked globals
@@ -183,15 +180,27 @@ namespace Catch {
// This is used for the "if" part of CHECKED_IF/CHECKED_ELSE // This is used for the "if" part of CHECKED_IF/CHECKED_ELSE
static thread_local bool g_lastAssertionPassed = false; static thread_local bool g_lastAssertionPassed = false;
// Should we clear message scopes before sending off the messages to
// reporter? Set in `assertionPassedFastPath` to avoid doing the full
// clear there for performance reasons.
static thread_local bool g_clearMessageScopes = false;
// This is the source location for last encountered macro. It is // This is the source location for last encountered macro. It is
// used to provide the users with more precise location of error // used to provide the users with more precise location of error
// when an unexpected exception/fatal error happens. // when an unexpected exception/fatal error happens.
static thread_local SourceLineInfo g_lastKnownLineInfo("DummyLocation", static_cast<size_t>(-1)); static thread_local SourceLineInfo g_lastKnownLineInfo("DummyLocation", static_cast<size_t>(-1));
}
// Should we clear message scopes before sending off the messages to
// reporter? Set in `assertionPassedFastPath` to avoid doing the full
// clear there for performance reasons.
static thread_local bool g_clearMessageScopes = false;
CATCH_INTERNAL_START_WARNINGS_SUPPRESSION
CATCH_INTERNAL_SUPPRESS_GLOBALS_WARNINGS
// Actual messages to be provided to the reporter
static thread_local std::vector<MessageInfo> g_messages;
// Owners for the UNSCOPED_X information macro
static thread_local std::vector<ScopedMessage> g_messageScopes;
CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION
} // namespace Detail
RunContext::RunContext(IConfig const* _config, IEventListenerPtr&& reporter) RunContext::RunContext(IConfig const* _config, IEventListenerPtr&& reporter)
: m_runInfo(_config->name()), : m_runInfo(_config->name()),
@@ -327,20 +336,21 @@ namespace Catch {
Detail::g_lastAssertionPassed = true; Detail::g_lastAssertionPassed = true;
} }
if ( Detail::g_clearMessageScopes ) {
Detail::g_messageScopes.clear();
Detail::g_clearMessageScopes = false;
}
// From here, we are touching shared state and need mutex. // From here, we are touching shared state and need mutex.
Detail::LockGuard lock( m_assertionMutex ); Detail::LockGuard lock( m_assertionMutex );
{ {
if ( Detail::g_clearMessageScopes ) {
m_messageScopes.clear();
Detail::g_clearMessageScopes = false;
}
auto _ = scopedDeactivate( *m_outputRedirect ); auto _ = scopedDeactivate( *m_outputRedirect );
updateTotalsFromAtomics(); updateTotalsFromAtomics();
m_reporter->assertionEnded( AssertionStats( result, m_messages, m_totals ) ); m_reporter->assertionEnded( AssertionStats( result, Detail::g_messages, m_totals ) );
} }
if ( result.getResultType() != ResultWas::Warning ) { if ( result.getResultType() != ResultWas::Warning ) {
m_messageScopes.clear(); Detail::g_messageScopes.clear();
} }
// Reset working state. assertion info will be reset after // Reset working state. assertion info will be reset after
@@ -473,8 +483,8 @@ namespace Catch {
m_reporter->benchmarkFailed( error ); m_reporter->benchmarkFailed( error );
} }
void RunContext::pushScopedMessage(MessageInfo const & message) { void RunContext::pushScopedMessage( MessageInfo const& message ) {
m_messages.push_back(message); Detail::g_messages.push_back( message );
} }
void RunContext::popScopedMessage( MessageInfo const& message ) { void RunContext::popScopedMessage( MessageInfo const& message ) {
@@ -483,16 +493,16 @@ namespace Catch {
// messages than low single digits, so the optimization is tiny, // messages than low single digits, so the optimization is tiny,
// and we would have to hand-write the loop to avoid terrible // and we would have to hand-write the loop to avoid terrible
// codegen of reverse iterators in debug mode. // codegen of reverse iterators in debug mode.
m_messages.erase( Detail::g_messages.erase(
std::find_if( m_messages.begin(), std::find_if( Detail::g_messages.begin(),
m_messages.end(), Detail::g_messages.end(),
[id = message.sequence]( MessageInfo const& msg ) { [id = message.sequence]( MessageInfo const& msg ) {
return msg.sequence == id; return msg.sequence == id;
} ) ); } ) );
} }
void RunContext::emplaceUnscopedMessage( MessageBuilder&& builder ) { void RunContext::emplaceUnscopedMessage( MessageBuilder&& builder ) {
m_messageScopes.emplace_back( CATCH_MOVE(builder) ); Detail::g_messageScopes.emplace_back( CATCH_MOVE(builder) );
} }
std::string RunContext::getCurrentTestName() const { std::string RunContext::getCurrentTestName() const {
@@ -651,10 +661,10 @@ namespace Catch {
m_testCaseTracker->close(); m_testCaseTracker->close();
handleUnfinishedSections(); handleUnfinishedSections();
m_messageScopes.clear(); Detail::g_messageScopes.clear();
// TBD: At this point, m_messages should be empty. Do we want to // TBD: At this point, m_messages should be empty. Do we want to
// assert that this is true, or keep the defensive clear call? // assert that this is true, or keep the defensive clear call?
m_messages.clear(); Detail::g_messages.clear();
SectionStats testCaseSectionStats(CATCH_MOVE(testCaseSection), assertions, duration, missingAssertions); SectionStats testCaseSectionStats(CATCH_MOVE(testCaseSection), assertions, duration, missingAssertions);
m_reporter->sectionEnded(testCaseSectionStats); m_reporter->sectionEnded(testCaseSectionStats);

View File

@@ -149,9 +149,6 @@ namespace Catch {
Totals m_totals; Totals m_totals;
Detail::AtomicCounts m_atomicAssertionCount; Detail::AtomicCounts m_atomicAssertionCount;
IEventListenerPtr m_reporter; IEventListenerPtr m_reporter;
std::vector<MessageInfo> m_messages;
// Owners for the UNSCOPED_X information macro
std::vector<ScopedMessage> m_messageScopes;
std::vector<SectionEndInfo> m_unfinishedSections; std::vector<SectionEndInfo> m_unfinishedSections;
std::vector<ITracker*> m_activeSections; std::vector<ITracker*> m_activeSections;
TrackerContext m_trackerContext; TrackerContext m_trackerContext;