Skip to main content

regain_core/
cooling.rs

1//! One acknowledged cooler command, driven by the existing Session owner.
2//! The handle grants no equipment lease; frontends must authorize callers.
3use crate::SharedStatus;
4use std::sync::{Arc, Mutex};
5use tokio::{
6    sync::oneshot,
7    time::{Duration, Instant},
8};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum CoolingError {
12    Busy,
13    Unavailable,
14    Expired,
15    Cancelled,
16    Invalid(String),
17    Uncertain { message: String, code: Option<i32> },
18}
19
20impl std::fmt::Display for CoolingError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Busy => f.write_str("Another cooler request is pending"),
24            Self::Unavailable => f.write_str("Camera controls are unavailable"),
25            Self::Expired => f.write_str("Cooler request expired before dispatch"),
26            Self::Cancelled => f.write_str("Cooler request was cancelled before dispatch"),
27            Self::Invalid(message) => f.write_str(message),
28            Self::Uncertain { message, .. } => write!(f, "Cooler outcome is uncertain: {message}"),
29        }
30    }
31}
32impl std::error::Error for CoolingError {}
33type Outcome = Result<i64, CoolingError>;
34#[derive(Clone, Copy, PartialEq, Eq)]
35enum Phase {
36    Queued,
37    Claimed,
38    Running,
39    Finished,
40}
41pub(crate) struct Request {
42    pub kind: i32,
43    pub value: i64,
44    pub deadline: Instant,
45    phase: Phase,
46    uncertain: bool,
47    reply: Option<oneshot::Sender<Outcome>>,
48}
49#[derive(Default)]
50struct State {
51    accepting: bool,
52    pending: Option<Arc<Mutex<Request>>>,
53}
54#[derive(Clone, Default)]
55pub(crate) struct Mailbox(Arc<Mutex<State>>);
56#[derive(Clone)]
57pub struct CoolingHandle {
58    pub(crate) mailbox: Mailbox,
59    pub(crate) status: SharedStatus,
60}
61pub struct CoolingReceipt {
62    mailbox: Mailbox,
63    request: Arc<Mutex<Request>>,
64    reply: oneshot::Receiver<Outcome>,
65    deadline: Instant,
66}
67/// Withdraw an unsent request without dropping its owner's receipt. Once
68/// dispatched this is inert: the owner must retain the command to its outcome.
69#[derive(Clone)]
70pub struct CoolingCancellation {
71    mailbox: Mailbox,
72    request: Arc<Mutex<Request>>,
73}
74impl CoolingCancellation {
75    pub fn cancel_before_dispatch(&self) {
76        self.mailbox.cancel_queued(&self.request);
77    }
78}
79pub(crate) fn validate(status: &SharedStatus, kind: i32, value: i64) -> Result<(), CoolingError> {
80    if !matches!(kind, 16 | 17) {
81        return Err(CoolingError::Invalid(
82            "Only cooler target and enable are supported".into(),
83        ));
84    }
85    let state = status.lock().unwrap();
86    if !state.connected || !state.control_connection_available {
87        return Err(CoolingError::Unavailable);
88    }
89    let cap = state
90        .controls
91        .get(&kind)
92        .ok_or_else(|| CoolingError::Invalid("Cooler control is unavailable".into()))?;
93    if !cap.writable || value < cap.min || value > cap.max {
94        return Err(CoolingError::Invalid(format!(
95            "Cooler control must be writable and between {} and {}",
96            cap.min, cap.max
97        )));
98    }
99    Ok(())
100}
101impl CoolingHandle {
102    /// Reserve synchronously; await the receipt while the owner drives Session.
103    /// The deadline covers queueing, write and readback, not thermal convergence.
104    pub fn submit(
105        &self,
106        kind: i32,
107        value: i64,
108        timeout: Duration,
109    ) -> Result<CoolingReceipt, CoolingError> {
110        validate(&self.status, kind, value)?;
111        let deadline = Instant::now()
112            .checked_add(timeout)
113            .filter(|_| !timeout.is_zero())
114            .ok_or(CoolingError::Expired)?;
115        let (sender, reply) = oneshot::channel();
116        let request = Arc::new(Mutex::new(Request {
117            kind,
118            value,
119            deadline,
120            phase: Phase::Queued,
121            uncertain: false,
122            reply: Some(sender),
123        }));
124        let mut state = self.mailbox.0.lock().unwrap();
125        if !state.accepting {
126            return Err(CoolingError::Unavailable);
127        }
128        if state.pending.is_some() {
129            return Err(CoolingError::Busy);
130        }
131        state.pending = Some(request.clone());
132        Ok(CoolingReceipt {
133            mailbox: self.mailbox.clone(),
134            request,
135            reply,
136            deadline,
137        })
138    }
139    pub fn pending(&self) -> bool {
140        self.mailbox.0.lock().unwrap().pending.is_some()
141    }
142}
143impl CoolingReceipt {
144    pub fn cancellation(&self) -> CoolingCancellation {
145        CoolingCancellation {
146            mailbox: self.mailbox.clone(),
147            request: self.request.clone(),
148        }
149    }
150    pub async fn wait(mut self) -> Outcome {
151        tokio::select! {
152            biased;
153            result=&mut self.reply=>result.unwrap_or(Err(CoolingError::Unavailable)),
154            _=tokio::time::sleep_until(self.deadline)=>{
155                if let Some(error)=self.mailbox.expire(&self.request) {return Err(error);}
156                // Completion and expiry raced under one lock. Completion already
157                // sent its outcome, including an acknowledgement before deadline.
158                (&mut self.reply).await.unwrap_or(Err(CoolingError::Unavailable))
159            }
160        }
161    }
162}
163impl Drop for CoolingReceipt {
164    fn drop(&mut self) {
165        self.mailbox.cancel_queued(&self.request);
166    }
167}
168impl Mailbox {
169    pub fn activate(&self) {
170        self.retire();
171        self.0.lock().unwrap().accepting = true;
172    }
173    pub fn retire(&self) {
174        let mut state = self.0.lock().unwrap();
175        state.accepting = false;
176        if let Some(request) = state.pending.take() {
177            let mut request = request.lock().unwrap();
178            let result = if request.phase == Phase::Running {
179                Err(CoolingError::Uncertain {
180                    message: "Camera owner retired during cooler dispatch".into(),
181                    code: None,
182                })
183            } else {
184                Err(CoolingError::Unavailable)
185            };
186            request.phase = Phase::Finished;
187            if let Some(reply) = request.reply.take() {
188                let _ = reply.send(result);
189            }
190        }
191    }
192    fn cancel_queued(&self, request: &Arc<Mutex<Request>>) {
193        let mut state = self.0.lock().unwrap();
194        if !state
195            .pending
196            .as_ref()
197            .is_some_and(|pending| Arc::ptr_eq(pending, request))
198        {
199            return;
200        }
201        let mut request = request.lock().unwrap();
202        if matches!(request.phase, Phase::Queued | Phase::Claimed) {
203            request.phase = Phase::Finished;
204            state.pending = None;
205            if let Some(reply) = request.reply.take() {
206                let _ = reply.send(Err(CoolingError::Cancelled));
207            }
208        }
209    }
210    fn expire(&self, request: &Arc<Mutex<Request>>) -> Option<CoolingError> {
211        let mut state = self.0.lock().unwrap();
212        if !state
213            .pending
214            .as_ref()
215            .is_some_and(|pending| Arc::ptr_eq(pending, request))
216        {
217            return None;
218        }
219        let mut request = request.lock().unwrap();
220        if matches!(request.phase, Phase::Queued | Phase::Claimed) {
221            request.phase = Phase::Finished;
222            state.pending = None;
223            let error = CoolingError::Expired;
224            if let Some(reply) = request.reply.take() {
225                let _ = reply.send(Err(error.clone()));
226            }
227            Some(error)
228        } else {
229            // Keep the slot and dispatched ownership until the Session finishes.
230            request.uncertain = true;
231            state.accepting = false;
232            Some(CoolingError::Uncertain {
233                message: "Cooler acknowledgement exceeded its deadline".into(),
234                code: None,
235            })
236        }
237    }
238    pub fn claim(&self) -> Option<Arc<Mutex<Request>>> {
239        let mut state = self.0.lock().unwrap();
240        let request = state.pending.clone()?;
241        let mut command = request.lock().unwrap();
242        if command.phase != Phase::Queued {
243            return None;
244        }
245        if Instant::now() >= command.deadline {
246            command.phase = Phase::Finished;
247            state.pending = None;
248            if let Some(reply) = command.reply.take() {
249                let _ = reply.send(Err(CoolingError::Expired));
250            }
251            return None;
252        }
253        command.phase = Phase::Claimed;
254        drop(command);
255        Some(request)
256    }
257    pub fn dispatch(&self, request: &Arc<Mutex<Request>>) -> bool {
258        let mut state = self.0.lock().unwrap();
259        if !state
260            .pending
261            .as_ref()
262            .is_some_and(|pending| Arc::ptr_eq(pending, request))
263        {
264            return false;
265        }
266        let mut command = request.lock().unwrap();
267        if command.phase != Phase::Claimed || !state.accepting {
268            return false;
269        }
270        if Instant::now() >= command.deadline {
271            command.phase = Phase::Finished;
272            state.pending = None;
273            if let Some(reply) = command.reply.take() {
274                let _ = reply.send(Err(CoolingError::Expired));
275            }
276            return false;
277        }
278        command.phase = Phase::Running;
279        true
280    }
281    /// Commit shared/core recovery state before publishing a known outcome.
282    /// Neither mutex is held over worker I/O.
283    pub fn finish(
284        &self,
285        request: &Arc<Mutex<Request>>,
286        mut result: Outcome,
287        commit: impl FnOnce(),
288    ) -> Outcome {
289        let mut state = self.0.lock().unwrap();
290        let current = state
291            .pending
292            .as_ref()
293            .is_some_and(|pending| Arc::ptr_eq(pending, request));
294        let mut command = request.lock().unwrap();
295        if !current || !matches!(command.phase, Phase::Claimed | Phase::Running) {
296            return Err(CoolingError::Uncertain {
297                message: "Cooler owner changed before acknowledgement".into(),
298                code: None,
299            });
300        }
301        if !matches!(result, Err(CoolingError::Uncertain { .. }))
302            && (command.uncertain
303                || !state.accepting
304                || (result.is_ok()
305                    && (command.phase != Phase::Running || Instant::now() > command.deadline)))
306        {
307            result = Err(CoolingError::Uncertain {
308                message: "Cooler acknowledgement exceeded its deadline".into(),
309                code: None,
310            });
311        }
312        if matches!(result, Err(CoolingError::Uncertain { .. })) {
313            state.accepting = false;
314        }
315        if result.is_ok() {
316            commit();
317        }
318        command.phase = Phase::Finished;
319        state.pending = None;
320        if let Some(reply) = command.reply.take() {
321            let _ = reply.send(result.clone());
322        }
323        result
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::{Control, Status};
331    fn handle() -> CoolingHandle {
332        let status = Status {
333            connected: true,
334            control_connection_available: true,
335            controls: [
336                (
337                    16,
338                    Control {
339                        kind: 16,
340                        min: -40,
341                        max: 30,
342                        value: 0,
343                        writable: true,
344                    },
345                ),
346                (
347                    17,
348                    Control {
349                        kind: 17,
350                        min: 0,
351                        max: 1,
352                        value: 0,
353                        writable: true,
354                    },
355                ),
356            ]
357            .into(),
358            values: [(16, 0), (17, 0)].into(),
359            ..Status::default()
360        };
361        let handle = CoolingHandle {
362            mailbox: Mailbox::default(),
363            status: Arc::new(Mutex::new(status)),
364        };
365        handle.mailbox.activate();
366        handle
367    }
368    fn dispatch(handle: &CoolingHandle) -> Arc<Mutex<Request>> {
369        let request = handle.mailbox.claim().unwrap();
370        assert!(handle.mailbox.dispatch(&request));
371        request
372    }
373    #[tokio::test]
374    async fn bounded_admission_validation_and_known_acknowledgement() {
375        let handle = handle();
376        for (kind, value) in [(0, 1), (16, -41), (16, 31), (17, 2)] {
377            assert!(matches!(
378                handle.submit(kind, value, Duration::from_secs(1)),
379                Err(CoolingError::Invalid(_))
380            ));
381        }
382        let receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
383        assert!(matches!(
384            handle.submit(17, 1, Duration::from_secs(5)),
385            Err(CoolingError::Busy)
386        ));
387        assert_eq!(handle.status.lock().unwrap().values[&16], 0);
388        let request = dispatch(&handle);
389        handle
390            .mailbox
391            .finish(&request, Ok(-10), || {
392                handle.status.lock().unwrap().values.insert(16, -10);
393            })
394            .unwrap();
395        assert_eq!(receipt.wait().await, Ok(-10));
396        assert!(!handle.pending());
397        assert_eq!(handle.status.lock().unwrap().values[&16], -10);
398    }
399    #[tokio::test]
400    async fn queued_expiry_and_waiter_loss_never_dispatch() {
401        let handle = handle();
402        drop(handle.submit(17, 1, Duration::from_secs(5)).unwrap());
403        assert!(!handle.pending());
404        assert!(handle.mailbox.claim().is_none());
405        let receipt = handle.submit(16, -10, Duration::from_millis(1)).unwrap();
406        assert_eq!(receipt.wait().await, Err(CoolingError::Expired));
407        assert!(handle.mailbox.claim().is_none());
408        assert!(!handle.pending());
409    }
410    #[tokio::test]
411    async fn dispatched_waiter_loss_keeps_owned_commit() {
412        let handle = handle();
413        let receipt = handle.submit(17, 1, Duration::from_secs(5)).unwrap();
414        let request = dispatch(&handle);
415        drop(receipt);
416        assert!(handle.pending());
417        handle
418            .mailbox
419            .finish(&request, Ok(1), || {
420                handle.status.lock().unwrap().values.insert(17, 1);
421            })
422            .unwrap();
423        assert!(!handle.pending());
424        assert_eq!(handle.status.lock().unwrap().values[&17], 1);
425    }
426    #[tokio::test]
427    async fn dispatched_deadline_fences_and_cannot_commit_late_or_after_reactivation() {
428        let handle = handle();
429        let mut receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
430        let request = dispatch(&handle);
431        // Put the already-dispatched command exactly at its deadline without a
432        // scheduling-sensitive window between submission and claim.
433        let deadline = Instant::now();
434        request.lock().unwrap().deadline = deadline;
435        receipt.deadline = deadline;
436        assert!(matches!(
437            receipt.wait().await,
438            Err(CoolingError::Uncertain { .. })
439        ));
440        assert!(handle.pending());
441        assert!(matches!(
442            handle.submit(17, 1, Duration::from_secs(1)),
443            Err(CoolingError::Unavailable)
444        ));
445        assert!(matches!(
446            handle.mailbox.finish(&request, Ok(-10), || panic!(
447                "Late cooler result must not commit"
448            )),
449            Err(CoolingError::Uncertain { .. })
450        ));
451        handle.mailbox.activate();
452        let next = handle.submit(16, -15, Duration::from_secs(5)).unwrap();
453        assert!(matches!(
454            handle.mailbox.finish(&request, Ok(-10), || panic!(
455                "Old cooler result must not commit"
456            )),
457            Err(CoolingError::Uncertain { .. })
458        ));
459        assert!(handle.pending());
460        drop(next);
461        assert!(!handle.pending());
462    }
463    #[tokio::test]
464    async fn retirement_distinguishes_unsent_and_dispatched_and_preserves_codes() {
465        let handle = handle();
466        let queued = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
467        handle.mailbox.retire();
468        assert_eq!(queued.wait().await, Err(CoolingError::Unavailable));
469        handle.mailbox.activate();
470        let running = handle.submit(17, 1, Duration::from_secs(5)).unwrap();
471        let request = dispatch(&handle);
472        handle.mailbox.retire();
473        assert!(matches!(
474            running.wait().await,
475            Err(CoolingError::Uncertain { .. })
476        ));
477        assert!(matches!(
478            handle
479                .mailbox
480                .finish(&request, Ok(1), || panic!("Retired cooler must not commit")),
481            Err(CoolingError::Uncertain { .. })
482        ));
483        handle.mailbox.activate();
484        let receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
485        let request = dispatch(&handle);
486        let error = CoolingError::Uncertain {
487            message: "private failure".into(),
488            code: Some(11),
489        };
490        assert_eq!(
491            handle
492                .mailbox
493                .finish(&request, Err(error.clone()), || panic!(
494                    "Failed cooler must not commit"
495                )),
496            Err(error.clone())
497        );
498        assert_eq!(receipt.wait().await, Err(error));
499        assert!(!handle.pending());
500        assert!(matches!(
501            handle.submit(17, 1, Duration::from_secs(1)),
502            Err(CoolingError::Unavailable)
503        ));
504    }
505    #[tokio::test]
506    async fn caller_loss_and_deadline_during_preflight_skip_dispatch() {
507        let handle = handle();
508        let receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
509        let request = handle.mailbox.claim().unwrap();
510        drop(receipt);
511        assert!(!handle.mailbox.dispatch(&request));
512        assert!(!handle.pending());
513        let mut receipt = handle.submit(17, 1, Duration::from_secs(5)).unwrap();
514        let request = handle.mailbox.claim().unwrap();
515        let deadline = Instant::now();
516        request.lock().unwrap().deadline = deadline;
517        receipt.deadline = deadline;
518        assert_eq!(receipt.wait().await, Err(CoolingError::Expired));
519        assert!(!handle.mailbox.dispatch(&request));
520        assert!(!handle.pending());
521    }
522    #[tokio::test]
523    async fn separate_caller_cancellation_preserves_the_owned_receipt() {
524        let handle = handle();
525        for claimed in [false, true] {
526            let receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
527            let cancellation = receipt.cancellation();
528            let request = claimed.then(|| handle.mailbox.claim().unwrap());
529            cancellation.cancel_before_dispatch();
530            assert_eq!(receipt.wait().await, Err(CoolingError::Cancelled));
531            if let Some(request) = request {
532                assert!(!handle.mailbox.dispatch(&request));
533            }
534            assert!(!handle.pending());
535        }
536        let receipt = handle.submit(17, 1, Duration::from_secs(5)).unwrap();
537        let cancellation = receipt.cancellation();
538        let request = dispatch(&handle);
539        cancellation.cancel_before_dispatch();
540        assert!(handle.pending());
541        handle.mailbox.finish(&request, Ok(1), || {}).unwrap();
542        assert_eq!(receipt.wait().await, Ok(1));
543        // An old caller must never cancel a later slot.
544        let next = handle.submit(16, -15, Duration::from_secs(5)).unwrap();
545        cancellation.cancel_before_dispatch();
546        assert!(handle.pending());
547        drop(next);
548    }
549}