Skip to main content

synaps_cli/tools/shell/
readiness.rs

1//! Output readiness detection — determines when a shell is waiting for input.
2//!
3//! Three strategies for detecting that a shell process has finished producing
4//! output and is waiting for the next command:
5//!
6//! - **Timeout**: pure silence-based — if N ms pass with no output, we assume ready.
7//! - **Prompt**: regex-based — scan the tail of output for known prompt patterns.
8//! - **Hybrid**: try prompt detection first, fall back to silence timeout.
9//!
10//! The `check()` method is stateless and designed for polling loops — the caller
11//! owns the timers, we just evaluate the current snapshot.
12
13use std::time::Duration;
14
15use regex::Regex;
16
17use crate::tools::strip_ansi;
18
19use super::config::ShellConfig;
20
21// ---------------------------------------------------------------------------
22// Strategy & result enums
23// ---------------------------------------------------------------------------
24
25/// The strategy for detecting when a shell is ready for input.
26#[derive(Debug, Clone)]
27pub enum ReadinessStrategy {
28    /// Wait for N ms of output silence
29    Timeout,
30    /// Wait until output matches a prompt regex
31    Prompt,
32    /// Prompt detection with timeout fallback (default)
33    Hybrid,
34}
35
36impl ReadinessStrategy {
37    pub fn from_str(s: &str) -> Self {
38        match s.to_lowercase().as_str() {
39            "timeout" => ReadinessStrategy::Timeout,
40            "prompt" => ReadinessStrategy::Prompt,
41            _ => ReadinessStrategy::Hybrid,
42        }
43    }
44}
45
46/// Result of a readiness check
47#[derive(Debug, PartialEq)]
48pub enum ReadinessResult {
49    /// Output matched a prompt pattern — shell is waiting for input
50    Ready,
51    /// Still receiving output, keep waiting
52    Waiting,
53    /// Silence timeout expired — return what we have
54    SilenceTimeout,
55    /// Maximum total wait time exceeded
56    MaxTimeout,
57}
58
59// ---------------------------------------------------------------------------
60// Detector
61// ---------------------------------------------------------------------------
62
63/// Stateless readiness evaluator — called repeatedly in a polling loop.
64pub struct ReadinessDetector {
65    strategy: ReadinessStrategy,
66    patterns: Vec<Regex>,
67    silence_timeout: Duration,
68    max_timeout: Duration,
69}
70
71/// How many chars from the tail of output to inspect for prompt matching.
72const PROMPT_TAIL_LEN: usize = 200;
73
74impl ReadinessDetector {
75    /// Build a detector from raw parts.
76    ///
77    /// Invalid regex patterns are silently skipped (with a `tracing::warn`).
78    /// If *no* patterns compile, the strategy is downgraded to `Timeout`.
79    pub fn new(
80        strategy: ReadinessStrategy,
81        patterns_str: &[String],
82        silence_timeout_ms: u64,
83        max_timeout_ms: u64,
84    ) -> Self {
85        let patterns: Vec<Regex> = patterns_str
86            .iter()
87            .filter_map(|p| match Regex::new(p) {
88                Ok(re) => Some(re),
89                Err(e) => {
90                    tracing::warn!(pattern = %p, error = %e, "skipping invalid prompt regex");
91                    None
92                }
93            })
94            .collect();
95
96        // If we wanted prompt detection but have no usable patterns, fall back.
97        let strategy = if patterns.is_empty() {
98            match strategy {
99                ReadinessStrategy::Prompt | ReadinessStrategy::Hybrid => {
100                    tracing::warn!(
101                        "no valid prompt patterns — falling back to Timeout strategy"
102                    );
103                    ReadinessStrategy::Timeout
104                }
105                other => other,
106            }
107        } else {
108            strategy
109        };
110
111        Self {
112            strategy,
113            patterns,
114            silence_timeout: Duration::from_millis(silence_timeout_ms),
115            max_timeout: Duration::from_millis(max_timeout_ms),
116        }
117    }
118
119    /// Build from the default `ShellConfig`.
120    pub fn from_config(config: &ShellConfig) -> Self {
121        let strategy = crate::tools::shell::readiness::ReadinessStrategy::from_str(&config.readiness_strategy);
122        Self::new(
123            strategy,
124            &config.prompt_patterns,
125            config.readiness_timeout_ms,
126            config.max_readiness_timeout_ms,
127        )
128    }
129
130    /// Evaluate the current output snapshot against the active strategy.
131    ///
132    /// Called in a tight poll loop — the caller tracks `silence_elapsed`
133    /// (time since last new output byte) and `total_elapsed` (wall-clock since
134    /// the command was sent).
135    pub fn check(
136        &self,
137        output: &str,
138        silence_elapsed: Duration,
139        total_elapsed: Duration,
140    ) -> ReadinessResult {
141        // Hard ceiling — always wins.
142        if total_elapsed >= self.max_timeout {
143            return ReadinessResult::MaxTimeout;
144        }
145
146        match &self.strategy {
147            ReadinessStrategy::Timeout => {
148                if silence_elapsed >= self.silence_timeout {
149                    ReadinessResult::SilenceTimeout
150                } else {
151                    ReadinessResult::Waiting
152                }
153            }
154            ReadinessStrategy::Prompt => {
155                if self.matches_prompt(output) {
156                    ReadinessResult::Ready
157                } else {
158                    ReadinessResult::Waiting
159                }
160            }
161            ReadinessStrategy::Hybrid => {
162                if self.matches_prompt(output) {
163                    ReadinessResult::Ready
164                } else if silence_elapsed >= self.silence_timeout {
165                    ReadinessResult::SilenceTimeout
166                } else {
167                    ReadinessResult::Waiting
168                }
169            }
170        }
171    }
172
173    /// Check the tail of `output` for a known prompt pattern.
174    ///
175    /// Only inspects the last ~200 chars (prompts live at the end), and strips
176    /// ANSI escapes before matching.  The match runs against the last non-empty
177    /// line.
178    pub fn matches_prompt(&self, output: &str) -> bool {
179        if output.is_empty() || self.patterns.is_empty() {
180            return false;
181        }
182
183        // Grab the tail to avoid scanning megabytes of scrollback.
184        // Walk backward to the previous UTF-8 char boundary to avoid panicking
185        // when multi-byte glyphs (●, ❯, box-drawing, emoji) straddle the cut.
186        // Walking backward (floor) instead of forward (ceil) ensures the tail
187        // is never empty when the cut lands inside the final multi-byte glyph.
188        let tail = if output.len() > PROMPT_TAIL_LEN {
189            let mut start = output.len() - PROMPT_TAIL_LEN;
190            while start > 0 && !output.is_char_boundary(start) {
191                start -= 1;
192            }
193            &output[start..]
194        } else {
195            output
196        };
197
198        let clean = strip_ansi(tail);
199
200        // Last non-empty line is where the prompt sits.
201        let last_line = clean
202            .lines()
203            .rev()
204            .find(|l| !l.trim().is_empty())
205            .unwrap_or("");
206
207        if last_line.is_empty() {
208            return false;
209        }
210
211        self.patterns.iter().any(|re| re.is_match(last_line))
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Tests
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    /// Helper: build a Timeout detector with sensible defaults.
224    fn timeout_detector(silence_ms: u64) -> ReadinessDetector {
225        ReadinessDetector::new(ReadinessStrategy::Timeout, &[], silence_ms, 10_000)
226    }
227
228    /// Helper: build a Prompt detector with the default pattern set.
229    fn prompt_detector() -> ReadinessDetector {
230        let config = ShellConfig::default();
231        ReadinessDetector::new(
232            ReadinessStrategy::Prompt,
233            &config.prompt_patterns,
234            config.readiness_timeout_ms,
235            config.max_readiness_timeout_ms,
236        )
237    }
238
239    /// Helper: build a Hybrid detector with the default pattern set.
240    fn hybrid_detector() -> ReadinessDetector {
241        ReadinessDetector::from_config(&ShellConfig::default())
242    }
243
244    // 1. Timeout: silence exceeds threshold → SilenceTimeout
245    #[test]
246    fn timeout_strategy_silence_triggers() {
247        let det = timeout_detector(300);
248        let result = det.check(
249            "some output\n",
250            Duration::from_millis(301),
251            Duration::from_millis(500),
252        );
253        assert_eq!(result, ReadinessResult::SilenceTimeout);
254    }
255
256    // 1b. Timeout: silence below threshold → Waiting
257    #[test]
258    fn timeout_strategy_still_waiting() {
259        let det = timeout_detector(300);
260        let result = det.check(
261            "some output\n",
262            Duration::from_millis(100),
263            Duration::from_millis(200),
264        );
265        assert_eq!(result, ReadinessResult::Waiting);
266    }
267
268    // 2. Prompt: output ending with `$ ` → Ready
269    #[test]
270    fn prompt_strategy_dollar_ready() {
271        let det = prompt_detector();
272        let result = det.check(
273            "user@host:~$ ",
274            Duration::from_millis(0),
275            Duration::from_millis(50),
276        );
277        assert_eq!(result, ReadinessResult::Ready);
278    }
279
280    // 3. Prompt: output ending with `>>> ` (Python) → Ready
281    #[test]
282    fn prompt_strategy_python_ready() {
283        let det = prompt_detector();
284        let result = det.check(
285            "Python 3.11.0\n>>> ",
286            Duration::from_millis(0),
287            Duration::from_millis(50),
288        );
289        assert_eq!(result, ReadinessResult::Ready);
290    }
291
292    // 4. Prompt: output NOT ending with prompt → still waiting (no silence fallback)
293    #[test]
294    fn prompt_strategy_no_match_silence_fallback() {
295        let det = prompt_detector();
296        // No prompt at the end, silence exceeded - but prompt strategy doesn't use silence
297        let result = det.check(
298            "compiling crate...\n",
299            Duration::from_millis(500),
300            Duration::from_millis(1000),
301        );
302        assert_eq!(result, ReadinessResult::Waiting);
303    }
304
305    // 4b. Prompt: no match, silence not exceeded → Waiting
306    #[test]
307    fn prompt_strategy_no_match_waiting() {
308        let det = prompt_detector();
309        let result = det.check(
310            "compiling crate...\n",
311            Duration::from_millis(100),
312            Duration::from_millis(200),
313        );
314        assert_eq!(result, ReadinessResult::Waiting);
315    }
316
317    // 5. Hybrid: prompt match → Ready (before silence would trigger)
318    #[test]
319    fn hybrid_prompt_match_before_silence() {
320        let det = hybrid_detector();
321        let result = det.check(
322            "welcome\nuser@host:~$ ",
323            Duration::from_millis(10), // well below silence threshold
324            Duration::from_millis(50),
325        );
326        assert_eq!(result, ReadinessResult::Ready);
327    }
328
329    // 6. Hybrid: no prompt match, silence elapsed → SilenceTimeout
330    #[test]
331    fn hybrid_silence_fallback() {
332        let det = hybrid_detector();
333        let result = det.check(
334            "running long task...\n",
335            Duration::from_millis(500),
336            Duration::from_millis(1000),
337        );
338        assert_eq!(result, ReadinessResult::SilenceTimeout);
339    }
340
341    // 7. MaxTimeout always wins regardless of strategy
342    #[test]
343    fn max_timeout_always_wins() {
344        for det in [timeout_detector(300), prompt_detector(), hybrid_detector()] {
345            let result = det.check(
346                "user@host:~$ ",
347                Duration::from_millis(0),
348                Duration::from_millis(10_001),
349            );
350            assert_eq!(result, ReadinessResult::MaxTimeout);
351        }
352    }
353
354    // 8. matches_prompt against common prompts
355    #[test]
356    fn matches_prompt_common_patterns() {
357        let det = hybrid_detector();
358        let prompts = [
359            "user@host:~$ ",
360            "root@server:/var# ",
361            ">>> ",
362            "(gdb) ",
363            "Password: ",
364        ];
365        for prompt in &prompts {
366            assert!(
367                det.matches_prompt(prompt),
368                "expected match for prompt: {:?}",
369                prompt,
370            );
371        }
372    }
373
374    // 9. Invalid regex patterns are skipped — no panic
375    #[test]
376    fn invalid_regex_skipped_no_panic() {
377        let patterns = vec![
378            "[invalid(".into(), // broken regex
379            r"[$#] $".into(),   // valid
380        ];
381        let det = ReadinessDetector::new(
382            ReadinessStrategy::Hybrid,
383            &patterns,
384            300,
385            10_000,
386        );
387        // Should still work — the valid pattern compiled.
388        assert!(det.matches_prompt("user@host:~$ "));
389    }
390
391    // 9b. ALL patterns invalid → falls back to Timeout
392    #[test]
393    fn all_invalid_patterns_fallback_to_timeout() {
394        let patterns = vec!["[broken(".into(), "(also[bad".into()];
395        let det = ReadinessDetector::new(
396            ReadinessStrategy::Hybrid,
397            &patterns,
398            300,
399            10_000,
400        );
401        // Strategy downgraded — prompt won't match, silence drives the result.
402        assert!(!det.matches_prompt("user@host:~$ "));
403        let result = det.check(
404            "user@host:~$ ",
405            Duration::from_millis(301),
406            Duration::from_millis(500),
407        );
408        assert_eq!(result, ReadinessResult::SilenceTimeout);
409    }
410
411    // 10. Empty output never matches
412    #[test]
413    fn empty_output_no_match() {
414        let det = hybrid_detector();
415        assert!(!det.matches_prompt(""));
416    }
417
418    // Bonus: ANSI-laden prompt still matches after stripping
419    #[test]
420    fn ansi_stripped_before_matching() {
421        let det = hybrid_detector();
422        // Prompt with color codes wrapping it
423        let ansi_prompt = "\x1b[32muser@host\x1b[0m:\x1b[34m~\x1b[0m$ ";
424        assert!(det.matches_prompt(ansi_prompt));
425    }
426
427    // Bonus: only last line matters
428    #[test]
429    fn only_last_line_checked() {
430        let det = hybrid_detector();
431        // Prompt-like text in the middle, non-prompt at the end
432        assert!(!det.matches_prompt("user@host:~$ \nstill running..."));
433        // Prompt at the end
434        assert!(det.matches_prompt("still running...\nuser@host:~$ "));
435    }
436
437    // Regression: multi-byte UTF-8 glyphs at the tail boundary must not panic.
438    // See BUG_REPORT_synaps_ssh_crash.md — starship/powerline prompts with
439    // ●/❯/box-drawing glyphs caused SIGABRT when the byte-offset slice landed
440    // inside a multi-byte sequence.
441    #[test]
442    fn matches_prompt_handles_multibyte_glyph_at_tail_boundary() {
443        let det = hybrid_detector();
444
445        // Build output where a 3-byte char straddles the tail-window cut.
446        // prefix_len puts the start of '●' (3 bytes) exactly 1 byte before
447        // the PROMPT_TAIL_LEN boundary, so a naive slice would land inside it.
448        let prefix_len = PROMPT_TAIL_LEN - 1;
449        let mut output = "x".repeat(prefix_len);
450        output.push('●'); // 3-byte: E2 97 8F
451        output.push_str("\n~/repo on  main\nuser@host:~$ ");
452
453        // Must not panic, and should still detect the prompt.
454        assert!(det.matches_prompt(&output));
455    }
456
457    #[test]
458    fn matches_prompt_handles_4byte_emoji_at_tail_boundary() {
459        let det = hybrid_detector();
460
461        // 4-byte emoji right at the boundary edge
462        for offset in 0..4 {
463            let prefix_len = PROMPT_TAIL_LEN - offset;
464            let mut output = "a".repeat(prefix_len);
465            output.push('🔥'); // 4-byte: F0 9F 94 A5
466            output.push_str("\nuser@host:~$ ");
467            assert!(det.matches_prompt(&output), "panicked at offset {}", offset);
468        }
469    }
470}