Skip to content

Commit b2df8a7

Browse files
committed
add Finally control node for guaranteed cleanup
1 parent fbbcdd1 commit b2df8a7

8 files changed

Lines changed: 1078 additions & 0 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ list(APPEND BT_SOURCE
187187
src/controls/sequence_node.cpp
188188
src/controls/sequence_with_memory_node.cpp
189189
src/controls/switch_node.cpp
190+
src/controls/finally_node.cpp
190191
src/controls/try_catch_node.cpp
191192
src/controls/while_do_else_node.cpp
192193

‎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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include "behaviortree_cpp/control_node.h"
4+
5+
#include <atomic>
6+
#include <exception>
7+
#include <thread>
8+
9+
namespace BT
10+
{
11+
/**
12+
* @brief The Finally node ticks its first child ("main") and then ticks its
13+
* second child ("cleanup"), like try/finally.
14+
*
15+
* - Cleanup runs after main returns SUCCESS or FAILURE. If main returns SKIPPED on
16+
* its first tick it did nothing, so cleanup is not run and the node returns
17+
* SKIPPED. If main is skipped after it returned RUNNING, for example by _while,
18+
* cleanup runs and the node returns SKIPPED.
19+
* - If main throws (any type), main is halted, cleanup runs, and then the
20+
* exception is rethrown.
21+
* - The node returns main's status, or FAILURE if cleanup fails.
22+
* - If this node is halted while main or cleanup is RUNNING, main is halted and
23+
* cleanup gets one tick, like TryCatch with catch_on_halt="true". A cleanup that
24+
* needs several ticks is cut short by the final halt of its subtree. That tick
25+
* runs inside halt(), on the calling thread and under any lock the caller holds,
26+
* so a cleanup action that blocks also blocks the caller.
27+
* - To let a multi-step cleanup finish, set "halt_timeout_msec" above 0. Then halt()
28+
* ticks cleanup once, and again every 10 ms until it finishes, fails or throws,
29+
* or the timeout runs out, in which case cleanup is halted unfinished. No tick
30+
* starts after the timeout, and a tick that blocks is not cut short. halt()
31+
* blocks its caller, and any lock the caller holds, so a slow cleanup also
32+
* delays a parent such as a ReactiveSequence.
33+
* - The loop runs only when halt() is called from the thread that last ticked this
34+
* node. A halt from another thread, such as a Timeout decorator's timer or a
35+
* service callback calling Tree::haltTree(), gives cleanup the single tick and
36+
* prints a note to stderr. A Finally nested inside that halt also gets a single
37+
* tick. halt() is not synchronized with a concurrent tick().
38+
* - An exception thrown by cleanup in tick() halts cleanup, leaves the node
39+
* IDLE and propagates. It replaces main's status, or main's exception,
40+
* which is dropped. The next tick starts with main.
41+
* - halt() never throws, because it also runs from ~Tree(). It prints to
42+
* stderr when cleanup throws, fails or times out, and when halting a child
43+
* throws. A pending exception from main is dropped by a halt.
44+
*
45+
* Requires exactly 2 children, checked when the XML is loaded and on tick.
46+
*/
47+
class FinallyNode : public ControlNode
48+
{
49+
public:
50+
FinallyNode(const std::string& name, const NodeConfig& config);
51+
52+
~FinallyNode() override = default;
53+
54+
FinallyNode(const FinallyNode&) = delete;
55+
FinallyNode& operator=(const FinallyNode&) = delete;
56+
FinallyNode(FinallyNode&&) = delete;
57+
FinallyNode& operator=(FinallyNode&&) = delete;
58+
59+
static PortsList providedPorts()
60+
{
61+
return { InputPort<unsigned>(
62+
"halt_timeout_msec", kDefaultHaltTimeoutMsec,
63+
"When halted, how long to keep ticking cleanup "
64+
"before halting it unfinished, in milliseconds. "
65+
"Below 10 (the tick period) cleanup gets a single tick") };
66+
}
67+
68+
void halt() override;
69+
70+
private:
71+
static constexpr unsigned kDefaultHaltTimeoutMsec = 0;
72+
73+
// Written in tick(), read in halt(), which may run on another thread.
74+
std::atomic<std::thread::id> tick_thread_{};
75+
bool in_cleanup_ = false;
76+
bool main_started_ = false;
77+
NodeStatus main_status_ = NodeStatus::IDLE;
78+
std::exception_ptr main_exception_;
79+
80+
void haltChildNoThrow(size_t i) noexcept;
81+
void finishCleanupDuringHalt(bool foreign_thread);
82+
83+
BT::NodeStatus tick() override;
84+
};
85+
86+
} // namespace BT

‎src/bt_factory.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ BehaviorTreeFactory::BehaviorTreeFactory() : _p(new PImpl)
134134
registerNodeType<IfThenElseNode>("IfThenElse");
135135
registerNodeType<WhileDoElseNode>("WhileDoElse");
136136
registerNodeType<TryCatchNode>("TryCatch");
137+
registerNodeType<FinallyNode>("Finally");
137138

138139
registerNodeType<InverterNode>("Inverter");
139140

‎src/controls/finally_node.cpp‎

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#include "behaviortree_cpp/controls/finally_node.h"
2+
3+
#include <chrono>
4+
#include <exception>
5+
#include <iostream>
6+
#include <string_view>
7+
#include <thread>
8+
#include <utility>
9+
10+
namespace BT
11+
{
12+
namespace
13+
{
14+
constexpr std::chrono::milliseconds kHaltTickPeriod{ 10 };
15+
16+
// True while this thread runs a halt() that came from a thread other than the one ticking the
17+
// node. A Finally ticked or halted inside it, e.g. as cleanup, must not run its blocking loop.
18+
thread_local bool tls_in_foreign_halt = false;
19+
20+
class ForeignHaltScope
21+
{
22+
public:
23+
explicit ForeignHaltScope(bool foreign)
24+
: previous_(std::exchange(tls_in_foreign_halt, tls_in_foreign_halt || foreign))
25+
{}
26+
~ForeignHaltScope()
27+
{
28+
tls_in_foreign_halt = previous_;
29+
}
30+
ForeignHaltScope(const ForeignHaltScope&) = delete;
31+
ForeignHaltScope& operator=(const ForeignHaltScope&) = delete;
32+
ForeignHaltScope(ForeignHaltScope&&) = delete;
33+
ForeignHaltScope& operator=(ForeignHaltScope&&) = delete;
34+
35+
private:
36+
bool previous_;
37+
};
38+
39+
void printException(std::string_view node_name, const char* context,
40+
const std::exception_ptr& exception) noexcept
41+
{
42+
std::cerr << "[" << node_name << "]: " << context << ": ";
43+
try
44+
{
45+
std::rethrow_exception(exception);
46+
}
47+
catch(const std::exception& ex)
48+
{
49+
std::cerr << ex.what() << std::endl;
50+
}
51+
catch(...)
52+
{
53+
std::cerr << "non-std exception" << std::endl;
54+
}
55+
}
56+
} // namespace
57+
58+
FinallyNode::FinallyNode(const std::string& name, const NodeConfig& config)
59+
: ControlNode::ControlNode(name, config)
60+
{
61+
setRegistrationID("Finally");
62+
}
63+
64+
void FinallyNode::halt()
65+
{
66+
// halt() also runs from ~Tree(), where a propagating exception terminates, so nothing here throws.
67+
const bool foreign =
68+
tls_in_foreign_halt || std::this_thread::get_id() != tick_thread_.load();
69+
{
70+
const ForeignHaltScope scope(foreign);
71+
if(status() == NodeStatus::RUNNING && children_nodes_.size() == 2)
72+
{
73+
if(!in_cleanup_)
74+
{
75+
haltChildNoThrow(0);
76+
}
77+
finishCleanupDuringHalt(foreign);
78+
}
79+
for(size_t i = 0; i < children_nodes_.size(); i++)
80+
{
81+
haltChildNoThrow(i);
82+
}
83+
}
84+
tick_thread_.store(std::thread::id{});
85+
in_cleanup_ = false;
86+
main_started_ = false;
87+
main_status_ = NodeStatus::IDLE;
88+
main_exception_ = nullptr;
89+
resetStatus();
90+
}
91+
92+
void FinallyNode::finishCleanupDuringHalt(bool foreign_thread)
93+
{
94+
unsigned timeout_msec = kDefaultHaltTimeoutMsec;
95+
if(auto input = getInput<unsigned>("halt_timeout_msec"))
96+
{
97+
timeout_msec = input.value();
98+
}
99+
else
100+
{
101+
std::cerr << "[" << name() << "]: cannot read halt_timeout_msec (" << input.error()
102+
<< "), using " << kDefaultHaltTimeoutMsec << " ms" << std::endl;
103+
}
104+
const auto deadline =
105+
std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_msec);
106+
try
107+
{
108+
NodeStatus cleanup_status = children_nodes_[1]->executeTick();
109+
// Cleanup gets one tick. Keep ticking only if halt_timeout_msec > 0 and this is the thread
110+
// that ticks the tree, so a halt from a timer or service thread does not block its caller.
111+
if(timeout_msec > 0 && foreign_thread && cleanup_status == NodeStatus::RUNNING)
112+
{
113+
std::cerr << "[" << name()
114+
<< "]: halted from a thread that does not tick this node, cleanup gets a "
115+
"single tick instead of halt_timeout_msec"
116+
<< std::endl;
117+
}
118+
const bool keep_ticking = timeout_msec > 0 && !foreign_thread;
119+
while(keep_ticking && cleanup_status == NodeStatus::RUNNING)
120+
{
121+
// Start no tick after the deadline, so a tick that blocks cannot extend the wait.
122+
const auto next_tick = std::chrono::steady_clock::now() + kHaltTickPeriod;
123+
if(next_tick > deadline)
124+
{
125+
std::cerr << "[" << name()
126+
<< "]: cleanup did not finish within halt_timeout_msec ("
127+
<< timeout_msec << " ms), halting it unfinished" << std::endl;
128+
return;
129+
}
130+
std::this_thread::sleep_until(next_tick);
131+
cleanup_status = children_nodes_[1]->executeTick();
132+
}
133+
if(cleanup_status == NodeStatus::FAILURE)
134+
{
135+
std::cerr << "[" << name() << "]: cleanup returned FAILURE during halt"
136+
<< std::endl;
137+
}
138+
}
139+
catch(...)
140+
{
141+
printException(name(), "cleanup threw during halt", std::current_exception());
142+
}
143+
}
144+
145+
void FinallyNode::haltChildNoThrow(size_t i) noexcept
146+
{
147+
try
148+
{
149+
haltChild(i);
150+
}
151+
catch(...)
152+
{
153+
printException(name(), "a child threw while being halted", std::current_exception());
154+
}
155+
}
156+
157+
NodeStatus FinallyNode::tick()
158+
{
159+
if(children_nodes_.size() != 2)
160+
{
161+
throw LogicError("[", name(), "]: Finally requires exactly 2 children");
162+
}
163+
164+
if(!isStatusActive(status()))
165+
{
166+
in_cleanup_ = false;
167+
main_started_ = false;
168+
main_exception_ = nullptr;
169+
}
170+
171+
setStatus(NodeStatus::RUNNING);
172+
tick_thread_.store(std::this_thread::get_id());
173+
174+
if(!in_cleanup_)
175+
{
176+
try
177+
{
178+
main_status_ = children_nodes_[0]->executeTick();
179+
}
180+
catch(...)
181+
{
182+
main_exception_ = std::current_exception();
183+
haltChildNoThrow(0);
184+
main_status_ = NodeStatus::FAILURE;
185+
}
186+
187+
if(main_status_ == NodeStatus::RUNNING)
188+
{
189+
main_started_ = true;
190+
return NodeStatus::RUNNING;
191+
}
192+
if(main_status_ == NodeStatus::IDLE)
193+
{
194+
throw LogicError("[", name(), "]: A child should not return IDLE");
195+
}
196+
if(main_status_ == NodeStatus::SKIPPED && !main_started_)
197+
{
198+
// Main did nothing, so there is nothing to clean up. executeTick() keeps our RUNNING
199+
// status on SKIPPED, and halt() would run cleanup.
200+
resetChildren();
201+
resetStatus();
202+
return NodeStatus::SKIPPED;
203+
}
204+
in_cleanup_ = true;
205+
main_started_ = false;
206+
}
207+
208+
NodeStatus cleanup_status = NodeStatus::IDLE;
209+
try
210+
{
211+
cleanup_status = children_nodes_[1]->executeTick();
212+
}
213+
catch(...)
214+
{
215+
// As in a finally block, the exception replaces main's outcome and ends this run, so a later
216+
// halt, such as the one ~Tree() runs, does not retry cleanup.
217+
// Main finished but was not reset yet. haltChildNoThrow() resets both without throwing, so the
218+
// cleanup exception is the one that propagates.
219+
for(size_t i = 0; i < children_nodes_.size(); i++)
220+
{
221+
haltChildNoThrow(i);
222+
}
223+
in_cleanup_ = false;
224+
main_exception_ = nullptr;
225+
resetStatus();
226+
throw;
227+
}
228+
if(cleanup_status == NodeStatus::RUNNING)
229+
{
230+
return NodeStatus::RUNNING;
231+
}
232+
233+
resetChildren();
234+
in_cleanup_ = false;
235+
if(main_exception_)
236+
{
237+
// executeTick() keeps our RUNNING status when tick() throws, and halt() would rerun cleanup.
238+
resetStatus();
239+
std::rethrow_exception(std::exchange(main_exception_, nullptr));
240+
}
241+
if(cleanup_status == NodeStatus::FAILURE)
242+
{
243+
return NodeStatus::FAILURE;
244+
}
245+
if(main_status_ == NodeStatus::SKIPPED)
246+
{
247+
// Main was skipped after it started, e.g. by _while. executeTick() keeps our RUNNING status
248+
// on SKIPPED, and halt() would rerun cleanup.
249+
resetStatus();
250+
}
251+
return main_status_;
252+
}
253+
254+
} // namespace BT

‎src/xml_parsing.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,11 @@ void VerifyXML(const std::string& xml_text,
682682
ThrowError(line_number, std::string("The node 'TryCatch' must have "
683683
"at least 2 children"));
684684
}
685+
if(registered_name == "Finally" && children_count != 2)
686+
{
687+
ThrowError(line_number, std::string("The node 'Finally' must have "
688+
"exactly 2 children"));
689+
}
685690
if(registered_name == "ReactiveSequence")
686691
{
687692
size_t async_count = 0;

‎tests/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ set(BT_TESTS
4949
gtest_switch.cpp
5050
gtest_tree.cpp
5151
gtest_try_catch.cpp
52+
gtest_finally.cpp
5253
gtest_halt_child.cpp
5354
gtest_exception_tracking.cpp
5455
gtest_updates.cpp

0 commit comments

Comments
 (0)