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