orchestrator sm: report deferred and aborted updates
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs index 877e1c9..7d31b67 100644 --- a/services/orchestrator/sm/src/lib.rs +++ b/services/orchestrator/sm/src/lib.rs
@@ -577,7 +577,11 @@ // returns `Handled` and the update continues untouched, so // its staged image must survive — do not discard then. if matches!(outcome, Outcome::Transition(State::Recovering(_))) { + // Dispose of the orphaned image *and* answer the update + // requester: the update it was awaiting a verdict on has + // been superseded by recovery, not merely delayed. ctx.emit(Effect::DiscardStaged); + ctx.emit(Effect::ReportUpdateAborted); } outcome } @@ -685,6 +689,17 @@ Outcome::Handled } } + // A new update cannot start from any supervised state except + // `Ready` (the machine is mid-walk, mid-update, or mid-recovery). + // `Ready` accepts `UpdateRequest` upstream in `handle` (→ `Updating`) + // and so never reaches here; this arm therefore fires only for + // `AwaitingReady`, `Updating`, and `Recovering`. Report the refusal + // rather than dropping it silently, and leave the in-flight + // walk/update/recovery untouched (`Handled`, no transition). + Event::UpdateRequest => { + ctx.emit(Effect::ReportUpdateDeferred); + Outcome::Handled + } Event::EffectFailed => Outcome::Transition(State::Locked), _ => Outcome::Super, }
diff --git a/services/orchestrator/sm/src/model.rs b/services/orchestrator/sm/src/model.rs index a937c84..f9537e9 100644 --- a/services/orchestrator/sm/src/model.rs +++ b/services/orchestrator/sm/src/model.rs
@@ -274,6 +274,28 @@ /// immediately before the machine latches to [`Locked`](crate::State::Locked). /// Names the component that forced the halt. ReportRecoveryFailed(ComponentId), + /// Report that an [`Event::UpdateRequest`] was declined because the machine + /// is busy in a supervised state other than [`Ready`](crate::State::Ready) + /// — a chain walk is finishing ([`AwaitingReady`](crate::State::AwaitingReady)), + /// an update is already staged ([`Updating`](crate::State::Updating)), or a + /// recovery is in flight ([`Recovering`](crate::State::Recovering)). Emitted + /// instead of silently dropping the request, so the refusal is visible in + /// the effect trace and the driver can answer the requester (e.g. a PLDM + /// "retry later" completion code). Advisory only: it changes no state and + /// does not perturb the in-flight update or recovery. `Ready` never produces + /// this — it accepts the request and transitions to `Updating`. + ReportUpdateDeferred, + /// Report that an update *already in flight* ([`Updating`](crate::State::Updating)) + /// was aborted because a `Required` component was found corrupt and recovery + /// preempted it (the machine left `Updating` for + /// [`Recovering`](crate::State::Recovering)). Its counterpart on the intake + /// side is [`ReportUpdateDeferred`]: `Deferred` refuses an update that never + /// started, `Aborted` announces the loss of one that had. Paired with the + /// [`DiscardStaged`] that cleans up the orphaned image — `DiscardStaged` + /// disposes of the *image*, this answers the *request*, so the requester + /// (which was awaiting `UpdateVerified`/`UpdateRejected`) learns the update + /// was superseded by recovery and can retry once the platform is whole. + ReportUpdateAborted, LatchLockdown, /// Internal only — tells the orchestrator to handle this event next. /// Never forwarded to a [`Platform`](crate::Platform).
diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs index 99896e0..bc4636d 100644 --- a/services/orchestrator/sm/src/tests.rs +++ b/services/orchestrator/sm/src/tests.rs
@@ -1061,6 +1061,74 @@ assert!(!effects.contains(&Effect::LatchLockdown)); } +/// An `UpdateRequest` arriving mid-recovery is declined, not silently dropped: +/// the machine emits `ReportUpdateDeferred` and stays in `Recovering`, leaving +/// the in-flight recovery untouched (single-flight, recovery-priority). +#[test] +fn update_request_while_recovering_is_deferred() { + let (effects, state) = drive( + passive_required(&[C0, C1]), + &[ + BOOT, + Event::VerificationFailed(C0), // → Recovering(C0) + Event::UpdateRequest, // declined while recovering + ], + ); + assert_eq!(state, State::Recovering(C0)); + assert_eq!(effects.last(), Some(&Effect::ReportUpdateDeferred)); +} + +/// A second `UpdateRequest` while an update is already staged (`Updating`) is +/// declined the same way — reported, not dropped, and the staged update is +/// left in place. +#[test] +fn update_request_while_updating_is_deferred() { + let (effects, state) = drive( + passive_required(&[C0]), + &[ + BOOT, + Event::VerificationPassed(C0), + Event::UpdateRequest, // → Updating + Event::UpdateRequest, // declined while updating + ], + ); + assert_eq!(state, State::Updating); + assert_eq!(effects.last(), Some(&Effect::ReportUpdateDeferred)); +} + +/// An `UpdateRequest` while the chain walk is still finishing (`AwaitingReady`) +/// is declined too: the machine is not yet `Ready`, so it reports the refusal +/// and keeps waiting on the outstanding component. +#[test] +fn update_request_while_awaiting_ready_is_deferred() { + let (effects, state) = drive( + chain(&[ + (C0, ComponentAttrs::active_required()), + (C1, ComponentAttrs::passive_required()), + ]), + &[ + BOOT, + Event::VerificationPassed(C0), // active released → AwaitingReady(Some(C0)) + Event::UpdateRequest, // declined while awaiting readiness + ], + ); + assert_eq!(state, State::AwaitingReady(Some(C0))); + assert_eq!(effects.last(), Some(&Effect::ReportUpdateDeferred)); +} + +/// Negative control: from `Ready` an `UpdateRequest` is *accepted* — it starts +/// an update (→ `Updating`) and never emits `ReportUpdateDeferred`. `Ready` +/// intercepts the request upstream, so it can never reach the deferral arm. +#[test] +fn update_request_in_ready_starts_update_not_deferred() { + let (effects, state) = drive( + passive_required(&[C0]), + &[BOOT, Event::VerificationPassed(C0), Event::UpdateRequest], + ); + assert_eq!(state, State::Updating); + assert!(!effects.contains(&Effect::ReportUpdateDeferred)); +} + /// UpdateVerified activates the staged image and returns to Ready. /// (Complements update_rollback_is_not_recovery which tests UpdateRejected.) #[test] @@ -1482,6 +1550,34 @@ effects.contains(&Effect::DiscardStaged), "leaving Updating for recovery must discard the staged image", ); + assert!( + effects.contains(&Effect::ReportUpdateAborted), + "preempting an in-flight update must report it aborted, not drop it silently", + ); +} + +/// The abort report is *only* for a genuine preemption. An `Isolable` +/// corruption of another device while `Updating` is contained (`Handled`) and +/// the update continues untouched — so neither `DiscardStaged` nor +/// `ReportUpdateAborted` is emitted, and the machine stays in `Updating`. +#[test] +fn contained_corruption_during_update_does_not_abort() { + let (effects, state) = drive( + chain(&[ + (C0, ComponentAttrs::passive_required()), + (C1, ComponentAttrs::passive_isolable()), + ]), + &[ + BOOT, + Event::VerificationPassed(C0), + Event::VerificationPassed(C1), // → Ready + Event::UpdateRequest, // → Updating + Event::CorruptionDetected(C1), // isolable: contained, update survives + ], + ); + assert_eq!(state, State::Updating); + assert!(!effects.contains(&Effect::ReportUpdateAborted)); + assert!(!effects.contains(&Effect::DiscardStaged)); } /// GAP 2 (red): a second `Required` corruption while already recovering clobbers