Skip to main content

zsh/
exec_jobs.rs

1//! Executor-side bg-job tracker. NOT a port of `Src/jobs.c`.
2//!
3//! `Src/jobs.c` uses a flat `struct job jobtab[]` global keyed by pid
4//! (ported to `crate::ported::jobs::JOBTAB`). C tracks child processes
5//! through their pid + waitpid(2). Rust prefers safe-Rust ownership
6//! of `std::process::Child` handles so the executor needs a parallel
7//! registry that owns those handles. That's what this file is.
8//!
9//! This module is segregated from `src/ported/jobs.rs` (the faithful
10//! C port) so the port file contains only direct ports of jobs.c
11//! decls. `JobState` / `JobInfo` / `JobTable` here are zshrs runtime
12//! state with no C counterpart by design.
13
14use std::process::Child;
15
16/// Running-job state tracked alongside each `Child` handle.
17/// Maps to C's `STAT_*` bits but is exposed as a typed enum since
18/// the executor's safe-Rust path doesn't manipulate the bitfield.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum JobState {
21    /// `Running` variant.
22    Running,
23    /// `Stopped` variant.
24    Stopped,
25    /// `Done` variant.
26    Done,
27}
28
29/// One entry in the executor's bg-job registry.
30#[derive(Debug)]
31pub struct JobInfo {
32    /// `id` field.
33    pub id: usize,
34    /// `pid` field.
35    pub pid: i32,
36    /// `child` field.
37    pub child: Option<Child>,
38    /// `command` field.
39    pub command: String,
40    /// `state` field.
41    pub state: JobState,
42    /// `is_current` field.
43    pub is_current: bool,
44}
45
46/// The executor's bg-job registry. Distinct from the C-port
47/// `JOBTAB` (a `Vec<Job>` keyed by index that mirrors `jobtab[]`):
48/// this table owns the `std::process::Child` handles needed for
49/// `try_wait` / `kill` on the safe-Rust path.
50pub struct JobTable {
51    /// `jobs` field.
52    jobs: Vec<Option<JobInfo>>,
53    /// `current_id` field.
54    current_id: Option<usize>,
55    /// `next_id` field.
56    next_id: usize,
57}
58
59impl Default for JobTable {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl JobTable {
66    /// `new` — see implementation.
67    pub fn new() -> Self {
68        JobTable {
69            jobs: Vec::with_capacity(16),
70            current_id: None,
71            next_id: 1,
72        }
73    }
74
75    /// Peek at the next id that would be assigned by `add_job`/`add_pid`.
76    /// Used by `wait %N` to distinguish a never-issued id (clear user
77    /// error) from a job that was issued and already reaped (silent
78    /// success in zshrs to keep the `cmd & wait %1` idiom working
79    /// across the races introduced by the threaded job table).
80    pub fn peek_next_id(&self) -> usize {
81        self.next_id
82    }
83
84    /// Add a job with a Child process
85    pub fn add_job(&mut self, child: Child, command: String, state: JobState) -> usize {
86        let id = self.next_id;
87        self.next_id += 1;
88
89        let pid = child.id() as i32;
90        let job = JobInfo {
91            id,
92            pid,
93            child: Some(child),
94            command,
95            state,
96            is_current: true,
97        };
98
99        // Mark previous current as not current
100        if let Some(cur_id) = self.current_id {
101            if let Some(j) = self.get_mut_internal(cur_id) {
102                j.is_current = false;
103            }
104        }
105
106        // Add new job
107        let slot = self.get_free_slot();
108        if slot >= self.jobs.len() {
109            self.jobs.resize_with(slot + 1, || None);
110        }
111        self.jobs[slot] = Some(job);
112        self.current_id = Some(id);
113
114        id
115    }
116
117    /// Register a backgrounded job that was forked via raw `libc::fork()`
118    /// (no `std::process::Child` wrapper). The wait path then has to
119    /// `waitpid(pid)` instead of `Child::wait()`. Used by
120    /// BUILTIN_RUN_BG so `wait` (no args) can synchronize on it.
121    pub fn add_pid_job(&mut self, pid: i32, command: String, state: JobState) -> usize {
122        let id = self.next_id;
123        self.next_id += 1;
124        let job = JobInfo {
125            id,
126            pid,
127            child: None,
128            command,
129            state,
130            is_current: true,
131        };
132        if let Some(cur_id) = self.current_id {
133            if let Some(j) = self.get_mut_internal(cur_id) {
134                j.is_current = false;
135            }
136        }
137        let slot = self.get_free_slot();
138        if slot >= self.jobs.len() {
139            self.jobs.resize_with(slot + 1, || None);
140        }
141        self.jobs[slot] = Some(job);
142        self.current_id = Some(id);
143        id
144    }
145
146    fn get_free_slot(&self) -> usize {
147        for (i, slot) in self.jobs.iter().enumerate() {
148            if slot.is_none() {
149                return i;
150            }
151        }
152        self.jobs.len()
153    }
154
155    fn get_mut_internal(&mut self, id: usize) -> Option<&mut JobInfo> {
156        self.jobs.iter_mut().flatten().find(|job| job.id == id)
157    }
158
159    /// Get a job by ID
160    pub fn get(&self, id: usize) -> Option<&JobInfo> {
161        self.jobs
162            .iter()
163            .flatten()
164            .find(|&job| job.id == id)
165            .map(|v| v as _)
166    }
167
168    /// Get a mutable job by ID
169    pub fn get_mut(&mut self, id: usize) -> Option<&mut JobInfo> {
170        self.get_mut_internal(id)
171    }
172
173    /// Remove a job by ID
174    pub fn remove(&mut self, id: usize) -> Option<JobInfo> {
175        for slot in self.jobs.iter_mut() {
176            if slot.as_ref().map(|j| j.id == id).unwrap_or(false) {
177                let job = slot.take();
178                if self.current_id == Some(id) {
179                    self.current_id = None;
180                }
181                return job;
182            }
183        }
184        None
185    }
186
187    /// List all active jobs
188    pub fn list(&self) -> Vec<&JobInfo> {
189        self.jobs.iter().filter_map(|j| j.as_ref()).collect()
190    }
191
192    /// Iterate over jobs with their IDs (for compatibility)
193    pub fn iter(&self) -> impl Iterator<Item = (usize, &JobInfo)> {
194        self.jobs
195            .iter()
196            .filter_map(|j| j.as_ref().map(|job| (job.id, job)))
197    }
198
199    /// Count number of active jobs
200    pub fn count(&self) -> usize {
201        self.jobs.iter().filter(|j| j.is_some()).count()
202    }
203
204    /// Check if there are any jobs
205    pub fn is_empty(&self) -> bool {
206        self.count() == 0
207    }
208
209    /// Get current job
210    pub fn current(&self) -> Option<&JobInfo> {
211        self.current_id.and_then(|id| self.get(id))
212    }
213
214    /// Reap finished jobs (check for completed processes)
215    pub fn reap_finished(&mut self) -> Vec<JobInfo> {
216        let mut finished = Vec::new();
217
218        for job in self.jobs.iter_mut().flatten() {
219            if let Some(ref mut child) = job.child {
220                // Try to check if child has finished without blocking
221                match child.try_wait() {
222                    Ok(Some(_status)) => {
223                        // Child finished
224                        job.state = JobState::Done;
225                    }
226                    Ok(None) => {
227                        // Still running
228                    }
229                    Err(_) => {
230                        // Error checking, assume done
231                        job.state = JobState::Done;
232                    }
233                }
234            }
235        }
236
237        // Remove done jobs
238        for slot in self.jobs.iter_mut() {
239            if slot
240                .as_ref()
241                .map(|j| j.state == JobState::Done)
242                .unwrap_or(false)
243            {
244                if let Some(job) = slot.take() {
245                    finished.push(job);
246                }
247            }
248        }
249
250        finished
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_job_table_new() {
260        let _g = crate::test_util::global_state_lock();
261        let table = JobTable::new();
262        assert!(table.is_empty());
263    }
264
265    #[test]
266    fn test_job_state_enum() {
267        let _g = crate::test_util::global_state_lock();
268        let state = JobState::Running;
269        assert_eq!(state, JobState::Running);
270        assert_ne!(state, JobState::Stopped);
271        assert_ne!(state, JobState::Done);
272    }
273
274    #[test]
275    fn test_add_pid_job_assigns_id() {
276        let _g = crate::test_util::global_state_lock();
277        let mut t = JobTable::new();
278        let id1 = t.add_pid_job(1234, "cmd1".into(), JobState::Running);
279        let id2 = t.add_pid_job(5678, "cmd2".into(), JobState::Running);
280        assert_ne!(id1, id2);
281        assert_eq!(t.list().len(), 2);
282        assert_eq!(t.current().map(|j| j.id), Some(id2));
283    }
284
285    #[test]
286    fn test_remove_drops_current() {
287        let _g = crate::test_util::global_state_lock();
288        let mut t = JobTable::new();
289        let id = t.add_pid_job(99, "x".into(), JobState::Running);
290        assert!(t.remove(id).is_some());
291        assert!(t.is_empty());
292        assert!(t.current().is_none());
293    }
294}