Skip to content

Commit 6fe6dcd

Browse files
Fix Groot2 publisher thread safety and hook handling (#1165)
* Fix Groot2 publisher thread safety * Simplify Groot2 publisher thread-safety machinery Apply code-review cleanups to the thread-safety fix: - Extract the duplicated callback drain-gate (CallbackState/CallbackGuard and HookCallbackState/HookCallbackGuard were byte-identical) into a single shared BT::details::CallbackGate in utils/callback_gate.h. notify_all() now fires only when a drain is actually waiting. - Replace the 69-line injected hook lambda and its immediately-invoked inner lambda with PImpl::runHook(). The BREAKPOINT_REACHED message is now built and sent without holding hook->mutex, and only the ZMQ send holds publisher_mutex. - Drop the Hook::removed tombstone from the public protocol header: with the single lock order established (hooks_map_mutex before hook->mutex), enableAllHooks() and HOOKS_DUMP traverse the maps under hooks_map_mutex, so no stale snapshot can resurrect an erased hook and 'enabled = false' suffices for stale callback copies. - GET_TRANSITIONS: swap the transitions buffer out under status_mutex and serialize outside the lock the status callback contends on. - Call unsubscribeFromTreeChanges() in the destructors of the five other StatusChangeLogger subclasses; the base-class destructor call runs too late to protect derived state, so each dtor must drain callbacks first. - Deduplicate the hook-JSON literals in the Groot2 tests into Groot2Test::makeHook(); use make_unique for PImpl; rely on CTAD for lock guards in touched code. Verified: 495/495 tests (Release), 499/499 under TSan with the CI suppressions file, zero ThreadSanitizer warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extend TSan suppressions to statically linked ZeroMQ CI builds zmq from source with Conan and links it statically, so the module-based rule 'race:libzmq.so' does not match and zmq-internal false positives (encoder_base_t::encode, msg_t::close) fail the Groot2 hook tests. Match the zmq namespace instead, which works regardless of how the library is linked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move StatusChangeLogger state behind a PImpl StatusChangeLogger is the base class of a public extension point: every logger, including user-defined ones, embeds its layout. The thread-safety fix already changes that layout (and, being header-only, the old inlined implementation survives in consumer binaries even after a library upgrade), so take the opportunity to pay the ABI break only once: - all data members move into a PImpl defined in the new src/loggers/abstract_logger.cpp - the member functions are no longer inline, so future behavior fixes in this class can ship as an ABI-compatible library update Adding or changing state in StatusChangeLogger no longer affects the layout derived classes compile against. Note that changing its virtual interface would still break ABI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b45100f commit 6fe6dcd

15 files changed

Lines changed: 964 additions & 297 deletions

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ list(APPEND BT_SOURCE
185185
src/controls/try_catch_node.cpp
186186
src/controls/while_do_else_node.cpp
187187

188+
src/loggers/abstract_logger.cpp
188189
src/loggers/bt_cout_logger.cpp
189190
src/loggers/bt_file_logger_v2.cpp
190191
src/loggers/bt_minitrace_logger.cpp

‎include/behaviortree_cpp/loggers/abstract_logger.h‎

Lines changed: 21 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
#include "behaviortree_cpp/behavior_tree.h"
55
#include "behaviortree_cpp/bt_factory.h"
66

7+
#include <memory>
8+
79
namespace BT
810
{
911
enum class TimestampType
@@ -18,7 +20,7 @@ class StatusChangeLogger
1820
/// Construct and immediately subscribe to status changes.
1921
StatusChangeLogger(TreeNode* root_node);
2022

21-
virtual ~StatusChangeLogger() = default;
23+
virtual ~StatusChangeLogger();
2224

2325
StatusChangeLogger(const StatusChangeLogger& other) = delete;
2426
StatusChangeLogger& operator=(const StatusChangeLogger& other) = delete;
@@ -31,87 +33,38 @@ class StatusChangeLogger
3133

3234
virtual void flush() = 0;
3335

34-
void setEnabled(bool enabled)
35-
{
36-
enabled_ = enabled;
37-
}
36+
void setEnabled(bool enabled);
3837

39-
void setTimestampType(TimestampType type)
40-
{
41-
type_ = type;
42-
}
38+
void setTimestampType(TimestampType type);
4339

44-
bool enabled() const
45-
{
46-
return enabled_;
47-
}
40+
bool enabled() const;
4841

4942
// false by default.
50-
bool showsTransitionToIdle() const
51-
{
52-
return show_transition_to_idle_;
53-
}
43+
bool showsTransitionToIdle() const;
5444

55-
void enableTransitionToIdle(bool enable)
56-
{
57-
show_transition_to_idle_ = enable;
58-
}
45+
void enableTransitionToIdle(bool enable);
5946

6047
protected:
6148
/// Default constructor for deferred subscription. Call subscribeToTreeChanges() when ready.
62-
StatusChangeLogger() = default;
49+
StatusChangeLogger();
6350

6451
/// Subscribe to status changes. Call at end of constructor for deferred subscription.
6552
void subscribeToTreeChanges(TreeNode* root_node);
6653

54+
/// Stop new callbacks and wait until callbacks already in progress have completed.
55+
/// Derived destructors must call this before destroying state used by callback().
56+
void unsubscribeFromTreeChanges();
57+
6758
private:
68-
bool enabled_ = true;
69-
bool show_transition_to_idle_ = true;
70-
std::vector<TreeNode::StatusChangeSubscriber> subscribers_;
71-
TimestampType type_ = TimestampType::absolute;
72-
BT::TimePoint first_timestamp_ = {};
73-
std::mutex callback_mutex_;
59+
/// Forward a status transition to callback(), unless disabled.
60+
void handleStatusChange(TimePoint timestamp, const TreeNode& node, NodeStatus prev,
61+
NodeStatus status);
62+
63+
// All state lives behind this pointer, so that changing it does not alter the
64+
// layout that derived classes (including user-defined loggers) compile against.
65+
struct PImpl;
66+
std::unique_ptr<PImpl> _p;
7467
};
75-
76-
//--------------------------------------------
77-
78-
inline StatusChangeLogger::StatusChangeLogger(TreeNode* root_node)
79-
{
80-
subscribeToTreeChanges(root_node);
81-
}
82-
83-
inline void StatusChangeLogger::subscribeToTreeChanges(TreeNode* root_node)
84-
{
85-
first_timestamp_ = std::chrono::high_resolution_clock::now();
86-
87-
auto subscribeCallback = [this](TimePoint timestamp, const TreeNode& node,
88-
NodeStatus prev, NodeStatus status) {
89-
// Copy state under lock, then release before calling user code
90-
// This prevents recursive mutex locking when multiple nodes change status
91-
bool should_callback = false;
92-
Duration adjusted_timestamp;
93-
{
94-
std::unique_lock lk(callback_mutex_);
95-
if(enabled_ && (status != NodeStatus::IDLE || show_transition_to_idle_))
96-
{
97-
should_callback = true;
98-
adjusted_timestamp = (type_ == TimestampType::absolute) ?
99-
timestamp.time_since_epoch() :
100-
(timestamp - first_timestamp_);
101-
}
102-
}
103-
if(should_callback)
104-
{
105-
this->callback(adjusted_timestamp, node, prev, status);
106-
}
107-
};
108-
109-
auto visitor = [this, subscribeCallback](TreeNode* node) {
110-
subscribers_.push_back(node->subscribeToStatusChange(std::move(subscribeCallback)));
111-
};
112-
113-
applyRecursiveVisitor(root_node, visitor);
114-
}
11568
} // namespace BT
11669

11770
#endif // ABSTRACT_LOGGER_H
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#pragma once
2+
3+
#include <condition_variable>
4+
#include <memory>
5+
#include <mutex>
6+
7+
namespace BT::details
8+
{
9+
10+
/**
11+
* @brief Synchronization gate used to coordinate callbacks with object teardown.
12+
*
13+
* Callbacks enter the gate with tryStart() and must signal completion when done
14+
* (use Guard for RAII). During teardown, call close() to reject new callbacks
15+
* and closeAndDrain() to block until the callbacks still running have completed.
16+
*/
17+
class CallbackGate
18+
{
19+
public:
20+
using Ptr = std::shared_ptr<CallbackGate>;
21+
22+
/// RAII helper that signals the completion of a callback entered with tryStart()
23+
class Guard
24+
{
25+
public:
26+
explicit Guard(Ptr gate) : gate_(std::move(gate))
27+
{}
28+
29+
Guard(const Guard&) = delete;
30+
Guard& operator=(const Guard&) = delete;
31+
Guard(Guard&&) = delete;
32+
Guard& operator=(Guard&&) = delete;
33+
34+
~Guard()
35+
{
36+
gate_->completed();
37+
}
38+
39+
private:
40+
Ptr gate_;
41+
};
42+
43+
/// Returns false if the gate has been closed and the callback must not run.
44+
bool tryStart()
45+
{
46+
const std::lock_guard lk(mutex_);
47+
if(!accepting_callbacks_)
48+
{
49+
return false;
50+
}
51+
running_callbacks_++;
52+
return true;
53+
}
54+
55+
/// Reject new callbacks, without waiting for the running ones.
56+
void close()
57+
{
58+
const std::lock_guard lk(mutex_);
59+
accepting_callbacks_ = false;
60+
}
61+
62+
/// Reject new callbacks and wait until callbacks already running have completed.
63+
void closeAndDrain()
64+
{
65+
std::unique_lock lk(mutex_);
66+
accepting_callbacks_ = false;
67+
condition_variable_.wait(lk, [this] { return running_callbacks_ == 0; });
68+
}
69+
70+
private:
71+
void completed()
72+
{
73+
const std::lock_guard lk(mutex_);
74+
running_callbacks_--;
75+
if(running_callbacks_ == 0 && !accepting_callbacks_)
76+
{
77+
condition_variable_.notify_all();
78+
}
79+
}
80+
81+
std::mutex mutex_;
82+
std::condition_variable condition_variable_;
83+
bool accepting_callbacks_ = true;
84+
size_t running_callbacks_ = 0;
85+
};
86+
87+
} // namespace BT::details

‎src/loggers/abstract_logger.cpp‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#include "behaviortree_cpp/loggers/abstract_logger.h"
2+
3+
#include "behaviortree_cpp/utils/callback_gate.h"
4+
5+
#include <mutex>
6+
7+
namespace BT
8+
{
9+
10+
struct StatusChangeLogger::PImpl
11+
{
12+
bool enabled = true;
13+
bool show_transition_to_idle = true;
14+
std::vector<TreeNode::StatusChangeSubscriber> subscribers;
15+
TimestampType type = TimestampType::absolute;
16+
BT::TimePoint first_timestamp = {};
17+
std::mutex callback_mutex;
18+
details::CallbackGate::Ptr callback_gate = std::make_shared<details::CallbackGate>();
19+
};
20+
21+
StatusChangeLogger::StatusChangeLogger() : _p(std::make_unique<PImpl>())
22+
{}
23+
24+
StatusChangeLogger::StatusChangeLogger(TreeNode* root_node) : StatusChangeLogger()
25+
{
26+
subscribeToTreeChanges(root_node);
27+
}
28+
29+
StatusChangeLogger::~StatusChangeLogger()
30+
{
31+
unsubscribeFromTreeChanges();
32+
}
33+
34+
void StatusChangeLogger::setEnabled(bool enabled)
35+
{
36+
const std::lock_guard lk(_p->callback_mutex);
37+
_p->enabled = enabled;
38+
}
39+
40+
void StatusChangeLogger::setTimestampType(TimestampType type)
41+
{
42+
const std::lock_guard lk(_p->callback_mutex);
43+
_p->type = type;
44+
}
45+
46+
bool StatusChangeLogger::enabled() const
47+
{
48+
const std::lock_guard lk(_p->callback_mutex);
49+
return _p->enabled;
50+
}
51+
52+
bool StatusChangeLogger::showsTransitionToIdle() const
53+
{
54+
const std::lock_guard lk(_p->callback_mutex);
55+
return _p->show_transition_to_idle;
56+
}
57+
58+
void StatusChangeLogger::enableTransitionToIdle(bool enable)
59+
{
60+
const std::lock_guard lk(_p->callback_mutex);
61+
_p->show_transition_to_idle = enable;
62+
}
63+
64+
void StatusChangeLogger::subscribeToTreeChanges(TreeNode* root_node)
65+
{
66+
_p->first_timestamp = std::chrono::high_resolution_clock::now();
67+
68+
auto subscribeCallback =
69+
[this, gate = _p->callback_gate](TimePoint timestamp, const TreeNode& node,
70+
NodeStatus prev, NodeStatus status) {
71+
if(!gate->tryStart())
72+
{
73+
return;
74+
}
75+
const details::CallbackGate::Guard callback_guard(gate);
76+
handleStatusChange(timestamp, node, prev, status);
77+
};
78+
79+
auto visitor = [this, subscribeCallback](TreeNode* node) {
80+
_p->subscribers.push_back(
81+
node->subscribeToStatusChange(std::move(subscribeCallback)));
82+
};
83+
84+
applyRecursiveVisitor(root_node, visitor);
85+
}
86+
87+
void StatusChangeLogger::handleStatusChange(TimePoint timestamp, const TreeNode& node,
88+
NodeStatus prev, NodeStatus status)
89+
{
90+
// Copy state under lock, then release before calling user code
91+
// This prevents recursive mutex locking when multiple nodes change status
92+
bool should_callback = false;
93+
Duration adjusted_timestamp;
94+
{
95+
std::unique_lock lk(_p->callback_mutex);
96+
if(_p->enabled && (status != NodeStatus::IDLE || _p->show_transition_to_idle))
97+
{
98+
should_callback = true;
99+
adjusted_timestamp = (_p->type == TimestampType::absolute) ?
100+
timestamp.time_since_epoch() :
101+
(timestamp - _p->first_timestamp);
102+
}
103+
}
104+
105+
if(should_callback)
106+
{
107+
this->callback(adjusted_timestamp, node, prev, status);
108+
}
109+
}
110+
111+
void StatusChangeLogger::unsubscribeFromTreeChanges()
112+
{
113+
_p->callback_gate->closeAndDrain();
114+
_p->subscribers.clear();
115+
}
116+
117+
} // namespace BT

‎src/loggers/bt_cout_logger.cpp‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ namespace BT
55

66
StdCoutLogger::StdCoutLogger(const BT::Tree& tree) : StatusChangeLogger(tree.rootNode())
77
{}
8-
StdCoutLogger::~StdCoutLogger() = default;
8+
StdCoutLogger::~StdCoutLogger()
9+
{
10+
// Stop status callbacks while the derived object is still intact.
11+
unsubscribeFromTreeChanges();
12+
}
913

1014
void StdCoutLogger::callback(Duration timestamp, const TreeNode& node,
1115
NodeStatus prev_status, NodeStatus status)

‎src/loggers/bt_file_logger_v2.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ FileLogger2::FileLogger2(const BT::Tree& tree, std::filesystem::path const& file
9494

9595
FileLogger2::~FileLogger2()
9696
{
97+
// Stop status callbacks before tearing down the state they use.
98+
unsubscribeFromTreeChanges();
99+
97100
_p->loop = false;
98101
_p->queue_cv.notify_one();
99102
_p->writer_thread.join();

‎src/loggers/bt_minitrace_logger.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ MinitraceLogger::MinitraceLogger(const Tree& tree, const char* filename_json)
1717

1818
MinitraceLogger::~MinitraceLogger()
1919
{
20+
// Stop status callbacks before shutting down minitrace.
21+
unsubscribeFromTreeChanges();
22+
2023
mtr_flush();
2124
mtr_shutdown();
2225
}

‎src/loggers/bt_observer.cpp‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ TreeObserver::TreeObserver(const BT::Tree& tree) : StatusChangeLogger(tree.rootN
4545
}
4646

4747
TreeObserver::~TreeObserver()
48-
{}
48+
{
49+
// Stop status callbacks before the statistics map is destroyed.
50+
unsubscribeFromTreeChanges();
51+
}
4952

5053
void TreeObserver::callback(Duration timestamp, const TreeNode& node,
5154
NodeStatus /*prev_status*/, NodeStatus status)

‎src/loggers/bt_sqlite_logger.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ SqliteLogger::SqliteLogger(const Tree& tree, std::filesystem::path const& filepa
140140

141141
SqliteLogger::~SqliteLogger()
142142
{
143+
// Stop status callbacks before tearing down the state they use.
144+
unsubscribeFromTreeChanges();
145+
143146
try
144147
{
145148
loop_ = false;

0 commit comments

Comments
 (0)