Skip to main content

regain_core/
timing.rs

1//! Outer supervision allowances for the existing native recovery workflow.
2//! These do not change command deadlines, retry eligibility or recovery policy.
3//! OS process startup/retirement and executor scheduling have no strict wall-time
4//! bound: an expired caller must still retain and drain owned worker cleanup.
5use crate::{RecoveryOptions, Selection, invalid};
6use anyhow::{Result, ensure};
7use std::time::Duration;
8
9pub(crate) const PERSISTENT_CONTROLS: [i32; 18] = [
10    0, 2, 3, 4, 5, 6, 7, 9, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23,
11];
12pub(crate) const ACKNOWLEDGED_CONTROLS: [i32; 4] = [0, 5, 16, 17];
13pub(crate) const SIMULATION_SETUP_SECONDS: f64 = 5.;
14pub(crate) const USB_BIND_SECONDS: u64 = 30;
15pub(crate) const USB_RESET_SECONDS: u64 = 80;
16pub(crate) const USB_REBIND_PAUSE_SECONDS: f64 = 0.5;
17pub(crate) const CLOSE_SECONDS: f64 = 2.;
18pub(crate) const MAX_READ_RETRY_OVERHEAD_SECONDS: f64 = 15.;
19const SUPERVISOR_MARGIN_SECONDS: f64 = 5.;
20
21#[derive(Clone, Debug)]
22pub struct NativeCameraTiming {
23    options: RecoveryOptions,
24    direct: bool,
25    sdk_fallback: bool,
26}
27impl NativeCameraTiming {
28    /// No probing or equipment I/O. The selection must remain fixed for this
29    /// owner; dynamic SDK fallback is already covered by the original selection.
30    pub fn new(selection: &Selection) -> Result<Self> {
31        selection.recovery.validate()?;
32        Ok(Self {
33            options: selection.recovery.clone(),
34            direct: selection.direct,
35            sdk_fallback: selection.direct && selection.sdk_fallback,
36        })
37    }
38    fn command(&self) -> f64 {
39        self.options.command_timeout_seconds
40    }
41    fn open_seconds(&self) -> f64 {
42        // Open plus up to two software-white-balance restoration commands.
43        // Include explicit SDK fixture setup without depending on simulation.
44        SIMULATION_SETUP_SECONDS + 3. * self.command()
45    }
46    fn restore_seconds(&self) -> f64 {
47        // Acknowledged controls share one deadline for write/readback; each
48        // other persistent control can require separate set and get commands.
49        (2 * PERSISTENT_CONTROLS.len() - ACKNOWLEDGED_CONTROLS.len()) as f64 * self.command()
50    }
51    fn close_seconds(&self) -> f64 {
52        CLOSE_SECONDS
53            + if self.direct {
54                // Failed direct close can reopen the exact cooled camera to
55                // disable its cooler, then close again. SDK fallback only
56                // reduces this allowance; the original direct choice is safe.
57                self.options.reconnect_delay_seconds
58                    + self.open_seconds()
59                    + self.command()
60                    + CLOSE_SECONDS
61            } else {
62                0.
63            }
64    }
65    fn usb_reset_seconds(&self) -> f64 {
66        // Rebinding can begin just before the reappearance deadline and spend
67        // one full bind-command allowance beyond it, plus its preceding delay.
68        USB_RESET_SECONDS as f64 + 2. * USB_BIND_SECONDS as f64 + USB_REBIND_PAUSE_SECONDS
69    }
70    fn cleanup_seconds(&self) -> f64 {
71        // A connecting task can close before opening, on failure and after a
72        // generation fence; the final source close also needs its own cleanup.
73        // A dispatched USB reset finishes before observing cancellation.
74        4. * self.close_seconds()
75            + self.command()
76            + if self.options.usb_reset_after_failures > 0 {
77                USB_RESET_SECONDS as f64
78            } else {
79                0.
80            }
81            + SUPERVISOR_MARGIN_SECONDS
82    }
83    pub fn cleanup_allowance(&self) -> Duration {
84        duration(self.cleanup_seconds()).expect("validated native cleanup timing")
85    }
86    pub fn connection_allowance(&self) -> Duration {
87        let usb = self.options.usb_reset_after_failures > 0;
88        let opens = 1 + usize::from(self.sdk_fallback) + usize::from(usb);
89        // Include old-generation drain, this connection's three possible closes,
90        // direct->SDK open fallback, USB binding/reopen and initial control/env
91        // restoration performed by the native owner before it publishes ready.
92        duration(
93            self.cleanup_seconds()
94                + 3. * self.close_seconds()
95                + opens as f64 * self.open_seconds()
96                + if self.sdk_fallback {
97                    self.options.reconnect_delay_seconds
98                } else {
99                    0.
100                }
101                + if usb { USB_BIND_SECONDS as f64 } else { 0. }
102                + self.restore_seconds()
103                + 2. * self.command()
104                + SUPERVISOR_MARGIN_SECONDS,
105        )
106        .expect("validated native connection timing")
107    }
108    pub fn control_allowance(&self) -> Duration {
109        // A setting may await idle telemetry (two reads), restore an Abort-
110        // retired worker and all known controls/environment, then acknowledge
111        // the new value. Ordinary polling still retains its scalar deadline.
112        duration(
113            self.options.reconnect_delay_seconds
114                + self.open_seconds()
115                + self.restore_seconds()
116                + 5. * self.command()
117                + SUPERVISOR_MARGIN_SECONDS,
118        )
119        .expect("validated native control timing")
120    }
121    pub fn capture_allowance(&self, microseconds: u64) -> Result<Duration> {
122        ensure!(
123            microseconds > 0,
124            invalid("Invalid native exposure duration")
125        );
126        let o = &self.options;
127        let replacements = o.replacement_exposures(microseconds);
128        let settle = o.cooling_timeout_seconds + 3. * self.command() + o.cooling_sample_seconds;
129        let ready = microseconds as f64 / 1e6
130            + o.exposure_grace_seconds
131            + if self.direct {
132                // Device capability is learned later. Conservatively cover
133                // retained direct rereads even above the replacement limit.
134                (1 + o.direct_read_retries) as f64 * o.download_timeout_seconds
135                    + o.direct_read_retries as f64 * MAX_READ_RETRY_OVERHEAD_SECONDS
136            } else {
137                0.
138            };
139        let reads = 1 + o.ready_frame_download_retries;
140        let download = reads as f64 * (o.download_timeout_seconds + self.command())
141            + o.ready_frame_download_retries as f64 * o.reconnect_delay_seconds;
142        let fallback = if self.sdk_fallback {
143            // Validate can reject direct capture after its first restoration
144            // and settle, requiring SDK open/restoration/settling again.
145            self.command()
146                + o.reconnect_delay_seconds
147                + self.open_seconds()
148                + self.restore_seconds()
149                + settle
150        } else {
151            0.
152        };
153        let attempt = o.reconnect_delay_seconds
154            + self.open_seconds()
155            + self.restore_seconds()
156            + settle
157            + fallback
158            + ready
159            + download
160            // Service checkpoints, two environment reads, Start, readiness
161            // overshoot (environment/service/status), and final cooler service.
162            + 10. * self.command()
163            + 0.025;
164        duration(
165            (1 + replacements) as f64 * attempt
166                + if o.usb_reset_after_failures > 0 && o.usb_reset_after_failures <= replacements {
167                    self.usb_reset_seconds()
168                } else {
169                    0.
170                }
171                + SUPERVISOR_MARGIN_SECONDS,
172        )
173    }
174}
175fn duration(seconds: f64) -> Result<Duration> {
176    Duration::try_from_secs_f64(seconds).map_err(|_| invalid("Native timing is not representable"))
177}
178
179impl RecoveryOptions {
180    /// Same microsecond exposure and inclusive threshold used by core capture
181    /// and every native outer supervisor. Rereads have separate eligibility.
182    pub fn replacement_exposures(&self, microseconds: u64) -> u32 {
183        if microseconds as f64 / 1e6 <= self.maximum_retry_exposure_seconds {
184            self.max_retries
185        } else {
186            0
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    fn selection(direct: bool) -> Selection {
195        Selection {
196            name: "Timing-only fixture".into(),
197            serial: None,
198            direct,
199            sdk_fallback: false,
200            recovery: RecoveryOptions::default(),
201        }
202    }
203    #[test]
204    fn replacement_threshold_is_inclusive_and_long_captures_get_no_replacements() {
205        let mut selection = selection(true);
206        assert_eq!(selection.recovery.replacement_exposures(30_000_000), 3);
207        assert_eq!(selection.recovery.replacement_exposures(30_000_001), 0);
208        let timing = NativeCameraTiming::new(&selection).unwrap();
209        assert!(
210            timing.capture_allowance(30_000_000).unwrap()
211                > timing.capture_allowance(30_000_001).unwrap()
212        );
213        selection.recovery.max_retries = 20;
214        assert_eq!(
215            NativeCameraTiming::new(&selection)
216                .unwrap()
217                .capture_allowance(600_000_000)
218                .unwrap(),
219            timing.capture_allowance(600_000_000).unwrap()
220        );
221        selection.recovery.direct_read_retries = 5;
222        assert!(
223            NativeCameraTiming::new(&selection)
224                .unwrap()
225                .capture_allowance(600_000_000)
226                .unwrap()
227                > timing.capture_allowance(600_000_000).unwrap(),
228            "retained rereads outlive the replacement threshold"
229        );
230    }
231    #[test]
232    fn usb_capture_allowance_requires_a_reachable_replacement_and_counts_one_reset() {
233        let mut selection = selection(true);
234        let base = NativeCameraTiming::new(&selection).unwrap();
235        selection.recovery.usb_reset_after_failures = 4;
236        assert_eq!(
237            NativeCameraTiming::new(&selection)
238                .unwrap()
239                .capture_allowance(1_000_000)
240                .unwrap(),
241            base.capture_allowance(1_000_000).unwrap()
242        );
243        selection.recovery.usb_reset_after_failures = 1;
244        let reset = NativeCameraTiming::new(&selection).unwrap();
245        assert_eq!(
246            reset.capture_allowance(1_000_000).unwrap()
247                - base.capture_allowance(1_000_000).unwrap(),
248            Duration::from_millis(140_500)
249        );
250        assert_eq!(
251            reset.capture_allowance(600_000_000).unwrap(),
252            base.capture_allowance(600_000_000).unwrap()
253        );
254    }
255    #[test]
256    fn sdk_fallback_and_late_control_restoration_are_included_only_for_native_paths() {
257        let mut selection = selection(true);
258        let direct = NativeCameraTiming::new(&selection).unwrap();
259        selection.sdk_fallback = true;
260        let fallback = NativeCameraTiming::new(&selection).unwrap();
261        assert!(fallback.connection_allowance() > direct.connection_allowance());
262        assert!(
263            fallback.capture_allowance(1_000_000).unwrap()
264                > direct.capture_allowance(1_000_000).unwrap()
265        );
266        selection.direct = false;
267        let sdk = NativeCameraTiming::new(&selection).unwrap();
268        selection.sdk_fallback = false;
269        let no_fallback = NativeCameraTiming::new(&selection).unwrap();
270        assert_eq!(
271            sdk.connection_allowance(),
272            no_fallback.connection_allowance()
273        );
274        assert_eq!(
275            sdk.capture_allowance(1_000_000).unwrap(),
276            no_fallback.capture_allowance(1_000_000).unwrap()
277        );
278        assert!(sdk.control_allowance() > Duration::from_secs(60));
279        assert!(sdk.connection_allowance() > Duration::from_secs(300));
280        assert!(sdk.capture_allowance(1_000_000).unwrap() > Duration::from_secs(300));
281    }
282    #[test]
283    fn all_accepted_policy_maxima_produce_representable_positive_allowances() {
284        use crate::recovery::RecoveryType;
285        let mut values = serde_json::Map::new();
286        for field in RecoveryOptions::fields() {
287            values.insert(
288                field.key.into(),
289                match field.value_type {
290                    RecoveryType::Integer(_, max) | RecoveryType::PowerOfTwo(_, max) => {
291                        serde_json::json!(max)
292                    }
293                    RecoveryType::Number { maximum, .. } => serde_json::json!(maximum),
294                    RecoveryType::Boolean => serde_json::json!(true),
295                },
296            );
297        }
298        for direct in [false, true] {
299            let mut selection = selection(direct);
300            selection.recovery = serde_json::from_value(values.clone().into()).unwrap();
301            selection.sdk_fallback = true;
302            let timing = NativeCameraTiming::new(&selection).unwrap();
303            for duration in [
304                timing.connection_allowance(),
305                timing.control_allowance(),
306                timing.cleanup_allowance(),
307                timing.capture_allowance(86_400_000_000).unwrap(),
308            ] {
309                assert!(!duration.is_zero());
310                assert!(std::time::Instant::now().checked_add(duration).is_some());
311            }
312            assert!(timing.capture_allowance(u64::MAX).is_ok());
313            assert!(timing.capture_allowance(0).is_err());
314        }
315    }
316    #[test]
317    fn invalid_recovery_policy_never_reaches_duration_conversion() {
318        let mut selection = selection(false);
319        selection.recovery.command_timeout_seconds = f64::NAN;
320        assert!(NativeCameraTiming::new(&selection).is_err());
321        selection.recovery.command_timeout_seconds = 3600.01;
322        assert!(NativeCameraTiming::new(&selection).is_err());
323    }
324}