Skip to content

Commit cadda8f

Browse files
facontidavideclaude
andcommitted
refactor(StatusChangeLogger): add deferred subscription for thread safety
Add support for deferred subscription in StatusChangeLogger to fix race conditions in loggers with worker threads (FileLogger2, SqliteLogger). Changes to StatusChangeLogger: - Add protected default constructor for deferred subscription mode - Add protected subscribeToTreeChanges() method - Derived classes can now complete initialization before receiving callbacks Changes to FileLogger2: - Remove FileLogger2PImplBase base-from-member idiom hack - Use simple Pimpl pattern with deferred subscription - Subscribe to tree changes AFTER writer thread is started - Remove ready flag and related synchronization complexity Changes to SqliteLogger: - Use deferred subscription to fix same race condition Remove all FileLogger2 TSAN suppressions - no longer needed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 195136d commit cadda8f

5 files changed

Lines changed: 57 additions & 81 deletions

File tree

include/behaviortree_cpp/loggers/abstract_logger.h

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ enum class TimestampType
1515
class StatusChangeLogger
1616
{
1717
public:
18+
/**
19+
* @brief Construct and immediately subscribe to status changes.
20+
* Use this for simple loggers that don't need deferred subscription.
21+
*/
1822
StatusChangeLogger(TreeNode* root_node);
23+
1924
virtual ~StatusChangeLogger() = default;
2025

2126
StatusChangeLogger(const StatusChangeLogger& other) = delete;
2227
StatusChangeLogger& operator=(const StatusChangeLogger& other) = delete;
2328

24-
StatusChangeLogger(StatusChangeLogger&& other) = default;
25-
StatusChangeLogger& operator=(StatusChangeLogger&& other) = default;
29+
StatusChangeLogger(StatusChangeLogger&& other) = delete;
30+
StatusChangeLogger& operator=(StatusChangeLogger&& other) = delete;
2631

2732
virtual void callback(BT::Duration timestamp, const TreeNode& node,
2833
NodeStatus prev_status, NodeStatus status) = 0;
@@ -55,6 +60,25 @@ class StatusChangeLogger
5560
show_transition_to_idle_ = enable;
5661
}
5762

63+
protected:
64+
/**
65+
* @brief Default constructor for deferred subscription mode.
66+
*
67+
* Use this when the derived class needs to complete initialization
68+
* (e.g., start worker threads) before receiving callbacks.
69+
* Call subscribeToTreeChanges() when ready to receive callbacks.
70+
*/
71+
StatusChangeLogger() = default;
72+
73+
/**
74+
* @brief Subscribe to status changes on the tree.
75+
*
76+
* For loggers with threading or complex initialization, call this
77+
* at the END of your constructor after all setup is complete.
78+
* This ensures callbacks won't fire until you're ready to handle them.
79+
*/
80+
void subscribeToTreeChanges(TreeNode* root_node);
81+
5882
private:
5983
bool enabled_ = true;
6084
bool show_transition_to_idle_ = true;
@@ -67,6 +91,11 @@ class StatusChangeLogger
6791
//--------------------------------------------
6892

