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