Bug 49

Test log callback writes to std::cout from several threads unlocked

Status
Fixed
Symptom
Data race; worst effect seen: sanitizer report only
Where seen
Libmultiprocess tests (mptest)
Trigger
Writing log output
First reported
2025-10-02 (first report)
Fixed
2025-10-07
Fix
mp#222
Shipped to Bitcoin Core
bitcoin#33518, bitcoin#33519

Summary

The mptest log callback wrote to std::cout from several threads at once without a lock, which ThreadSanitizer reported as a data race inside libc++'s stream formatting once libc++ itself was built with TSan. The defect was in test code and did not affect users. Fixed in mp#222 ("test, ci: Fix threadsanitizer errors in mptest") by logging through KJ_LOG instead.

What happened

ryanofsky switched the libmultiprocess sanitize CI job to a TSan-instrumented libc++ in mp#222 (2025-10-02), to catch a different test race that had been reported in Bitcoin Core CI. mptest immediately reported a TSan data race on std::__1::ios_base::width between a worker thread and the main thread, both inside operator<< called from the test's EventLoop log callback at test/mp/test/test.cpp:71. The mp#222 description notes that these errors "are suppressed in bitcoin CI". No issue or CI failure was filed for it.

Root cause

TestSetup, the fixture that runs a client and server for most mptest tests, created its EventLoop in test/mp/test/test.cpp with this log callback:

std::cout << "LOG" << (int)log_data.level << ": " << log_data.message << "\n";

libmultiprocess calls the log callback on whichever thread produces the message (the event loop thread, server worker threads, the main test thread), and the callback took no lock. libc++'s formatted output reads and resets the stream's width field without synchronization, which TSan flags once libc++ is instrumented. The C++ standard says concurrent use of the synchronized standard stream objects does not cause a data race, so this is arguably a libc++ shortcoming as much as a test bug. Bitcoin Core has suppressed race:std::__1::ios_base::* in its TSan suppressions since 2021 (bitcoin#23366), which is why its CI never showed this.

The callback used printf, which locks stdout, from the initial commit until c8351c5f0b70 ("refactor: Remove locale-dependent function calls", mp#156) replaced it with std::cout. From then on it was a race, but libmultiprocess's CI did not instrument the standard library and Bitcoin Core's CI suppressed the report.

The code

The test fixture TestSetup in test/mp/test/test.cpp, quoted from ca3c05d56709^, gives each test's EventLoop a log callback that writes to std::cout without a lock:

EventLoop loop("mptest", [](mp::LogMessage log_data) {
    std::cout << "LOG" << (int)log_data.level << ": " << log_data.message << "\n";
    if (log_data.level == mp::Log::Raise) throw std::runtime_error(log_data.message);
});

libmultiprocess calls the callback on whichever thread logs: the event-loop thread, server worker threads, or the main test thread when it makes a client call. When two threads log at once, both run libc++'s operator<< on std::cout, which reads and then resets the stream's width field without synchronization. With an instrumented libc++, TSan reports a read in ios_base::width() on one thread racing a write in ios_base::width(long) from the callback on the main thread.

The fix (ca3c05d56709) logs through KJ instead. The KJ_STRINGIFY(Log) function it adds to include/mp/proxy-io.h and src/mp/proxy.cpp, which lets KJ_LOG print the level, is omitted:

@@ -67,9 +66,10 @@ public:
     TestSetup(bool client_owns_connection = true)
         : thread{[&] {
-              EventLoop loop("mptest", [](mp::LogMessage log_data) {
-                  std::cout << "LOG" << (int)log_data.level << ": " << log_data.message << "\n";
-                  if (log_data.level == mp::Log::Raise) throw std::runtime_error(log_data.message);
+              EventLoop loop("mptest", [](mp::LogMessage log) {
+                  // Info logs are not printed by default, but will be shown with `mptest --verbose`
+                  KJ_LOG(INFO, log.level, log.message);
+                  if (log.level == mp::Log::Raise) throw std::runtime_error(log.message);
               });

KJ's logger builds each message as a local string and passes it to write() on stderr, so threads that log at the same time no longer share any std::ostream formatting state.

Fix

The fix commit ca3c05d56709 replaces the std::cout line with KJ_LOG(INFO, log.level, log.message), which is thread-safe and only printed with mptest --verbose. To make that compile it adds a KJ_STRINGIFY(Log) function in include/mp/proxy-io.h and src/mp/proxy.cpp (+20/-4 in total). @theuni had already noted that the std::cout line was a placeholder he had meant to revisit. He had ported that line to the new LogMessage callback signature in mp#220 ("Add log levels and advertise them to users via logging callback").

Timeline

Lessons

What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.

Pattern A contract changed and a caller was left behind: A refactor that dropped a guarantee

  • Prevented by a written spec or documentation, a review checklist item
  • Earliest detector ThreadSanitizer (did not exist then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids sanitizer output
  • Rust yes

Would Rust have prevented it? Yes. Stdout is Sync only because every println! and write takes its lock, so concurrent logging from several threads is serialized by construction; the race lived in libc++'s unlocked stream formatting state, which has no Rust counterpart. More on Rust

Could it have been prevented?

Yes, by stating the callback's contract where it is defined: the EventLoop log callback is invoked on whichever thread logs, so it must be safe to call concurrently. The original printf met that by accident (stdout is locked per call), and the 2025-02 locale refactor (c8351c5f0b70, mp#156) swapped it for std::cout << chains, which libc++ formats through unsynchronized stream state. A one-line doc comment on the callback type, or a review question "is the replacement as thread-safe as what it replaces?", catches a refactor that looks like a pure rewrite. theuni later ported the same line to the new LogMessage signature and called it a placeholder he meant to revisit, so the smell was noticed before the race was.

Could it have been found sooner?

Only by TSan with an instrumented libc++, and both halves were missing. libmultiprocess had no TSan job when the line changed (the job came in mp#186, June 2025), and that job ran against an uninstrumented standard library, which cannot see a race inside ios_base::width. Bitcoin Core's TSan job could have seen it but has suppressed race:std::__1::ios_base::* since 2021 (bitcoin#23366), because the standard promises concurrent std::cout use is not a data race and libc++ does not deliver that. The report appeared the moment ryanofsky built libc++ with TSan for mp#222, on the first run.

What made it hard to debug?

Nothing. Both TSan stacks end at the callback's line in test.cpp, and the fix (KJ_LOG, which builds each message locally and writes it in one call) followed in five days; the only extra work was a KJ_STRINGIFY(Log) so the level could be printed. The one judgement was whether to fix or suppress, since the race is strictly a libc++ shortcoming; fixing was cheaper and left the new instrumented-libc++ job clean for the production race it had been built to catch.

Lesson

A refactor that swaps one output or locking primitive for a look-alike can drop a guarantee nobody wrote down; document which callbacks may be called concurrently, and when a sanitizer job depends on an instrumented standard library, know that an uninstrumented one is silently blind to races inside it.

Classification

The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.

Field Value
Symptom data-race
Worst effect observed sanitizer-report-only
Seen in mptest
Scope test-code
Trigger Logging (logging)
Phase any
Side n/a
Component test-harness
Root cause kind missing-lock
Fix kind test-only
Detection tsan
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see yes
Confidence medium