Skip to main content

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(&current).await;
129        if restored && registry.is_current(&current) {
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}