Skip to content

add Finally control node for guaranteed cleanup - #1229

Merged
facontidavide merged 4 commits into
BehaviorTree:masterfrom
dvadovsz:feat/finally-node
Nov 8, 2026
Merged

facontidavide merged 4 commits into
BehaviorTree:masterfrom
dvadovsz:feat/finally-node

Conversation

@dvadovsz

@dvadovsz dvadovsz commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1228.

Adds a Finally control node with two children, main and cleanup. It ticks main, then ticks cleanup.

  • After main returns SUCCESS or FAILURE, cleanup runs and the node returns main's status. If cleanup fails, the node returns FAILURE.
  • If main returns SKIPPED on its first tick it did nothing, so cleanup does not run and the node returns SKIPPED. If main already returned RUNNING and is then skipped, for example by _while, cleanup runs and the node returns SKIPPED.
  • If main throws, the node halts main, runs cleanup, and then rethrows the exception, the same as finally in C++/Java/Python.
  • If the node is halted while main or cleanup is RUNNING, it halts main and gives cleanup one tick, like TryCatch with catch_on_halt="true".
  • Setting the halt_timeout_msec port above 0 (default 0) lets a multi-step cleanup finish. halt() then keeps ticking cleanup every 10 ms until it finishes, fails or throws, or the timeout runs out and cleanup is halted unfinished. This loop runs only when halt() is called from the thread that last ticked the node. A halt from another thread, such as the Timeout decorator's timer or a Tree::haltTree() from a service callback, gives cleanup the single tick and prints a note to stderr. A Finally nested inside such a halt also gets a single tick.
  • An exception thrown by cleanup resets the node and propagates, replacing main's result, as an exception from a finally block does.
  • halt() never throws, because ~Tree() calls haltTree(). It prints exceptions from cleanup or from halting a child, and a cleanup FAILURE, to stderr.
  • The XML loader rejects a Finally without exactly 2 children, as it does for TryCatch.

This PR includes the commit from #1273, which fixes ControlNode::haltChild leaving a child RUNNING when its halt() threw. Without it, the test binary aborts in std::terminate on a Finally whose main throws from halt(). Once #1273 merges, I will rebase this PR onto master to drop that commit.

Why not an option on TryCatch? The semantics differ in three places. Finally runs cleanup on SUCCESS, returns main's status instead of always FAILURE, and lets main's exception through after cleanup. Packing that into TryCatch would make one node's return value depend on a flag.

Two details worth checking in review:

  • executeTick() does not store SKIPPED as the node's status, and it doesn't update the status when tick() throws. In both cases Finally would stay RUNNING, and a later halt would run cleanup a second time. So the node calls resetStatus() before returning SKIPPED or rethrowing.
  • This registers a new built-in ID, Finally. An application that already registers its own Finally node will get an "already registered" error, so this targets 4.11.

API/ABI: one new header and one registered node. No signatures change.

Testing: tests/gtest_finally.cpp has 44 cases. They cover sync and async main and cleanup, a halt in each phase, the single-tick default, the opt-in loop finishing and timing out, halts from another thread and from Timeout (including a nested Finally), exceptions from main, from cleanup, and from main's own halt(), non-std exceptions, SKIPPED main before and after RUNNING, a restart after a throw, SubTree and ReactiveSequence parents, and child-count validation. I checked that the key tests fail without their fix. The full suite passes in Release (644 tests), the Finally tests pass 50 times in a row under parallel load, ThreadSanitizer reports no races on them, and ASan/UBSan pass per test. pre-commit passes. clang-tidy-21 isn't installed on my machine, so run_clang_tidy.sh will first run in CI.

@facontidavide facontidavide left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it's a carefully done PR. The exception semantics are well thought out, halt() never throwing is the right call, and the tests are thorough. I ran them 30 times, plus 10 more times under 5× parallel load, and they never failed. The full suite passes too.

Before this can go in, there is one design problem to solve, plus a few points to settle.

1. halt() ticks cleanup on whatever thread calls it

TimeoutNode halts its child from its TimerQueue callback (src/decorators/timeout_node.cpp:54), while holding timeout_mutex_. Inside a Timeout, Finally::halt() therefore ticks the cleanup child on the timer thread, and the tick thread is blocked on that mutex until cleanup finishes. I checked it with this test:

