feat(hiroz): debug-time barrier against callbacks under a lock - #255
feat(hiroz): debug-time barrier against callbacks under a lock#255YuanYuYuan wants to merge 7 commits into
Conversation
ad3a4d7 to
9bc6dfd
Compare
There was a problem hiding this comment.
Pull request overview
Adds debug-time callback re-entrancy detection and strengthens test/local-check coverage.
Changes:
- Adds tracked synchronization primitives and callback assertions.
- Ensures feature-gated integration tests run in CI and coverage.
- Aligns local formatting and validation checks.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
crates/hiroz/src/reentrancy.rs |
Implements the re-entrancy detector. |
crates/hiroz/src/lib.rs |
Exports the new module. |
crates/hiroz-tests/tests/feature_gate.rs |
Detects featureless test runs. |
scripts/test-pure-rust.nu |
Runs integration tests with required features. |
scripts/check-local.sh |
Formats all workspace members. |
scripts/check-local.nu |
Expands local validation checks. |
.github/workflows/ci.yml |
Enables test features during coverage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (8)
scripts/check-local.nu:83
check-hualways builds the WASM plugins, but the defaultpureRustdevelopment shell intentionally omits thewasm32-wasip2sysroot (flake.nix:469-472). Adding it unconditionally here makes the full local suite fail in the repository's default environment. Please either gate/split the WASM portion or provision/requirepureRust-wasmbefore running this check.
{name: "hu clippy (check-hu)", cmd: "nu scripts/test-pure-rust.nu check-hu"},
crates/hiroz-tests/tests/feature_gate.rs:34
- This failure message names
reentrant_service.rs, but that test target does not exist, so users are told that nonexistent coverage was skipped. Describe the actual gated suites unless the missing target is added.
The suites gated on it — `reentrant_service.rs` among them — have been \
compiled to empty test binaries and will report `0 passed`, which reads \
as green. That is not a pass; it is no coverage.\n\
crates/hiroz/src/reentrancy.rs:223
- This replacement guard loses the standard library guard's
must_usediagnostic, allowing an acquired read lock to be immediately and silently discarded.
#[derive(Debug)]
pub struct TrackedReadGuard<'a, T> {
crates/hiroz/src/reentrancy.rs:236
- This replacement guard loses the standard library guard's
must_usediagnostic, allowing an acquired write lock to be immediately and silently discarded.
#[derive(Debug)]
pub struct TrackedWriteGuard<'a, T> {
scripts/check-local.nu:82
- No workflow invokes
check-local.sh; CI directly invokes selectedtest-pure-rust.nusubcommands, and the Rustdoc-link check is not present in the workflows. Please avoid claiming that remote CI runs the shell script.
This issue also appears on line 83 of the same file.
# The four checks below exist in scripts/check-local.sh (which remote CI
# runs) but were absent here, so they could only ever fail remotely.
crates/hiroz-tests/tests/feature_gate.rs:5
reentrant_service.rsdoes not exist in this repository (the only references are the new comments/messages), whiletest_parameter_validation_callbackis ungated atparameter_tests.rs:61. Consequently this gate does not protect the callback-under-lock test coverage claimed here and in the PR description. Please add the referenced test target in this PR or base the gate and rationale on coverage that actually exists.
This issue also appears on line 32 of the same file.
//! Several suites in this crate are `#![cfg(feature = "ros-msgs")]` — most
//! importantly `reentrant_service.rs`, which is the only coverage the
//! callback-under-lock deadlocks in the service and parameter paths have. A
scripts/test-pure-rust.nu:25
- The named
reentrant_service.rstest target is absent from the repository, so this comment misstates which tests are enabled by the second command.
# Several of its suites — `reentrant_service.rs` most importantly — are
# `#![cfg(feature = "ros-msgs")]`, and a crate-level cfg that is not satisfied
# compiles to an empty test binary reporting `0 passed`. That reads as green.
crates/hiroz/src/reentrancy.rs:168
- Unlike
std::sync::MutexGuard, this public replacement is notmust_use, sotracked.lock().unwrap();silently drops the lock immediately. Preserve the standard guard diagnostic to prevent accidental no-op locking.
This issue also appears in the following locations of the same file:
- line 222
- line 235
#[derive(Debug)]
pub struct TrackedMutexGuard<'a, T> {
hiroz has repeatedly shipped the same defect: a user callback invoked while a non-reentrant hiroz lock guard is still live on the calling thread. Any callback that re-enters hiroz there blocks on a lock its own thread already holds. It needs no race and is reachable from ordinary public API. Add `reentrancy`, a debug-only tripwire for the invariant "a user callback is never invoked while a hiroz lock guard is live". `TrackedMutex` and `TrackedRwLock` wrap the `std::sync` types and count live guards in a thread-local; `invoke_user_callback!(site, call)` asserts the count is zero and names the site before dispatching. Field order is fixed so the inner guard releases before the counter decrements. Everything is behind `debug_assertions` and compiles to nothing in release; tests and CI run in debug, which is where the assertion is wanted. A cheaper option was tried first and does not work. `clippy::significant_drop_in_scrutinee` reports zero hits on code known to contain the defect, because the shape is `if let Ok(cb) = holder.lock()`, which *binds* the guard rather than leaving it a scrutinee temporary. The defect is a dynamic guard lifetime spanning a dynamic dispatch through `Arc<dyn Fn>` or an `extern "C" fn` pointer; static analysis sees through neither, a counter sees both. The module carries a unit test that asserts the tripwire panics while a guard is live, so the detector cannot silently rot into a no-op. Two adjacent fixes are needed for this barrier to actually run: - `hiroz-tests` was linted under interop features but tested under none. Several of its suites are `#![cfg(feature = "ros-msgs")]`, and a crate-level cfg that is not satisfied compiles to an empty test binary reporting `0 passed` — indistinguishable from green. `run-tests` now runs the workspace excluding `hiroz-tests`, then `hiroz-tests` with `ros-msgs,jazzy`; coverage gets the same features for the same reason. `feature_gate.rs` is not gated, so a featureless build cannot compile it away, and it fails with a message naming the cause. - `check-local.nu` (the local gate) was missing four checks that `check-local.sh` (what CI runs) has: check-hu, test-shm, check-distro-features and the rustdoc intra-doc link check, and neither passed `--all` to `cargo fmt`, which skips `hiroz-py` and `rmw-zenoh-rs`. The rustdoc detector was validated by injecting a broken link: rc=0 clean, rc=1 broken, rc=0 restored. The five new intra-doc links in `reentrancy.rs` are fully qualified so they resolve under that gate. The three pre-existing unresolved links in `error.rs` are a separate defect on main and are fixed in their own change, not buried here.
… gate `GuardCount` was a fieldless unit struct, so any code able to name it could construct one -- and therefore drop one. `Drop` decrements the thread-local, so a stray `drop(GuardCount)` while a tracked guard was live took the count to zero, `assert_no_guards_held` then passed, and a genuine callback-under-lock went unreported. `saturating_sub` made that desync permanently silent. A private field means only this module can mint one, so the count moves only by acquiring and releasing a real guard. The module docs called it a unit struct; they now say zero-sized newtype. `check-local.nu`'s rustdoc gate matched only `unresolved link`, while `check-local.sh:95` -- what CI runs -- also matches `broken_intra_doc_links`. A diagnostic carrying the lint name but not that phrase passed locally and failed remotely, which is the specific way this gate has already wasted CI cycles on this branch series. Both patterns now, as the shell gate does. No behaviour change: the four `reentrancy::tests` still pass, including `assert_fires_while_a_guard_is_live`, which is the one that proves the counter still detects.
`tests/feature_gate.rs` only fails when Cargo happens to select that target. `cargo test -p hiroz-tests --test reentrant_service` with no features builds only that target, the crate-level `cfg` compiles it to an empty binary, `0 passed` is reported, and the guard never runs -- so the silent-skip mode it exists to close stayed reachable through the narrower invocation. A build script runs for every build of the package whatever targets are selected, so that is the one place the requirement holds everywhere. Verified both ways: `cargo check -p hiroz-tests --tests` exits 101 with the message, and with `--features ros-msgs,jazzy` exits 0. Note `cargo check -p hiroz-tests` *without* `--tests` proves nothing here -- the crate has no lib target, so cargo builds nothing and never runs the build script at all. That invocation reports success on a crate it did not compile. `scripts/test-pure-rust.nu`'s `shm_example` step named no features; it now does. `shm_example` itself is ungated, but the crate no longer has a supported featureless configuration, which `feature_gate.rs` already declared.
Two things the GitHub pre-commit gate caught that the local runner does not. `scripts/test-ros.nu` ran `cargo clippy --all-targets --workspace -F rmw`, which selects `hiroz-tests` with no features. Its suites are `#![cfg(feature = "ros-msgs")]`, so clippy was linting a set of empty files and reporting success -- the same hollow result the new build-script guard exists to refuse. That guard turned this from a silent no-op into a hard failure, which is the point. Exclude the crate from the workspace pass and lint it separately with `ros-interop,<distro>`, matching how the same script already builds and tests it. Also drops a stray blank line in `crates/hiroz-tests/build.rs` that the pinned stable rustfmt in the pre-commit hook rejects. Worth noting the local `cargo fmt --all --check` does not catch it: CI runs `nix build .#checks...pre-commit-check`, a different and stricter gate.
`run-tests` gained `--workspace --exclude hiroz-tests` so the gated suites would actually run. That also pulled in `rmw-zenoh-rs`, whose build script generates bindings from ROS C headers -- so the ROS-*independent* job now fails with `'rcutils/strdup.h' file not found` before running a single test. `rmw-zenoh-rs` is not in `default-members`, so it was never built by this job before `--workspace` was introduced. Excluding it restores the previous selection rather than dropping coverage; the ROS jobs build and lint it via `-F rmw`. This did not show up on the local runner because its dev shell patches the workspace member list to drop that crate. GitHub's runner does not, which is why the wider matrix caught it.
`--workspace` also pulled in `hiroz-msgs`, whose `shm_size_estimation` suite asks the OS for a POSIX shared-memory segment big enough for a PointCloud2. A GitHub runner's /dev/shm cannot provide one: the failure is `OS error 12` (ENOMEM) raised inside `zenoh-shm` before any hiroz code runs. Three tests fail for a property of the machine, not of the change under test. Skip that binary rather than exclude the whole crate, so the rest of `hiroz-msgs` keeps the coverage `--workspace` was added to gain. SHM itself is still covered by the dedicated `test-shm` step, which allocates segments a runner can serve. Like `rmw-zenoh-rs`, `hiroz-msgs` is not in `default-members`, so none of this ran in this job before `--workspace` was introduced -- these are newly-surfaced, not newly-broken.
21c96f5 to
69326f0
Compare
…history Review found three false claims shipped in code, not just prose. 1. The panic message told developers to "see GraphEventManager:: trigger_graph_change for the pattern". On this branch that function holds trigger_guard_condition AND graph_guard_conditions across trigger(gc), an opaque extern "C" pointer -- it is a live instance of the defect the tripwire just caught. It only becomes the pattern after pr/4b rewrites it. The pattern is now stated inline instead of cross-referenced. 2. feature_gate.rs, build.rs and test-pure-rust.nu all cited reentrant_service.rs as the most important gated suite, and asserted "that is exactly how those four tests stopped running without anyone noticing". That file does not exist on main or on this branch -- it arrives with pr/3. Those tests never ran, so they cannot have stopped running. Now cites the ten suites that actually are gated here. 3. The "0 passed" story conflated two mechanisms. default-members excludes hiroz-tests, so a bare `cargo nextest run` never built the crate at all -- it was not built empty. The empty-compilation mode is real but reachable only when the crate IS selected without features. Both are now stated, and kept distinct.
Description
Closes #254
This PR fixes no defect. It lands a regression barrier — the enforcement mechanism for an invariant that has been violated five separate times in this repository, each time with the same shape: a user callback invoked while a non-reentrant hiroz lock guard is still live on the calling thread.
Scope, stated up front: this PR delivers the mechanism, not its call sites. Nothing in production code uses
TrackedMutex,TrackedRwLock,assert_no_guards_heldorinvoke_user_callback!on this branch — every use is insidereentrancy.rsand its own unit tests. The five call sites arrive with the fixes that need them (#257 parameter, #260 event/graph, #262 rmw), each of which converts its lock and routes its callback through the macro. So the barrier is inert as merged and becomes load-bearing as those land. That ordering is deliberate: the type has to exist before three PRs can reference it, and bundling the conversions here would make this PR the whole series.It should land first. Three of the fixes in this series will not compile without it.
Key Changes
crates/hiroz/src/reentrancy.rs(new) —TrackedMutex<T>/TrackedRwLock<T>wrap thestd::synctypes; their guards carry aGuardCountthat increments a thread-local on acquire and decrements on drop. Field order is fixed so the inner guard releases before the counter decrements.assert_no_guards_held(site)panics if any tracked guard is live, naming the call site;invoke_user_callback!(site, call)does both in one line. Everything is behinddebug_assertionsand compiles to nothing in release — tests and CI run in debug, which is where the assertion is wanted.crates/hiroz-tests/build.rs— the package-wide half of the feature enforcement.tests/feature_gate.rsfails loudly on a featureless build, but only if Cargo selects that target:cargo test -p hiroz-tests --test cachewith no features builds onlycache, the crate-levelcfgcompiles it to an empty binary,0 passedis reported, and the guard never runs. A build script runs for every build of the package regardless of target selection, so it is the only place the requirement can be stated once and hold everywhere. Consequence, declared in both places:hiroz-testshas no supported featureless configuration.scripts/test-pure-rust.nu,.github/workflows/ci.yml,crates/hiroz-tests/tests/feature_gate.rs— a barrier that is not executed is not a barrier.hiroz-testswas linted under interop features but tested under none. Several suites are#![cfg(feature = "ros-msgs")], and a crate-level cfg that is not satisfied compiles to an empty test binary reporting0 passed, which is indistinguishable from green. That is what CI saw: the clippy step compiled the files, the test step did not run them, and four re-entrancy tests had no coverage in the job that gates every PR.run-testsnow runs the workspace excludinghiroz-tests, thenhiroz-testswithros-msgs,jazzy; coverage gets the same features for the same reason. The features need no ROS installation —hiroz-msgsbundles the definitions — so there was never a reason to run the crate blind.scripts/check-local.nu,scripts/check-local.sh—check-local.nu(the local gate) was missing four checks thatcheck-local.sh(what CI runs) has:check-console,test-shm,check-distro-featuresand the rustdoc intra-doc link check. Neither passed--alltocargo fmt, which silently skipshiroz-pyandrmw-zenoh-rs. Three of five failures in a recent CI run landed in exactly that blind spot, so no local run could have caught them.What fails without this
No failing baseline, and that is the honest answer: this PR fixes no bug. The justification is that it makes an entire class of defect detectable rather than each instance individually.
What can be shown is that the detector detects, which matters more here than usual — a check that has never printed a failure is unvalidated. Two demonstrations, and they are not equally scoped:
1. Self-contained, reproducible on this branch.
reentrancy.rscarries four unit tests of its own, includingassert_fires_while_a_guard_is_live— ashould_panictest that fires the assertion with a live tracked guard, so the tripwire cannot silently rot into a no-op.hiroz --libgoes 282 -> 286. This is the only evidence this PR alone can produce, because nothing else on this branch holds a tracked guard.2. End-to-end, and it requires #257's wiring — not reproducible on this branch alone. Run against
pr/1+pr/3combined, reintroducing the parameter-service violation (holding the read guard across the callback instead of cloning theArcout) makes the barrier fire ontest_parameter_validation_callback, an ordinary parameter test rather than a deadlock test:Removing the violation restores 13/13. That it fired on an ordinary test is the point: the previous barrier was a hand-written deadline test per known defect, which by construction cannot catch the next one. Read this as evidence about the mechanism's behaviour once wired, which is what #257/#260/#262 do — not as something a reviewer can reproduce from this PR's tree.
For the test-wiring half:
crates/hiroz-tests/feature_gate.rsfails loudly with a featureless invocation and passes with--features ros-msgs,jazzy, andbuild.rscloses the narrower--test <name>path that would otherwise bypass it.A cheaper option was tried first and rejected on evidence.
clippy::significant_drop_in_scrutineereports zero hits on code containing five genuine instances of the defect, because the shape isif let Ok(cb) = holder.lock()— which binds the guard, so it is not a scrutinee temporary. The defect is a dynamic guard lifetime spanning a dynamic dispatch throughArc<dyn Fn>or an opaqueextern "C" fnpointer; static analysis sees through neither, a counter sees both.Breaking Changes
None. Zero cost in release builds — the counter operations and the assertion are
#[cfg(debug_assertions)].hiroz-testsnow requires--features ros-msgsto build at all. That is a change to how the test crate is invoked, not to any published API, and it is the point of the change rather than a side effect.Notes
hiroz, plus aclippy.tomldisallowed-typesentry forcing new code throughTrackedMutex/TrackedRwLock, would make the barrier total rather than targeted. Worth doing; out of scope here.cargo docis red onmaintoday for unrelated reasons (three unresolved intra-doc links inerror.rs). This branch's own five new intra-doc links inreentrancy.rsare fully qualified and resolve cleanly;pr/0-rustdoc-linksclears the pre-existing three. The two were deliberately not combined — theerror.rsfix should not depend on this PR being accepted.Checklist
./scripts/check-local.shsuccessfully