Skip to content

Commit a63c668

Browse files
dv-picknikclaude
andcommitted
add Finally control node for guaranteed cleanup
Finally ticks its first child (main), then ticks its second child (cleanup) after main returns SUCCESS, FAILURE or SKIPPED, or throws. If main threw, the exception is rethrown once cleanup finishes. Otherwise the node returns main's status, or FAILURE if cleanup failed. When halted while main is RUNNING, it halts main and ticks cleanup once, synchronously. halt() never throws, because it also runs from ~Tree(), so it prints exceptions to stderr instead. ControlNode::haltChild now resets the child's status before rethrowing an exception from its halt(), so a later reset does not halt that child a second time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6a3b337 commit a63c668

9 files changed

Lines changed: 682 additions & 1 deletion

File tree

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ list(APPEND BT_SOURCE
182182
src/controls/sequence_node.cpp
183183
src/controls/sequence_with_memory_node.cpp
184184
src/controls/switch_node.cpp
185+
src/controls/finally_node.cpp
185186
src/controls/try_catch_node.cpp
186187
src/controls/while_do_else_node.cpp
187188

‎include/behaviortree_cpp/behavior_tree.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include "behaviortree_cpp/actions/updated_action.h"
2626
#include "behaviortree_cpp/condition_node.h"
2727
#include "behaviortree_cpp/controls/fallback_node.h"
28+
#include "behaviortree_cpp/controls/finally_node.h"
2829
#include "behaviortree_cpp/controls/if_then_else_node.h"
2930
#include "behaviortree_cpp/controls/parallel_all_node.h"
3031
#include "behaviortree_cpp/controls/parallel_node.h"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#pragma once
2+
3+
#include "behaviortree_cpp/control_node.h"
4+
5+
#include <exception>
6+
7+
namespace BT
8+
{
9+
/**
10+
* @brief The Finally node ticks its first child ("main") and then ticks its
11+
* second child ("cleanup"), like try/finally.
12+
*
13+
* - Cleanup runs after main returns SUCCESS, FAILURE or SKIPPED.
14+
* - If main throws (any type), main is halted, cleanup runs, and then the
15+
* exception is rethrown.
16+
* - The node returns main's status, or FAILURE if cleanup fails.
17+
* - If this node is halted while main is RUNNING, main is halted and cleanup
18+
* is ticked once, synchronously, on the thread calling halt(). Halt-time
19+
* cleanup must therefore be synchronous: if it returns RUNNING, it is halted.
20+
* A slow cleanup delays the parent, for example a ReactiveSequence whose
21+
* condition changed. Call halt() from the thread that ticks the tree.
22+
* - If this node is halted while cleanup is RUNNING, cleanup is halted and
23+
* does not finish.
24+
* - Exceptions thrown by cleanup propagate from tick(), and the next tick
25+
* retries cleanup. If the tree is halted instead, cleanup is not retried.
26+
* - halt() never throws, because it also runs from ~Tree(). It prints
27+
* exceptions from cleanup or from halting a child, and a cleanup FAILURE,
28+
* to stderr.
29+
*
30+
* Requires exactly 2 children, checked when the XML is loaded and on tick.
31+
*/
32+
class FinallyNode : public ControlNode
33+
{
34+
public:
35+
FinallyNode(const std::string& name, const NodeConfig& config);
36+
37+
~FinallyNode() override = default;
38+
39+
FinallyNode(const FinallyNode&) = delete;
40+
FinallyNode& operator=(const FinallyNode&) = delete;
41+
FinallyNode(FinallyNode&&) = delete;
42+
FinallyNode& operator=(FinallyNode&&) = delete;
43+
44+
static PortsList providedPorts()
45+
{
46+
return {};
47+
}
48+
49+
void halt() override;
50+
51+
private:
52+
bool in_cleanup_ = false;
53+
NodeStatus main_status_ = NodeStatus::IDLE;
54+
std::exception_ptr main_exception_;
55+
56+
void haltChildNoThrow(size_t i);
57+
58+
BT::NodeStatus tick() override;
59+
};
60+
61+
} // namespace BT

‎src/bt_factory.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ BehaviorTreeFactory::BehaviorTreeFactory() : _p(new PImpl)
132132
registerNodeType<IfThenElseNode>("IfThenElse");
133133
registerNodeType<WhileDoElseNode>("WhileDoElse");
134134
registerNodeType<TryCatchNode>("TryCatch");
135+
registerNodeType<FinallyNode>("Finally");
135136

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

‎src/control_node.cpp‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,16 @@ void ControlNode::haltChild(size_t i)
5757
auto* child = children_nodes_[i];
5858
if(child->status() == NodeStatus::RUNNING)
5959
{
60-
child->haltNode();
60+
try
61+
{
62+
child->haltNode();
63+
}
64+
catch(...)
65+
{
66+
// Don't leave the child RUNNING, or the next reset would halt it again.
67+
child->resetStatus();
68+
throw;
69+
}
6170
}
6271
child->resetStatus();
6372
}