TEST(FinallyProbe, HaltedByTimeout_CleanupThread)
{
  BT::BehaviorTreeFactory factory;
  const auto tick_thread = std::this_thread::get_id();
  std::thread::id cleanup_thread;
  int cleanup_ticks = 0;
  factory.registerSimpleCondition("Forever",
                                  [](BT::TreeNode&) { return BT::NodeStatus::RUNNING; });
  factory.registerSimpleCondition("SlowCleanup", [&](BT::TreeNode&) {
    cleanup_thread = std::this_thread::get_id();
    return ++cleanup_ticks < 30 ? BT::NodeStatus::RUNNING : BT::NodeStatus::SUCCESS;
  });
  auto tree = factory.createTreeFromText(R"(<root BTCPP_format="4"><BehaviorTree>
      <Timeout msec="50"><Finally><Forever/><SlowCleanup/></Finally></Timeout>
      </BehaviorTree></root>)");
  tree.tickWhileRunning();
  EXPECT_EQ(cleanup_thread, tick_thread);  // fails: all 30 cleanup ticks ran on the timer thread
}

The doc comment says "Call halt() from the thread that ticks the tree", but a built-in decorator doesn't do that. Neither does a common application pattern: calling Tree::haltTree() from another thread, such as a ROS service callback. Cleanup nodes like action clients, or anything else that isn't thread-safe, would then run on a thread they don't expect.

2. A blocking halt() with a 10 s default

When a ReactiveSequence, ReactiveFallback or Parallel halts a branch that contains a Finally, the whole tree stops for up to halt_timeout_msec. During that time no other node is ticked, including conditions that the reactive parent is supposed to keep re-checking. You describe the trade-off clearly. Still, it changes what halt() means in the library, and the 10 s default makes it the normal case rather than an opt-in.

Some possible directions, and I'm open to others:

  • on halt, give cleanup a single tick, as TryCatch catch_on_halt="true" does, and document that a multi-step cleanup will be cut short;
  • keep the multi-tick loop but make it opt-in, e.g. halt_timeout_msec defaulting to 0 meaning "one tick";
  • keep the loop, but don't run it when halt() is called from a thread other than the one that ticks the tree.

Which one would still cover your collision-checking use case?

