Skip to main content

speccoder_runtime/acp/
terminal.rs

1//! `terminal/*`: the commands an agent runs through speccoder, started by
2//! the attempt's runner — the OS sandbox, the scrubbed environment — after
3//! the policy allowed them, their output masked before the agent reads it.
4
5use std::collections::HashMap;
6use std::io::Read;
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::sync::{Arc, Condvar, Mutex};
10use std::time::{Duration, Instant};
11
12use nix::sys::signal::Signal;
13
14/// The most of a terminal's output kept (the agent may ask for less).
15pub const KEPT: usize = 1024 * 1024;
16
17/// How a command ended.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Exit {
20    /// Its exit code, if it exited.
21    pub code: Option<u32>,
22    /// The signal that ended it, if one did.
23    pub signal: Option<String>,
24}
25
26#[derive(Debug, Default)]
27struct Shared {
28    output: Vec<u8>,
29    total: usize,
30    exit: Option<Exit>,
31}
32
33#[derive(Debug)]
34struct Term {
35    shared: Arc<(Mutex<Shared>, Condvar)>,
36    pid: Option<u32>,
37    limit: Option<usize>,
38}
39
40/// The terminals of one attempt.
41#[derive(Debug, Default)]
42pub struct Terminals {
43    next: Mutex<u64>,
44    all: Mutex<HashMap<String, Term>>,
45}
46
47/// Ends the command and everything it started.
48fn kill(pid: Option<u32>) {
49    if let Some(p) = pid {
50        crate::session::end(p);
51    }
52}
53
54impl Terminals {
55    /// Starts `argv` (already decided) in `cwd` with `env` through `runner`,
56    /// killed after `deadline`.
57    ///
58    /// # Errors
59    ///
60    /// It could not be started.
61    pub fn create(
62        &self,
63        runner: &crate::sandbox::Runner,
64        argv: &[String],
65        cwd: PathBuf,
66        mut env: Vec<(String, String)>,
67        limit: Option<usize>,
68        deadline: Duration,
69    ) -> Result<String, String> {
70        let (wrapped, tmp) = runner.wrap(argv)?;
71        // Through the sandbox helper it leads a session of its own, else a
72        // process group.
73        let caged = tmp.is_some();
74        if let Some(tmp) = tmp {
75            env.retain(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"));
76            let t = tmp.to_string_lossy().into_owned();
77            env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), t.clone())));
78        }
79        let Some((program, args)) = wrapped.split_first() else {
80            return Err("empty command".to_owned());
81        };
82        let mut cmd = Command::new(program);
83        cmd.args(args)
84            .current_dir(&cwd)
85            .env_clear()
86            .envs(env)
87            .stdin(Stdio::null())
88            .stdout(Stdio::piped())
89            .stderr(Stdio::piped());
90        #[cfg(unix)]
91        if !caged {
92            std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
93        }
94        let mut child = cmd
95            .spawn()
96            .map_err(|e| format!("could not start `{}`: {e}", argv.join(" ")))?;
97        let pid = Some(child.id());
98        let shared: Arc<(Mutex<Shared>, Condvar)> = Arc::default();
99        let finished_reading = Arc::new(std::sync::atomic::AtomicUsize::new(0));
100        let readers: Vec<std::thread::JoinHandle<()>> = [
101            child
102                .stdout
103                .take()
104                .map(|s| Box::new(s) as Box<dyn Read + Send>),
105            child
106                .stderr
107                .take()
108                .map(|s| Box::new(s) as Box<dyn Read + Send>),
109        ]
110        .into_iter()
111        .flatten()
112        .map(|mut stream| {
113            let shared = Arc::clone(&shared);
114            let finished = Arc::clone(&finished_reading);
115            std::thread::spawn(move || {
116                let mut buf = [0u8; 8192];
117                while let Ok(n) = stream.read(&mut buf) {
118                    if n == 0 {
119                        break;
120                    }
121                    if let Ok(mut s) = shared.0.lock() {
122                        s.total += n;
123                        s.output.extend_from_slice(&buf[..n]);
124                        // The end is kept: what a command printed last.
125                        let keep = KEPT + crate::secrets::longest();
126                        if s.output.len() > keep {
127                            let cut = s.output.len() - keep;
128                            s.output.drain(..cut);
129                        }
130                    }
131                }
132                finished.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
133            })
134        })
135        .collect();
136        let waiter = Arc::clone(&shared);
137        std::thread::spawn(move || {
138            let began = Instant::now();
139            let status = loop {
140                match child.try_wait() {
141                    Ok(Some(s)) => break Some(s),
142                    Ok(None) if began.elapsed() >= deadline => {
143                        kill(pid);
144                        break child.wait().ok();
145                    }
146                    Ok(None) => std::thread::sleep(Duration::from_millis(10)),
147                    Err(_) => break None,
148                }
149            };
150            // Stragglers that outlived the leader go too.
151            kill(pid);
152            // Its output, as far as it comes within a moment: a process
153            // that left the run (`setsid` where it can) may hold the pipes open for
154            // ever, and never keeps the exit from being said.
155            let n = readers.len();
156            let read_until = Instant::now() + Duration::from_secs(2);
157            while finished_reading.load(std::sync::atomic::Ordering::SeqCst) < n
158                && Instant::now() < read_until
159            {
160                std::thread::sleep(Duration::from_millis(10));
161            }
162            drop(readers);
163            let exit = {
164                #[cfg(unix)]
165                let signal = status
166                    .and_then(|s| std::os::unix::process::ExitStatusExt::signal(&s))
167                    .map(|n| {
168                        Signal::try_from(n)
169                            .map_or_else(|_| n.to_string(), |s| s.as_str().to_owned())
170                    });
171                #[cfg(not(unix))]
172                let signal: Option<String> = None;
173                Exit {
174                    code: status
175                        .and_then(|s| s.code())
176                        .and_then(|c| u32::try_from(c).ok()),
177                    signal,
178                }
179            };
180            if let Ok(mut s) = waiter.0.lock() {
181                s.exit = Some(exit);
182            }
183            waiter.1.notify_all();
184        });
185        let id = {
186            let mut n = self.next.lock().map_err(|e| e.to_string())?;
187            *n += 1;
188            format!("term-{n}")
189        };
190        self.all
191            .lock()
192            .map_err(|e| e.to_string())?
193            .insert(id.clone(), Term { shared, pid, limit });
194        Ok(id)
195    }
196
197    fn with<T>(&self, id: &str, f: impl FnOnce(&Term) -> T) -> Result<T, String> {
198        let all = self.all.lock().map_err(|e| e.to_string())?;
199        all.get(id)
200            .map(f)
201            .ok_or_else(|| format!("no terminal `{id}`"))
202    }
203
204    /// What it printed so far (masked, the end kept within its limit),
205    /// whether that was cut, and how it ended if it has.
206    ///
207    /// # Errors
208    ///
209    /// No such terminal.
210    pub fn output(&self, id: &str) -> Result<(String, bool, Option<Exit>), String> {
211        self.with(id, |t| {
212            let Ok(s) = t.shared.0.lock() else {
213                return (String::new(), false, None);
214            };
215            let masked = crate::secrets::mask(&String::from_utf8_lossy(&s.output));
216            let limit = t.limit.unwrap_or(KEPT).min(KEPT);
217            let mut cut = masked.len().saturating_sub(limit);
218            while !masked.is_char_boundary(cut) {
219                cut += 1;
220            }
221            let truncated = cut > 0 || s.total > s.output.len();
222            (masked[cut..].to_owned(), truncated, s.exit.clone())
223        })
224    }
225
226    /// Waits for it to end.
227    ///
228    /// # Errors
229    ///
230    /// No such terminal.
231    pub fn wait(&self, id: &str) -> Result<Exit, String> {
232        let shared = self.with(id, |t| Arc::clone(&t.shared))?;
233        let (lock, cv) = &*shared;
234        let mut s = lock.lock().map_err(|e| e.to_string())?;
235        loop {
236            if let Some(e) = &s.exit {
237                return Ok(e.clone());
238            }
239            s = cv.wait(s).map_err(|e| e.to_string())?;
240        }
241    }
242
243    /// Kills it; it stays readable.
244    ///
245    /// # Errors
246    ///
247    /// No such terminal.
248    pub fn kill(&self, id: &str) -> Result<(), String> {
249        self.with(id, |t| kill(t.pid))
250    }
251
252    /// Kills it and forgets it.
253    ///
254    /// # Errors
255    ///
256    /// No such terminal.
257    pub fn release(&self, id: &str) -> Result<(), String> {
258        let t = self
259            .all
260            .lock()
261            .map_err(|e| e.to_string())?
262            .remove(id)
263            .ok_or_else(|| format!("no terminal `{id}`"))?;
264        kill(t.pid);
265        Ok(())
266    }
267
268    /// Kills every terminal still running (the attempt ended).
269    pub fn kill_all(&self) {
270        if let Ok(all) = self.all.lock() {
271            for t in all.values() {
272                if t.shared.0.lock().is_ok_and(|s| s.exit.is_none()) {
273                    kill(t.pid);
274                }
275            }
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn a_command_runs_and_its_output_is_kept_from_the_end() {
286        let t = Terminals::default();
287        let dir = std::env::temp_dir();
288        let env: Vec<(String, String)> = std::env::vars().collect();
289        let argv = ["sh", "-c", "printf 'hello world'; exit 3"].map(str::to_owned);
290        let id = t
291            .create(
292                &crate::sandbox::Runner::Plain,
293                &argv,
294                dir.clone(),
295                env.clone(),
296                Some(5),
297                Duration::from_secs(10),
298            )
299            .unwrap_or_default();
300        let exit = t.wait(&id).unwrap_or(Exit {
301            code: None,
302            signal: None,
303        });
304        assert_eq!(exit.code, Some(3));
305        let (out, truncated, ended) = t.output(&id).unwrap_or_default();
306        assert_eq!(
307            (out.as_str(), truncated, ended.is_some()),
308            ("world", true, true)
309        );
310        // Killed at its deadline.
311        let argv = ["sleep", "5"].map(str::to_owned);
312        let id = t
313            .create(
314                &crate::sandbox::Runner::Plain,
315                &argv,
316                dir,
317                env,
318                None,
319                Duration::from_millis(100),
320            )
321            .unwrap_or_default();
322        let exit = t.wait(&id).unwrap_or(Exit {
323            code: Some(0),
324            signal: None,
325        });
326        assert_eq!(exit.code, None);
327        assert!(t.release(&id).is_ok());
328        assert!(t.output(&id).is_err());
329    }
330
331    #[test]
332    fn a_process_that_leaves_the_group_never_keeps_the_exit_from_being_said() {
333        let t = Terminals::default();
334        let env: Vec<(String, String)> = std::env::vars().collect();
335        // Its child starts a session of its own and holds stdout open.
336        let argv = [
337            "python3",
338            "-c",
339            "import os, time\nif os.fork() == 0:\n    os.setsid()\n    time.sleep(30)\nprint('done')",
340        ]
341        .map(str::to_owned);
342        let began = Instant::now();
343        let id = t
344            .create(
345                &crate::sandbox::Runner::Plain,
346                &argv,
347                std::env::temp_dir(),
348                env,
349                None,
350                Duration::from_secs(60),
351            )
352            .unwrap_or_default();
353        let exit = t.wait(&id).unwrap_or(Exit {
354            code: None,
355            signal: None,
356        });
357        assert_eq!(exit.code, Some(0));
358        assert!(
359            began.elapsed() < Duration::from_secs(10),
360            "{:?}",
361            began.elapsed()
362        );
363        assert!(t.output(&id).unwrap_or_default().0.contains("done"));
364    }
365}