fix(lifecycle): run transition callbacks outside the state-machine lock - #253
Conversation
ed77e5b to
de0039e
Compare
There was a problem hiding this comment.
Pull request overview
Fixes lifecycle callback deadlocks by releasing the state-machine lock during callbacks and exposing accurate intermediate states.
Changes:
- Splits transitions into locked
begin/finishphases. - Corrects lifecycle client transition-state mappings.
- Adds re-entrancy regression tests and lock-free managed-entity notifications.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
state_machine.rs |
Adds split transition APIs. |
node.rs |
Runs callbacks outside mutex guards. |
client.rs |
Maps intermediate state IDs correctly. |
reentrant_lifecycle.rs |
Tests callback state queries. |
💡 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 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
crates/hiroz-tests/tests/reentrant_lifecycle.rs:16
- This says observers retain the old behavior, but previously the mutex prevented observers from reading any state until the callback completed, and the client also mapped busy-state IDs to
Unconfigured. The PR intentionally changes observers to see the intermediate state, so describe that new behavior rather than claiming it was already observable.
//! the guard while the callback runs. Observers keep seeing exactly what they
//! saw before: the intermediate state (`configuring`, `activating`, …).
crates/hiroz/src/lifecycle/node.rs:212
- If
on_errorpanics after an error from an Active-state transition, this branch moves the machine toFinalizedand immediately unwinds past the managed-entity synchronization below. The node then reportsFinalizedwhile its lifecycle publishers remain activated and can still publish. Deactivate the managed entities before resuming the panic (ideally through the same helper used by normal completion) so the externally visible state and entity gating remain consistent.
self.state_machine
.lock()
.unwrap()
.finish_error_processing(CallbackReturn::Error);
resume_unwind(payload);
crates/hiroz/src/lifecycle/state_machine.rs:216
finishis a new public safe API that can bypass every transition invariant: for example, callingStateMachine::new().finish(Activate, Inactive, Success)moves directly fromUnconfiguredtoActive, because neither the matchingbeginnor the expected intermediate state is validated. The documented caller obligation does not prevent accidental state corruption. Keep the split methods crate-private if they are only forZLifecycleNode, or makebeginreturn an opaque token thatfinishconsumes and validates; apply the same restriction tofinish_error_processing.
/// Second half of [`Self::trigger`]: apply the user callback's verdict.
///
/// `start_state` must be the value returned by the matching [`Self::begin`].
pub fn finish(
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
crates/hiroz/src/lifecycle/node.rs:205
- The panic-recovery branch for
on_erroris not covered. If thiscatch_unwindor its settling call regresses, the node remains silently stuck inErrorProcessing; the new test only checks anon_errorcallback that returns normally, while the normal transition callback has a dedicated panic test. Please add the equivalent panicking-on_errortest and assert both panic propagation and the documented settled state (Finalized).
let ret = match catch_unwind(cb) {
3fdbc4b to
67d4ca7
Compare
`ZLifecycleNode::trigger_transition` invoked the user's transition callback
from inside `state_machine.lock().unwrap().trigger(..)`. That mutex is shared
with the node's own `~/get_state`, `~/change_state` and
`~/get_available_transitions` handlers and with `get_current_state()`, so a
node could not answer any question about its own state while a transition
callback ran — and a callback that asks waits forever.
Split `StateMachine::trigger` into `begin` (validate, enter the intermediate
"busy" state) and `finish` (apply the callback's verdict), with `trigger` kept
as a wrapper for callers that own the machine outright. The error path gets the
same `finish_error_processing` split. The callback now runs between the two
locked steps, with no guard held, and observers see the genuine intermediate
state while it runs.
The `begin` call is bound to its own `let` on purpose: writing it as a `match`
scrutinee keeps the temporary guard alive for the whole `match` body and
silently reinstates the deadlock. That mistake was made and caught by these
tests during development.
Also fixes `state_from_lc`, which mapped every transition state onto
`Unconfigured`. A node genuinely reports `configuring` / `activating` while a
callback runs; telling a lifecycle manager it had reset itself instead is wrong
and was indistinguishable from the real thing. Without this the intermediate
state is unobservable over the wire, so the deadlock test could not check it.
Managed entities get the same collect-then-invoke treatment:
`trigger_transition` called `e.on_activate()` / `e.on_deactivate()` while
iterating under `managed_entities.lock()`, which `create_publisher` also takes.
This is not reachable today — the only way to register an entity is
`create_publisher`, so every element is a `ZLifecyclePublisher` whose
activate/deactivate only flip an atomic — and it ships without a test on
purpose, because a test for it would have to fabricate a registration path that
does not exist, and a detector that can only fail against invented code proves
nothing. The comment says exactly that so the next reader does not re-derive it.
Detector evidence, both directions. Two deadline-guarded tests in
`crates/hiroz-tests/tests/reentrant_lifecycle.rs`. With the fix reverted and
the tests kept, both fail:
transition_callback_querying_own_state_does_not_deadlock
~/get_state did not answer while the transition callback was
running - the state-machine mutex was held across the callback: Timeout(8s)
failing_transition_callback_still_observes_intermediate_state (same)
With the fix, both pass, and `lifecycle` stays at 30/30.
Moving the transition callback out of the state-machine lock left a worse failure mode than the deadlock it removed. `begin` enters the intermediate state and drops its guard; if the callback then panics, the unwind escapes `trigger_transition` before `finish` runs, so `current` stays at `Configuring`/`Activating`/... forever. Because the guard was already gone, the mutex is not poisoned either, so nothing reports it: every later `begin` returns `None` and `~/get_state` answers `configuring` for the life of the process. While the callback ran under the guard, the same panic unwound through a live `MutexGuard` and poisoned the mutex, so the next access failed loudly. Fail-fast had become a silent permanent wedge. Catch the unwind, settle on `Failure` — revert to the start state, the same "nothing changed" outcome as the invalid-transition arm — and re-raise the payload so the panic is still as loud as before. `on_error` is deliberately not run on that path: we are already unwinding, and running more user code on the way out risks a second panic. The `on_error` invocation gets the same guard, since a panic there would otherwise strand the node in `ErrorProcessing`.
…mmary The error path splits the same way the normal path does -- `finish` records `CallbackReturn::Error` and drops the guard, `on_error` runs unlocked, `finish_error_processing` applies its verdict -- but nothing exercised it. Both existing scenarios return `Success` or `Failure`, so a regression that ran `on_error` back under the mutex would have passed the entire suite. The new scenario uses the same detector as the working one: `on_error` asks its own node, through a real client in another context, what state it is in. Verified in both directions -- reintroducing the guard across `on_error` fails it on the client timeout rather than hanging the suite. It also pins the state that should be observable there, `ErrorProcessing`. The module summary claimed observers "keep seeing exactly what they saw before". They do not, and that is the point of the change: previously a `~/get_state` during a transition blocked for the callback's whole duration, and `ZLifecycleClient` mapped every transition-state id onto `Unconfigured`. The intermediate state is newly observable, which is what rclcpp reports.
67d4ca7 to
2091b74
Compare
state_from_lc folded id 0 (PRIMARY_STATE_UNKNOWN) and any unrecognised id into Unconfigured -- the same conflation this branch fixes for ids 10-15, just narrower. A manager was told the node is unconfigured, an actionable state, when the node had claimed nothing. Reachable: the decoder reads other nodes, including rclcpp ones, and a default-constructed State has id 0. State gains Unknown = 0, mirroring lifecycle_msgs/msg/State exactly. Only label() needed a new arm; the other matches already had catch-alls, and is_primary correctly excludes it. Comments trimmed to the load-bearing facts.
Part of #282 — the defect class, the shared fix shape and the merge order are stated there.
Role in #282
Instance fix. Independent: targets
main, consumes nothing from #255, collides with nothing else in flight. Can merge in any order.Issue
Fixes #252 —
ZLifecycleNode::trigger_transitioninvoked the user's transition callback from insidestate_machine.lock().unwrap().trigger(..). That mutex is shared with the node's own~/get_state,~/change_stateand~/get_available_transitionshandlers and withget_current_state(), so for the whole duration of a transition callback the node could not answer any question about itself — for anybody, including a lifecycle manager in another process. A callback that asked never completed at all.Verified both directions, 2026-08-05 — stated with the commit each direction was measured on, because they differ:
Unknownchangelifecycle/production files reverted, tests keptmainThe revert run has not been repeated since the rebase. Nothing in the rebase or the two commits after it touched the transition path, so it should still hold — but it is not re-measured, and this note is here rather than a claim that it is.
The failure path is the reproduction: the inner
~/get_statequery does not return within its 8 s call timeout, and the test converts that into a named panic rather than an unhelpful whole-scenario deadline.What this PR does
Split
StateMachine::triggerintobeginandfinish.beginvalidates and enters the intermediate ("busy") state; the callback runs with no guard held;finishapplies its verdict.triggeris kept as a wrapper for callers that own the machine outright, and the error path gets the same split.This is the family's
acq · callout · rel → acq · rel · calloutrewrite, expressed as a state-machine split rather than a clone-and-drop, because the callback's verdict has to be applied afterwards.sequenceDiagram autonumber participant M as Manager<br/>(other process) participant T as Node thread participant SM as state_machine<br/>mutex participant CB as user on_configure T->>SM: lock() → begin(Configure) activate SM SM-->>T: start_state, state := Configuring deactivate SM Note over T,SM: guard released before the callout T->>CB: callback(start_state) activate CB CB->>SM: get_current_state() → lock() SM-->>CB: Configuring ✓ M->>T: ~/get_state T-->>M: Configuring ✓ CB-->>T: CallbackReturn::Success deactivate CB T->>SM: lock() → finish(verdict)Compare against the diagram in #252: there the activation bar on
state_machinespans the callback; here it does not. That is the whole change.Two consequences worth reviewing rather than skimming:
beginresult is bound to its ownletdeliberately. Writingmatch self.state_machine.lock().unwrap().begin(..) { .. }would keep the temporary guard alive for the wholematchbody and silently reintroduce the exact deadlock this split removes. There is a comment saying so at the site.currentatConfiguring/Activatingforever with an unpoisoned mutex — every laterbeginreturnsNoneand~/get_stateanswersconfiguringfor the life of the process. A silent wedge. Hencecatch_unwind+resume_unwind.state_from_lcinlifecycle/client.rsstopped mapping every transition state ontoUnconfigured. This is load-bearing for the tests, not cosmetic: without it the intermediate state is unobservable over the wire, so a test cannot tell "answered correctly" from "answeredUnconfigured".StategainsUnknown = 0. Having fixed ids 10–15, the catch-all still foldedPRIMARY_STATE_UNKNOWN— and any unrecognised id — intoUnconfigured: the same conflation, one value narrower. It matters because unknown and unconfigured are different instructions to a manager; unconfigured is actionable, and the correct response is to callconfigure()on a node that never claimed to need it.It is reachable. hiroz can never emit
0— a live machine starts atUnconfiguredand only ever assigns primary or transition states, socurrent.id()never yieldsUnknown— but this decoder reads other nodes — including rclcpp ones, which do haveUNKNOWNin their vocabulary — and a default-constructedStatemessage also carriesid = 0, so a response that failed to populate decoded as a confident "unconfigured".The enum now mirrors
lifecycle_msgs/msg/Stateexactly. Onlylabel()needed a new arm:shutdown_forandavailable_transitionsalready had catch-alls, andis_primarylists the primary states explicitly, soUnknownis correctly excluded.Breaking changes
ZLifecycleClientreports transition statesUnconfigured→Configuring/Activating/ …Unconfiguredas "transition in progress" — that reading was this client's bugLifecycleStategainsUnknown = 0matchon itUnknownarmNot affected: non-hiroz observers — an rclcpp lifecycle manager,
ros2 lifecycle get. The node's publishedidwas always correct; only hiroz's own client mis-decoded it.[profile.opt](panic = "abort") a panicking callback aborts the process, as before.dev,testandreleaseunwind and are covered.Nothing removed —
StateMachine::triggerandtrigger_error_processingare retained as wrappers.Known, not fixed here
ZLifecycleClient::shutdown()picks a transition withmatch … { Unconfigured => …, Active => …, _ => InactiveShutdown }, so anUnknownnode now gets anInactiveShutdownguess. That is pre-existing behaviour — the transition states already fell through the same arm — and it is the same "guess rather than admit ignorance" shape this PR fixes one layer down. Out of scope here; worth its own look.Checklist
cargo clippy -p hiroz --all-targets --features jazzy -- -D warningsclean, andreentrant_lifecycle4/4, on the current tip./scripts/check-local.sh— passed on the original commits, not re-run after the rebase and the two commits since. CI is covering it.