Skip to main content

start_command/
isolation_screen.rs

1//! Screen-specific isolation helpers extracted from isolation.rs
2
3use std::fs;
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::thread;
7use std::time::Duration;
8
9use super::{get_shell, is_debug, wrap_command_with_user, IsolationResult};
10use crate::isolation::isolation_log::{get_temp_dir, wrap_command_with_log_footer};
11
12/// Get the installed screen version
13pub fn get_screen_version() -> Option<(u32, u32, u32)> {
14    let output = Command::new("screen").arg("--version").output().ok()?;
15
16    let output_str = String::from_utf8_lossy(&output.stdout);
17    let stderr_str = String::from_utf8_lossy(&output.stderr);
18    let combined = format!("{}{}", output_str, stderr_str);
19
20    // Match patterns like "4.09.01", "4.00.03", "4.5.1"
21    let re = regex::Regex::new(r"(\d+)\.(\d+)\.(\d+)").ok()?;
22    let caps = re.captures(&combined)?;
23
24    Some((
25        caps.get(1)?.as_str().parse().ok()?,
26        caps.get(2)?.as_str().parse().ok()?,
27        caps.get(3)?.as_str().parse().ok()?,
28    ))
29}
30
31/// Check if screen supports the -Logfile option (added in 4.5.1)
32pub fn supports_logfile_option() -> bool {
33    match get_screen_version() {
34        Some((major, minor, patch)) => {
35            if major > 4 {
36                return true;
37            }
38            if major < 4 {
39                return false;
40            }
41            // major == 4
42            if minor > 5 {
43                return true;
44            }
45            if minor < 5 {
46                return false;
47            }
48            // minor == 5
49            patch >= 1
50        }
51        None => false,
52    }
53}
54
55/// Polls without the session in `screen -ls` and without an exit code before
56/// the session counts as gone. `screen -dmS` can return before the session is
57/// listed, so a missing session alone is no proof that the command finished;
58/// its exit code is written just before the session ends.
59pub const SCREEN_UNLISTED_GRACE_POLLS: u32 = 20;
60
61/// Whether an attached screen run is over: the session is not in
62/// `screen -ls` and either the exit-code sidecar has content (or none is
63/// expected, as for a bare shell) or the grace polls ran out.
64pub fn is_screen_run_finished(listed: bool, exit_code_ready: bool, unlisted_polls: u32) -> bool {
65    !listed && (exit_code_ready || unlisted_polls >= SCREEN_UNLISTED_GRACE_POLLS)
66}
67
68/// Run screen with log capture using `-L` flag + screenrc directives (for attached mode without TTY).
69///
70/// Uses a unified approach combining the `-L` flag with screenrc directives:
71/// - `-L` flag enables logging for the initial window (available on ALL screen versions)
72/// - `logfile <path>` in screenrc sets the log file path (replaces `-Logfile` CLI option)
73/// - `logfile flush 0` forces immediate flushing (no 10-second delay)
74/// - `deflog on` enables logging for any additional windows
75///
76/// Key insight: `deflog on` only applies to windows created AFTER screenrc processing,
77/// but the default window is created BEFORE screenrc is processed. The `-L` flag is
78/// needed to enable logging for that initial window.
79///
80/// This replaces the previous version-dependent approach that used:
81/// - `-L -Logfile` for screen >= 4.5.1 (native logging)
82/// - `tee` fallback for screen < 4.5.1 (e.g., macOS bundled 4.0.3)
83pub fn run_screen_with_log_capture(
84    command: &str,
85    session_name: &str,
86    user: Option<&str>,
87    log_path: Option<&Path>,
88    env: &crate::execution_env::ExecutionEnv,
89) -> IsolationResult {
90    let (shell, shell_arg) = get_shell();
91    let screen_temp_dir = get_temp_dir(&["isolation", "screen"]);
92    let log_file = log_path
93        .map(|p| p.to_path_buf())
94        .unwrap_or_else(|| screen_temp_dir.join(format!("screen-output-{}.log", session_name)));
95    let should_cleanup_log_file = log_path.is_none();
96    let exit_code_file = screen_temp_dir.join(format!("screen-exit-{}.code", session_name));
97    if let Some(parent) = log_file.parent() {
98        let _ = fs::create_dir_all(parent);
99    }
100    let log_start_offset = if log_path.is_some() {
101        fs::metadata(&log_file)
102            .map(|m| m.len() as usize)
103            .unwrap_or(0)
104    } else {
105        0
106    };
107    let effective_command = wrap_command_with_user(command, user);
108
109    // Check if command is an interactive shell (bare shell invocation)
110    let is_bare_shell = command.trim() == "bash"
111        || command.trim() == "zsh"
112        || command.trim() == "sh"
113        || command.trim() == "/bin/bash"
114        || command.trim() == "/bin/zsh"
115        || command.trim() == "/bin/sh";
116
117    // Wrap command to capture exit code in a sidecar file
118    let final_command = if is_bare_shell {
119        effective_command.clone()
120    } else {
121        format!(
122            "{}; echo $? > \"{}\"",
123            effective_command,
124            exit_code_file.display()
125        )
126    };
127
128    // Create temporary screenrc with logging configuration.
129    // Combined with the -L flag (which enables logging for the initial window),
130    // these directives work on ALL screen versions (including macOS 4.00.03):
131    // - `logfile <path>` sets the output log path (replaces -Logfile CLI option)
132    // - `logfile flush 0` forces immediate buffer flush (prevents output loss)
133    // - `deflog on` enables logging for any subsequently created windows
134    let screenrc_path = screen_temp_dir.join(format!("screenrc-{}", session_name));
135    let screenrc_content = format!(
136        "logfile {}\nlogfile flush 0\ndeflog on\n",
137        log_file.display()
138    );
139    if let Err(e) = fs::write(&screenrc_path, &screenrc_content) {
140        if is_debug() {
141            eprintln!("[screen-isolation] Failed to create screenrc: {}", e);
142        }
143        return IsolationResult {
144            success: false,
145            session_name: Some(session_name.to_string()),
146            message: format!("Failed to create screenrc for logging: {}", e),
147            ..Default::default()
148        };
149    }
150
151    // Build screen arguments:
152    //   screen -dmS <session> -L -c <screenrc> <shell> -c '<command>'
153    //
154    // The -L flag explicitly enables logging for the initial window.
155    // Without -L, `deflog on` in screenrc only applies to windows created
156    // AFTER the screenrc is processed — but the default window is created
157    // BEFORE screenrc processing. This caused output to be silently lost
158    // on macOS screen 4.00.03 (issue #96).
159    //
160    // The -L flag is available on ALL screen versions (including 4.00.03).
161    // Combined with `logfile <path>` in screenrc, -L logs to our custom path
162    // instead of the default `screenlog.0`.
163    let screen_args: Vec<String> = if is_bare_shell {
164        let mut args = vec![
165            "-dmS".to_string(),
166            session_name.to_string(),
167            "-L".to_string(),
168            "-c".to_string(),
169            screenrc_path.to_string_lossy().to_string(),
170        ];
171        args.extend(super::to_shell_words(command));
172        args
173    } else {
174        vec![
175            "-dmS".to_string(),
176            session_name.to_string(),
177            "-L".to_string(),
178            "-c".to_string(),
179            screenrc_path.to_string_lossy().to_string(),
180            shell.clone(),
181            shell_arg.clone(),
182            final_command.clone(),
183        ]
184    };
185
186    if is_debug() {
187        eprintln!("[screen-isolation] Running: screen {:?}", screen_args);
188        eprintln!("[screen-isolation] screenrc: {}", screenrc_content.trim());
189        eprintln!("[screen-isolation] Log file: {}", log_file.display());
190        eprintln!(
191            "[screen-isolation] Exit code file: {}",
192            exit_code_file.display()
193        );
194    }
195
196    // Each `screen -dmS` server inherits this env (START_*, issue #230).
197    let status = Command::new("screen")
198        .args(&screen_args)
199        .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
200        .stdout(Stdio::null())
201        .stderr(Stdio::null())
202        .status();
203
204    if status.is_err() {
205        // Clean up temp files on error
206        let _ = fs::remove_file(&screenrc_path);
207        return IsolationResult {
208            success: false,
209            session_name: Some(session_name.to_string()),
210            message: "Failed to start screen session".to_string(),
211            ..Default::default()
212        };
213    }
214
215    // Helper to read log file with retries for race conditions.
216    // Uses multiple retries with increasing delays (50ms, 100ms, 200ms).
217    let read_log_with_retry = || -> Option<String> {
218        let retry_delays = [50u64, 100, 200];
219
220        let content = fs::read_to_string(&log_file)
221            .ok()
222            .map(|s| s.chars().skip(log_start_offset).collect::<String>());
223        if let Some(ref s) = content {
224            if !s.trim().is_empty() {
225                return content;
226            }
227        }
228
229        // Retry with increasing delays
230        for (i, delay) in retry_delays.iter().enumerate() {
231            if is_debug() {
232                eprintln!(
233                    "[screen-isolation] Log file empty, retry {}/{} after {}ms",
234                    i + 1,
235                    retry_delays.len(),
236                    delay
237                );
238            }
239            thread::sleep(Duration::from_millis(*delay));
240            let retry_content = fs::read_to_string(&log_file)
241                .ok()
242                .map(|s| s.chars().skip(log_start_offset).collect::<String>());
243            if let Some(ref s) = retry_content {
244                if !s.trim().is_empty() {
245                    return retry_content;
246                }
247            }
248        }
249
250        if is_debug() {
251            eprintln!(
252                "[screen-isolation] Log file still empty after {} retries",
253                retry_delays.len()
254            );
255            match fs::metadata(&log_file) {
256                Ok(meta) => eprintln!(
257                    "[screen-isolation] Log file exists, size: {} bytes",
258                    meta.len()
259                ),
260                Err(_) => eprintln!("[screen-isolation] Log file does not exist"),
261            }
262        }
263
264        content
265    };
266
267    // Read exit code from sidecar file
268    let read_exit_code = || -> i32 {
269        if is_bare_shell {
270            return 0;
271        }
272        match fs::read_to_string(&exit_code_file) {
273            Ok(content) => {
274                let code = content.trim().parse::<i32>().unwrap_or(0);
275                if is_debug() {
276                    eprintln!("[screen-isolation] Captured exit code: {}", code);
277                }
278                code
279            }
280            Err(_) => {
281                if is_debug() {
282                    eprintln!("[screen-isolation] Could not read exit code file, defaulting to 0");
283                }
284                0
285            }
286        }
287    };
288
289    // Clean up temp files helper
290    let cleanup = || {
291        if should_cleanup_log_file {
292            let _ = fs::remove_file(&log_file);
293        }
294        let _ = fs::remove_file(&screenrc_path);
295        let _ = fs::remove_file(&exit_code_file);
296    };
297
298    // Poll for session completion
299    let max_wait = Duration::from_secs(300);
300    let check_interval = Duration::from_millis(100);
301    let mut waited = Duration::ZERO;
302
303    let exit_code_ready = || {
304        is_bare_shell
305            || fs::read_to_string(&exit_code_file).is_ok_and(|content| !content.trim().is_empty())
306    };
307    let mut unlisted_polls = 0;
308
309    loop {
310        // Check if session still exists
311        let listed = Command::new("screen")
312            .arg("-ls")
313            .output()
314            .map(|o| String::from_utf8_lossy(&o.stdout).contains(session_name))
315            .unwrap_or(false);
316        unlisted_polls = if listed { 0 } else { unlisted_polls + 1 };
317
318        if is_screen_run_finished(listed, exit_code_ready(), unlisted_polls) {
319            // Session ended, read output and exit code
320            let output = read_log_with_retry();
321            let exit_code = read_exit_code();
322
323            // Display output
324            if let Some(ref out) = output {
325                if !out.trim().is_empty() {
326                    print!("{}", out);
327                }
328            }
329
330            // Clean up temp files
331            cleanup();
332
333            return IsolationResult {
334                diagnostics: None,
335                success: exit_code == 0,
336                session_name: Some(session_name.to_string()),
337                container_id: None,
338                message: format!(
339                    "Screen session \"{}\" exited with code {}",
340                    session_name, exit_code
341                ),
342                exit_code: Some(exit_code),
343                output,
344                container_removed: false,
345            };
346        }
347
348        thread::sleep(check_interval);
349        waited += check_interval;
350
351        if waited >= max_wait {
352            cleanup();
353            return IsolationResult {
354                diagnostics: None,
355                success: false,
356                session_name: Some(session_name.to_string()),
357                message: format!(
358                    "Screen session \"{}\" timed out after {} seconds",
359                    session_name,
360                    max_wait.as_secs()
361                ),
362                exit_code: Some(1),
363                ..Default::default()
364            };
365        }
366    }
367}
368
369/// Start detached screen with live logging to the provided log path.
370pub fn start_detached_screen_with_log_capture(
371    command: &str,
372    session_name: &str,
373    user: Option<&str>,
374    keep_alive: bool,
375    log_path: Option<&Path>,
376    env: &crate::execution_env::ExecutionEnv,
377) -> IsolationResult {
378    let (shell, shell_arg) = get_shell();
379    let screen_temp_dir = get_temp_dir(&["isolation", "screen"]);
380    let log_file = log_path
381        .map(|p| p.to_path_buf())
382        .unwrap_or_else(|| screen_temp_dir.join(format!("screen-output-{}.log", session_name)));
383    if let Some(parent) = log_file.parent() {
384        let _ = fs::create_dir_all(parent);
385    }
386
387    let screenrc_path = screen_temp_dir.join(format!("screenrc-{}", session_name));
388    let screenrc_content = format!(
389        "logfile {}\nlogfile flush 0\ndeflog on\n",
390        log_file.display()
391    );
392    if let Err(e) = fs::write(&screenrc_path, &screenrc_content) {
393        if is_debug() {
394            eprintln!("[screen-isolation] Failed to create screenrc: {}", e);
395        }
396        return IsolationResult {
397            success: false,
398            session_name: Some(session_name.to_string()),
399            message: format!("Failed to create screenrc for logging: {}", e),
400            ..Default::default()
401        };
402    }
403
404    let effective_command = wrap_command_with_user(command, user);
405    let final_command = wrap_command_with_log_footer(&effective_command, &shell, keep_alive);
406    let screen_args = vec![
407        "-dmS".to_string(),
408        session_name.to_string(),
409        "-L".to_string(),
410        "-c".to_string(),
411        screenrc_path.to_string_lossy().to_string(),
412        shell.clone(),
413        shell_arg,
414        final_command,
415    ];
416
417    if is_debug() {
418        eprintln!("[screen-isolation] Running: screen {:?}", screen_args);
419        eprintln!("[screen-isolation] screenrc: {}", screenrc_content.trim());
420        eprintln!("[screen-isolation] Log file: {}", log_file.display());
421    }
422
423    match Command::new("screen")
424        .args(&screen_args)
425        .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
426        .status()
427    {
428        Ok(status) if status.success() => {
429            let mut message = format!(
430                "Command started in detached screen session: {}",
431                session_name
432            );
433            if keep_alive {
434                message.push_str("\nSession will stay alive after command completes.");
435            } else {
436                message.push_str("\nSession will exit automatically after command completes.");
437            }
438            message.push_str(&format!("\nReattach with: screen -r {}", session_name));
439            message.push_str(&format!("\nLive log: {}", log_file.display()));
440            IsolationResult {
441                success: true,
442                session_name: Some(session_name.to_string()),
443                message,
444                ..Default::default()
445            }
446        }
447        Ok(status) => IsolationResult {
448            success: false,
449            session_name: Some(session_name.to_string()),
450            message: format!(
451                "Failed to start screen session (exit code {})",
452                status.code().unwrap_or(-1)
453            ),
454            ..Default::default()
455        },
456        Err(e) => IsolationResult {
457            success: false,
458            session_name: Some(session_name.to_string()),
459            message: format!("Failed to start screen session: {}", e),
460            ..Default::default()
461        },
462    }
463}