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}