Skip to content

Commit cb93feb

Browse files
facontidavideclaude
andcommitted
Fix Blackboard thread-safety bugs and XML null pointer dereference
Fix 8 bugs found via code analysis and verified with ThreadSanitizer: Blackboard thread-safety (6 fixes): - BUG-1/8: set() new-entry path wrote value/sequence_id/stamp without entry_mutex after createEntryImpl — add scoped_lock on entry_mutex - BUG-2: set() existing-entry path held raw reference after unlocking storage_mutex_, risking use-after-free if concurrent unset() erases the entry — copy shared_ptr before unlocking - BUG-3: cloneInto() read/wrote entry members without entry_mutex — add scoped_lock on src and dst entry mutexes - BUG-4: ImportBlackboardFromJSON wrote entry->value without any lock — add scoped_lock on entry_mutex - BUG-5: debugMessage() iterated storage_ without storage_mutex_ — add unique_lock - BUG-6: getKeys() iterated storage_ without storage_mutex_ — add unique_lock XML parser (2 fixes): - BUG-7: loadSubtreeModel crashed on null subtree_id when <SubTree> in <TreeNodesModel> was missing ID attribute — add null check with descriptive RuntimeError - BUG-10: Variable name shadowing in loadSubtreeModel inner loop — rename to port_name All fixes include regression tests (TSan-verified for thread-safety bugs). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fee8964 commit cb93feb

File tree

12 files changed

+1086
-8
lines changed

12 files changed

+1086
-8
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ list(APPEND BT_SOURCE
180180
src/controls/sequence_node.cpp
181181
src/controls/sequence_with_memory_node.cpp
182182
src/controls/switch_node.cpp
183+
src/controls/try_catch_node.cpp
183184
src/controls/while_do_else_node.cpp
184185

185186
src/loggers/bt_cout_logger.cpp

include/behaviortree_cpp/behavior_tree.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include "behaviortree_cpp/controls/sequence_node.h"
3434
#include "behaviortree_cpp/controls/sequence_with_memory_node.h"
3535
#include "behaviortree_cpp/controls/switch_node.h"
36+
#include "behaviortree_cpp/controls/try_catch_node.h"
3637
#include "behaviortree_cpp/controls/while_do_else_node.h"
3738
#include "behaviortree_cpp/decorators/delay_node.h"
3839
#include "behaviortree_cpp/decorators/force_failure_node.h"

include/behaviortree_cpp/blackboard.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,10 @@ inline void Blackboard::set(const std::string& key, const T& value)
300300
GetAnyFromStringFunctor<T>());
301301
entry = createEntryImpl(key, new_port);
302302
}
303-
storage_lock.lock();
304303

