Skip to content

feat: add input port update subscriptions - #1247

Merged
facontidavide merged 5 commits into
BehaviorTree:masterfrom
kVinsom:feat/872-input-port-callbacks
Oct 24, 2026
Merged

facontidavide merged 5 commits into
BehaviorTree:masterfrom
kVinsom:feat/872-input-port-callbacks

Conversation

@kVinsom

@kVinsom kVinsom commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Input-port consumers currently have to poll during a tick to discover blackboard writes. This draft adds instance-level TreeNode::subscribeToInputPort() so a node can react before its next tick, without putting instance callbacks into static providedPorts() declarations.

Related to #872. Design proposal for BT.CPP 5: this adds storage to Blackboard, so it changes its binary layout and requires rebuilding clients. The draft targets master for discussion because there is no public v5 branch. Please advise on the preferred target/API before merging.

Behavior

  • Subscriptions resolve explicit/automatic subtree remapping, root keys, {=}, and blackboard-backed defaults. Constant inputs return an empty handle.
  • Notifications cover set(), setOutput(), script assignments, JSON import, and snapshot restoration. Subscribe-before-first-write does not create or type-lock an entry; subscriptions survive removal and recreation of the key.
  • Callbacks run synchronously outside blackboard locks. Handles unsubscribe on destruction; reentrant notification and subscription changes are supported. The existing status-change Signal is unchanged.
  • Documentation and 15 regression tests cover remapping, defaults, invalid ports, failed writes, reentrancy, concurrent writers/subscribers, and lifetime behavior.

Contract to review

This observes successful writes, including equal values, rather than comparing arbitrary Any values. Callbacks read the latest value; concurrent writers can overlap callbacks and do not guarantee delivery order. Resetting a handle does not wait for callbacks already selected for invocation: callers must stop writers before destroying captured targets. Exceptions propagate after the write commits and stop the remaining callbacks in that notification.

Raw Entry/AnyPtrLocked writes require an explicit notifyEntryUpdated() after unlocking. Remapping is resolved at registration; changing it requires a new subscription. Subscription records remain associated with keys for the blackboard lifetime.

Validation

  • Debug build, including examples and tools: passed.
  • Full serial CTest run: 565/565 passed, no skips.
  • New regression suite under ThreadSanitizer: 15/15 passed without suppressions.
  • Pre-commit hooks on all changed files: passed.
  • Full ./run_clang_tidy.sh: passed.
  • Full-repository pre-commit: all hooks except clang-tidy passed. Clang-tidy reports three existing findings in unchanged tests/test_helper.hpp (member initialization and special members) and tests/gtest_loggers.cpp (reinterpret cast).
  • Full ASan/UBSan suite with leak detection: 565/565 passed, no skips.
  • Full TSan suite with the project's existing suppressions: 569/569 passed, no skips.

Tests are run serially because the existing logger tests share and delete /tmp/bt_logger_test; a parallel trial produced file/database collisions. macOS, Windows and ROS builds have not been run locally.

Developed with assistance from OpenAI Codex.

@kVinsom
kVinsom marked this pull request as ready for review October 23, 2026 15:11
@kVinsom

kVinsom commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Could a maintainer please re-run the failed Linux/Pixi job in this workflow run? The build was interrupted with exit code 143 and "The runner has received a shutdown signal." The Windows/Pixi job passed. I tried to re-run the failed job, but GitHub rejected the request because I do not have the required repository permissions. Thank you!

@facontidavide

Copy link
Copy Markdown
Collaborator

Thanks @kVinsom, this is a carefully built proposal: the documented contract, re-entrancy handling and 15 focused tests are appreciated.

We can't take it into 4.x, though, for two reasons:

  1. ABI: the new update_signals_ member changes the layout of Blackboard. Because the library has no SOVERSION, plugins compiled against an older 4.x would break silently at runtime.
  2. Hot-path cost with no subscribers: every set() / setOutput() / script assignment now calls getUpdateSignal(), which takes storage_mutex_ exclusively and does two lookups per level up the parent chain. On master getEntry() takes a shared lock, so with this PR every write serializes against concurrent readers, even when nobody has subscribed.

