Skip to content

Commit 664d4b2

Browse files
Add exception tracking with node backtrace (fixes #990) (#1106)
When an exception is thrown during tick(), it is now wrapped in a NodeExecutionError that includes: - The failing node's name, path, and registration name - Full tick backtrace from root to the failing node - Original exception message This helps users debug which node caused an exception during tree execution, addressing the feature request in issue #990. Example output: Exception in node '/MainTree/Seq/MyAction': Connection timeout Tick backtrace: /MainTree (RootNode) /MainTree/Seq (Sequence) -> /MainTree/Seq/MyAction (MyCustomAction) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b9bc25c commit 664d4b2

File tree

7 files changed

+356
-6
lines changed

7 files changed

+356
-6
lines changed

include/behaviortree_cpp/exceptions.h

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
#include <stdexcept>
1818
#include <string>
19+
#include <vector>
1920

2021
#include "utils/strcat.hpp"
2122

@@ -67,6 +68,63 @@ class RuntimeError : public BehaviorTreeException
6768
{}
6869
};
6970

71+
/// Information about a node in the tick backtrace.
72+
struct TickBacktraceEntry
73+
{
74+
std::string node_name;
75+
std::string node_path;
76+
std::string registration_name;
77+
};
78+
79+
/// Exception thrown when a node's tick() method throws an exception.
80+
/// Contains the originating node and full tick backtrace showing the path through the tree.
81+
class NodeExecutionError : public RuntimeError
82+
{
83+
public:
84+
NodeExecutionError(std::vector<TickBacktraceEntry> backtrace,
85+
const std::string& original_message)
86+
: RuntimeError(formatMessage(backtrace, original_message))
87+
, backtrace_(std::move(backtrace))
88+
, original_message_(original_message)
89+
{}
90+
91+
/// The node that threw the exception (innermost in the backtrace)
92+
[[nodiscard]] const TickBacktraceEntry& failedNode() const
93+
{
94+
return backtrace_.back();
95+
}
96+
97+
/// Full tick backtrace from root to failing node
98+
[[nodiscard]] const std::vector<TickBacktraceEntry>& backtrace() const
99+
{
100+
return backtrace_;
101+
}
102+
103+
[[nodiscard]] const std::string& originalMessage() const
104+
{
105+
return original_message_;
106+
}
107+
108+
private:
109+
std::vector<TickBacktraceEntry> backtrace_;
110+
std::string original_message_;
111+
112+
static std::string formatMessage(const std::vector<TickBacktraceEntry>& bt,
113+
const std::string& original_msg)
114+
{
115+
std::string msg =
116+
StrCat("Exception in node '", bt.back().node_path, "': ", original_msg);
117+
msg += "\nTick backtrace:";
118+
for(size_t i = 0; i < bt.size(); ++i)
119+
{
120+
const bool is_last = (i == bt.size() - 1);
121+
msg += StrCat("\n ", is_last ? "-> " : " ", bt[i].node_path, " (",
122+
bt[i].registration_name, ")");
123+
}
124+
return msg;
125+
}
126+
};
127+
70128
} // namespace BT
71129

72130
#endif

src/tree_node.cpp

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,43 @@
1616
#include <array>
1717
#include <atomic>
1818
#include <cstring>
19+
#include <vector>
1920

