Repository navigation
Halt the running branch before starting another asynchronous action - #1217
Quchaosheng wants to merge 4 commits into
Conversation
ReactiveSequence and ReactiveFallback halt the siblings of a child only after that child returned RUNNING. When the new child starts an asynchronous action in its onStart(), the action of the branch that is about to be preempted is still alive, so for a short time two actions run concurrently. With the ROS action wrappers that means that a new goal is sent before the previous one is cancelled, which is the race described in issue BehaviorTree#1031. Let an asynchronous node stop the branches that are still RUNNING right before it transitions out of IDLE, walking up to the root. Parallel is the only control node designed to keep several children running at the same time, so it stops the walk. To know the ancestors, every node now stores the parent that the parser sets when the children are added to the tree.
|
A gentle first ping on this one — it has not had a review yet. The race is the one from #1031, and the diff is the direction that issue invited. Reproduced here with The test in the PR fails without the fix and passes with it; the full suite is 523/523. CI is green on the current head as well. Nothing here needs an answer from me — it needs a reviewer's time. If the parent-pointer approach is not the shape you want for this, say so and I will rework it. |
|
I reran the full suite from this branch on current master, not just the new test: cmake -S . -B build -DBTCPP_SQLITE_LOGGING=ON -DBUILD_TESTING=ON 523 tests from 61 test suites ran. PASSED 523. The branch is based on ca4edbf and still merges cleanly into today's master; nothing since then touched the eight files in the diff. @facontidavide the design question is whether a node may walk up to the root and halt siblings before its own onStart(). The alternative in #93 was to have every control node know about asynchronous children (propagateHalt); this keeps that knowledge in the action node instead. Parallel and ParallelAll opt out so their branches still run concurrently. If you prefer the other shape, or want this limited to StatefulActionNode instead of also ThreadedAction and CoroActionNode, tell me which and I will change it. If the direction is fine, a review is all it needs. |
|
Thanks @Quchaosheng for taking on #1031. The bug is real. In 1. Running actions are restarted on every tick. The halt happens on the IDLE →
So 2. ABI break. 3. Halts reach beyond the reactive parent. The walk halts RUNNING siblings at every ancestor up to the root, across SubTree boundaries. Only A version I could accept would:
Happy to discuss the design here before you rework it. |
|
Thanks Davide — all three are right, and I checked each one against a build of the What I confirmed on the branch1. The restart is real, and not limited to "on every tick" of a stateful action. 2. ABI. 3. Reach. I copied nav2's A version I think you can mergeThree changes to your sketch. The substantive one is where the halt is triggered. Trigger it in if(_p->status == NodeStatus::IDLE && type() == NodeType::ACTION)
{
preemptRunningSibling();
}Don't walk to the root — stop at the nearest reactive ancestor, and halt through it.
Opt-in per node, and store the flag in the PImpl. Net effect on the halting scope: halts only ever come from the reactive node itself, Tests, including the one you asked forBoth in
The full suite is green with the change in place: 575 tests, 573 passed, 2 skipped — What this does not fixIf the previous branch completes on its own while the next one starts — a plain How do you want itI have this working as a prototype on current master: about 120 lines of code across
My preference is 1, with 3 as a follow-up if people ask for it. The one thing I do not Thanks for the detailed review — the QuickCheck case in particular is the one I should Chaosheng |
Pin the behavior of master that a fix for #1031 must keep: - In ReactiveSequence / ReactiveFallback, the children before the RUNNING one are re-ticked on every tick. When they complete immediately (a check implemented as a StatefulActionNode or a CoroActionNode), the RUNNING child keeps running: it is neither halted nor started again. - A control node that keeps several children running at once, like nav2's PipelineSequence, does not get them halted when one of them starts. All three tests pass on master and fail on the current #1217 branch (the RUNNING action is started 5 times and halted 4 times in 5 ticks). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @Quchaosheng, this is a very useful analysis, and you confirmed each point with a real build. Please rebase onto master. #1258 added three tests that pin the behavior master must keep: your QuickCheck case for both On the design: I agree with your option 1 (off by default), but I don't think the "sibling re-evaluation vs new branch" rule can work. Before
Suggestion: let the tree author mark the switch point explicitly, for example with a new decorator around the action that starts the new branch: <ReactiveSequence>
<Fallback>
<BatteryOk/>
<Preempt><GotoCharging/></Preempt>
</Fallback>
<GotoKitchen/>
</ReactiveSequence>On its first tick, before ticking its child, For 4.x the parent pointer and the halt helpers should stay internal: state in the existing PImpl, non-virtual members, no new virtuals. Tests I'd like to see with it, in addition to #1258:
One caveat for the ROS use case in #1031: halting only sends the cancel; it doesn't wait for the remote action to stop. |
The previous version halted the running siblings from inside the asynchronous node, whenever it left IDLE. That cannot be made to work: before onStart() runs, a check that will return SUCCESS and a new action that will keep running look identical, so the rule has to guess from the shape of the tree. It also misses ThreadedAction, whose executeTick() is final and does not run the body of TreeNode::executeTick(). Replace it with a decorator that answers the question where the answer is known. Preempt wraps the action that starts the new branch; on its first tick, before ticking its child, it halts the RUNNING child of every reactive ancestor it is not itself inside. Children of a Parallel are not touched, since that node is allowed to keep several of them RUNNING. ReactiveSequence and ReactiveFallback now share a ReactiveControlNode base that owns the running child index and can let go of it on request, so the index stays consistent with the child that is actually RUNNING. The base adds state and non-virtual members only: no existing vtable changes, and the parent pointer lives in the existing PImpl. Tests: both fallbacks, an action inside a SubTree, a ThreadedAction, and a Parallel inside the new branch whose children must keep running together. The three cases pinned by BehaviorTree#1258 pass, as does the rest of the suite (591 passed, 2 skipped). Suggested-by: Davide Faconti <davide.faconti@gmail.com>
|
You are right on all counts, and I reproduced the numbers you quote before touching anything: on the previous head, the three tests from #1258 fail with I have replaced the approach with the one you suggested and pushed What it does nowA new decorator, <ReactiveSequence>
<ReactiveFallback>
<BatteryOk/>
<Preempt>
<GotoCharging/>
</Preempt>
</ReactiveFallback>
<GotoKitchen/>
</ReactiveSequence>On its first tick, before ticking its child, it walks up the tree and halts the RUNNING child of every reactive ancestor it is not itself inside. The "not inside" part is what makes it safe: when the ancestor's running child is the branch we came from, halting it would halt ourselves, so it is skipped. Children of a
What is goneThe old mechanism is removed entirely: the TestsAdded
I also updated Full suite: 591 passed, 2 skipped, 0 failed. The three #1258 tests pass. Two caveats
|
|
CI is red |
Two separate CI failures. The tsan job fails on Preempt.threadedAction, and the race is in the new test rather than in the library: Tracker::events is written by the ThreadedAction worker thread from onStart/onHalted while waitFor() polls hasEvent() on the main thread, so the vector can be reallocated while it is being read. Tracker now guards the vector with a mutex and exposes count(), so the fixture no longer reaches into the list directly. Reproduced on the previous commit with setarch -R: 3/3 runs report the race and exit 66. After the change, 5/5 runs are clean and the whole Preempt and ReactiveBackchaining suites pass under tsan. The clang-format failure is unrelated: it reorders the preempt.h include and drops three blank lines. No behaviour change.
|
Both red checks are fixed in b231399. pre-commit was formatting only: clang-format reorders the build (ubuntu-22.04, tsan) was a real race, but in the new test rather than in the library. On the previous commit it reproduces 3/3 locally with |
ReactiveSequence and ReactiveFallback halt the siblings of a child only after that child returned RUNNING. When the new child starts an asynchronous action in its onStart(), the action of the branch that is about to be preempted is still alive, so for a short time two actions run concurrently. With the ROS action wrappers that means that a new goal is sent before the previous one is cancelled, which is the race described in issue #1031.
Let an asynchronous node stop the branches that are still RUNNING right before it transitions out of IDLE, walking up to the root. Parallel is the only control node designed to keep several children running at the same time, so it stops the walk.
To know the ancestors, every node now stores the parent that the parser sets when the children are added to the tree.
This follows the direction invited in #1031 for reworking #93, without the propagateHalt coupling: the async node asks its ancestors to release the other branches, instead of every control node having to know about asynchronous children.
Reproduced with ReactiveSequence[ ReactiveFallback[BatteryOk, GotoCharging], GotoKitchen ]:
before: GotoKitchen:onStart ... GotoCharging:onStart GotoKitchen:onHalted
max concurrent actions = 2
after: GotoKitchen:onHalted GotoCharging:onStart
max concurrent actions = 1
The new test fails without the fix (max concurrent actions 2, halt after start) and passes with it. All 523 tests pass.
Fixes #1031