‎src/controls/finally_node.cpp‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#include "behaviortree_cpp/controls/finally_node.h"
2+
3+
#include <exception>
4+
#include <iostream>
5+
#include <string_view>
6+
#include <utility>
7+
8+
namespace BT
9+
{
10+
namespace
11+
{
12+
void printException(std::string_view node_name, const char* context,
13+
const std::exception_ptr& exception)
14+
{
15+
std::cerr << "[" << node_name << "]: " << context << ": ";
16+
try
17+
{
18+
std::rethrow_exception(exception);
19+
}
20+
catch(const std::exception& ex)
21+
{
22+
std::cerr << ex.what() << std::endl;
23+
}
24+
catch(...)
25+
{
26+
std::cerr << "non-std exception" << std::endl;
27+
}
28+
}
29+
} // namespace
30+
31+
FinallyNode::FinallyNode(const std::string& name, const NodeConfig& config)
32+
: ControlNode::ControlNode(name, config)
33+
{
34+
setRegistrationID("Finally");
35+
}
36+
37+
void FinallyNode::halt()
38+
{
39+
// halt() also runs from ~Tree(), where a propagating exception terminates, so nothing here throws.
40+
if(!in_cleanup_ && status() == NodeStatus::RUNNING && children_nodes_.size() == 2)
41+
{
42+
haltChildNoThrow(0);
43+
try
44+
{
45+
if(children_nodes_[1]->executeTick() == NodeStatus::FAILURE)
46+
{
47+
std::cerr << "[" << name() << "]: cleanup returned FAILURE during halt"
48+
<< std::endl;
49+
}
50+
}
51+
catch(...)
52+
{
53+
printException(name(), "cleanup threw during halt", std::current_exception());
54+
}
55+
}
56+
for(size_t i = 0; i < children_nodes_.size(); i++)
57+
{
58+
haltChildNoThrow(i);
59+
}
60+
in_cleanup_ = false;
61+
main_status_ = NodeStatus::IDLE;
62+
main_exception_ = nullptr;
63+
resetStatus();
64+
}
65+
66+
void FinallyNode::haltChildNoThrow(size_t i)
67+
{
68+
try
69+
{
70+
haltChild(i);
71+
}
72+
catch(...)
73+
{
74+
printException(name(), "a child threw while being halted", std::current_exception());
75+
}
76+
}
77+
78+
NodeStatus FinallyNode::tick()
79+
{
80+
if(children_nodes_.size() != 2)
81+
{
82+
throw LogicError("[", name(), "]: Finally requires exactly 2 children");
83+
}
84+
85+
if(!isStatusActive(status()))
86+
{
87+
in_cleanup_ = false;
88+
main_exception_ = nullptr;
89+
}
90+
91+
setStatus(NodeStatus::RUNNING);
92+
93+
if(!in_cleanup_)
94+
{
95+
try
96+
{
97+
main_status_ = children_nodes_[0]->executeTick();
98+
}
99+
catch(...)
100+
{
101+
main_exception_ = std::current_exception();
102+
haltChildNoThrow(0);
103+
main_status_ = NodeStatus::FAILURE;
104+
}
105+
106+
if(main_status_ == NodeStatus::RUNNING)
107+
{
108+
return NodeStatus::RUNNING;
109+
}
110+
if(main_status_ == NodeStatus::IDLE)
111+
{
112+
throw LogicError("[", name(), "]: A child should not return IDLE");
113+
}
114+
in_cleanup_ = true;
115+
}
116+
117+
const NodeStatus cleanup_status = children_nodes_[1]->executeTick();
118+
if(cleanup_status == NodeStatus::RUNNING)
119+
{
120+
return NodeStatus::RUNNING;
121+
}
122+
123+
resetChildren();
124+
in_cleanup_ = false;
125+
if(main_exception_)
126+
{
127+
// executeTick() keeps our RUNNING status when tick() throws, and halt() would rerun cleanup.
128+
resetStatus();
129+
std::rethrow_exception(std::exchange(main_exception_, nullptr));
130+
}
131+
if(cleanup_status == NodeStatus::FAILURE)
132+
{
133+
return NodeStatus::FAILURE;
134+
}
135+
if(main_status_ == NodeStatus::SKIPPED)
136+
{
137+
// executeTick() keeps our RUNNING status on SKIPPED, and halt() would rerun cleanup.
138+
resetStatus();
139+
}
140+
return main_status_;
141+
}
142+
143+
} // namespace BT

‎src/xml_parsing.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,11 @@ void VerifyXML(const std::string& xml_text,
642642
ThrowError(line_number, std::string("The node 'TryCatch' must have "
643643
"at least 2 children"));
644644
}
645+
if(registered_name == "Finally" && children_count != 2)
646+
{
647+
ThrowError(line_number, std::string("The node 'Finally' must have "
648+
"exactly 2 children"));
649+
}
645650
if(registered_name == "ReactiveSequence")
646651
{
647652
size_t async_count = 0;

‎tests/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ set(BT_TESTS
4747
gtest_switch.cpp
4848
gtest_tree.cpp
4949
gtest_try_catch.cpp
50+
gtest_finally.cpp
5051
gtest_exception_tracking.cpp
5152
gtest_updates.cpp
5253
gtest_wakeup.cpp

0 commit comments

Comments
 (0)