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