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/// !!! WARNING: RUST-ONLY ADAPTER !!! The job status C derives from a
22/// finished process's wait status — update_job's `val`
23/// (c:Src/jobs.c:492-496): `WIFSIGNALED ? 0200 | WTERMSIG : WIFSTOPPED ?
24/// 0200 | WSTOPSIG : WEXITSTATUS`. zshrs waits on its foreground externals
25/// through `std::process::ExitStatus`, whose `code()` is `None` for a
26/// signalled child; the call sites turned that into 1, so
27/// `sh -c 'kill $$'; print $?` printed 1 where zsh prints 143.
28pub fn wait_status_val(s: std::process::ExitStatus) -> i32 {
29    use std::os::unix::process::ExitStatusExt as _;
30    if let Some(sig) = s.signal() {
31        0o200 | sig // c:493
32    } else if let Some(sig) = s.stopped_signal() {
33        0o200 | sig // c:495
34    } else {
35        s.code().unwrap_or(0) // c:496
36    }
37}
38
39thread_local! {
40    /// Non-zero while a pipeline's in-shell last stage runs. update_job's
41    /// foreground tail reads the status of the job's process-group leader
42    /// (c:Src/jobs.c:497-498), which is the FIRST stage, so the externals
43    /// the last stage waits for do not drive it.
44    ///
45    /// !!! WARNING: RUST-ONLY COUNTER — C READS `jn->gleader` !!!
46    pub static NOT_JOB_LEADER: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
47}
48
49/// !!! WARNING: RUST-ONLY HELPER — C WRITES THROUGH THE `shout` FILE* !!!
50/// printjob's asynchronous report goes to `fout = (synch == 2 || !shout) ?
51/// stdout : shout` (c:Src/jobs.c:1153), and an interactive shell's `shout`
52/// is the terminal, `fdopen(SHTTY, "w")`, or stderr when there is no tty
53/// (c:Src/init.c:734-748). Writing to fd 2 instead sent the report through
54/// the command's own redirection: `sh -c 'kill -HUP $$' 2>/dev/null` lost
55/// its `zsh: hangup` line.
56fn shout_write(s: &str) {
57    let tty = crate::ported::init::SHTTY.load(std::sync::atomic::Ordering::Relaxed);
58    let fd = if tty >= 0 { tty } else { libc::STDERR_FILENO };
59    let _ = crate::ported::utils::write_loop(fd, s.as_bytes());
60}
61
62/// !!! WARNING: RUST-ONLY ADAPTER !!! update_job's report for the foreground
63/// job (c:Src/jobs.c:645-650), for a simple external zshrs waited for outside
64/// the job table: `if ((isset(NOTIFY) || job == thisjob) && (jn->stat &
65/// STAT_LOCKED)) printjob(jn, !!isset(LONGLISTJOBS), 0);`. The job is one
66/// process whose text is the command's job text (`None` when the dispatch
67/// carried none, e.g. inside a function, c:Src/exec.c:3536-3538).
68///
69/// printjob prints nothing for a job marked STAT_NOPRINT, and execpline marks
70/// every job run while another pipeline is executing (`pline_level`,
71/// c:Src/exec.c:1829-1831): a command inside a function, `eval`, a loop, a
72/// `{ … }` or an `if` is never reported, only one at the top of the command
73/// line — including either side of `&&`/`||`. `nested` is the caller's
74/// in-process `( … )` (C forks it, and the child has no MONITOR).
75///
76/// Then printjob's own gate (exec_jobs::printjob_async): an interactive
77/// shell with MONITOR reports a signal other than SIGINT/SIGPIPE as
78/// `zsh: terminated  cmd`, and a SIGINT only with a newline (c:1203-1205,
79/// c:1260-1263, c:1338-1341).
80pub fn foreground_job_report(status: i32, text: Option<String>, nested: bool) {
81    use crate::ported::zsh_h::{CS_CMDAND, CS_CMDOR, SFC_NONE};
82    use std::sync::atomic::Ordering;
83    let Some(text) = text else { return };
84    if nested
85        || !libc::WIFSIGNALED(status)
86        || NOT_JOB_LEADER.with(|d| d.get()) > 0
87        || crate::ported::exec::sfcontext.load(Ordering::Relaxed) != SFC_NONE
88        || crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.get()) > 0
89    {
90        return;
91    }
92    // c:Src/exec.c:1829-1831 — nested in a compound command (cmdstack holds
93    // more than the `&&`/`||` list connectors).
94    let in_compound = crate::ported::prompt::CMDSTACK.with(|s| {
95        s.borrow()
96            .iter()
97            .any(|&cs| cs as i32 != CS_CMDAND as i32 && cs as i32 != CS_CMDOR as i32)
98    });
99    if in_compound {
100        return;
101    }
102    let jn = job {
103        stat: stat::INUSE | stat::LOCKED | stat::DONE | stat::CHANGED,
104        procs: vec![crate::ported::zsh_h::process {
105            pid: 0,
106            text,
107            status,
108            ti: Default::default(),
109            bgtime: None,
110            endtime: None,
111        }],
112        ..Default::default()
113    };
114    // The job sits at index 1 and is `thisjob` (c:Src/jobs.c:645).
115    let tab = vec![job::default(), jn];
116    printjob_async(&tab, 1, 1);
117}
118
119/// !!! WARNING: RUST-ONLY ADAPTER !!! The tail of C `update_job` for the
120/// FOREGROUND job (`job == thisjob`, not STAT_CURSH, so `inforeground = 2`,
121/// c:Src/jobs.c:558-561), for an external zshrs waited for outside the job
122/// table. `status` is the raw wait status of the job's last process.
123///
124/// c:Src/jobs.c:654-679 —
125/// ```c
126/// /* When MONITOR is set, the foreground process runs in a different *
127///  * process group from the shell, so the shell will not receive     *
128///  * terminal signals, therefore we pretend that the shell got       *
129///  * the signal too.                                                 */
130/// if (inforeground == 2 && isset(MONITOR) && WIFSIGNALED(status)) {
131///     int sig = WTERMSIG(status);
132///     if (sig == SIGINT || sig == SIGQUIT) {
133///         if (sigtrapped[sig]) {
134///             dotrap(sig);
135///             if (errflag) breaks = loops;
136///         } else {
137///             breaks = loops;
138///             errflag |= ERRFLAG_INT;
139///         }
140///         check_cursh_sig(sig);
141///     }
142/// }
143/// ```
144/// So a child that dies of SIGINT/SIGQUIT ends the rest of the command line
145/// (`for i in 1 2; do sh -c 'kill -INT $$'; print $i; done` prints nothing)
146/// unless a trap for the signal returns zero.
147pub fn foreground_job_signalled(status: i32) {
148    use crate::ported::zsh_h::{isset, ERRFLAG_INT, MONITOR};
149    use std::sync::atomic::Ordering;
150    if NOT_JOB_LEADER.with(|d| d.get()) > 0 || !isset(MONITOR) || !libc::WIFSIGNALED(status) {
151        return;
152    }
153    let sig = libc::WTERMSIG(status); // c:659
154    if sig != libc::SIGINT && sig != libc::SIGQUIT {
155        return; // c:661
156    }
157    let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
158    let trapped = crate::ported::signals::sigtrapped
159        .lock()
160        .ok()
161        .and_then(|t| t.get(sig as usize).copied())
162        .unwrap_or(0);
163    if trapped != 0 {
164        crate::ported::signals::dotrap(sig); // c:663
165        if crate::ported::utils::errflag.load(Ordering::Relaxed) != 0 {
166            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed); // c:671-672
167        }
168    } else {
169        crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed); // c:674
170        crate::ported::utils::errflag.fetch_or(ERRFLAG_INT, Ordering::Relaxed); // c:675
171    }
172    // c:677 — `check_cursh_sig(sig);`
173    if let Some(tab) = crate::ported::jobs::JOBTAB.get() {
174        if let Ok(jt) = tab.lock() {
175            crate::ported::jobs::check_cursh_sig(&jt, sig);
176        }
177    }
178}
179
180/// Executor-side stand-in for C `printjob`'s done-job delete tail,
181/// `Src/jobs.c:1350-1363`:
182/// ```c
183/// if (jn->stat & STAT_DONE) {
184///     ...
185///     deletejob(jn, 0);
186///     if (job == curjob) { curjob = prevjob; prevjob = job; }
187///     if (job == prevjob) setprevjob();
188/// }
189/// ```
190/// The ported `printjob` (src/ported/jobs.rs) is a pure formatter
191/// returning a String; C's version mutates the table as a side
192/// effect. Every site that calls (or would call) printjob on a
193/// possibly-done job runs this tail so finished jobs leave the table
194/// exactly when they do in C. Lives here (not src/ported/) because
195/// it has no C name of its own — it is the side-effect half of
196/// printjob, split out by the Rust purity refactor.
197pub fn printjob_delete_tail(tab: &mut [job], idx: usize) {
198    if idx >= tab.len() || (tab[idx].stat & stat::DONE) == 0 {
199        return;
200    }
201    deletejob(&mut tab[idx], false); // c:Src/jobs.c:1356
202    let mut cj = CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
203    let mut pj = PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
204    if *cj == idx as i32 {
205        // c:Src/jobs.c:1357-1360
206        *cj = *pj;
207        *pj = idx as i32;
208    }
209    let need_setprev = *pj == idx as i32; // c:Src/jobs.c:1361
210    drop(cj);
211    drop(pj);
212    if need_setprev {
213        setprevjob_locked(tab); // c:Src/jobs.c:1362
214    }
215}
216
217/// `printjob(jn, !!isset(LONGLISTJOBS), 0)` — the asynchronous (synch 0)
218/// report update_job issues at `Src/jobs.c:647` when a job changes state.
219/// Returns 1-as-true when something was printed (C's `doneprint`).
220///
221/// Lives here (not src/ported/) because it has no C name of its own. The Rust `printjob` is a pure formatter with no
222/// print gate; this is the gate + output half of C's printjob for synch 0.
223/// The done-job delete tail (c:1350-1363) stays with the caller
224/// (`printjob_delete_tail`), which runs it whether or not this printed —
225/// exactly as C falls through to it.
226///
227/// This path was missing entirely: update_bg_job deleted a finished job
228/// without reporting it, so `: &` + `wait` never said `[1]  + done  :`,
229/// `kill %1` never said `[1]  + terminated  ...`, and a foreground job
230/// killed by a signal never said `zsh: terminated  ...`.
231///
232/// NOT PORTED: the STAT_SUPERJOB -> subjob redirect (c:1171-1185) and the
233/// `(pwd now: …)` line (c:1349-1353).
234///
235/// WARNING: param names don't match C — Rust=(tab, ji, thisjob) vs
236/// C=(jn, lng, synch)
237pub fn printjob_async(tab: &[job], ji: usize, thisjob: i32) -> bool {
238    use crate::ported::builtins::sched::zleactive;
239    use crate::ported::zsh_h::{isset, INTERACTIVE, LONGLISTJOBS, PRINTEXITVALUE, SHINSTDIN, SP_RUNNING};
240    use std::sync::atomic::Ordering;
241    let jn = &tab[ji];
242    // c:1163-1164 — `if (jn->stat & STAT_NOPRINT) skip_print = 1;`
243    let mut skip_print = (jn.stat & stat::NOPRINT) != 0;
244    let mut sflag = false; // c:1150
245    let mut doputnl = false; // c:1151
246    let is_thisjob = ji as i32 == thisjob;
247    // c:1190-1221 — does any finished process force a report?
248    for pn in jn.procs.iter() {
249        if pn.status == SP_RUNNING {
250            continue; // c:1193
251        }
252        if libc::WIFSIGNALED(pn.status) {
253            let sig = libc::WTERMSIG(pn.status); // c:1195
254            if sig != libc::SIGINT && sig != libc::SIGPIPE {
255                sflag = true; // c:1202-1203
256            }
257            if is_thisjob && sig == libc::SIGINT {
258                doputnl = true; // c:1204-1205
259            }
260            if isset(PRINTEXITVALUE) && isset(SHINSTDIN) {
261                sflag = true; // c:1206-1208
262                skip_print = false;
263            }
264        } else if libc::WIFSTOPPED(pn.status) {
265            let sig = libc::WSTOPSIG(pn.status); // c:1210
266            if is_thisjob && sig == libc::SIGTSTP {
267                doputnl = true; // c:1214-1215
268            }
269        } else if isset(PRINTEXITVALUE) && isset(SHINSTDIN) && libc::WEXITSTATUS(pn.status) != 0
270        {
271            sflag = true; // c:1216-1219
272            skip_print = false;
273        }
274    }
275    // c:1224-1238 — a skipped job is only deleted (caller's tail).
276    if skip_print {
277        return false;
278    }
279    let interact = isset(INTERACTIVE);
280    let stopped = (jn.stat & stat::STOPPED) != 0;
281    // c:1248-1250 — `(interact || synch) && jobbing &&
282    //                ((jn->stat & STAT_STOPPED) || sflag || job != thisjob)`
283    if interact && crate::ported::zsh_h::jobbing() && (stopped || sflag || !is_thisjob) {
284        // c:1258-1259 — `if (!synch) zleentry(ZLE_CMD_TRASH);` (trashzle
285        // itself is a no-op unless zleactive).
286        if zleactive.load(Ordering::Relaxed) != 0 {
287            crate::ported::init::zleentry(crate::ported::zsh_h::ZLE_CMD_TRASH);
288        }
289        let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
290        let prevjob = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
291        let s = crate::ported::jobs::printjob(
292            jn,
293            ji,
294            isset(LONGLISTJOBS) as i32,
295            (curjob >= 0).then_some(curjob as usize),
296            (prevjob >= 0).then_some(prevjob as usize),
297            is_thisjob, // c:1255 — `thisfmt = job == thisjob && synch != 2`
298        );
299        // c:1260-1263 — `if (doputnl && !synch) putc('\n', fout);`
300        let nl = if doputnl { "\n" } else { "" };
301        shout_write(&format!("{}{}\n", nl, s));
302        return true;
303    } else if doputnl && interact {
304        // c:1338-1341
305        shout_write("\n");
306        return true;
307    }
308    false
309}
310
311
312/// `setprevjob` (Src/jobs.c:698-717) body operating on an
313/// already-locked table slice — `printjob_delete_tail` callers hold
314/// the JOBTAB lock, so the re-locking ported `setprevjob()` would
315/// deadlock. Same walk, same candidate order.
316fn setprevjob_locked(tab: &[job]) {
317    let maxjob = *MAXJOB
318        .get_or_init(|| Mutex::new(0))
319        .lock()
320        .expect("maxjob poisoned");
321    let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
322    let thisjob = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
323    let pick = |want_stopped: bool| -> i32 {
324        for i in (1..=maxjob).rev() {
325            if i >= tab.len() {
326                continue;
327            }
328            let j = &tab[i];
329            let stat_ok = if want_stopped {
330                (j.stat & (stat::INUSE | stat::STOPPED)) == (stat::INUSE | stat::STOPPED)
331            } else {
332                (j.stat & stat::INUSE) != 0
333            };
334            if stat_ok && (j.stat & stat::SUBJOB) == 0 && i as i32 != curjob && i as i32 != thisjob
335            {
336                return i as i32;
337            }
338        }
339        -1
340    };
341    let mut found = pick(true); // c:Src/jobs.c:702-707
342    if found < 0 {
343        found = pick(false); // c:Src/jobs.c:709-714
344    }
345    *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = found; // c:716
346}
347
348/// One job slot held by a running synchronous pipeline.
349///
350/// !!! WARNING: RUST-ONLY ADAPTER !!! C keeps this on the C stack:
351/// execpline's locals `pj` and `newjob` (c:Src/exec.c:1637,1656,1666) live
352/// exactly as long as the pipeline runs. A compiled chunk has no such frame
353/// around the pipeline, and can jump past its end (`break`, `return`, an
354/// errflag abort), so the slot is recorded here with the VM that opened it
355/// (`frame`) and the pipeline's nesting depth inside that chunk (`depth`).
356struct HeldSlot {
357    frame: usize,
358    depth: u32,
359    slot: usize,
360    pj: i32,
361}
362
363thread_local! {
364    /// The job slots of the synchronous pipelines running on this thread,
365    /// outermost first. See [`HeldSlot`].
366    static HELD_SLOTS: std::cell::RefCell<Vec<HeldSlot>> =
367        const { std::cell::RefCell::new(Vec::new()) };
368}
369
370/// !!! WARNING: RUST-ONLY ADAPTER !!! The opening half of execpline's job
371/// frame, run by BUILTIN_EXECPLINE_CHILD_BLOCK:
372/// ```c
373/// pj = thisjob;                                  /* c:Src/exec.c:1656 */
374/// if ((thisjob = newjob = initjob()) == -1) {   /* c:Src/exec.c:1666 */
375/// ```
376/// Every synchronous pipeline that is not `execsimple` holds a slot while
377/// it runs, including a function call and a builtin. That is visible:
378/// a job started inside a function gets the slot after the function's
379/// own (`f() { sleep 1 & }; f` prints `[2]`), and setprevjob
380/// (c:Src/jobs.c:672-677) can pick the running function's slot as
381/// `prevjob`, which deletejob later frees without touching `prevjob`.
382///
383/// Slots this frame left at `depth` or deeper — skipped by a `break` or a
384/// `continue` before their close ran — are released first.
385///
386/// Worker threads take no slot: the job table is the main shell's, and C
387/// has no second thread to allocate from it.
388pub fn execpline_slot_open(frame: usize, depth: u32) {
389    if crate::worker::in_worker_thread() {
390        return;
391    }
392    execpline_slot_close(frame, depth);
393    let table = crate::ported::jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
394    let slot = {
395        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
396        crate::ported::jobs::initjob(&mut tab) // c:Src/exec.c:1666
397    };
398    let mut tj = THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap_or_else(|e| e.into_inner());
399    let pj = *tj; // c:Src/exec.c:1656
400    *tj = slot as i32; // c:Src/exec.c:1666
401    drop(tj);
402    HELD_SLOTS.with(|h| h.borrow_mut().push(HeldSlot { frame, depth, slot, pj }));
403}
404
405/// !!! WARNING: RUST-ONLY ADAPTER !!! The closing half of execpline's job
406/// frame, run by BUILTIN_EXECPLINE_CHILD_UNBLOCK: the procs-less job is
407/// deleted (waitonejob, c:Src/jobs.c:1750-1756) and `thisjob = pj` restores
408/// the enclosing pipeline's (c:Src/exec.c:1981).
409///
410/// Closes the slot `frame` holds at `depth` together with every slot opened
411/// after it: those belong to pipelines nested inside this one, which have
412/// necessarily finished, whether or not their own close ran.
413pub fn execpline_slot_close(frame: usize, depth: u32) {
414    let start = HELD_SLOTS.with(|h| {
415        h.borrow()
416            .iter()
417            .position(|s| s.frame == frame && s.depth >= depth)
418    });
419    if let Some(start) = start {
420        release_held_slots(start);
421    }
422}
423
424/// !!! WARNING: RUST-ONLY ADAPTER !!! How many pipeline slots are held now.
425/// A chunk runner takes this before running a chunk and hands it to
426/// [`execpline_slots_release_to`] afterwards, so a chunk abandoned mid-
427/// pipeline (errflag, `return` from a sourced file) holds nothing after it.
428pub fn execpline_slots_mark() -> usize {
429    HELD_SLOTS.with(|h| h.borrow().len())
430}
431
432/// !!! WARNING: RUST-ONLY ADAPTER !!! Close every pipeline slot opened since
433/// `mark`, as their closes would have.
434pub fn execpline_slots_release_to(mark: usize) {
435    release_held_slots(mark);
436}
437
438/// !!! WARNING: RUST-ONLY ADAPTER !!! Drop the record of every slot opened
439/// since `mark` WITHOUT touching the job table. For the in-process stand-ins
440/// for a fork (`$(...)`, `( ... )`): the slots were taken from the child's
441/// cleared table, which the parent's restored table has since replaced, so
442/// the indices name nothing of the child's any more.
443pub fn execpline_slots_forget_to(mark: usize) {
444    HELD_SLOTS.with(|h| h.borrow_mut().truncate(mark));
445}
446
447/// Port of execcmd_exec's current-shell arm (c:Src/exec.c:3674-3682), for
448/// the VM's direct builtin and shell-function dispatch, which does not pass
449/// through `execcmd_exec`:
450/// ```c
451/// } else if (is_cursh) {
452///     jobtab[thisjob].stat |= STAT_CURSH;
453///     if (!jobtab[thisjob].procs)
454///         jobtab[thisjob].stat |= STAT_NOPRINT;
455///     if (is_builtin)
456///         jobtab[thisjob].stat |= STAT_BUILTIN;
457/// ```
458/// Without STAT_NOPRINT the running function's own slot is a live job to
459/// getjob/bin_fg: `f() { wait %1 }; f` waited on it and returned 0 where
460/// zsh reports `%1: no such job` and returns 127 (c:Src/jobs.c:2585-2590).
461pub fn mark_thisjob_cursh(is_builtin: bool) {
462    let tj = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap_or_else(|e| e.into_inner());
463    if tj < 0 {
464        return;
465    }
466    let table = crate::ported::jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
467    let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
468    if let Some(j) = tab.get_mut(tj as usize) {
469        j.stat |= stat::CURSH; // c:3678
470        if j.procs.is_empty() {
471            j.stat |= stat::NOPRINT; // c:3679-3680
472        }
473        if is_builtin {
474            j.stat |= stat::BUILTIN; // c:3681-3682
475        }
476    }
477}
478
479/// The job slot of the innermost running pipeline, if one is held.
480pub fn execpline_slot_current() -> Option<usize> {
481    HELD_SLOTS.with(|h| h.borrow().last().map(|s| s.slot))
482}
483
484/// Delete the job slots held from position `start` on, innermost first, and
485/// put `thisjob` back to what it was before the outermost of them opened.
486fn release_held_slots(start: usize) {
487    let released: Vec<HeldSlot> = HELD_SLOTS.with(|h| {
488        let mut h = h.borrow_mut();
489        if start >= h.len() {
490            return Vec::new();
491        }
492        h.split_off(start)
493    });
494    let Some(outermost) = released.first() else {
495        return;
496    };
497    let table = crate::ported::jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
498    {
499        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
500        for held in released.iter().rev() {
501            // A stopped job stays in the table for `fg` (zwaitjob returns on
502            // STAT_STOPPED and nothing deletes it), and a superjob belongs to
503            // the list_pipe machinery, not to this frame.
504            if let Some(jn) = tab.get_mut(held.slot) {
505                if (jn.stat & stat::INUSE) != 0
506                    && (jn.stat & (stat::STOPPED | stat::SUPERJOB)) == 0
507                {
508                    deletejob(jn, false); // c:Src/jobs.c:1754
509                }
510            }
511        }
512        // c:Src/jobs.c:1441-1443 — freejob's `maxjob` shrink, which the
513        // ported freejob (one job, no table) cannot do.
514        let mut mj = MAXJOB.get_or_init(|| Mutex::new(0)).lock().unwrap_or_else(|e| e.into_inner());
515        while *mj > 0 && tab.get(*mj).map_or(true, |j| (j.stat & stat::INUSE) == 0) {
516            *mj -= 1;
517        }
518    }
519    *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap_or_else(|e| e.into_inner()) =
520        outermost.pj; // c:Src/exec.c:1981 `thisjob = pj;`
521}
522
523/// Running-job state tracked alongside each `Child` handle.
524/// Maps to C's `STAT_*` bits but is exposed as a typed enum since
525/// the executor's safe-Rust path doesn't manipulate the bitfield.
526#[derive(Clone, Copy, Debug, PartialEq, Eq)]
527pub enum JobState {
528    /// `Running` variant.
529    Running,
530    /// `Stopped` variant.
531    Stopped,
532    /// `Done` variant.
533    Done,
534}
535
536/// One entry in the executor's bg-job registry.
537#[derive(Debug)]
538pub struct JobInfo {
539    /// `id` field.
540    pub id: usize,
541    /// `pid` field.
542    pub pid: i32,
543    /// `child` field.
544    pub child: Option<Child>,
545    /// `command` field.
546    pub command: String,
547    /// `state` field.
548    pub state: JobState,
549    /// `is_current` field.
550    pub is_current: bool,
551}
552
553/// The executor's bg-job registry. Distinct from the C-port
554/// `JOBTAB` (a `Vec<Job>` keyed by index that mirrors `jobtab[]`):
555/// this table owns the `std::process::Child` handles needed for
556/// `try_wait` / `kill` on the safe-Rust path.
557pub struct JobTable {
558    /// `jobs` field.
559    jobs: Vec<Option<JobInfo>>,
560    /// `current_id` field.
561    current_id: Option<usize>,
562    /// `next_id` field.
563    next_id: usize,
564}
565
566impl Default for JobTable {
567    fn default() -> Self {
568        Self::new()
569    }
570}
571
572impl JobTable {
573    /// `new` — see implementation.
574    pub fn new() -> Self {
575        JobTable {
576            jobs: Vec::with_capacity(16),
577            current_id: None,
578            next_id: 1,
579        }
580    }
581
582    /// Peek at the next id that would be assigned by `add_job`/`add_pid`.
583    /// Used by `wait %N` to distinguish a never-issued id (clear user
584    /// error) from a job that was issued and already reaped (silent
585    /// success in zshrs to keep the `cmd & wait %1` idiom working
586    /// across the races introduced by the threaded job table).
587    pub fn peek_next_id(&self) -> usize {
588        self.next_id
589    }
590
591    /// Add a job with a Child process
592    pub fn add_job(&mut self, child: Child, command: String, state: JobState) -> usize {
593        let id = self.next_id;
594        self.next_id += 1;
595
596        let pid = child.id() as i32;
597        let job = JobInfo {
598            id,
599            pid,
600            child: Some(child),
601            command,
602            state,
603            is_current: true,
604        };
605
606        // Mark previous current as not current
607        if let Some(cur_id) = self.current_id {
608            if let Some(j) = self.get_mut_internal(cur_id) {
609                j.is_current = false;
610            }
611        }
612
613        // Add new job
614        let slot = self.get_free_slot();
615        if slot >= self.jobs.len() {
616            self.jobs.resize_with(slot + 1, || None);
617        }
618        self.jobs[slot] = Some(job);
619        self.current_id = Some(id);
620
621        id
622    }
623
624    /// Register a backgrounded job that was forked via raw `libc::fork()`
625    /// (no `std::process::Child` wrapper). The wait path then has to
626    /// `waitpid(pid)` instead of `Child::wait()`. Used by
627    /// BUILTIN_RUN_BG so `wait` (no args) can synchronize on it.
628    pub fn add_pid_job(&mut self, pid: i32, command: String, state: JobState) -> usize {
629        let id = self.next_id;
630        self.next_id += 1;
631        let job = JobInfo {
632            id,
633            pid,
634            child: None,
635            command,
636            state,
637            is_current: true,
638        };
639        if let Some(cur_id) = self.current_id {
640            if let Some(j) = self.get_mut_internal(cur_id) {
641                j.is_current = false;
642            }
643        }
644        let slot = self.get_free_slot();
645        if slot >= self.jobs.len() {
646            self.jobs.resize_with(slot + 1, || None);
647        }
648        self.jobs[slot] = Some(job);
649        self.current_id = Some(id);
650        id
651    }
652
653    fn get_free_slot(&self) -> usize {
654        for (i, slot) in self.jobs.iter().enumerate() {
655            if slot.is_none() {
656                return i;
657            }
658        }
659        self.jobs.len()
660    }
661
662    fn get_mut_internal(&mut self, id: usize) -> Option<&mut JobInfo> {
663        self.jobs.iter_mut().flatten().find(|job| job.id == id)
664    }
665
666    /// Get a job by ID
667    pub fn get(&self, id: usize) -> Option<&JobInfo> {
668        self.jobs
669            .iter()
670            .flatten()
671            .find(|&job| job.id == id)
672            .map(|v| v as _)
673    }
674
675    /// Get a mutable job by ID
676    pub fn get_mut(&mut self, id: usize) -> Option<&mut JobInfo> {
677        self.get_mut_internal(id)
678    }
679
680    /// Remove a job by ID
681    pub fn remove(&mut self, id: usize) -> Option<JobInfo> {
682        for slot in self.jobs.iter_mut() {
683            if slot.as_ref().map(|j| j.id == id).unwrap_or(false) {
684                let job = slot.take();
685                if self.current_id == Some(id) {
686                    self.current_id = None;
687                }
688                return job;
689            }
690        }
691        None
692    }
693
694    /// List all active jobs
695    pub fn list(&self) -> Vec<&JobInfo> {
696        self.jobs.iter().filter_map(|j| j.as_ref()).collect()
697    }
698
699    /// Iterate over jobs with their IDs (for compatibility)
700    pub fn iter(&self) -> impl Iterator<Item = (usize, &JobInfo)> {
701        self.jobs
702            .iter()
703            .filter_map(|j| j.as_ref().map(|job| (job.id, job)))
704    }
705
706    /// Count number of active jobs
707    pub fn count(&self) -> usize {
708        self.jobs.iter().filter(|j| j.is_some()).count()
709    }
710
711    /// Check if there are any jobs
712    pub fn is_empty(&self) -> bool {
713        self.count() == 0
714    }
715
716    /// Get current job
717    pub fn current(&self) -> Option<&JobInfo> {
718        self.current_id.and_then(|id| self.get(id))
719    }
720
721    /// Reap finished jobs (check for completed processes)
722    pub fn reap_finished(&mut self) -> Vec<JobInfo> {
723        let mut finished = Vec::new();
724
725        for job in self.jobs.iter_mut().flatten() {
726            if let Some(ref mut child) = job.child {
727                // Try to check if child has finished without blocking
728                match child.try_wait() {
729                    Ok(Some(_status)) => {
730                        // Child finished
731                        job.state = JobState::Done;
732                    }
733                    Ok(None) => {
734                        // Still running
735                    }
736                    Err(_) => {
737                        // Error checking, assume done
738                        job.state = JobState::Done;
739                    }
740                }
741            }
742        }
743
744        // Remove done jobs
745        for slot in self.jobs.iter_mut() {
746            if slot
747                .as_ref()
748                .map(|j| j.state == JobState::Done)
749                .unwrap_or(false)
750            {
751                if let Some(job) = slot.take() {
752                    finished.push(job);
753                }
754            }
755        }
756
757        finished
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn test_job_table_new() {
767        let _g = crate::test_util::global_state_lock();
768        let table = JobTable::new();
769        assert!(table.is_empty());
770    }
771
772    #[test]
773    fn test_job_state_enum() {
774        let _g = crate::test_util::global_state_lock();
775        let state = JobState::Running;
776        assert_eq!(state, JobState::Running);
777        assert_ne!(state, JobState::Stopped);
778        assert_ne!(state, JobState::Done);
779    }
780
781    #[test]
782    fn test_add_pid_job_assigns_id() {
783        let _g = crate::test_util::global_state_lock();
784        let mut t = JobTable::new();
785        let id1 = t.add_pid_job(1234, "cmd1".into(), JobState::Running);
786        let id2 = t.add_pid_job(5678, "cmd2".into(), JobState::Running);
787        assert_ne!(id1, id2);
788        assert_eq!(t.list().len(), 2);
789        assert_eq!(t.current().map(|j| j.id), Some(id2));
790    }
791
792    #[test]
793    fn test_remove_drops_current() {
794        let _g = crate::test_util::global_state_lock();
795        let mut t = JobTable::new();
796        let id = t.add_pid_job(99, "x".into(), JobState::Running);
797        assert!(t.remove(id).is_some());
798        assert!(t.is_empty());
799        assert!(t.current().is_none());
800    }
801}