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