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;
15use std::sync::Mutex;
16
17use crate::ported::jobs::stat;
18use crate::ported::jobs::{deletejob, CURJOB, MAXJOB, PREVJOB, THISJOB};
19use crate::ported::zsh_h::job;
20
21/// Executor-side stand-in for C `printjob`'s done-job delete tail,
22/// `Src/jobs.c:1350-1363`:
23/// ```c
24/// if (jn->stat & STAT_DONE) {
25///     ...
26///     deletejob(jn, 0);
27///     if (job == curjob) { curjob = prevjob; prevjob = job; }
28///     if (job == prevjob) setprevjob();
29/// }
30/// ```
31/// The ported `printjob` (src/ported/jobs.rs) is a pure formatter
32/// returning a String; C's version mutates the table as a side
33/// effect. Every site that calls (or would call) printjob on a
34/// possibly-done job runs this tail so finished jobs leave the table
35/// exactly when they do in C. Lives here (not src/ported/) because
36/// it has no C name of its own — it is the side-effect half of
37/// printjob, split out by the Rust purity refactor.
38pub fn printjob_delete_tail(tab: &mut [job], idx: usize) {
39    if idx >= tab.len() || (tab[idx].stat & stat::DONE) == 0 {
40        return;
41    }
42    deletejob(&mut tab[idx], false); // c:Src/jobs.c:1356
43    let mut cj = CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
44    let mut pj = PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
45    if *cj == idx as i32 {
46        // c:Src/jobs.c:1357-1360
47        *cj = *pj;
48        *pj = idx as i32;
49    }
50    let need_setprev = *pj == idx as i32; // c:Src/jobs.c:1361
51    drop(cj);
52    drop(pj);
53    if need_setprev {
54        setprevjob_locked(tab); // c:Src/jobs.c:1362
55    }
56}
57
58/// `setprevjob` (Src/jobs.c:698-717) body operating on an
59/// already-locked table slice — `printjob_delete_tail` callers hold
60/// the JOBTAB lock, so the re-locking ported `setprevjob()` would
61/// deadlock. Same walk, same candidate order.
62fn setprevjob_locked(tab: &[job]) {
63    let maxjob = *MAXJOB
64        .get_or_init(|| Mutex::new(0))
65        .lock()
66        .expect("maxjob poisoned");
67    let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
68    let thisjob = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
69    let pick = |want_stopped: bool| -> i32 {
70        for i in (1..=maxjob).rev() {
71            if i >= tab.len() {
72                continue;
73            }
74            let j = &tab[i];
75            let stat_ok = if want_stopped {
76                (j.stat & (stat::INUSE | stat::STOPPED)) == (stat::INUSE | stat::STOPPED)
77            } else {
78                (j.stat & stat::INUSE) != 0
79            };
80            if stat_ok && (j.stat & stat::SUBJOB) == 0 && i as i32 != curjob && i as i32 != thisjob
81            {
82                return i as i32;
83            }
84        }
85        -1
86    };
87    let mut found = pick(true); // c:Src/jobs.c:702-707
88    if found < 0 {
89        found = pick(false); // c:Src/jobs.c:709-714
90    }
91    *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = found; // c:716
92}
93
94/// Running-job state tracked alongside each `Child` handle.
95/// Maps to C's `STAT_*` bits but is exposed as a typed enum since
96/// the executor's safe-Rust path doesn't manipulate the bitfield.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum JobState {
99    /// `Running` variant.
100    Running,
101    /// `Stopped` variant.
102    Stopped,
103    /// `Done` variant.
104    Done,
105}
106
107/// One entry in the executor's bg-job registry.
108#[derive(Debug)]
109pub struct JobInfo {
110    /// `id` field.
111    pub id: usize,
112    /// `pid` field.
113    pub pid: i32,
114    /// `child` field.
115    pub child: Option<Child>,
116    /// `command` field.
117    pub command: String,
118    /// `state` field.
119    pub state: JobState,
120    /// `is_current` field.
121    pub is_current: bool,
122}
123
124/// The executor's bg-job registry. Distinct from the C-port
125/// `JOBTAB` (a `Vec<Job>` keyed by index that mirrors `jobtab[]`):
126/// this table owns the `std::process::Child` handles needed for
127/// `try_wait` / `kill` on the safe-Rust path.
128pub struct JobTable {
129    /// `jobs` field.
130    jobs: Vec<Option<JobInfo>>,
131    /// `current_id` field.
132    current_id: Option<usize>,
133    /// `next_id` field.
134    next_id: usize,
135}
136
137impl Default for JobTable {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl JobTable {
144    /// `new` — see implementation.
145    pub fn new() -> Self {
146        JobTable {
147            jobs: Vec::with_capacity(16),
148            current_id: None,
149            next_id: 1,
150        }
151    }
152
153    /// Peek at the next id that would be assigned by `add_job`/`add_pid`.
154    /// Used by `wait %N` to distinguish a never-issued id (clear user
155    /// error) from a job that was issued and already reaped (silent
156    /// success in zshrs to keep the `cmd & wait %1` idiom working
157    /// across the races introduced by the threaded job table).
158    pub fn peek_next_id(&self) -> usize {
159        self.next_id
160    }
161
162    /// Add a job with a Child process
163    pub fn add_job(&mut self, child: Child, command: String, state: JobState) -> usize {
164        let id = self.next_id;
165        self.next_id += 1;
166
167        let pid = child.id() as i32;
168        let job = JobInfo {
169            id,
170            pid,
171            child: Some(child),
172            command,
173            state,
174            is_current: true,
175        };
176
177        // Mark previous current as not current
178        if let Some(cur_id) = self.current_id {
179            if let Some(j) = self.get_mut_internal(cur_id) {
180                j.is_current = false;
181            }
182        }
183
184        // Add new job
185        let slot = self.get_free_slot();
186        if slot >= self.jobs.len() {
187            self.jobs.resize_with(slot + 1, || None);
188        }
189        self.jobs[slot] = Some(job);
190        self.current_id = Some(id);
191
192        id
193    }
194
195    /// Register a backgrounded job that was forked via raw `libc::fork()`
196    /// (no `std::process::Child` wrapper). The wait path then has to
197    /// `waitpid(pid)` instead of `Child::wait()`. Used by
198    /// BUILTIN_RUN_BG so `wait` (no args) can synchronize on it.
199    pub fn add_pid_job(&mut self, pid: i32, command: String, state: JobState) -> usize {
200        let id = self.next_id;
201        self.next_id += 1;
202        let job = JobInfo {
203            id,
204            pid,
205            child: None,
206            command,
207            state,
208            is_current: true,
209        };
210        if let Some(cur_id) = self.current_id {
211            if let Some(j) = self.get_mut_internal(cur_id) {
212                j.is_current = false;
213            }
214        }
215        let slot = self.get_free_slot();
216        if slot >= self.jobs.len() {
217            self.jobs.resize_with(slot + 1, || None);
218        }
219        self.jobs[slot] = Some(job);
220        self.current_id = Some(id);
221        id
222    }
223
224    fn get_free_slot(&self) -> usize {
225        for (i, slot) in self.jobs.iter().enumerate() {
226            if slot.is_none() {
227                return i;
228            }
229        }
230        self.jobs.len()
231    }
232
233    fn get_mut_internal(&mut self, id: usize) -> Option<&mut JobInfo> {
234        self.jobs.iter_mut().flatten().find(|job| job.id == id)
235    }
236
237    /// Get a job by ID
238    pub fn get(&self, id: usize) -> Option<&JobInfo> {
239        self.jobs
240            .iter()
241            .flatten()
242            .find(|&job| job.id == id)
243            .map(|v| v as _)
244    }
245
246    /// Get a mutable job by ID
247    pub fn get_mut(&mut self, id: usize) -> Option<&mut JobInfo> {
248        self.get_mut_internal(id)
249    }
250
251    /// Remove a job by ID
252    pub fn remove(&mut self, id: usize) -> Option<JobInfo> {
253        for slot in self.jobs.iter_mut() {
254            if slot.as_ref().map(|j| j.id == id).unwrap_or(false) {
255                let job = slot.take();
256                if self.current_id == Some(id) {
257                    self.current_id = None;
258                }
259                return job;
260            }
261        }
262        None
263    }
264
265    /// List all active jobs
266    pub fn list(&self) -> Vec<&JobInfo> {
267        self.jobs.iter().filter_map(|j| j.as_ref()).collect()
268    }
269
270    /// Iterate over jobs with their IDs (for compatibility)
271    pub fn iter(&self) -> impl Iterator<Item = (usize, &JobInfo)> {
272        self.jobs
273            .iter()
274            .filter_map(|j| j.as_ref().map(|job| (job.id, job)))
275    }
276
277    /// Count number of active jobs
278    pub fn count(&self) -> usize {
279        self.jobs.iter().filter(|j| j.is_some()).count()
280    }
281
282    /// Check if there are any jobs
283    pub fn is_empty(&self) -> bool {
284        self.count() == 0
285    }
286
287    /// Get current job
288    pub fn current(&self) -> Option<&JobInfo> {
289        self.current_id.and_then(|id| self.get(id))
290    }
291
292    /// Reap finished jobs (check for completed processes)
293    pub fn reap_finished(&mut self) -> Vec<JobInfo> {
294        let mut finished = Vec::new();
295
296        for job in self.jobs.iter_mut().flatten() {
297            if let Some(ref mut child) = job.child {
298                // Try to check if child has finished without blocking
299                match child.try_wait() {
300                    Ok(Some(_status)) => {
301                        // Child finished
302                        job.state = JobState::Done;
303                    }
304                    Ok(None) => {
305                        // Still running
306                    }
307                    Err(_) => {
308                        // Error checking, assume done
309                        job.state = JobState::Done;
310                    }
311                }
312            }
313        }
314
315        // Remove done jobs
316        for slot in self.jobs.iter_mut() {
317            if slot
318                .as_ref()
319                .map(|j| j.state == JobState::Done)
320                .unwrap_or(false)
321            {
322                if let Some(job) = slot.take() {
323                    finished.push(job);
324                }
325            }
326        }
327
328        finished
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_job_table_new() {
338        let _g = crate::test_util::global_state_lock();
339        let table = JobTable::new();
340        assert!(table.is_empty());
341    }
342
343    #[test]
344    fn test_job_state_enum() {
345        let _g = crate::test_util::global_state_lock();
346        let state = JobState::Running;
347        assert_eq!(state, JobState::Running);
348        assert_ne!(state, JobState::Stopped);
349        assert_ne!(state, JobState::Done);
350    }
351
352    #[test]
353    fn test_add_pid_job_assigns_id() {
354        let _g = crate::test_util::global_state_lock();
355        let mut t = JobTable::new();
356        let id1 = t.add_pid_job(1234, "cmd1".into(), JobState::Running);
357        let id2 = t.add_pid_job(5678, "cmd2".into(), JobState::Running);
358        assert_ne!(id1, id2);
359        assert_eq!(t.list().len(), 2);
360        assert_eq!(t.current().map(|j| j.id), Some(id2));
361    }
362
363    #[test]
364    fn test_remove_drops_current() {
365        let _g = crate::test_util::global_state_lock();
366        let mut t = JobTable::new();
367        let id = t.add_pid_job(99, "x".into(), JobState::Running);
368        assert!(t.remove(id).is_some());
369        assert!(t.is_empty());
370        assert!(t.current().is_none());
371    }
372}