Skip to main content

frost_exec/
job.rs

1//! Job control — tracking background and suspended processes.
2//!
3//! Uses [`crate::sys`] for wait operations.
4
5use nix::unistd::Pid;
6
7use crate::sys;
8
9/// Status of a job.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum JobStatus {
12    /// Running in the background.
13    Running,
14    /// Stopped by a signal (e.g. SIGTSTP).
15    Stopped,
16    /// Terminated with an exit code.
17    Done(i32),
18    /// Killed by a signal.
19    Signaled(i32),
20}
21
22/// A single job tracked by the shell.
23#[derive(Debug, Clone)]
24pub struct Job {
25    /// Job number (1-indexed, displayed as `[1]`, `[2]`, etc.).
26    pub id: usize,
27    /// Process ID of the job leader.
28    pub pid: Pid,
29    /// Process group ID.
30    pub pgid: Pid,
31    /// Current status.
32    pub status: JobStatus,
33    /// The command string (for display in `jobs` output).
34    pub command: String,
35}
36
37/// A table of active jobs.
38#[derive(Debug, Default)]
39pub struct JobTable {
40    jobs: Vec<Job>,
41    next_id: usize,
42}
43
44impl JobTable {
45    /// Create an empty job table.
46    pub fn new() -> Self {
47        Self {
48            jobs: Vec::new(),
49            next_id: 1,
50        }
51    }
52
53    /// Add a new job and return its id.
54    pub fn add(&mut self, pid: Pid, pgid: Pid, command: String) -> usize {
55        let id = self.next_id;
56        self.next_id += 1;
57        self.jobs.push(Job {
58            id,
59            pid,
60            pgid,
61            status: JobStatus::Running,
62            command,
63        });
64        id
65    }
66
67    /// Remove a job by id.
68    pub fn remove(&mut self, id: usize) -> Option<Job> {
69        if let Some(pos) = self.jobs.iter().position(|j| j.id == id) {
70            Some(self.jobs.remove(pos))
71        } else {
72            None
73        }
74    }
75
76    /// Look up a job by id.
77    pub fn get(&self, id: usize) -> Option<&Job> {
78        self.jobs.iter().find(|j| j.id == id)
79    }
80
81    /// Look up a job by id (mutable).
82    pub fn get_mut(&mut self, id: usize) -> Option<&mut Job> {
83        self.jobs.iter_mut().find(|j| j.id == id)
84    }
85
86    /// Look up a job by its leader PID.
87    pub fn find_by_pid(&self, pid: Pid) -> Option<&Job> {
88        self.jobs.iter().find(|j| j.pid == pid)
89    }
90
91    /// All jobs.
92    pub fn iter(&self) -> impl Iterator<Item = &Job> {
93        self.jobs.iter()
94    }
95
96    /// Number of tracked jobs.
97    pub fn len(&self) -> usize {
98        self.jobs.len()
99    }
100
101    /// Whether the table is empty.
102    pub fn is_empty(&self) -> bool {
103        self.jobs.is_empty()
104    }
105
106    /// Wait for a specific job to finish or stop.
107    ///
108    /// Returns the final status. Updates the job entry in-place.
109    pub fn wait_for(&mut self, id: usize) -> Option<JobStatus> {
110        let job = self.jobs.iter_mut().find(|j| j.id == id)?;
111
112        match sys::wait_pid(job.pid) {
113            Ok(sys::ChildStatus::Exited(code)) => {
114                job.status = JobStatus::Done(code);
115            }
116            Ok(sys::ChildStatus::Signaled(code)) => {
117                job.status = JobStatus::Signaled(code);
118            }
119            Ok(sys::ChildStatus::Stopped) => {
120                job.status = JobStatus::Stopped;
121            }
122            _ => {}
123        }
124
125        Some(job.status)
126    }
127
128    /// Non-blocking reap of any finished jobs.
129    pub fn reap_finished(&mut self) -> Vec<usize> {
130        let mut finished = Vec::new();
131
132        for job in &mut self.jobs {
133            if job.status != JobStatus::Running {
134                continue;
135            }
136            match sys::try_wait_pid(job.pid) {
137                Ok(sys::ChildStatus::Exited(code)) => {
138                    job.status = JobStatus::Done(code);
139                    finished.push(job.id);
140                }
141                Ok(sys::ChildStatus::Signaled(code)) => {
142                    job.status = JobStatus::Signaled(code);
143                    finished.push(job.id);
144                }
145                Ok(sys::ChildStatus::Stopped) => {
146                    job.status = JobStatus::Stopped;
147                    finished.push(job.id);
148                }
149                _ => {}
150            }
151        }
152
153        finished
154    }
155
156    /// Remove all jobs that have completed (Done or Signaled).
157    pub fn prune_done(&mut self) {
158        self.jobs
159            .retain(|j| !matches!(j.status, JobStatus::Done(_) | JobStatus::Signaled(_)));
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use nix::unistd::Pid;
167
168    #[test]
169    fn add_and_lookup() {
170        let mut table = JobTable::new();
171        let id = table.add(Pid::from_raw(1234), Pid::from_raw(1234), "sleep 10".into());
172        assert_eq!(id, 1);
173        assert_eq!(table.len(), 1);
174
175        let job = table.get(id).unwrap();
176        assert_eq!(job.pid, Pid::from_raw(1234));
177        assert_eq!(job.status, JobStatus::Running);
178        assert_eq!(job.command, "sleep 10");
179    }
180
181    #[test]
182    fn remove_job() {
183        let mut table = JobTable::new();
184        let id = table.add(Pid::from_raw(100), Pid::from_raw(100), "echo".into());
185        assert!(table.remove(id).is_some());
186        assert!(table.is_empty());
187    }
188
189    #[test]
190    fn find_by_pid() {
191        let mut table = JobTable::new();
192        table.add(Pid::from_raw(42), Pid::from_raw(42), "ls".into());
193        assert!(table.find_by_pid(Pid::from_raw(42)).is_some());
194        assert!(table.find_by_pid(Pid::from_raw(99)).is_none());
195    }
196
197    #[test]
198    fn prune_done() {
199        let mut table = JobTable::new();
200        let id1 = table.add(Pid::from_raw(1), Pid::from_raw(1), "a".into());
201        let _id2 = table.add(Pid::from_raw(2), Pid::from_raw(2), "b".into());
202
203        table.get_mut(id1).unwrap().status = JobStatus::Done(0);
204        table.prune_done();
205        assert_eq!(table.len(), 1);
206        assert!(table.get(id1).is_none());
207    }
208
209    #[test]
210    fn sequential_ids() {
211        let mut table = JobTable::new();
212        let id1 = table.add(Pid::from_raw(1), Pid::from_raw(1), "a".into());
213        let id2 = table.add(Pid::from_raw(2), Pid::from_raw(2), "b".into());
214        assert_eq!(id1, 1);
215        assert_eq!(id2, 2);
216    }
217}