For the use case in #872 (react when the "topic" input changes), polling at tick time with getInputStamped() already detects "written since last seen" without any API change.

As you suggested yourself, this fits BT.CPP 5 better. If you want to keep it alive for v5, a fast path when nobody subscribes (e.g. an atomic subscriber count checked before any locking) and a small write-throughput benchmark would make it much easier to evaluate. I'll leave the PR open with that in mind.

(The red pixi_conda_build job is not caused by your change: an unbounded parallel build killed the runner. We'll fix that separately.)

facontidavide and others added 2 commits October 24, 2026 16:04
…x ABI

Blackboard::create() is inline, so client binaries bake in
sizeof(Blackboard): the per-key update_signals_ map broke the ABI of the
4.x series. It also made every write take storage_mutex_ exclusively and
look the key up again, even without subscribers, and set() could notify
the signal of a different key resolution than the one it wrote to.

The subscribers are now kept in a shared_ptr appended as the last member
of Blackboard::Entry. Entries are allocated only inside the library and
inline code only touches the earlier members, whose offsets do not
change; Blackboard is byte-identical to before. The pointer is guarded
by entry_mutex and created lazily on the first subscription. Writers
copy it under the entry lock they already hold and notify after
releasing it, so a write without subscribers costs a null check.

subscribeToEntry() follows remapping and '@' via getEntry() and, when
the entry is missing, creates a generic one (subscribeToInputPort() uses
the port's type, like tree creation does). As a consequence a
subscription is bound to the entry: unset() drops it and a re-created
entry is not observed. Writes from code compiled against older headers
do not notify. Both are documented and covered by tests.

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

Copy link
Copy Markdown
Collaborator

Update to my earlier comment: we found a way to make this ABI-compatible with 4.x, so I reworked it on top of your branch (thanks for enabling maintainer edits; your commit is unchanged). Two commits: a merge of master, and 1c37445.

What changed:

  • Blackboard keeps exactly the same data members as 4.10. update_signals_ and getUpdateSignal() are gone.
  • Subscribers live in a new update_signal member appended as the last field of Blackboard::Entry. Entry is only allocated inside the library, so appending keeps the offsets that inline header code uses. I checked: sizeof(Blackboard) and all pre-existing Entry offsets are identical to master, and only sizeof(Entry) grows.
  • Writers (set<T>, script assignment, JSON import, cloneInto) copy the signal pointer under the entry_mutex they already hold, and notify after releasing it. With no subscribers, a write costs one null check: no storage_mutex_ and no second key lookup.
  • subscribeToEntry binds to the resolved entry, creating a generic one if needed. subscribeToInputPort creates it with the port's type.

Semantics that differ from your version (documented in docs/INPUT_PORT_CALLBACKS.md):

  1. A subscription is bound to the entry: unset(key) drops it, and a re-created entry isn't observed.
  2. Writes from code compiled against headers older than this change (≤ 4.10 plugins) don't notify. Every write path is an inline template, so there's no way around that without breaking ABI.

Your tests are kept, with a few adapted to (1), plus new ones: the subscription is bound to the entry, and writes without subscribers don't allocate a signal. Locally: full suite green, ASan/UBSan and TSan clean on the callback and blackboard tests, pre-commit clean. Comments welcome. I'd like your 👍 before this gets merged.

@facontidavide
facontidavide merged commit 430ea97 into BehaviorTree:master Oct 24, 2026
17 checks passed
@facontidavide

Copy link
Copy Markdown
Collaborator

Merged as 430ea97, thanks @kVinsom! The idea and the subscription contract are yours, and your commit authorship is kept on the squash. The only change on our side was moving the subscribers into Blackboard::Entry, so it fits the 4.x ABI. It will be in the next minor release (4.11).

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.

2 participants