3. Smaller points

  • SKIPPED main: cleanup runs and the node returns SKIPPED, so the parent is told that nothing happened even though cleanup did. A skipped main changed nothing, so not running cleanup seems more consistent. What do you think?
  • New built-in ID: registering Finally breaks applications that already register their own node with that name (#547 suggests some exist). That's acceptable in a minor release, so this would target 4.11 rather than a patch release.
  • The halt loop polls every 10 ms and ignores the tree's wake-up signal, so a cleanup that calls emitWakeUpSignal() doesn't finish any sooner. Minor.

4. Please split out the haltChild fix

Resetting the child before rethrowing in ControlNode::haltChild is a real bug fix, independent of Finally: without it, ~Tree() can halt the same child twice and terminate. Could you open it as a separate small PR with its test? I'd merge that right away, while we settle the design of Finally.

@facontidavide

Copy link
Copy Markdown
Collaborator

I would like to merge this before the end of the week and release 4.11.

Do you think you will have some time to look into this?

@facontidavide facontidavide self-assigned this Oct 26, 2026
@dvadovsz

Copy link
Copy Markdown
Contributor Author

I would like to merge this before the end of the week and release 4.11.

Do you think you will have some time to look into this?

Yes, I'll try to look into this over the next few days. Thank you for the in depth review.

Previously the child stayed RUNNING, so the next halt of the parent halted
it again and threw a second time. When that happened inside ~Tree(), the
process terminated. Callers still see the exception.

This covers haltChild() and haltChildren(), which calls it.
ControlNode::resetChildren() and DecoratorNode::resetChild() have the same
pattern and are left for a follow-up.
@dvadovsz

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the Timeout probe. I've pushed a revision that takes your second and third options together, and split out the haltChild fix as #1273.

1 and 2. halt_timeout_msec now defaults to 0, so on halt cleanup gets a single tick, like TryCatch catch_on_halt="true". A value above 0 turns the multi-tick loop back on, and the loop only runs when halt() is called from the thread that last ticked the node. A halt from the Timeout timer or from a Tree::haltTree() on another thread gets the single tick and a note on stderr when a timeout was set. That single tick still runs on the halting thread, under timeout_mutex_ in the Timeout case, the same as TryCatch. I didn't see a way to give cleanup any tick on a halt without that. A Finally nested inside such a halt, for example as another Finally's cleanup, also gets one tick. Your probe is in the tests, along with the nested case and a tick on one thread followed by a halt on another.

For the collision-checking case, re-enabling collisions is a call to another process that returns RUNNING on its first tick, so a single tick would cut it short. The halts that matter there come from a ReactiveSequence or SubTree parent on the tick thread, so setting halt_timeout_msec on that Finally still covers it. A halt from a Timeout around it would get the single tick, and I think that's the right trade-off for that case.

  1. Agreed. A main that is SKIPPED on its first tick now skips cleanup and the node returns SKIPPED. If main already returned RUNNING and is then skipped, for example by _while turning false, it may have started work, so cleanup still runs and the node returns SKIPPED.

  2. Done. The fix and its test are in reset a child's status when its halt() throws in ControlNode::haltChild #1273. This PR is rebased on master and still carries that commit until reset a child's status when its halt() throws in ControlNode::haltChild #1273 merges. The same pattern exists in ControlNode::resetChildren() and DecoratorNode::resetChild(). I left those for a follow-up to keep the fix small, but can fold them in if you prefer.

On the new built-in ID, 4.11 works for me. The 10 ms poll is unchanged, since the node has no access to the tree's wake-up signal, and it now only applies when the loop is opted in.

dvadovsz added a commit to dvadovsz/BehaviorTree.CPP that referenced this pull request Oct 29, 2026
Ports the revision made for BehaviorTree#1229 after
review, keeping halt_timeout_msec at 10000 by default because MoveIt Pro
10.2 shipped with it (upstream now defaults to 0, a single cleanup tick).

- The halt loop runs only when halt() is called from the thread that last
  ticked the node. A halt from another thread, such as the Timeout
  decorator's timer, gives cleanup one tick and prints a note to stderr.
  A Finally nested inside such a halt also gets one tick.
- A main that is SKIPPED on its first tick no longer runs cleanup. A main
  skipped after it returned RUNNING, for example by _while, still does.
- halt_timeout_msec of 0 gives cleanup one tick on halt.
- Adds a regression test for the haltChild fix already on this branch.

Refs PickNikRobotics/moveit_pro#20169

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@facontidavide facontidavide left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @dvadovsz for the careful revision. I went through it against the code, and every claim in your Oct 29 comment holds. I also ran FinallyTest.* ×30, the timing tests ×20 under full CPU load, TSan ×5 (Finally plus every *Timeout* test) and ASan+UBSan. All clean.

One design change before merging: please drop halt_timeout_msec and the multi-tick loop, and ship Finally with a single cleanup tick on halt, the same as TryCatch catch_on_halt="true". About 90 of the 254 lines (the thread id, the thread-local, the sleep loop, the deadline, two stderr paths) exist only for the loop, and the loop has two properties I don't want in a built-in node:

  • it blocks reactive parents for up to halt_timeout_msec;
  • it can't be bounded by an enclosing Timeout: it runs on the tick thread while that Timeout holds timeout_mutex_, so <Timeout msec="100"> … <Finally halt_timeout_msec="5000"> waits 5 s.

If users need multi-tick cleanup on halt, we can add it later as a separate feature.

Small things while you're at it:

  1. finally_node.cpp:88: if the main child threw, cleanup is RUNNING and a halt arrives, main_exception_ is cleared silently. Please log it (e.g. printException) before dropping it.
  2. Declare halt() noexcept, so the "halt never throws" guarantee is checked by the compiler.
  3. In the header and docs, say that the single tick on halt suits synchronous cleanup: an async cleanup child gets onStart() and then onHalted() right away.
  4. #1273 is being reworked: the status reset moves into TreeNode::haltNode() so that every halt path gets it, not only haltChild(). Once that lands, please drop the control_node.cpp commit from this PR and merge master.

facontidavide and others added 2 commits November 7, 2026 19:45
Take master's src/control_node.cpp and tests/gtest_halt_child.cpp (BehaviorTree#1273,
b6709b6), which supersede this branch's copy of the haltChild fix
(fbbcdd1): the status reset now lives in TreeNode::haltNode().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME1hnSFSzibU5phxboGsjk
- A halt() that arrives while another one runs (e.g. Tree::haltTree() from
  another thread while the tick thread is in the halt_timeout_msec loop)
  returns at once, so cleanup is never ticked concurrently. An atomic
  halting_ flag is set with compare_exchange and cleared by RAII.
- halt() is noexcept. The status reset at its end notifies observers that
  may throw, so it is wrapped and reported like the rest.
- A pending main exception dropped by a halt is now printed.
- Document that halt_timeout_msec > 10 blocks the halting parent and any
  enclosing Timeout, adds up across nested Finally nodes, and gives
  foreign-thread halts a single tick; that the default single tick cuts an
  asynchronous cleanup short; and "10 or less" means a single tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME1hnSFSzibU5phxboGsjk

@facontidavide facontidavide left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requested changes addressed in 01d987d.

