Skip to main content

start_command/
session_probe.rs

1//! Session liveness probing for tracked executions.
2//!
3//! `--attach`, `--resume` and `--resume-all` all need the same question
4//! answered first: is the isolation session behind this execution record still
5//! alive, stopped but recoverable, or gone entirely? The probe never mutates
6//! anything, so it is safe to run against every tracked record.
7
8use crate::docker_cleanup::docker_command;
9use crate::execution_control::CommandRunner;
10use crate::execution_store::ExecutionRecord;
11
12/// States a tracked isolation session can be observed in.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SessionState {
15    /// The session/container is alive and can be attached to.
16    Running,
17    /// The container still exists but is not running (resumable).
18    Stopped,
19    /// No trace of the session/container remains.
20    Missing,
21    /// The backend cannot be probed locally (e.g. ssh).
22    Unknown,
23}
24
25impl SessionState {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            SessionState::Running => "running",
29            SessionState::Stopped => "stopped",
30            SessionState::Missing => "missing",
31            SessionState::Unknown => "unknown",
32        }
33    }
34}
35
36/// Observation about the isolation session behind an execution record.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SessionProbe {
39    pub backend: Option<String>,
40    pub session_name: Option<String>,
41    pub state: SessionState,
42    pub alive: bool,
43    pub container_status: Option<String>,
44}
45
46impl Default for SessionProbe {
47    fn default() -> Self {
48        SessionProbe {
49            backend: None,
50            session_name: None,
51            state: SessionState::Unknown,
52            alive: false,
53            container_status: None,
54        }
55    }
56}
57
58/// Map a `docker inspect -f {{.State.Status}}` value to a session state.
59pub fn map_docker_status_to_state(status: Option<&str>) -> SessionState {
60    let normalized = status.unwrap_or("").trim().to_lowercase();
61    if normalized.is_empty() {
62        return SessionState::Unknown;
63    }
64    if normalized == "running" || normalized == "restarting" {
65        return SessionState::Running;
66    }
67    SessionState::Stopped
68}
69
70/// Find a session in `screen -ls` output.
71///
72/// Session names are matched exactly against the `<pid>.<name>` column so a
73/// prefix like "session" never matches "my-session".
74pub fn parse_screen_session_state(output: &str, session_name: &str) -> SessionState {
75    for line in output.lines() {
76        let first_column = line.split_whitespace().next().unwrap_or("");
77        let Some((pid, name)) = first_column.split_once('.') else {
78            continue;
79        };
80        if !pid.is_empty() && pid.chars().all(|c| c.is_ascii_digit()) && name == session_name {
81            return SessionState::Running;
82        }
83    }
84    SessionState::Missing
85}
86
87fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
88    record.options.get(key).and_then(|value| value.as_str())
89}
90
91/// Probe the isolation session behind an execution record.
92pub fn probe_session<R: CommandRunner>(record: &ExecutionRecord, runner: &R) -> SessionProbe {
93    let backend = record_option(record, "isolated").map(str::to_string);
94    let session_name = record_option(record, "sessionName").map(str::to_string);
95
96    let mut probe = SessionProbe {
97        backend: backend.clone(),
98        session_name: session_name.clone(),
99        ..SessionProbe::default()
100    };
101
102    let (Some(backend), Some(session_name)) = (backend, session_name) else {
103        return probe;
104    };
105
106    match backend.as_str() {
107        "docker" => {
108            let docker = docker_command().to_string_lossy().to_string();
109            let result = runner.run(
110                &docker,
111                &[
112                    "inspect".to_string(),
113                    "-f".to_string(),
114                    "{{.State.Status}}".to_string(),
115                    session_name,
116                ],
117            );
118            if !result.success {
119                probe.state = SessionState::Missing;
120                return probe;
121            }
122            let status = result.stdout.trim().to_string();
123            probe.container_status = (!status.is_empty()).then_some(status);
124            probe.state = map_docker_status_to_state(probe.container_status.as_deref());
125        }
126        "screen" => {
127            let result = runner.run("screen", &["-ls".to_string()]);
128            // `screen -ls` exits non-zero even when it lists sessions, so the
129            // output is authoritative, not the exit status.
130            let combined = format!("{}{}", result.stdout, result.stderr);
131            probe.state = parse_screen_session_state(&combined, &session_name);
132        }
133        "tmux" => {
134            let result = runner.run(
135                "tmux",
136                &["has-session".to_string(), "-t".to_string(), session_name],
137            );
138            probe.state = if result.success {
139                SessionState::Running
140            } else {
141                SessionState::Missing
142            };
143        }
144        _ => {}
145    }
146
147    probe.alive = probe.state == SessionState::Running;
148    probe
149}
150
151#[cfg(test)]
152#[path = "session_probe_cases.rs"]
153mod tests;