From 2fb0a51f096bf6142ec9bd1134deaafa9b1fe60d Mon Sep 17 00:00:00 2001 From: David Vadovszki Date: Mon, 5 Oct 2026 12:48:10 -0600 Subject: [PATCH] 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. An exception from cleanup resets the node and propagates. When halted while main or cleanup is RUNNING, it halts main and keeps ticking cleanup every 10 ms until cleanup finishes or the halt_timeout_msec port (default 10000) runs out, so asynchronous cleanup completes. halt() never throws, because it also runs from ~Tree(), so it prints problems 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 --- CMakeLists.txt | 1 + include/behaviortree_cpp/behavior_tree.h | 1 + .../behaviortree_cpp/controls/finally_node.h | 68 ++ src/bt_factory.cpp | 1 + src/control_node.cpp | 11 +- src/controls/finally_node.cpp | 203 ++++++ src/xml_parsing.cpp | 5 + tests/CMakeLists.txt | 1 + tests/gtest_finally.cpp | 607 ++++++++++++++++++ 9 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 include/behaviortree_cpp/controls/finally_node.h create mode 100644 src/controls/finally_node.cpp create mode 100644 tests/gtest_finally.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ce00cdea0..af5d7adbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -182,6 +182,7 @@ list(APPEND BT_SOURCE src/controls/sequence_node.cpp src/controls/sequence_with_memory_node.cpp src/controls/switch_node.cpp + src/controls/finally_node.cpp src/controls/try_catch_node.cpp src/controls/while_do_else_node.cpp diff --git a/include/behaviortree_cpp/behavior_tree.h b/include/behaviortree_cpp/behavior_tree.h index e7373d4dc..86e0d4812 100644 --- a/include/behaviortree_cpp/behavior_tree.h +++ b/include/behaviortree_cpp/behavior_tree.h @@ -25,6 +25,7 @@ #include "behaviortree_cpp/actions/updated_action.h" #include "behaviortree_cpp/condition_node.h" #include "behaviortree_cpp/controls/fallback_node.h" +#include "behaviortree_cpp/controls/finally_node.h" #include "behaviortree_cpp/controls/if_then_else_node.h" #include "behaviortree_cpp/controls/parallel_all_node.h" #include "behaviortree_cpp/controls/parallel_node.h" diff --git a/include/behaviortree_cpp/controls/finally_node.h b/include/behaviortree_cpp/controls/finally_node.h new file mode 100644 index 000000000..88b799d9f --- /dev/null +++ b/include/behaviortree_cpp/controls/finally_node.h @@ -0,0 +1,68 @@ +#pragma once + +#include "behaviortree_cpp/control_node.h" + +#include + +namespace BT +{ +/** + * @brief The Finally node ticks its first child ("main") and then ticks its + * second child ("cleanup"), like try/finally. + * + * - Cleanup runs after main returns SUCCESS, FAILURE or SKIPPED. + * - If main throws (any type), main is halted, cleanup runs, and then the + * exception is rethrown. + * - The node returns main's status, or FAILURE if cleanup fails. + * - If this node is halted while main or cleanup is RUNNING, main is halted + * and cleanup is ticked again every 10 ms on the thread calling halt(), so + * asynchronous cleanup can finish. This stops when cleanup finishes, fails + * or throws, or when "halt_timeout_msec" runs out, in which case cleanup is + * halted unfinished. Cleanup always gets at least one tick, no later tick + * starts after the timeout, and a tick that blocks is not cut short. halt() blocks its caller, and any lock the + * caller holds, until then, so a slow cleanup also delays a parent such as + * a ReactiveSequence. Call halt() from the thread that ticks the tree. + * - An exception thrown by cleanup in tick() halts cleanup, leaves the node + * IDLE and propagates. It replaces main's status, or main's exception, + * which is dropped. The next tick starts with main. + * - halt() never throws, because it also runs from ~Tree(). It prints to + * stderr when cleanup throws, fails or times out, and when halting a child + * throws. A pending exception from main is dropped by a halt. + * + * Requires exactly 2 children, checked when the XML is loaded and on tick. + */ +class FinallyNode : public ControlNode +{ +public: + FinallyNode(const std::string& name, const NodeConfig& config); + + ~FinallyNode() override = default; + + FinallyNode(const FinallyNode&) = delete; + FinallyNode& operator=(const FinallyNode&) = delete; + FinallyNode(FinallyNode&&) = delete; + FinallyNode& operator=(FinallyNode&&) = delete; + + static PortsList providedPorts() + { + return { InputPort("halt_timeout_msec", kDefaultHaltTimeoutMsec, + "When halted, how long to keep ticking cleanup " + "before halting it unfinished, in milliseconds") }; + } + + void halt() override; + +private: + static constexpr unsigned kDefaultHaltTimeoutMsec = 10000; + + bool in_cleanup_ = false; + NodeStatus main_status_ = NodeStatus::IDLE; + std::exception_ptr main_exception_; + + void haltChildNoThrow(size_t i); + void finishCleanupDuringHalt(); + + BT::NodeStatus tick() override; +}; + +} // namespace BT diff --git a/src/bt_factory.cpp b/src/bt_factory.cpp index 77f186481..af956442d 100644 --- a/src/bt_factory.cpp +++ b/src/bt_factory.cpp @@ -132,6 +132,7 @@ BehaviorTreeFactory::BehaviorTreeFactory() : _p(new PImpl) registerNodeType("IfThenElse"); registerNodeType("WhileDoElse"); registerNodeType("TryCatch"); + registerNodeType("Finally"); registerNodeType("Inverter"); diff --git a/src/control_node.cpp b/src/control_node.cpp index 535165376..b28fec77c 100644 --- a/src/control_node.cpp +++ b/src/control_node.cpp @@ -57,7 +57,16 @@ void ControlNode::haltChild(size_t i) auto* child = children_nodes_[i]; if(child->status() == NodeStatus::RUNNING) { - child->haltNode(); + try + { + child->haltNode(); + } + catch(...) + { + // Don't leave the child RUNNING, or the next reset would halt it again. + child->resetStatus(); + throw; + } } child->resetStatus(); } diff --git a/src/controls/finally_node.cpp b/src/controls/finally_node.cpp new file mode 100644 index 000000000..a828b6e55 --- /dev/null +++ b/src/controls/finally_node.cpp @@ -0,0 +1,203 @@ +#include "behaviortree_cpp/controls/finally_node.h" + +#include +#include +#include +#include +#include +#include + +namespace BT +{ +namespace +{ +constexpr std::chrono::milliseconds kHaltTickPeriod{ 10 }; + +void printException(std::string_view node_name, const char* context, + const std::exception_ptr& exception) +{ + std::cerr << "[" << node_name << "]: " << context << ": "; + try + { + std::rethrow_exception(exception); + } + catch(const std::exception& ex) + { + std::cerr << ex.what() << std::endl; + } + catch(...) + { + std::cerr << "non-std exception" << std::endl; + } +} +} // namespace + +FinallyNode::FinallyNode(const std::string& name, const NodeConfig& config) + : ControlNode::ControlNode(name, config) +{ + setRegistrationID("Finally"); +} + +void FinallyNode::halt() +{ + // halt() also runs from ~Tree(), where a propagating exception terminates, so nothing here throws. + if(status() == NodeStatus::RUNNING && children_nodes_.size() == 2) + { + if(!in_cleanup_) + { + haltChildNoThrow(0); + } + finishCleanupDuringHalt(); + } + for(size_t i = 0; i < children_nodes_.size(); i++) + { + haltChildNoThrow(i); + } + in_cleanup_ = false; + main_status_ = NodeStatus::IDLE; + main_exception_ = nullptr; + resetStatus(); +} + +void FinallyNode::finishCleanupDuringHalt() +{ + unsigned timeout_msec = kDefaultHaltTimeoutMsec; + if(auto input = getInput("halt_timeout_msec")) + { + timeout_msec = input.value(); + } + else + { + std::cerr << "[" << name() << "]: cannot read halt_timeout_msec (" << input.error() + << "), using " << kDefaultHaltTimeoutMsec << " ms" << std::endl; + } + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_msec); + try + { + // Cleanup is commonly asynchronous, so keep ticking it until it finishes rather than + // halting it after its first tick. + NodeStatus cleanup_status = children_nodes_[1]->executeTick(); + while(cleanup_status == NodeStatus::RUNNING) + { + // Start no tick after the deadline, so a tick that blocks cannot extend the wait. + const auto next_tick = std::chrono::steady_clock::now() + kHaltTickPeriod; + if(next_tick > deadline) + { + std::cerr << "[" << name() + << "]: cleanup did not finish within halt_timeout_msec (" + << timeout_msec << " ms), halting it unfinished" << std::endl; + return; + } + std::this_thread::sleep_until(next_tick); + cleanup_status = children_nodes_[1]->executeTick(); + } + if(cleanup_status == NodeStatus::FAILURE) + { + std::cerr << "[" << name() << "]: cleanup returned FAILURE during halt" + << std::endl; + } + } + catch(...) + { + printException(name(), "cleanup threw during halt", std::current_exception()); + } +} + +void FinallyNode::haltChildNoThrow(size_t i) +{ + try + { + haltChild(i); + } + catch(...) + { + printException(name(), "a child threw while being halted", std::current_exception()); + } +} + +NodeStatus FinallyNode::tick() +{ + if(children_nodes_.size() != 2) + { + throw LogicError("[", name(), "]: Finally requires exactly 2 children"); + } + + if(!isStatusActive(status())) + { + in_cleanup_ = false; + main_exception_ = nullptr; + } + + setStatus(NodeStatus::RUNNING); + + if(!in_cleanup_) + { + try + { + main_status_ = children_nodes_[0]->executeTick(); + } + catch(...) + { + main_exception_ = std::current_exception(); + haltChildNoThrow(0); + main_status_ = NodeStatus::FAILURE; + } + + if(main_status_ == NodeStatus::RUNNING) + { + return NodeStatus::RUNNING; + } + if(main_status_ == NodeStatus::IDLE) + { + throw LogicError("[", name(), "]: A child should not return IDLE"); + } + in_cleanup_ = true; + } + + NodeStatus cleanup_status = NodeStatus::IDLE; + try + { + cleanup_status = children_nodes_[1]->executeTick(); + } + catch(...) + { + // As in a finally block, the exception replaces main's outcome and ends this run, so a later + // halt, such as the one ~Tree() runs, does not retry cleanup. + // Main finished but was not reset yet. haltChildNoThrow() resets both without throwing, so the + // cleanup exception is the one that propagates. + for(size_t i = 0; i < children_nodes_.size(); i++) + { + haltChildNoThrow(i); + } + in_cleanup_ = false; + main_exception_ = nullptr; + resetStatus(); + throw; + } + if(cleanup_status == NodeStatus::RUNNING) + { + return NodeStatus::RUNNING; + } + + resetChildren(); + in_cleanup_ = false; + if(main_exception_) + { + // executeTick() keeps our RUNNING status when tick() throws, and halt() would rerun cleanup. + resetStatus(); + std::rethrow_exception(std::exchange(main_exception_, nullptr)); + } + if(cleanup_status == NodeStatus::FAILURE) + { + return NodeStatus::FAILURE; + } + if(main_status_ == NodeStatus::SKIPPED) + { + // executeTick() keeps our RUNNING status on SKIPPED, and halt() would rerun cleanup. + resetStatus(); + } + return main_status_; +} + +} // namespace BT diff --git a/src/xml_parsing.cpp b/src/xml_parsing.cpp index b979968ef..6da58951f 100644 --- a/src/xml_parsing.cpp +++ b/src/xml_parsing.cpp @@ -642,6 +642,11 @@ void VerifyXML(const std::string& xml_text, ThrowError(line_number, std::string("The node 'TryCatch' must have " "at least 2 children")); } + if(registered_name == "Finally" && children_count != 2) + { + ThrowError(line_number, std::string("The node 'Finally' must have " + "exactly 2 children")); + } if(registered_name == "ReactiveSequence") { size_t async_count = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 49fcce397..837e4ea65 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -47,6 +47,7 @@ set(BT_TESTS gtest_switch.cpp gtest_tree.cpp gtest_try_catch.cpp + gtest_finally.cpp gtest_exception_tracking.cpp gtest_updates.cpp gtest_wakeup.cpp diff --git a/tests/gtest_finally.cpp b/tests/gtest_finally.cpp new file mode 100644 index 000000000..74b17eb76 --- /dev/null +++ b/tests/gtest_finally.cpp @@ -0,0 +1,607 @@ +#include "behaviortree_cpp/bt_factory.h" + +#include +#include + +#include + +using BT::NodeStatus; + +struct TestError : std::runtime_error +{ + using std::runtime_error::runtime_error; +}; + +// RUNNING until halted. Optionally throws on its second tick, or from onHalted(). +class AsyncMain : public BT::StatefulActionNode +{ +public: + AsyncMain(const std::string& name, const BT::NodeConfig& config, int* halted, + bool throw_on_running, bool throw_on_halt) + : StatefulActionNode(name, config) + , halted_(halted) + , throw_(throw_on_running) + , throw_on_halt_(throw_on_halt) + {} + static BT::PortsList providedPorts() + { + return {}; + } + NodeStatus onStart() override + { + return NodeStatus::RUNNING; + } + NodeStatus onRunning() override + { + if(throw_) + { + throw TestError("boom"); + } + return NodeStatus::RUNNING; + } + void onHalted() override + { + (*halted_)++; + if(throw_on_halt_) + { + throw TestError("halt boom"); + } + } + +private: + int* halted_; + bool throw_; + bool throw_on_halt_; +}; + +// RUNNING on start, SUCCESS on the next tick. Counts how often it starts. +class TwoTickMain : public BT::StatefulActionNode +{ +public: + TwoTickMain(const std::string& name, const BT::NodeConfig& config, int* starts) + : StatefulActionNode(name, config), starts_(starts) + {} + static BT::PortsList providedPorts() + { + return {}; + } + NodeStatus onStart() override + { + (*starts_)++; + return NodeStatus::RUNNING; + } + NodeStatus onRunning() override + { + return NodeStatus::SUCCESS; + } + void onHalted() override + {} + +private: + int* starts_; +}; + +class FinallyTest : public testing::Test +{ +protected: + BT::BehaviorTreeFactory factory; + int cleanup_count = 0; + int main_ticks = 0; + int main_halted = 0; + int cleanup_ticks = 0; + int throw_once_ticks = 0; + int spin_ticks = 0; + int two_tick_starts = 0; + int second_tick_ticks = 0; + bool gate = true; + + void SetUp() override + { + factory.registerSimpleAction("Cleanup", [this](BT::TreeNode&) { + cleanup_count++; + return NodeStatus::SUCCESS; + }); + factory.registerSimpleAction( + "Throw", [](BT::TreeNode&) -> NodeStatus { throw TestError("boom"); }); + // RUNNING twice, then SUCCESS + factory.registerSimpleCondition("RunTwice", [this](BT::TreeNode&) { + return ++main_ticks < 3 ? NodeStatus::RUNNING : NodeStatus::SUCCESS; + }); + factory.registerNodeType("AsyncMain", &main_halted, false, false); + factory.registerNodeType("AsyncThrow", &main_halted, true, false); + factory.registerNodeType("AsyncHaltThrows", &main_halted, false, true); + factory.registerNodeType("AsyncThrowHaltThrows", &main_halted, true, true); + factory.registerSimpleAction("ThrowInt", + [](BT::TreeNode&) -> NodeStatus { throw 1; }); + factory.registerSimpleAction("ThrowOnce", [this](BT::TreeNode&) { + if(++throw_once_ticks == 1) + { + throw TestError("once"); + } + return NodeStatus::SUCCESS; + }); + factory.registerNodeType("TwoTickMain", &two_tick_starts); + factory.registerSimpleCondition("Spin", [this](BT::TreeNode&) { + spin_ticks++; + return NodeStatus::RUNNING; + }); + // RUNNING on its first tick, then throws on its second + factory.registerSimpleCondition("RunThenThrow", [this](BT::TreeNode&) -> NodeStatus { + if(++second_tick_ticks < 2) + { + return NodeStatus::RUNNING; + } + throw TestError("late boom"); + }); + // RUNNING on its first tick, then FAILURE + factory.registerSimpleCondition("RunThenFail", [this](BT::TreeNode&) { + return ++second_tick_ticks < 2 ? NodeStatus::RUNNING : NodeStatus::FAILURE; + }); + factory.registerSimpleCondition("Gate", [this](BT::TreeNode&) { + return gate ? NodeStatus::SUCCESS : NodeStatus::FAILURE; + }); + // Cleanup that is RUNNING on its first tick, then SUCCESS + factory.registerSimpleCondition("AsyncCleanup", [this](BT::TreeNode&) { + return ++cleanup_ticks < 2 ? NodeStatus::RUNNING : NodeStatus::SUCCESS; + }); + factory.registerSimpleCondition("AlwaysRunning", [this](BT::TreeNode&) { + main_ticks++; + return NodeStatus::RUNNING; + }); + } + + NodeStatus run(const std::string& main, const std::string& cleanup = "") + { + auto tree = + factory.createTreeFromText(R"()" + + main + cleanup + ""); + return tree.tickWhileRunning(); + } +}; + +TEST_F(FinallyTest, MainSucceeds_CleanupRuns) +{ + EXPECT_EQ(run(""), NodeStatus::SUCCESS); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, MainFails_CleanupRuns) +{ + EXPECT_EQ(run(""), NodeStatus::FAILURE); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, CleanupFails_ReturnsFailure) +{ + EXPECT_EQ(run("", ""), NodeStatus::FAILURE); +} + +TEST_F(FinallyTest, MainThrows_CleanupRunsThenRethrows) +{ + EXPECT_THROW(run(""), BT::RuntimeError); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, NestedMainThrows_CleanupRunsThenRethrows) +{ + EXPECT_THROW(run(""), BT::RuntimeError); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, CleanupThrows_Propagates) +{ + EXPECT_THROW(run("", ""), BT::RuntimeError); +} + +TEST_F(FinallyTest, AsyncMain_CleanupRunsOnceAfterMainCompletes) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(cleanup_count, 0); + EXPECT_EQ(tree.tickOnce(), NodeStatus::SUCCESS); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, HaltWhileMainRunning_CleanupRuns) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(cleanup_count, 1); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); + + // A second halt on an idle node does not run cleanup again. + tree.haltTree(); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, TickedAgain_CleanupRunsEachTime) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickWhileRunning(), NodeStatus::SUCCESS); + EXPECT_EQ(tree.tickWhileRunning(), NodeStatus::SUCCESS); + EXPECT_EQ(cleanup_count, 2); +} + +TEST_F(FinallyTest, WrongChildCount_RejectedAtLoad) +{ + EXPECT_THROW((void)factory.createTreeFromText(R"( + + + )"), + BT::RuntimeError); + EXPECT_THROW((void)factory.createTreeFromText(R"( + + + )"), + BT::RuntimeError); +} + +TEST_F(FinallyTest, MainSkipped_CleanupRunsAndReturnsSkipped) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickWhileRunning(), NodeStatus::SKIPPED); + EXPECT_EQ(cleanup_count, 1); + // The node must not be left RUNNING, or this halt would run cleanup again. + tree.haltTree(); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, AsyncCleanup_ReturnsMainStatusWhenCleanupCompletes) +{ + EXPECT_EQ(run("", ""), NodeStatus::FAILURE); + EXPECT_EQ(cleanup_ticks, 2); +} + +TEST_F(FinallyTest, AsyncMainThrows_MainHaltedAndCleanupRuns) +{ + EXPECT_THROW(run(""), BT::RuntimeError); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, HaltWhileMainRunning_MainHalted) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, HaltWhileCleanupRunning_CleanupFinishes) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(cleanup_ticks, 2); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); +} + +TEST_F(FinallyTest, HaltWhileMainRunning_AsyncCleanupFinishes) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(cleanup_ticks, 2); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); +} + +TEST_F(FinallyTest, HaltWhileMainRunning_EveryStepOfAsyncCleanupRuns) +{ + auto tree = factory.createTreeFromText(R"( + + + + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(cleanup_ticks, 2); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, HaltWithCleanupThatNeverFinishes_HaltsItAfterTimeout) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + const auto start = std::chrono::steady_clock::now(); + tree.haltTree(); + const auto elapsed = std::chrono::steady_clock::now() - start; + // The wait stops before a tick that would start after the timeout, up to one 10 ms period early. + EXPECT_GE(elapsed, std::chrono::milliseconds(40)); + EXPECT_LT(elapsed, std::chrono::seconds(5)); + // At least one tick from the run and one more during the halt. + EXPECT_GE(spin_ticks, 2); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); +} + +TEST_F(FinallyTest, CleanupThrowsDuringHalt_DoesNotPropagate) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_NO_THROW(tree.haltTree()); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); +} + +TEST_F(FinallyTest, MainThrowsAndItsHaltThrows_CleanupStillRuns) +{ + EXPECT_THROW(run(""), BT::RuntimeError); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, HaltWhereMainHaltThrows_CleanupRunsAndNothingPropagates) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_NO_THROW(tree.haltTree()); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, MainThrowsNonStdException_CleanupRunsThenRethrows) +{ + EXPECT_THROW(run(""), int); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, CleanupThrowsNonStdExceptionDuringHalt_DoesNotPropagate) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_NO_THROW(tree.haltTree()); +} + +TEST_F(FinallyTest, CleanupThrows_EndsTheRunAndHaltDoesNotRetry) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_THROW(tree.tickOnce(), BT::RuntimeError); + tree.haltTree(); + EXPECT_EQ(throw_once_ticks, 1); + + // The next tick starts again with main. + EXPECT_EQ(tree.tickOnce(), NodeStatus::SUCCESS); + EXPECT_EQ(main_ticks, 4); + EXPECT_EQ(throw_once_ticks, 2); +} + +TEST_F(FinallyTest, CleanupSkipped_ReturnsMainStatus) +{ + EXPECT_EQ(run("", R"()"), + NodeStatus::FAILURE); +} + +TEST_F(FinallyTest, HaltedByReactiveSequence_CleanupRunsOnce) +{ + auto tree = factory.createTreeFromText(R"( + + + + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(cleanup_count, 0); + gate = false; + EXPECT_EQ(tree.tickOnce(), NodeStatus::FAILURE); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); + tree.haltTree(); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, InsideHaltedSubTree_CleanupRuns) +{ + auto tree = factory.createTreeFromText(R"( + + + + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, NestedFinallyAsCleanup_RunsDuringOuterHalt) +{ + auto tree = factory.createTreeFromText(R"( + + + + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 2); +} + +TEST_F(FinallyTest, NestedFinallyAsMain_BothCleanupsRunOnHalt) +{ + auto tree = factory.createTreeFromText(R"( + + + + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + tree.haltTree(); + EXPECT_EQ(main_halted, 1); + EXPECT_EQ(cleanup_count, 2); +} + +TEST_F(FinallyTest, CleanupFailsDuringHalt_IsReported) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + testing::internal::CaptureStderr(); + tree.haltTree(); + EXPECT_NE(testing::internal::GetCapturedStderr().find("cleanup returned FAILURE during " + "halt"), + std::string::npos); +} + +TEST_F(FinallyTest, MainThrows_HaltAfterRethrowDoesNotRerunCleanup) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_THROW(tree.tickOnce(), BT::RuntimeError); + tree.haltTree(); + EXPECT_EQ(cleanup_count, 1); +} + +TEST_F(FinallyTest, MainThrowsWithAsyncCleanup_RethrowsWhenCleanupCompletes) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_THROW(tree.tickOnce(), BT::RuntimeError); + EXPECT_EQ(cleanup_ticks, 2); +} + +TEST_F(FinallyTest, MainThrows_NextTickStartsFresh) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_THROW(tree.tickOnce(), BT::RuntimeError); + EXPECT_EQ(tree.tickOnce(), NodeStatus::SUCCESS); + EXPECT_EQ(throw_once_ticks, 2); + EXPECT_EQ(cleanup_count, 2); +} + +TEST_F(FinallyTest, CleanupThrowsAfterRunningDuringHalt_DoesNotPropagate) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_NO_THROW(tree.haltTree()); + EXPECT_EQ(second_tick_ticks, 2); + EXPECT_EQ(tree.rootNode()->status(), NodeStatus::IDLE); +} + +TEST_F(FinallyTest, CleanupFailsAfterRunningDuringHalt_IsReported) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + testing::internal::CaptureStderr(); + tree.haltTree(); + EXPECT_NE(testing::internal::GetCapturedStderr().find("cleanup returned FAILURE during " + "halt"), + std::string::npos); + EXPECT_EQ(second_tick_ticks, 2); +} + +TEST_F(FinallyTest, UnreadableHaltTimeout_FallsBackToDefault) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + testing::internal::CaptureStderr(); + tree.haltTree(); + EXPECT_NE(testing::internal::GetCapturedStderr().find("cannot read halt_timeout_msec"), + std::string::npos); + EXPECT_EQ(cleanup_ticks, 2); +} + +TEST_F(FinallyTest, CleanupThrows_StatefulMainRestartsOnNextTick) +{ + auto tree = factory.createTreeFromText(R"( + + + )"); + + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_THROW(tree.tickOnce(), BT::RuntimeError); + + // Main was reset with the node, so it starts again rather than reporting its old result. + EXPECT_EQ(tree.tickOnce(), NodeStatus::RUNNING); + EXPECT_EQ(two_tick_starts, 2); + EXPECT_EQ(tree.tickOnce(), NodeStatus::SUCCESS); +}