@facontidavide
facontidavide merged commit 4f672ea into BehaviorTree:master Nov 8, 2026
17 of 19 checks passed
facontidavide added a commit to vivoeiva/BehaviorTree.CPP that referenced this pull request Nov 8, 2026
facontidavide added a commit to vivoeiva/BehaviorTree.CPP that referenced this pull request Nov 8, 2026
facontidavide added a commit that referenced this pull request Nov 8, 2026
* Add 'generic' parameter to writeTreeXSD()

When generic=true, oneNodeGroup uses xs:any processContents="lax" instead
of a closed xs:choice, allowing unknown custom node elements to pass
validation. Top-level xs:element declarations are also emitted so that lax
processing can still resolve and validate the known built-in node types by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add generate_xsd tool

Generates the generic XSD schema from the default factory (built-in nodes
only) and prints it to stdout, suitable for use as a generic static validator
that stays in sync with the built-in node inventory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix non-deterministic port attribute order in writeTreeXSD()

PortsList is an unordered_map so iterating it directly produced XSD
attributes in hash-table order, which varies across runs and platforms.
Sort ports into a std::map before emitting xs:attribute elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add writeTreeSchematron() for cross-reference validation of BT XML files

XSD alone cannot express constraints like "every custom node element used
in a BehaviorTree body must have a matching TreeNodesModel entry"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add generate_schematron tool

Generates an ISO Schematron schema (btcpp4.sch) complementing the generic XSD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add generated XSD and Schematron schemas

* Add validate_xml.py - XSD and Schematron validation tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Only built-ins should go into generic xsd file

* Add tests for writeTreeXSD() and writeTreeSchematron()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix implicit conversion

* Also handle inout_port in XSD

* Fix reconfiguration after editing src/btcpp4_schematron.sch

* Fix writeTreeXSD() signature not to break ABI

* Change placeholder replacement logic for Schematron

* Add more test cases for InsideXmlComment()

* Add xsd and schematron checks to CI (intentionally broken)

* Fix clang-format issues

* Regenerate schemas to pass CI

* Add guard for --no-xsd --no-sch together

to validate_xml.py

* Remove rule checking explicit notation against TreeNodeModel

* Regenerate Schematron file

* Add Schematron Rule B: explicit-notation node structure constraints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Regenerate Schematron file

* Drop insideXmlComment(): keep the placeholder out of the template comment

insideXmlComment() was added to the public xml_parsing.h only so that
writeTreeSchematron() could skip the ##BUILTIN_PIPE## placeholder where
the template comment mentions it. Rewording the comment so it no longer
contains the placeholder makes a plain replace-all enough, removes the
helper from the public API, and also fixes the generated bt4.sch, whose
comment still described an unexpanded placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate tools/generated/bt4.xsd for _onlyIf, _onStart, BTCPP_version

Master gained the _onlyIf/_onStart preconditions (#1261) and the
BTCPP_version root attribute (#1264); the checked-in XSD was stale and
the new 'generated schemas are up to date' CI step would fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME1hnSFSzibU5phxboGsjk

* Regenerate bt4.xsd and bt4.sch for the Finally node (#1229)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME1hnSFSzibU5phxboGsjk

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Davide Faconti <davide.faconti@gmail.com>
@facontidavide

Copy link
Copy Markdown
Collaborator

@dvadovsz I take back point 1 of my review from yesterday. halt_timeout_msec stays. Your revision implements exactly the option I offered on Oct 24, and asynchronous cleanup on halt (the EnableCollisions case from #1228) is the reason the node exists. Removing the loop would have defeated it. Sorry for moving the goalposts.

To get this into 4.11 I pushed 01d987d on top of a merge of master (maintainer edits), and it is now merged as 4f672ea. That commit contains only the small changes:

  • reset a child's status when its halt() throws in ControlNode::haltChild #1273 is merged (the guard now lives in TreeNode::haltNode()), so src/control_node.cpp and tests/gtest_halt_child.cpp are back to master's version, and this PR no longer carries its own copy.
  • Docs: the port description and the header now say that with halt_timeout_msec above 10, halt() blocks the halting parent for up to that time; that an enclosing Timeout is delayed by the same amount and nested Finally nodes add up; that halts from other threads get a single tick; and that with the default 0, an asynchronous cleanup child gets onStart() and then onHalted() right away. Also changed "below 10" to "10 or less".
  • Concurrent second halt: if another thread calls haltTree() while the multi-tick loop runs, it no longer ticks cleanup at the same time (atomic guard, with a test).
  • A pending main-child exception that a halt drops is now logged instead of discarded silently.
  • halt() is noexcept.

Locally: full suite, Finally* ×30, TSan, ASan+UBSan, and the timing tests under CPU load are all clean. Thanks for the careful work and the patience on this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Finally control node: always run a cleanup child, including on exception and halt

2 participants