6993
inline StatusChangeLogger::StatusChangeLogger(TreeNode* root_node)
94+
{
95+
subscribeToTreeChanges(root_node);
96+
}
97+
98+
inline void StatusChangeLogger::subscribeToTreeChanges(TreeNode* root_node)
7099
{
71100
first_timestamp_ = std::chrono::high_resolution_clock::now();
72101

include/behaviortree_cpp/loggers/bt_file_logger_v2.h

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
11
#pragma once
22
#include "behaviortree_cpp/loggers/abstract_logger.h"
33

4-
#include <array>
5-
#include <deque>
64
#include <filesystem>
7-
#include <fstream>
5+
#include <memory>
86

97
namespace BT
108
{
119

12-
// Forward declaration for base-from-member idiom
13-
namespace detail
14-
{
15-
struct FileLogger2Private;
16-
17-
// This base class ensures _p is initialized BEFORE StatusChangeLogger,
18-
// which subscribes to callbacks in its constructor. Without this,
19-
// callbacks could fire before _p exists (C++ initializes bases before members).
20-
struct FileLogger2PImplBase
21-
{
22-
std::unique_ptr<FileLogger2Private> _p;
23-
FileLogger2PImplBase();
24-
~FileLogger2PImplBase();
25-
FileLogger2PImplBase(const FileLogger2PImplBase&) = delete;
26-
FileLogger2PImplBase& operator=(const FileLogger2PImplBase&) = delete;
27-
FileLogger2PImplBase(FileLogger2PImplBase&&) = default;
28-
FileLogger2PImplBase& operator=(FileLogger2PImplBase&&) = default;
29-
};
30-
} // namespace detail
31-
3210
/**
3311
* @brief The FileLogger2 is a logger that saves the tree as
3412
* XML and all the transitions.
@@ -42,7 +20,7 @@ struct FileLogger2PImplBase
4220
* - next: each 9 bytes is a FileLogger2::Transition. See definition.
4321
*
4422
*/
45-
class FileLogger2 : private detail::FileLogger2PImplBase, public StatusChangeLogger
23+
class FileLogger2 : public StatusChangeLogger
4624
{
4725
public:
4826
/**
@@ -79,6 +57,9 @@ class FileLogger2 : private detail::FileLogger2PImplBase, public StatusChangeLog
7957
void flush() override;
8058

8159
private:
60+
struct Pimpl;
61+
std::unique_ptr<Pimpl> _p;
62+
8263
void writerLoop();
8364
};
8465

src/loggers/bt_file_logger_v2.cpp

Lines changed: 16 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
#include "behaviortree_cpp/xml_parsing.h"
44

5+
#include <array>
6+
#include <condition_variable>
7+
#include <cstring>
8+
#include <deque>
9+
#include <fstream>
10+
#include <mutex>
11+
#include <thread>
12+
513
#include "flatbuffers/base.h"
614

715
namespace BT
@@ -16,7 +24,7 @@ int64_t ToUsec(Duration ts)
1624
} // namespace
1725

1826
// Define the private implementation struct
19-
struct detail::FileLogger2Private
27+
struct FileLogger2::Pimpl
2028
{
2129
std::ofstream file_stream;
2230
std::mutex file_mutex; // Protects file_stream access from multiple threads
@@ -29,32 +37,12 @@ struct detail::FileLogger2Private
2937

3038
std::thread writer_thread;
3139
std::atomic_bool loop = true;
32-
33-
// Atomic flag for thread startup - callbacks check this before accessing queue
34-
std::atomic_bool ready = false;
3540
};
3641

37-
// Base class constructor - initializes _p before StatusChangeLogger
38-
detail::FileLogger2PImplBase::FileLogger2PImplBase()
39-
: _p(std::make_unique<FileLogger2Private>())
40-
{
41-
// Acquire mutex once to establish happens-before relationship for TSAN.
42-
// This must happen before StatusChangeLogger's constructor subscribes to
43-
// callbacks that may access the mutex from other threads.
44-
std::scoped_lock lock(_p->queue_mutex);
45-
}
46-
47-
// Destructor must be defined where FileLogger2Private is complete
48-
detail::FileLogger2PImplBase::~FileLogger2PImplBase() = default;
49-
5042
FileLogger2::FileLogger2(const BT::Tree& tree, std::filesystem::path const& filepath)
51-
: StatusChangeLogger(tree.rootNode())
43+
: StatusChangeLogger() // Deferred subscription - don't subscribe yet
44+
, _p(std::make_unique<Pimpl>())
5245
{
53-
// Note: _p is already initialized by FileLogger2PImplBase before
54-
// StatusChangeLogger's constructor runs. This is critical because
55-
// StatusChangeLogger subscribes to callbacks in its constructor,
56-
// and those callbacks may access _p.
57-
5846
if(filepath.filename().extension() != ".btlog")
5947
{
6048
throw RuntimeError("FileLogger2: the file extension must be [.btlog]");
@@ -93,13 +81,12 @@ FileLogger2::FileLogger2(const BT::Tree& tree, std::filesystem::path const& file
9381
flatbuffers::WriteScalar(write_buffer.data(), timestamp_usec);
9482
_p->file_stream.write(write_buffer.data(), 8);
9583

84+
// Start the writer thread
9685
_p->writer_thread = std::thread(&FileLogger2::writerLoop, this);
9786

98-
// Wait for writer thread to signal it's ready
99-
while(!_p->ready.load(std::memory_order_acquire))
100-
{
101-
std::this_thread::yield();
102-
}
87+
// NOW subscribe to tree changes - after everything is initialized and
88+
// the writer thread is running. This ensures callbacks are safe to handle.
89+
subscribeToTreeChanges(tree.rootNode());
10390
}
10491

10592
FileLogger2::~FileLogger2()
@@ -113,13 +100,6 @@ FileLogger2::~FileLogger2()
113100
void FileLogger2::callback(Duration timestamp, const TreeNode& node,
114101
NodeStatus /*prev_status*/, NodeStatus status)
115102
{
116-
// Ignore callbacks that arrive before the writer thread is ready.
117-
// This can happen during StatusChangeLogger construction.
118-
if(!_p->ready.load(std::memory_order_acquire))
119-
{
120-
return;
121-
}
122-
123103
Transition trans{};
124104
trans.timestamp_usec = uint64_t(ToUsec(timestamp - _p->first_timestamp));
125105
trans.node_uid = node.UID();
@@ -142,21 +122,13 @@ void FileLogger2::writerLoop()
142122
// local buffer in this thread
143123
std::deque<Transition> transitions;
144124

145-
// Signal that the writer thread is ready to accept callbacks.
146-
// IMPORTANT: This must happen AFTER acquiring the mutex for the first time,
147-
// to establish proper synchronization with the callback thread.
148-
{
149-
std::scoped_lock lock(_p->queue_mutex);
150-
_p->ready.store(true, std::memory_order_release);
151-
}
152-
153125
while(_p->loop)
154126
{
155127
transitions.clear();
156128
{
157129
std::unique_lock lock(_p->queue_mutex);
158130
_p->queue_cv.wait_for(lock, std::chrono::milliseconds(10), [this]() {
159-
return !_p->transitions_queue.empty() && _p->loop;
131+
return !_p->transitions_queue.empty() || !_p->loop;
160132
});
161133
// simple way to pop all the transitions from _p->transitions_queue into transitions
162134
std::swap(transitions, _p->transitions_queue);

src/loggers/bt_sqlite_logger.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ void execStatement(sqlite3_stmt* stmt)
5757

5858
SqliteLogger::SqliteLogger(const Tree& tree, std::filesystem::path const& filepath,
5959
bool append)
60-
: StatusChangeLogger(tree.rootNode())
60+
: StatusChangeLogger() // Deferred subscription - don't subscribe yet
6161
{
6262
const auto extension = filepath.filename().extension();
6363
if(extension != ".db3" && extension != ".btdb")
@@ -129,6 +129,10 @@ SqliteLogger::SqliteLogger(const Tree& tree, std::filesystem::path const& filepa
129129
}
130130

131131
writer_thread_ = std::thread(&SqliteLogger::writerLoop, this);
132+
133+
// NOW subscribe to tree changes - after everything is initialized and
134+
// the writer thread is running. This ensures callbacks are safe to handle.
135+
subscribeToTreeChanges(tree.rootNode());
132136
}
133137

134138
SqliteLogger::~SqliteLogger()

tests/tsan_suppressions.txt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,3 @@
22

33
# ZeroMQ false positives
44
race:zmq::epoll_t::add_fd
5-
6-
# FileLogger2 false positive: TSAN doesn't properly track happens-before
7-
# relationship between mutex creation in make_unique and first cross-thread
8-
# access, even though std::thread::thread provides the synchronization.
9-
# The mutex is properly protected: base class constructor acquires/releases it
10-
# before StatusChangeLogger subscribes to callbacks, and writer thread acquires
11-
# it before setting ready flag.
12-
mutex:BT::FileLogger2
13-
race:BT::FileLogger2::callback
14-
race:BT::FileLogger2::writerLoop

0 commit comments

Comments
 (0)