fix(parameter): stop deadlocking on re-entry from an on_set callback - #257
Open
YuanYuYuan wants to merge 2 commits into
Open
fix(parameter): stop deadlocking on re-entry from an on_set callback#257YuanYuYuan wants to merge 2 commits into
YuanYuYuan wants to merge 2 commits into
Conversation
YuanYuYuan
force-pushed
the
pr/1-reentrancy-tripwire
branch
from
July 28, 2026 06:35
ad3a4d7 to
9bc6dfd
Compare
YuanYuYuan
force-pushed
the
pr/3-parameter-reentrancy
branch
from
July 28, 2026 07:33
83a1839 to
f1bfde9
Compare
There was a problem hiding this comment.
Pull request overview
Fixes parameter callback re-entry deadlocks by releasing the callback lock before invoking user code.
Changes:
- Clones the callback outside its lock guard.
- Adds debug-time re-entrancy detection.
- Adds deadline-guarded service and parameter regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
crates/hiroz/src/parameter/service.rs |
Prevents callback-under-lock deadlocks. |
crates/hiroz-tests/tests/reentrant_service.rs |
Adds re-entrancy regression coverage. |
Comments suppressed due to low confidence (2)
crates/hiroz-tests/tests/reentrant_service.rs:311
- This comment says
validate_and_applystill holds the read lock across the callback, which the production change specifically prevents. Describe this as the former defect so future readers do not infer that the regression remains.
/// This is the deterministic form of the same defect. `validate_and_apply` holds
/// `on_set_callback.read()` across the user callback; `on_set_parameters` takes
/// `on_set_callback.write()`. A callback that re-registers therefore asks the
/// same thread for a write lock while it still holds a read lock on the same
/// `std::sync::RwLock` — a guaranteed self-deadlock, no race required.
crates/hiroz-tests/tests/reentrant_service.rs:337
- At this point the callback no longer holds the read lock, so this inline comment is stale. Make clear that it describes the pre-fix failure mode rather than the current execution.
// Re-register from inside the callback: write lock requested
// while this thread still holds the read lock.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
YuanYuYuan
force-pushed
the
pr/3-parameter-reentrancy
branch
from
July 28, 2026 10:29
f1bfde9 to
33d3cce
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
crates/hiroz-tests/tests/reentrant_service.rs:16
- This points readers to
reentrant_publish.rs, but no such file exists in the repository. Please remove the stale reference (or name the actual test/helper if one was intended).
//! Every scenario runs on a dedicated thread behind a hard deadline, so a
//! re-entrancy deadlock fails the test instead of wedging the suite — the same
//! shape as `reentrant_publish.rs`.
YuanYuYuan
force-pushed
the
pr/3-parameter-reentrancy
branch
4 times, most recently
from
July 28, 2026 13:09
1d90d41 to
74d5aaa
Compare
YuanYuYuan
force-pushed
the
pr/1-reentrancy-tripwire
branch
from
July 28, 2026 14:23
21c96f5 to
69326f0
Compare
YuanYuYuan
force-pushed
the
pr/3-parameter-reentrancy
branch
from
July 28, 2026 14:23
74d5aaa to
1a92da7
Compare
`ParameterState::validate_and_apply` bound the `on_set_callback.read()` guard to a named local and invoked the user callback under it. Two re-entrant paths, both reachable from the public API: - `on_set_parameters` from inside the callback takes `on_set_callback.write()` while the same thread still holds the read guard — a guaranteed self-deadlock, no race required. - `set_parameter` from inside the callback re-enters `validate_and_apply` and takes the read lock recursively, which `std::sync::RwLock` does not guarantee: it deadlocks if a writer is queued between the two acquisitions. `SetCallback` is already an `Arc`, so the fix is to clone it out and let the guard drop before the call — one refcount bump, no structural change. The store guard in the same function was already correctly scoped; only the callback guard was wrong. The lock becomes a `TrackedRwLock` and both call sites dispatch through `invoke_user_callback!`, so a reintroduction panics in debug naming the site instead of hanging. Detector evidence, both directions. Against the unfixed source `parameter_on_set_callback_reregistering_does_not_deadlock` fails on its 30s deadline (3 passed; 1 failed, 35.59s); with the fix all four pass in 6.59s. Separately, reverting only the guard-drop and keeping the tripwire makes the violation fire on `test_parameter_validation_callback` — an *ordinary* test, not a deadlock test — naming the site and the live guard count. Services and actions were audited for the same shape and are clean. `ZServer::build_internal` declares a plain zenoh queryable and calls `handler.handle(query)` with no hiroz lock held; the action server's user handler is awaited with no guard on the stack, and the action client never runs user code on a zenoh thread at all.
Three comments in `reentrant_service.rs` described the pre-fix implementation in the present tense -- "`validate_and_apply` holds `on_set_callback.read()` across the user callback" -- in a branch whose entire purpose is that it no longer does. A reader arriving later would conclude the defect is still live. Also states plainly what the recursive-`set_parameter` scenario detects. Recursive `read()` on one thread succeeds unless a writer is queued between the two acquisitions, and nothing in the test queues one, so against unfixed source it is a coin flip rather than a detector -- which matches this PR's own evidence, where only the re-registering case fired. It is a regression test for the fixed behaviour; the re-registering case is the deterministic one. Saying so stops the next reader trusting it as proof the defect existed.
YuanYuYuan
force-pushed
the
pr/3-parameter-reentrancy
branch
from
July 30, 2026 02:55
1a92da7 to
4cf5cd7
Compare
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes #256
ParameterState::validate_and_applyinvoked the user'son_setcallback while holding theRwLockread guard the callback is registered under. Re-entering the parameter API from that callback — re-registering it, or setting another parameter — blocks on a lock the same thread already holds. No race required.Depends on
pr/1-reentrancy-tripwire, which suppliesTrackedRwLockandinvoke_user_callback!. Branched off it accordingly.Stacked PR — this PR's base is
pr/1-reentrancy-tripwire, notmain. The diff therefore shows only this branch's own commit. Mergepr/1first; GitHub will retarget this one tomainautomatically when it does.Key Changes
crates/hiroz/src/parameter/service.rs— clone theArc<SetCallback>out and let the read guard drop before invoking.SetCallbackis already anArc, so this is one refcount bump and no structural change. The store guard in the same function was already correctly scoped and is untouched.TrackedRwLockand both call sites dispatch throughinvoke_user_callback!, so a reintroduction panics in debug naming the site instead of hanging.crates/hiroz-tests/tests/reentrant_service.rs— four deadline-guarded scenarios: a service handler publishing on its own session, a service handler calling a second service on its own session, anon_setcallback setting another parameter, and anon_setcallback re-registering itself. The harness distinguishes a worker panic from a timeout, so an ordinary assertion failure is not misreported as a deadlock.Services and actions were swept for the same shape and are clean, demonstrably rather than by assumption:
ZServer::build_internaldeclares a plain zenoh queryable and callshandler.handle(query)with no hiroz lock held, and zenoh core clones queryable callbacks and drops its state before invoking them, exactly as it does for subscribers; the action server's user handler is awaited inrun_driver_loopwith no guard on the stack,SafeGoalManager::modifyis only ever handed crate-internal closures, and the action client never runs user code on a zenoh thread at all — feedback and status are forwarded into channels and consumed on the user's own task.What fails without this
Detector proven in both directions.
Against the unfixed source,
parameter_on_set_callback_reregistering_does_not_deadlockfails on its 30 s deadline:With the fix, all four pass in 6.59 s.
Separately, reverting only the guard-drop and keeping the tripwire makes the violation fire on
test_parameter_validation_callback— an ordinary parameter test, not a deadlock test:Removing the violation restores 13/13. That property is the point of the barrier: any existing test that merely exercises a callback becomes a detector for the whole class.
No regression:
parameter_tests13/13,hiroz --lib286/286,reentrant_service4/4.Rebased onto
pr/1's current tip (a70f7336) and re-verified there. The rebase replayed this branch's two commits with no conflicts, and its own diff is byte-identical before and after — it adds onlyreentrant_service.rsandparameter/service.rs.Measured on the rebased head:
The last line matters after a rebase:
pr/1enforces that requirement frombuild.rs, and this confirms picking up that commit did not defeat it.None of this ran on a GitHub runner, and it cannot until the base moves:
ci.ymltriggers only on pull requests targetingmain, so a PR based onpr/1gets 3 checks instead of 27. Re-verify after the retarget tomain.Breaking Changes
One, and it is the same trade
pr/2discloses for subscribers.An
on_setcallback that unconditionally sets another parameter used to deadlock — the secondsetblocked on a lock the same thread held. It now recurses: the guard is dropped before the callback runs, so the nestedsetacquires the lock, invokes the callback again, and repeats until the stack is exhausted. The failure mode changes from a silent hang to afatal runtime error: stack overflow.This is the intended direction — an observable, stoppable crash beats an unkillable hang, and it is the failure any other ROS 2 stack would give — but it is a behaviour change, not "None". A callback that guards its own re-entry (the ordinary case, and what the new tests do) is unaffected.
Notes
cargo docis red onmaintoday for reasons unrelated to this PR (three unresolved intra-doc links inerror.rs), so this branch inherits a failing rustdoc check. It adds no doc links of its own;pr/0-rustdoc-linksclears the gate for every branch in this series.Run with
--features ros-msgs,jazzy, which is whatcrates/hiroz-tests/build.rsrequires package-wide onpr/1. Verified:cargo check -p hiroz-tests --tests --features ros-msgs,jazzysucceeds on this branch.Checklist
./scripts/check-local.shsuccessfully