Skip to main content

smoo_gadget_core/
link.rs

1use std::io;
2use std::time::{Duration, Instant};
3use usb_gadget::function::custom::Event;
4
5/// Current USB link state as observed from FunctionFS lifecycle events and liveness pings.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum LinkState {
8    Offline,
9    Ready,
10    Online,
11}
12
13/// Commands emitted by the link controller for the runtime to act upon.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum LinkCommand {
16    /// Link state became invalid and the runtime should terminate.
17    Fatal,
18}
19
20/// Most recent reason the link transitioned Offline.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum LinkOfflineReason {
23    Ep0Disable,
24    Ep0Unbind,
25    IoError,
26    LivenessTimeout,
27}
28
29/// Drives link state transitions based on ep0 lifecycle events, heartbeat pings,
30/// endpoint I/O errors, and periodic liveness ticks.
31///
32/// This controller is intentionally small and side-effect-free: it only tracks state and
33/// emits commands. The caller is responsible for actually opening/closing endpoints and
34/// replaying or parking I/O.
35pub struct LinkController {
36    state: LinkState,
37    last_status: Option<Instant>,
38    last_offline_reason: Option<LinkOfflineReason>,
39    liveness_timeout: Duration,
40    reopen_backoff: Duration,
41    reopen_backoff_max: Duration,
42    reopen_not_before: Option<Instant>,
43    pending_drop: bool,
44}
45
46impl LinkController {
47    /// Construct a new controller with a configurable liveness timeout.
48    pub fn new(liveness_timeout: Duration) -> Self {
49        Self {
50            state: LinkState::Offline,
51            last_status: None,
52            last_offline_reason: None,
53            liveness_timeout,
54            reopen_backoff: Duration::from_secs(1),
55            reopen_backoff_max: Duration::from_secs(30),
56            reopen_not_before: None,
57            pending_drop: false,
58        }
59    }
60
61    /// Current link state snapshot.
62    pub fn state(&self) -> LinkState {
63        self.state
64    }
65
66    /// Most recent reason the link moved Offline.
67    pub fn last_offline_reason(&self) -> Option<LinkOfflineReason> {
68        self.last_offline_reason
69    }
70
71    /// Notify the controller of an ep0 lifecycle event.
72    pub fn on_ep0_event(&mut self, event: Event) {
73        match event {
74            Event::Bind | Event::Enable | Event::Resume => {
75                self.reset_reopen_backoff();
76                self.enter_ready();
77            }
78            Event::Disable => {
79                self.enter_offline(LinkOfflineReason::Ep0Disable);
80            }
81            Event::Unbind => {
82                self.enter_offline(LinkOfflineReason::Ep0Unbind);
83            }
84            Event::Suspend => {
85                // The bus may briefly suspend while the host reconfigures; keep the data plane
86                // around and let liveness/status pings drive us back to Online.
87                self.state = LinkState::Ready;
88                self.pending_drop = false;
89            }
90            Event::SetupDeviceToHost(_) | Event::SetupHostToDevice(_) => { /* ignored */ }
91            Event::Unknown(_) => {}
92            _ => {}
93        }
94    }
95
96    /// Notify the controller that a SMOO_STATUS (or equivalent heartbeat) was seen.
97    pub fn on_status_ping(&mut self) {
98        let now = Instant::now();
99        self.last_status = Some(now);
100        if matches!(self.state, LinkState::Offline) {
101            if let Some(not_before) = self.reopen_not_before {
102                if now < not_before {
103                    return;
104                }
105            }
106            // Host is talking to ep0; ensure data plane is reopened.
107            self.enter_ready();
108        }
109        if matches!(self.state, LinkState::Ready | LinkState::Online) {
110            self.state = LinkState::Online;
111            self.reset_reopen_backoff();
112        }
113    }
114
115    /// Notify the controller that an endpoint I/O error occurred.
116    pub fn on_io_error(&mut self, _err: &io::Error) {
117        if matches!(self.state, LinkState::Offline) {
118            return;
119        }
120        self.reopen_not_before = Some(Instant::now() + self.reopen_backoff);
121        self.reopen_backoff = self
122            .reopen_backoff
123            .saturating_mul(2)
124            .min(self.reopen_backoff_max);
125        self.enter_offline(LinkOfflineReason::IoError);
126    }
127
128    /// Advance the controller based on the current time to detect liveness timeouts.
129    pub fn tick(&mut self, now: Instant) {
130        if let Some(last) = self.last_status {
131            if now.saturating_duration_since(last) > self.liveness_timeout {
132                self.enter_offline(LinkOfflineReason::LivenessTimeout);
133            }
134        }
135    }
136
137    /// Drain the next pending command, if any.
138    pub fn take_command(&mut self) -> Option<LinkCommand> {
139        if self.pending_drop {
140            self.pending_drop = false;
141            return Some(LinkCommand::Fatal);
142        }
143        None
144    }
145
146    fn enter_ready(&mut self) {
147        self.state = LinkState::Ready;
148        self.reopen_not_before = None;
149        self.last_offline_reason = None;
150        self.pending_drop = false;
151    }
152
153    fn enter_offline(&mut self, reason: LinkOfflineReason) {
154        self.state = LinkState::Offline;
155        self.last_status = None;
156        self.last_offline_reason = Some(reason);
157        self.pending_drop = true;
158    }
159
160    fn reset_reopen_backoff(&mut self) {
161        self.reopen_backoff = Duration::from_secs(1);
162        self.reopen_not_before = None;
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn transitions_on_events_and_status() {
172        let mut ctrl = LinkController::new(Duration::from_secs(5));
173        assert_eq!(ctrl.state(), LinkState::Offline);
174
175        ctrl.on_ep0_event(Event::Enable);
176        assert_eq!(ctrl.state(), LinkState::Ready);
177        assert_eq!(ctrl.take_command(), None);
178
179        ctrl.on_status_ping();
180        assert_eq!(ctrl.state(), LinkState::Online);
181
182        let err = io::Error::from_raw_os_error(libc::EPIPE);
183        ctrl.on_io_error(&err);
184        assert_eq!(ctrl.state(), LinkState::Offline);
185        assert_eq!(ctrl.last_offline_reason(), Some(LinkOfflineReason::IoError));
186        assert_eq!(ctrl.take_command(), Some(LinkCommand::Fatal));
187    }
188
189    #[test]
190    fn liveness_timeout_forces_offline() {
191        let mut ctrl = LinkController::new(Duration::from_millis(100));
192        ctrl.on_ep0_event(Event::Enable);
193        ctrl.take_command();
194        ctrl.on_status_ping();
195        let now = Instant::now() + Duration::from_millis(250);
196        ctrl.tick(now);
197        assert_eq!(ctrl.state(), LinkState::Offline);
198        assert_eq!(
199            ctrl.last_offline_reason(),
200            Some(LinkOfflineReason::LivenessTimeout)
201        );
202        assert_eq!(ctrl.take_command(), Some(LinkCommand::Fatal));
203    }
204}