Skip to main content

agent_tui/tui/
quit_guard.rs

1//! F27: quit-while-streaming guard for socket (daemon) sessions.
2//!
3//! When a user presses Ctrl+C (or `/quit`) while streaming over a socket
4//! transport, the turn keeps running headlessly in the daemon. This module
5//! provides a two-press state machine: the first press shows a notice; the
6//! second press within [`WINDOW`] detaches as today.
7//!
8//! In-process (`TransportMode::Local`) and idle (not streaming) quit paths
9//! are unchanged — they bypass this guard entirely.
10
11use std::time::{Duration, Instant};
12
13/// Window within which a second Ctrl+C detaches.
14pub const WINDOW: Duration = Duration::from_secs(3);
15
16/// Notice shown on the first Ctrl+C while streaming over a socket transport.
17pub const NOTICE: &str =
18    "turn still running in the daemon \u{2014} press Esc to abort it, or Ctrl+C again within 3 s to detach (session keeps running)";
19
20/// Same notice for the line client (`synaps attach`), written to stderr.
21pub const NOTICE_LINE: &str =
22    "turn still running in the daemon \u{2014} Ctrl+C again within 3 s to detach (session keeps running)";
23
24/// Two-press quit guard. Timestamps are injectable for testing.
25#[derive(Debug, Clone)]
26pub struct QuitGuard {
27    /// Timestamp of the first (warning) press, if any.
28    first_press: Option<Instant>,
29}
30
31impl QuitGuard {
32    pub fn new() -> Self {
33        Self { first_press: None }
34    }
35
36    /// Called on a quit intent while streaming over a socket transport.
37    /// Returns `true` if the caller should proceed with detach (second press
38    /// within the window). Returns `false` if this was the first press (the
39    /// caller should show the notice instead).
40    pub fn press(&mut self, now: Instant) -> bool {
41        if let Some(first) = self.first_press {
42            if now.duration_since(first) <= WINDOW {
43                self.first_press = None;
44                return true;
45            }
46        }
47        // First press (or expired window) — record and warn.
48        self.first_press = Some(now);
49        false
50    }
51
52    /// Reset when streaming ends (the guard is only relevant while streaming).
53    pub fn reset(&mut self) {
54        self.first_press = None;
55    }
56}
57
58impl Default for QuitGuard {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    /// Helper: create a fake "now" at `offset` from a shared anchor.
69    fn fake_now(offset: Duration) -> Instant {
70        use std::sync::OnceLock;
71        static ANCHOR: OnceLock<Instant> = OnceLock::new();
72        *ANCHOR.get_or_init(Instant::now) + offset
73    }
74
75    #[test]
76    fn first_press_returns_false() {
77        let mut g = QuitGuard::new();
78        assert!(!g.press(fake_now(Duration::from_secs(0))));
79    }
80
81    #[test]
82    fn second_press_within_window_returns_true() {
83        let mut g = QuitGuard::new();
84        assert!(!g.press(fake_now(Duration::from_secs(0))));
85        assert!(g.press(fake_now(Duration::from_secs(2))));
86    }
87
88    #[test]
89    fn second_press_after_window_returns_false() {
90        let mut g = QuitGuard::new();
91        assert!(!g.press(fake_now(Duration::from_secs(0))));
92        assert!(!g.press(fake_now(Duration::from_secs(4))));
93    }
94
95    #[test]
96    fn third_press_within_new_window_returns_true() {
97        let mut g = QuitGuard::new();
98        assert!(!g.press(fake_now(Duration::from_secs(0))));
99        // Expired.
100        assert!(!g.press(fake_now(Duration::from_secs(4))));
101        // Now within the new window.
102        assert!(g.press(fake_now(Duration::from_secs(5))));
103    }
104
105    #[test]
106    fn reset_clears_state() {
107        let mut g = QuitGuard::new();
108        assert!(!g.press(fake_now(Duration::from_secs(0))));
109        g.reset();
110        assert!(!g.press(fake_now(Duration::from_secs(1))));
111    }
112
113    #[test]
114    fn exact_boundary_is_within_window() {
115        let mut g = QuitGuard::new();
116        assert!(!g.press(fake_now(Duration::from_secs(0))));
117        assert!(g.press(fake_now(Duration::from_secs(3))));
118    }
119
120    #[test]
121    fn just_past_boundary_is_outside_window() {
122        let mut g = QuitGuard::new();
123        assert!(!g.press(fake_now(Duration::from_secs(0))));
124        assert!(!g.press(fake_now(Duration::from_millis(3001))));
125    }
126}