Skip to content

feat(hiroz): debug-time barrier against callbacks under a lock - #255

Open
YuanYuYuan wants to merge 7 commits into
mainfrom
pr/1-reentrancy-tripwire
Open

feat(hiroz): debug-time barrier against callbacks under a lock#255
YuanYuYuan wants to merge 7 commits into
mainfrom
pr/1-reentrancy-tripwire

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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_held or invoke_user_callback! on this branch — every use is inside reentrancy.rs and 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 the std::sync types; their guards carry a GuardCount that 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 behind debug_assertions and compiles to nothing in release — tests and CI run in debug, which is where the assertion is wanted.

  • crates/hiroz-tests/build.rsthe package-wide half of the feature enforcement. tests/feature_gate.rs fails loudly on a featureless build, but only if Cargo selects that target: cargo test -p hiroz-tests --test cache with no features builds only cache, the crate-level cfg compiles it to an empty binary, 0 passed is 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-tests has no supported featureless configuration.

  • scripts/test-pure-rust.nu, .github/workflows/ci.yml, crates/hiroz-tests/tests/feature_gate.rsa barrier that is not executed is not a barrier. hiroz-tests was 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 reporting 0 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-tests now runs the workspace excluding hiroz-tests, then hiroz-tests with ros-msgs,jazzy; coverage gets the same features for the same reason. The features need no ROS installation — hiroz-msgs bundles the definitions — so there was never a reason to run the crate blind.

  • scripts/check-local.nu, scripts/check-local.shcheck-local.nu (the local gate) was missing four checks that check-local.sh (what CI runs) has: check-console, test-shm, check-distro-features and the rustdoc intra-doc link check. Neither passed --all to cargo fmt, which silently skips hiroz-py and rmw-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.rs carries four unit tests of its own, including assert_fires_while_a_guard_is_live — a should_panic test that fires the assertion with a live tracked guard, so the tripwire cannot silently rot into a no-op. hiroz --lib goes 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/3 combined, reintroducing the parameter-service violation (holding the read guard across the callback instead of cloning the Arc out) makes the barrier fire on test_parameter_validation_callback, an ordinary parameter test rather than a deadlock test:

thread 'test_parameter_validation_callback' panicked at crates/hiroz/src/reentrancy.rs:103:9:
hiroz re-entrancy rule violated at `ParameterState::validate_and_apply (per-parameter)`:
about to invoke a user callback with 1 hiroz lock guard(s) still live on this thread.
A callback that re-enters hiroz here will deadlock. Collect what you need under the
lock, drop the guard, then call — see `GraphEventManager::trigger_graph_change` for
the pattern.

test result: FAILED. 10 passed; 3 failed

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.rs fails loudly with a featureless invocation and passes with --features ros-msgs,jazzy, and build.rs closes the narrower --test <name> path that would otherwise bypass it.

A cheaper option was tried first and rejected on evidence. clippy::significant_drop_in_scrutinee reports zero hits on code containing five genuine instances of the defect, because the shape is if 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 through Arc<dyn Fn> or an opaque extern "C" fn pointer; 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-tests now requires --features ros-msgs to 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

  • The tripwire is wired into the guards behind the defects in this series, not all 191 lock acquisition sites in the workspace. Extending it to the rest of hiroz, plus a clippy.toml disallowed-types entry forcing new code through TrackedMutex/TrackedRwLock, would make the barrier total rather than targeted. Worth doing; out of scope here.
  • cargo doc is red on main today for unrelated reasons (three unresolved intra-doc links in error.rs). This branch's own five new intra-doc links in reentrancy.rs are fully qualified and resolve cleanly; pr/0-rustdoc-links clears the pre-existing three. The two were deliberately not combined — the error.rs fix should not depend on this PR being accepted.

Checklist

  • Ran ./scripts/check-local.sh successfully
  • Added/updated tests/documentation (if applicable)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread crates/hiroz/src/reentrancy.rs
Comment thread crates/hiroz/src/reentrancy.rs
Comment thread scripts/check-local.nu Outdated
Comment thread crates/hiroz-tests/tests/feature_gate.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-hu always builds the WASM plugins, but the default pureRust development shell intentionally omits the wasm32-wasip2 sysroot (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/require pureRust-wasm before 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_use diagnostic, 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_use diagnostic, 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 selected test-pure-rust.nu subcommands, 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.rs does not exist in this repository (the only references are the new comments/messages), while test_parameter_validation_callback is ungated at parameter_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.rs test 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 not must_use, so tracked.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.
@YuanYuYuan
YuanYuYuan force-pushed the pr/1-reentrancy-tripwire branch from 21c96f5 to 69326f0 Compare July 28, 2026 14:23
…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.
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.

Enhancement: add a debug-time guard against invoking user callbacks under a lock

2 participants