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 /// Debt aimed at `route` with no retired channel.
78 pub(crate) fn without_channel(route: DeviceRoute, plan: P) -> Self {
79 Self {
80 route,
81 retired_channel: Weak::new(),
82 retired_policy: RetiredChannelPolicy::ReplacementOnly,
83 plan,
84 }
85 }
86
87 /// Permit a retry on the original channel after a normal teardown write
88 /// failed while that publication was still current.
89 pub(crate) fn allow_current_channel(mut self) -> Self {
90 self.retired_policy = RetiredChannelPolicy::CurrentAllowed;
91 self
92 }
93
94 /// The route the next retry looks up.
95 #[must_use]
96 pub fn route(&self) -> &DeviceRoute {
97 &self.route
98 }
99
100 /// Aim later retries at `route` and allow the channel that originally armed
101 /// capture when that route is still the current publication.
102 pub(crate) fn retarget(mut self, route: DeviceRoute) -> Self {
103 self.route = route;
104 self.retired_policy = RetiredChannelPolicy::CurrentAllowed;
105 self
106 }
107}
108
109impl<P: RestorePlan> PendingRestore<P> {
110 /// Retry through the exact-route channel currently published by inventory.
111 ///
112 /// Success is accepted only if the same publication remains current after
113 /// every awaited restore write. A concurrent replacement returns this
114 /// token as pending, with every control still owed, so the new winner is
115 /// restored on the next attempt.
116 pub async fn retry(self, registry: &ChannelRegistry) -> RestoreOutcome<P> {
117 let Some(current) = registry.lookup(&self.route) else {
118 return RestoreOutcome::RestorePending(self);
119 };
120 if matches!(self.retired_policy, RetiredChannelPolicy::ReplacementOnly)
121 && self
122 .retired_channel
123 .upgrade()
124 .is_some_and(|retired| Arc::ptr_eq(current.channel(), &retired))
125 {
126 return RestoreOutcome::RestorePending(self);
127 }
128 let restored = self.plan.restore_on(¤t).await;
129 if restored && registry.is_current(¤t) {
130 RestoreOutcome::Restored
131 } else {
132 RestoreOutcome::RestorePending(self)
133 }
134 }
135
136 /// Retry through a route elected for the same physical device.
137 ///
138 /// The route sticks, so a later retry keeps following the device when the
139 /// first elected transport is not published yet.
140 pub async fn retry_via(
141 self,
142 route: DeviceRoute,
143 registry: &ChannelRegistry,
144 ) -> RestoreOutcome<P> {
145 self.retarget(route).retry(registry).await
146 }
147}
148
149/// How a session, or one later retry, left its firmware restoration.
150#[must_use = "a pending restore must be retained until firmware ownership is released"]
151pub enum RestoreOutcome<P> {
152 /// Every control was restored on a publication that remained current.
153 Restored,
154 /// Restoration is incomplete. The caller must retain this token and retry
155 /// it before arming a successor for the same physical device.
156 RestorePending(PendingRestore<P>),
157}
158
159/// A session setup failure plus any firmware ownership its rollback could not
160/// release.
161#[derive(Debug, Error)]
162#[error("{error}")]
163pub struct SessionFailure<E, P> {
164 #[source]
165 error: E,
166 /// Boxed: only a failed rollback carries one, and unboxed it would make
167 /// every session `Result` as large as the token.
168 pending_restore: Option<Box<PendingRestore<P>>>,
169}
170
171impl<E, P> SessionFailure<E, P> {
172 pub(crate) fn clean(error: E) -> Self {
173 Self {
174 error,
175 pending_restore: None,
176 }
177 }
178
179 pub(crate) fn with_pending(error: E, pending: PendingRestore<P>) -> Self {
180 Self {
181 error,
182 pending_restore: Some(Box::new(pending)),
183 }
184 }
185
186 /// Split the setup error from firmware ownership the caller must retain.
187 #[must_use]
188 pub fn into_parts(self) -> (E, Option<PendingRestore<P>>) {
189 (self.error, self.pending_restore.map(|pending| *pending))
190 }
191}
192
193impl<E, P> From<E> for SessionFailure<E, P> {
194 fn from(error: E) -> Self {
195 Self::clean(error)
196 }
197}
198
199/// Roll back a partially armed session without losing firmware ownership when
200/// any compensating write fails.
201pub(crate) async fn rollback_start<E, P: RestorePlan>(
202 error: E,
203 pending: Option<PendingRestore<P>>,
204 registry: &ChannelRegistry,
205) -> SessionFailure<E, P> {
206 let Some(pending) = pending else {
207 return SessionFailure::clean(error);
208 };
209 match pending.allow_current_channel().retry(registry).await {
210 RestoreOutcome::Restored => SessionFailure::clean(error),
211 RestoreOutcome::RestorePending(pending) => SessionFailure::with_pending(error, pending),
212 }
213}