openlogi_device/session/restore.rs
1//! Firmware ownership that outlives the session which took it.
2//!
3//! A session that diverts controls owes the firmware a restore. When that
4//! restore cannot complete — inventory replaced the transport, or a write
5//! failed — the debt leaves the session as a [`PendingRestore`] token, which
6//! the caller retains and retries on whatever channel inventory publishes
7//! next. The capture sessions and the host-switch session share everything
8//! about that token except *what* it writes back, which is their
9//! [`RestorePlan`].
10
11use std::fmt;
12use std::future::Future;
13use std::sync::{Arc, Weak};
14
15use hidpp::channel::HidppChannel;
16use thiserror::Error;
17
18use crate::{ChannelRegistry, DeviceRoute, SharedChannel};
19
20/// What one kind of session writes to hand its controls back to the firmware.
21pub trait RestorePlan {
22 /// Write every recorded control back through `current`, the channel
23 /// inventory publishes now, and report whether all of them landed.
24 ///
25 /// How a write is bounded and whether it is repeated is the plan's own
26 /// policy: the host-switch plan times each write and tries it twice, the
27 /// capture plan makes one untimed attempt.
28 fn restore_on(&self, current: &SharedChannel) -> impl Future<Output = bool> + Send;
29}
30
31/// Whether a retry may use the transport on which the session originally ran.
32#[derive(Clone, Copy)]
33enum RetiredChannelPolicy {
34 /// Inventory declared the transport obsolete; wait for another current
35 /// publication instead of writing underneath its replacement.
36 ReplacementOnly,
37 /// The session stopped normally but a restore write failed, so retrying
38 /// the still-current original transport is safe.
39 CurrentAllowed,
40}
41
42/// Opaque firmware ownership that survives the transport which armed it.
43///
44/// The token owns its original route and its [`RestorePlan`], and holds only a
45/// weak reference to the retired channel: every retry resolves the exact-route
46/// winner from the current inventory publication. It is consumed by every
47/// retry, and a failed retry returns it through
48/// [`RestoreOutcome::RestorePending`], so callers cannot accidentally treat a
49/// borrowed `false` as completion.
50pub struct PendingRestore<P> {
51 route: DeviceRoute,
52 retired_channel: Weak<HidppChannel>,
53 retired_policy: RetiredChannelPolicy,
54 plan: P,
55}
56
57impl<P: fmt::Debug> fmt::Debug for PendingRestore<P> {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.debug_struct("PendingRestore")
60 .field("route", &self.route)
61 .field("plan", &self.plan)
62 .finish_non_exhaustive()
63 }
64}
65
66impl<P> PendingRestore<P> {
67 /// Record that `plan` is owed to the device behind `retired`.
68 pub(crate) fn owing(retired: &SharedChannel, plan: P) -> Self {
69 Self {
70 route: retired.route().clone(),
71 retired_channel: Arc::downgrade(retired.channel()),
72 retired_policy: RetiredChannelPolicy::ReplacementOnly,
73 plan,
74 }
75 }
76
77 /// Permit a retry on the original channel after a normal teardown write
78 /// failed while that publication was still current.
79 pub(crate) fn allow_current_channel(mut self) -> Self {
80 self.retired_policy = RetiredChannelPolicy::CurrentAllowed;
81 self
82 }
83}
84
85impl<P: RestorePlan> PendingRestore<P> {
86 /// Retry through the exact-route channel currently published by inventory.
87 ///
88 /// Success is accepted only if the same publication remains current after
89 /// every awaited restore write. A concurrent replacement returns this
90 /// token as pending, with every control still owed, so the new winner is
91 /// restored on the next attempt.
92 pub async fn retry(self, registry: &ChannelRegistry) -> RestoreOutcome<P> {
93 let Some(current) = registry.lookup(&self.route) else {
94 return RestoreOutcome::RestorePending(self);
95 };
96 if matches!(self.retired_policy, RetiredChannelPolicy::ReplacementOnly)
97 && self
98 .retired_channel
99 .upgrade()
100 .is_some_and(|retired| Arc::ptr_eq(current.channel(), &retired))
101 {
102 return RestoreOutcome::RestorePending(self);
103 }
104 let restored = self.plan.restore_on(¤t).await;
105 if restored && registry.is_current(¤t) {
106 RestoreOutcome::Restored
107 } else {
108 RestoreOutcome::RestorePending(self)
109 }
110 }
111}
112
113/// How a session, or one later retry, left its firmware restoration.
114#[must_use = "a pending restore must be retained until firmware ownership is released"]
115pub enum RestoreOutcome<P> {
116 /// Every control was restored on a publication that remained current.
117 Restored,
118 /// Restoration is incomplete. The caller must retain this token and retry
119 /// it before arming a successor for the same physical device.
120 RestorePending(PendingRestore<P>),
121}
122
123/// A session setup failure plus any firmware ownership its rollback could not
124/// release.
125#[derive(Debug, Error)]
126#[error("{error}")]
127pub struct SessionFailure<E, P> {
128 #[source]
129 error: E,
130 /// Boxed: only a failed rollback carries one, and unboxed it would make
131 /// every session `Result` as large as the token.
132 pending_restore: Option<Box<PendingRestore<P>>>,
133}
134
135impl<E, P> SessionFailure<E, P> {
136 pub(crate) fn clean(error: E) -> Self {
137 Self {
138 error,
139 pending_restore: None,
140 }
141 }
142
143 pub(crate) fn with_pending(error: E, pending: PendingRestore<P>) -> Self {
144 Self {
145 error,
146 pending_restore: Some(Box::new(pending)),
147 }
148 }
149
150 /// Split the setup error from firmware ownership the caller must retain.
151 #[must_use]
152 pub fn into_parts(self) -> (E, Option<PendingRestore<P>>) {
153 (self.error, self.pending_restore.map(|pending| *pending))
154 }
155}
156
157impl<E, P> From<E> for SessionFailure<E, P> {
158 fn from(error: E) -> Self {
159 Self::clean(error)
160 }
161}
162
163/// Roll back a partially armed session without losing firmware ownership when
164/// any compensating write fails.
165pub(crate) async fn rollback_start<E, P: RestorePlan>(
166 error: E,
167 pending: Option<PendingRestore<P>>,
168 registry: &ChannelRegistry,
169) -> SessionFailure<E, P> {
170 let Some(pending) = pending else {
171 return SessionFailure::clean(error);
172 };
173 match pending.allow_current_channel().retry(registry).await {
174 RestoreOutcome::Restored => SessionFailure::clean(error),
175 RestoreOutcome::RestorePending(pending) => SessionFailure::with_pending(error, pending),
176 }
177}