304+
// Lock entry_mutex before writing to prevent data races with
305+
// concurrent readers (BUG-1/BUG-8 fix).
306+
std::scoped_lock entry_lock(entry->entry_mutex);
305307
entry->value = new_value;
306308
entry->sequence_id++;
307309
entry->stamp = std::chrono::steady_clock::now().time_since_epoch();
@@ -310,8 +312,11 @@ inline void Blackboard::set(const std::string& key, const T& value)
310312
{
311313
// this is not the first time we set this entry, we need to check
312314
// if the type is the same or not.
313-
Entry& entry = *it->second;
315+
// Copy shared_ptr to prevent use-after-free if another thread
316+
// calls unset() while we hold the reference (BUG-2 fix).
317+
auto entry_ptr = it->second;
314318
storage_lock.unlock();
319+
Entry& entry = *entry_ptr;
315320

316321
std::scoped_lock scoped_lock(entry.entry_mutex);
317322

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* Copyright (C) 2018-2025 Davide Faconti, Eurecat - All Rights Reserved
2+
*
3+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
4+
* to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
5+
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7+
*
8+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
9+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
10+
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
11+
*/
12+
13+
#pragma once
14+
15+
#include "behaviortree_cpp/control_node.h"
16+
17+
namespace BT
18+
{
19+
/**
20+
* @brief The TryCatch node executes children 1..N-1 as a Sequence ("try" block).
21+
*
22+
* If all children in the try-block succeed, this node returns SUCCESS.
23+
*
24+
* If any child in the try-block fails, the last child N is executed as a
25+
* "catch" (cleanup) action, and this node returns FAILURE regardless of
26+
* the catch child's result.
27+
*
28+
* - If a try-child returns RUNNING, this node returns RUNNING.
29+
* - If a try-child returns SUCCESS, continue to the next try-child.
30+
* - If a try-child returns FAILURE, enter catch mode and tick the last child.
31+
* - If the catch child returns RUNNING, this node returns RUNNING.
32+
* - When the catch child finishes (SUCCESS or FAILURE), this node returns FAILURE.
33+
* - SKIPPED try-children are skipped over (not treated as failure).
34+
*
35+
* Port "catch_on_halt" (default false): if true, the catch child is also
36+
* executed when the TryCatch node is halted while the try-block is RUNNING.
37+
*
38+
* Requires at least 2 children.
39+
*/
40+
class TryCatchNode : public ControlNode
41+
{
42+
public:
43+
TryCatchNode(const std::string& name, const NodeConfig& config);
44+
45+
~TryCatchNode() override = default;
46+
47+
TryCatchNode(const TryCatchNode&) = delete;
48+
TryCatchNode& operator=(const TryCatchNode&) = delete;
49+
TryCatchNode(TryCatchNode&&) = delete;
50+
TryCatchNode& operator=(TryCatchNode&&) = delete;
51+
52+
static PortsList providedPorts()
53+
{
54+
return { InputPort<bool>("catch_on_halt", false,
55+
"If true, execute the catch child when "
56+
"the node is halted during the try-block") };
57+
}
58+
59+
virtual void halt() override;
60+
61+
private:
62+
size_t current_child_idx_;
63+
size_t skipped_count_;
64+
bool in_catch_;
65+
66+
virtual BT::NodeStatus tick() override;
67+
};
68+
69+
} // namespace BT

src/blackboard.cpp

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ void Blackboard::addSubtreeRemapping(StringView internal, StringView external)
103103

104104
void Blackboard::debugMessage() const
105105
{
106+
// Lock storage_mutex_ to prevent iterator invalidation from
107+
// concurrent modifications (BUG-5 fix).
108+
const std::unique_lock<std::mutex> storage_lock(storage_mutex_);
106109
for(const auto& [key, entry] : storage_)
107110
{
108111
auto port_type = entry->info.type();
@@ -136,6 +139,9 @@ void Blackboard::debugMessage() const
136139

137140
std::vector<StringView> Blackboard::getKeys() const
138141
{
142+
// Lock storage_mutex_ to prevent iterator invalidation and
143+
// dangling StringView from concurrent modifications (BUG-6 fix).
144+
const std::unique_lock<std::mutex> storage_lock(storage_mutex_);
139145
if(storage_.empty())
140146
{
141147
return {};
@@ -200,8 +206,10 @@ void Blackboard::cloneInto(Blackboard& dst) const
200206
auto it = dst_storage.find(src_key);
201207
if(it != dst_storage.end())
202208
{
203-
// overwrite
209+
// overwrite — lock both entry mutexes to prevent data races
210+
// with concurrent readers that hold shared_ptr<Entry> (BUG-3 fix).
204211
auto& dst_entry = it->second;
212+
std::scoped_lock entry_locks(src_entry->entry_mutex, dst_entry->entry_mutex);
205213
dst_entry->string_converter = src_entry->string_converter;
206214
dst_entry->value = src_entry->value;
207215
dst_entry->info = src_entry->info;
@@ -210,7 +218,8 @@ void Blackboard::cloneInto(Blackboard& dst) const
210218
}
211219
else
212220
{
213-
// create new
221+
// create new — lock src entry to read its members safely
222+
std::scoped_lock src_lock(src_entry->entry_mutex);
214223
auto new_entry = std::make_shared<Entry>(src_entry->info);
215224
new_entry->value = src_entry->value;
216225
new_entry->string_converter = src_entry->string_converter;
@@ -317,6 +326,8 @@ void ImportBlackboardFromJSON(const nlohmann::json& json, Blackboard& blackboard
317326
blackboard.createEntry(it.key(), res->second);
318327
entry = blackboard.getEntry(it.key());
319328
}
329+
// Lock entry_mutex before writing to prevent data races (BUG-4 fix).
330+
std::scoped_lock lk(entry->entry_mutex);
320331
entry->value = res->first;
321332
}
322333
}

src/bt_factory.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ BehaviorTreeFactory::BehaviorTreeFactory() : _p(new PImpl)
131131
registerNodeType<ReactiveFallback>("ReactiveFallback");
132132
registerNodeType<IfThenElseNode>("IfThenElse");
133133
registerNodeType<WhileDoElseNode>("WhileDoElse");
134+
registerNodeType<TryCatchNode>("TryCatch");
134135

135136
registerNodeType<InverterNode>("Inverter");
136137

src/controls/try_catch_node.cpp

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/* Copyright (C) 2018-2025 Davide Faconti, Eurecat - All Rights Reserved
2+
*
3+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
4+
* to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
5+
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7+
*
8+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
9+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
10+
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
11+
*/
12+
13+
#include "behaviortree_cpp/controls/try_catch_node.h"
14+
15+
namespace BT
16+
{
17+
TryCatchNode::TryCatchNode(const std::string& name, const NodeConfig& config)
18+
: ControlNode::ControlNode(name, config)
19+
, current_child_idx_(0)
20+
, skipped_count_(0)
21+
, in_catch_(false)
22+
{
23+
setRegistrationID("TryCatch");
24+
}
25+
26+
void TryCatchNode::halt()
27+
{
28+
bool catch_on_halt = false;
29+
getInput("catch_on_halt", catch_on_halt);
30+
31+
// If catch_on_halt is enabled and we were in the try-block (not already in catch),
32+
// execute the catch child synchronously before halting.
33+
if(catch_on_halt && !in_catch_ && isStatusActive(status()) &&
34+
children_nodes_.size() >= 2)
35+
{
36+
// Halt all try-block children first
37+
for(size_t i = 0; i < children_nodes_.size() - 1; i++)
38+
{
39+
haltChild(i);
40+
}
41+
42+
// Tick the catch child. If it returns RUNNING, halt it too
43+
// (best-effort cleanup during halt).
44+
TreeNode* catch_child = children_nodes_.back();
45+
const NodeStatus catch_status = catch_child->executeTick();
46+
if(catch_status == NodeStatus::RUNNING)
47+
{
48+
haltChild(children_nodes_.size() - 1);
49+
}
50+
}
51+
52+
current_child_idx_ = 0;
53+
skipped_count_ = 0;
54+
in_catch_ = false;
55+
ControlNode::halt();
56+
}
57+
58+
NodeStatus TryCatchNode::tick()
59+
{
60+
const size_t children_count = children_nodes_.size();
61+
62+
if(children_count < 2)
63+
{
64+
throw LogicError("[", name(), "]: TryCatch requires at least 2 children");
65+
}
66+
67+
if(!isStatusActive(status()))
68+
{
69+
skipped_count_ = 0;
70+
in_catch_ = false;
71+
}
72+
73+
setStatus(NodeStatus::RUNNING);
74+
75+
const size_t try_count = children_count - 1;
76+
77+
// If we are in catch mode, tick the last child (cleanup)
78+
if(in_catch_)
79+
{
80+
TreeNode* catch_child = children_nodes_.back();
81+
const NodeStatus catch_status = catch_child->executeTick();
82+
83+
if(catch_status == NodeStatus::RUNNING)
84+
{
85+
return NodeStatus::RUNNING;
86+
}
87+
88+
// Catch child finished (SUCCESS or FAILURE): return FAILURE
89+
resetChildren();
90+
current_child_idx_ = 0;
91+
in_catch_ = false;
92+
return NodeStatus::FAILURE;
93+
}
94+
95+
// Try-block: execute children 0..N-2 as a Sequence
96+
while(current_child_idx_ < try_count)
97+
{
98+
TreeNode* current_child_node = children_nodes_[current_child_idx_];
99+
const NodeStatus child_status = current_child_node->executeTick();
100+
101+
switch(child_status)
102+
{
103+
case NodeStatus::RUNNING: {
104+
return NodeStatus::RUNNING;
105+
}
106+
case NodeStatus::FAILURE: {
107+
// Enter catch mode: halt try-block children, then tick catch child
108+
resetChildren();
109+
current_child_idx_ = 0;
110+
in_catch_ = true;
111+
return tick(); // re-enter to tick the catch child
112+
}
113+
case NodeStatus::SUCCESS: {
114+
current_child_idx_++;
115+
}
116+
break;
117+
case NodeStatus::SKIPPED: {
118+
current_child_idx_++;
119+
skipped_count_++;
120+
}
121+
break;
122+
case NodeStatus::IDLE: {
123+
throw LogicError("[", name(), "]: A child should not return IDLE");
124+
}
125+
}
126+
}
127+
128+
// All try-children completed successfully (or were skipped)
129+
const bool all_skipped = (skipped_count_ == try_count);
130+
resetChildren();
131+
current_child_idx_ = 0;
132+
skipped_count_ = 0;
133+
134+
return all_skipped ? NodeStatus::SKIPPED : NodeStatus::SUCCESS;
135+
}
136+
137+
} // namespace BT

src/xml_parsing.cpp

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@ void BT::XMLParser::PImpl::loadSubtreeModel(const XMLElement* xml_root)
293293
sub_node = sub_node->NextSiblingElement("SubTree"))
294294
{
295295
auto subtree_id = sub_node->Attribute("ID");
296+
if(subtree_id == nullptr)
297+
{
298+
throw RuntimeError("Missing attribute 'ID' in SubTree element "
299+
"within TreeNodesModel");
300+
}
296301
auto& subtree_model = subtree_models[subtree_id];
297302

298303
const std::pair<const char*, BT::PortDirection> port_types[3] = {
@@ -307,13 +312,13 @@ void BT::XMLParser::PImpl::loadSubtreeModel(const XMLElement* xml_root)
307312
port_node = port_node->NextSiblingElement(name))
308313
{
309314
BT::PortInfo port(direction);
310-
auto name = port_node->Attribute("name");
311-
if(name == nullptr)
315+
auto port_name = port_node->Attribute("name");
316+
if(port_name == nullptr)
312317
{
313318
throw RuntimeError("Missing attribute [name] in port (SubTree model)");
314319
}
315320
// Validate port name
316-
validatePortName(name, port_node->GetLineNum());
321+
validatePortName(port_name, port_node->GetLineNum());
317322
if(auto default_value = port_node->Attribute("default"))
318323
{
319324
port.setDefaultValue(default_value);
@@ -322,7 +327,7 @@ void BT::XMLParser::PImpl::loadSubtreeModel(const XMLElement* xml_root)
322327
{
323328
port.setDescription(description);
324329
}
325-
subtree_model.ports[name] = std::move(port);
330+
subtree_model.ports[port_name] = std::move(port);
326331
}
327332
}
328333
}
@@ -627,6 +632,11 @@ void VerifyXML(const std::string& xml_text,
627632
ThrowError(line_number, std::string("The node '") + registered_name +
628633
"' must have 1 or more children");
629634
}
635+
if(registered_name == "TryCatch" && children_count < 2)
636+
{
637+
ThrowError(line_number, std::string("The node 'TryCatch' must have "
638+
"at least 2 children"));
639+
}
630640
if(registered_name == "ReactiveSequence")
631641
{
632642
size_t async_count = 0;

tests/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ set(BT_TESTS
4646
gtest_subtree.cpp
4747
gtest_switch.cpp
4848
gtest_tree.cpp
49+
gtest_try_catch.cpp
4950
gtest_exception_tracking.cpp
5051
gtest_updates.cpp
5152
gtest_wakeup.cpp
@@ -54,6 +55,8 @@ set(BT_TESTS
5455
gtest_simple_string.cpp
5556
gtest_polymorphic_ports.cpp
5657
gtest_plugin_issue953.cpp
58+
gtest_blackboard_thread_safety.cpp
59+
gtest_xml_null_subtree_id.cpp
5760

5861
script_parser_test.cpp
5962
test_helper.hpp

0 commit comments

Comments
 (0)