2021
namespace BT
2122
{
2223

24+
// Thread-local stack tracking the current tick hierarchy.
25+
// Used to build a backtrace when an exception is thrown during tick().
26+
static thread_local std::vector<const TreeNode*> tick_stack_;
27+
28+
// RAII guard to push/pop nodes from the tick stack
29+
class TickStackGuard
30+
{
31+
public:
32+
explicit TickStackGuard(const TreeNode* node)
33+
{
34+
tick_stack_.push_back(node);
35+
}
36+
~TickStackGuard()
37+
{
38+
tick_stack_.pop_back();
39+
}
40+
TickStackGuard(const TickStackGuard&) = delete;
41+
TickStackGuard& operator=(const TickStackGuard&) = delete;
42+
43+
// Build a backtrace from the current tick stack
44+
static std::vector<TickBacktraceEntry> buildBacktrace()
45+
{
46+
std::vector<TickBacktraceEntry> backtrace;
47+
backtrace.reserve(tick_stack_.size());
48+
for(const auto* node : tick_stack_)
49+
{
50+
backtrace.push_back({ node->name(), node->fullPath(), node->registrationName() });
51+
}
52+
return backtrace;
53+
}
54+
};
55+
2356
struct TreeNode::PImpl
2457
{
2558
PImpl(std::string name, NodeConfig config)
@@ -69,6 +102,9 @@ TreeNode::~TreeNode() = default;
69102

70103
NodeStatus TreeNode::executeTick()
71104
{
105+
// Track this node in the tick stack for exception backtrace
106+
TickStackGuard stack_guard(this);
107+
72108
auto new_status = _p->status;
73109
PreTickCallback pre_tick;
74110
PostTickCallback post_tick;
@@ -109,7 +145,20 @@ NodeStatus TreeNode::executeTick()
109145
// See issue #861 for details.
110146
const auto t1 = steady_clock::now();
111147
std::atomic_thread_fence(std::memory_order_seq_cst);
112-
new_status = tick();
148+
try
149+
{
150+
new_status = tick();
151+
}
152+
catch(const NodeExecutionError&)
153+
{
154+
// Already wrapped by a child node, re-throw as-is to preserve original info
155+
throw;
156+
}
157+
catch(const std::exception& ex)
158+
{
159+
// Wrap the exception with node context and backtrace
160+
throw NodeExecutionError(TickStackGuard::buildBacktrace(), ex.what());
161+
}
113162
std::atomic_thread_fence(std::memory_order_seq_cst);
114163
const auto t2 = steady_clock::now();
115164
if(monitor_tick)

tests/CMakeLists.txt

Lines changed: 1 addition & 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_exception_tracking.cpp
4950
gtest_updates.cpp
5051
gtest_wakeup.cpp
5152
gtest_while_do_else.cpp

tests/gtest_exception_tracking.cpp

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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
4+
* software and associated documentation files (the "Software"), to deal in the Software
5+
* without restriction, including without limitation the rights to use, copy, modify,
6+
* merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
7+
* permit persons to whom the Software is furnished to do so, subject to the following
8+
* conditions: The above copyright notice and this permission notice shall be included in
9+
* all copies or substantial portions of the Software.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
12+
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
13+
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
14+
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
15+
* CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
16+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17+
*/
18+
19+
#include "behaviortree_cpp/bt_factory.h"
20+
21+
#include <gtest/gtest.h>
22+
23+
using namespace BT;
24+
25+
// Test node that throws an exception
26+
class ThrowingAction : public SyncActionNode
27+
{
28+
public:
29+
ThrowingAction(const std::string& name, const NodeConfig& config)
30+
: SyncActionNode(name, config)
31+
{}
32+
33+
NodeStatus tick() override
34+
{
35+
throw std::runtime_error("Test exception from ThrowingAction");
36+
}
37+
38+
static PortsList providedPorts()
39+
{
40+
return {};
41+
}
42+
};
43+
44+
// Test node that succeeds
45+
class SucceedingAction : public SyncActionNode
46+
{
47+
public:
48+
SucceedingAction(const std::string& name, const NodeConfig& config)
49+
: SyncActionNode(name, config)
50+
{}
51+
52+
NodeStatus tick() override
53+
{
54+
return NodeStatus::SUCCESS;
55+
}
56+
57+
static PortsList providedPorts()
58+
{
59+
return {};
60+
}
61+
};
62+
63+
TEST(ExceptionTracking, BasicExceptionCapture)
64+
{
65+
// Simple tree: Sequence -> ThrowingAction
66+
const char* xml = R"(
67+
<root BTCPP_format="4">
68+
<BehaviorTree ID="MainTree">
69+
<ThrowingAction name="thrower"/>
70+
</BehaviorTree>
71+
</root>
72+
)";
73+
74+
BehaviorTreeFactory factory;
75+
factory.registerNodeType<ThrowingAction>("ThrowingAction");
76+
77+
auto tree = factory.createTreeFromText(xml);
78+
79+
try
80+
{
81+
tree.tickOnce();
82+
FAIL() << "Expected NodeExecutionError to be thrown";
83+
}
84+
catch(const NodeExecutionError& e)
85+
{
86+
// Verify the failed node info
87+
EXPECT_EQ(e.failedNode().node_name, "thrower");
88+
EXPECT_EQ(e.failedNode().registration_name, "ThrowingAction");
89+
EXPECT_EQ(e.originalMessage(), "Test exception from ThrowingAction");
90+
91+
// Verify backtrace has the node
92+
ASSERT_GE(e.backtrace().size(), 1u);
93+
EXPECT_EQ(e.backtrace().back().node_name, "thrower");
94+
}
95+
}
96+
97+
TEST(ExceptionTracking, NestedExceptionBacktrace)
98+
{
99+
// Tree: Sequence -> RetryNode -> ThrowingAction
100+
// This tests that the backtrace shows the full path
101+
const char* xml = R"(
102+
<root BTCPP_format="4">
103+
<BehaviorTree ID="MainTree">
104+
<Sequence name="main_seq">
105+
<SucceedingAction name="first"/>
106+
<RetryUntilSuccessful num_attempts="1" name="retry">
107+
<ThrowingAction name="nested_thrower"/>
108+
</RetryUntilSuccessful>
109+
</Sequence>
110+
</BehaviorTree>
111+
</root>
112+
)";
113+
114+
BehaviorTreeFactory factory;
115+
factory.registerNodeType<ThrowingAction>("ThrowingAction");
116+
factory.registerNodeType<SucceedingAction>("SucceedingAction");
117+
118+
auto tree = factory.createTreeFromText(xml);
119+
120+
try
121+
{
122+
tree.tickOnce();
123+
FAIL() << "Expected NodeExecutionError to be thrown";
124+
}
125+
catch(const NodeExecutionError& e)
126+
{
127+
// Verify the failed node is the innermost throwing node
128+
EXPECT_EQ(e.failedNode().node_name, "nested_thrower");
129+
130+
// Verify backtrace shows the full path (at least 3 nodes: Sequence, Retry, Thrower)
131+
ASSERT_GE(e.backtrace().size(), 3u);
132+
133+
// Check the what() message contains backtrace info
134+
std::string what_msg = e.what();
135+
EXPECT_NE(what_msg.find("nested_thrower"), std::string::npos);
136+
EXPECT_NE(what_msg.find("Tick backtrace"), std::string::npos);
137+
}
138+
}
139+
140+
TEST(ExceptionTracking, SubtreeExceptionBacktrace)
141+
{
142+
// Tree with subtree: MainTree -> Subtree -> ThrowingAction
143+
const char* xml = R"(
144+
<root BTCPP_format="4" main_tree_to_execute="MainTree">
145+
<BehaviorTree ID="MainTree">
146+
<Sequence name="outer_seq">
147+
<SubTree ID="InnerTree" name="subtree_call"/>
148+
</Sequence>
149+
</BehaviorTree>
150+
<BehaviorTree ID="InnerTree">
151+
<Sequence name="inner_seq">
152+
<ThrowingAction name="subtree_thrower"/>
153+
</Sequence>
154+
</BehaviorTree>
155+
</root>
156+
)";
157+
158+
BehaviorTreeFactory factory;
159+
factory.registerNodeType<ThrowingAction>("ThrowingAction");
160+
161+
auto tree = factory.createTreeFromText(xml);
162+
163+
try
164+
{
165+
tree.tickOnce();
166+
FAIL() << "Expected NodeExecutionError to be thrown";
167+
}
168+
catch(const NodeExecutionError& e)
169+
{
170+
// Verify the failed node is the one in the subtree
171+
EXPECT_EQ(e.failedNode().node_name, "subtree_thrower");
172+
173+
// Verify fullPath includes the subtree hierarchy
174+
std::string full_path = e.failedNode().node_path;
175+
EXPECT_NE(full_path.find("subtree_thrower"), std::string::npos);
176+
}
177+
}
178+
179+
TEST(ExceptionTracking, NoExceptionNoWrapping)
180+
{
181+
// Verify that trees that don't throw work normally
182+
const char* xml = R"(
183+
<root BTCPP_format="4">
184+
<BehaviorTree ID="MainTree">
185+
<Sequence>
186+
<SucceedingAction name="a"/>
187+
<SucceedingAction name="b"/>
188+
</Sequence>
189+
</BehaviorTree>
190+
</root>
191+
)";
192+
193+
BehaviorTreeFactory factory;
194+
factory.registerNodeType<SucceedingAction>("SucceedingAction");
195+
196+
auto tree = factory.createTreeFromText(xml);
197+
198+
// Should not throw
199+
EXPECT_NO_THROW({
200+
auto status = tree.tickOnce();
201+
EXPECT_EQ(status, NodeStatus::SUCCESS);
202+
});
203+
}
204+
205+
TEST(ExceptionTracking, BacktraceEntryContents)
206+
{
207+
// Test that TickBacktraceEntry contains all expected fields
208+
const char* xml = R"(
209+
<root BTCPP_format="4">
210+
<BehaviorTree ID="MainTree">
211+
<ThrowingAction name="my_action"/>
212+
</BehaviorTree>
213+
</root>
214+
)";
215+
216+
BehaviorTreeFactory factory;
217+
factory.registerNodeType<ThrowingAction>("ThrowingAction");
218+
219+
auto tree = factory.createTreeFromText(xml);
220+
221+
try
222+
{
223+
tree.tickOnce();
224+
FAIL() << "Expected exception";
225+
}
226+
catch(const NodeExecutionError& e)
227+
{
228+
const auto& entry = e.failedNode();
229+
// Check all fields are populated
230+
EXPECT_FALSE(entry.node_name.empty());
231+
EXPECT_FALSE(entry.node_path.empty());
232+
EXPECT_FALSE(entry.registration_name.empty());
233+
234+
EXPECT_EQ(entry.node_name, "my_action");
235+
EXPECT_EQ(entry.registration_name, "ThrowingAction");
236+
}
237+
}

0 commit comments

Comments
 (0)