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