Skip to main content

zsh/ported/
jobs.rs

1//! job control for zshrs
2//!
3//! Port from zsh/Src/jobs.c
4//!
5//! the process group of the shell                                           // c:60
6//! the job we are working on, or -1 if none                                 // c:70
7//! the current job (%+)                                                     // c:75
8//! the previous job (%-)                                                    // c:80
9//! the job table                                                            // c:85
10//! Size of the job table.                                                   // c:90
11//! Update status of job, possibly printing it                               // c:456
12//! wait for running job to finish                                           // c:1759
13//! clear job table when entering subshells                                  // c:1776
14//! Initialise job handling.                                                 // c:2160
15//!
16//! Provides job control, process management, and signal handling for jobs.
17
18use crate::exec_jobs::JobTable;
19use crate::ported::builtin::{SHELL_EXITING, STOPMSG};
20use crate::ported::builtins::sched::zleactive;
21use crate::ported::hashtable_h::{BIN_BG, BIN_DISOWN, BIN_FG, BIN_JOBS, BIN_WAIT};
22use crate::ported::options::opt_state_set;
23use crate::ported::params::{getsparam, setsparam, unsetparam};
24use crate::ported::signals::{
25    killjb, queue_signals, signal_block, signal_setmask, unqueue_signals, wait_for_processes,
26};
27use crate::ported::signals_h::{signal_default, signal_ignore, sigs_name, sigs_number};
28use crate::ported::utils::zwarnnam;
29use crate::ported::utils::{fdtable_get, zclose};
30use crate::ported::zsh_h::{
31    isset, job, jobfile, options, process, FDT_PROC_SUBST, INTERACTIVE, LONGLISTJOBS, MONITOR,
32    OPT_ISSET, POSIXBUILTINS, POSIXJOBS, STAT_ATTACH, STAT_INUSE, STAT_SUBJOB,
33    STAT_SUBJOB_ORPHANED, STAT_SUPERJOB,
34};
35pub use crate::ported::zsh_h::{timeinfo, MAXJOBS_ALLOC, MAX_PIPESTATS, SP_RUNNING};
36use crate::DPUTS;
37use std::env;
38use std::os::unix::process::ExitStatusExt;
39use std::process::Child;
40use std::sync::atomic::Ordering;
41use std::sync::{Mutex, OnceLock};
42use std::time::{Duration, Instant};
43
44/// job status flags. `i32` to match C's `int stat` field on
45/// `struct job` (`Src/zsh.h:1062`).
46///
47/// **Bit values MUST match C's `STAT_*` defines verbatim** at
48/// `Src/zsh.h:1073-1094`. Previous Rust port used sequential bit
49/// shifts (1<<0, 1<<1, …) which produced DIFFERENT values from C
50/// for EVERY flag — `stat::STOPPED = 0x01` vs C `STAT_STOPPED =
51/// 0x0002`, etc. Any data ferried between C-side and Rust-side
52/// (or between bytecode and runtime state) would mis-interpret
53/// every stat-flag check. Now canonical.
54///
55/// Also added missing flags (CHANGED, TIMED, LOCKED, NOPRINT,
56/// NOSTTY, SUBLEADER) and removed bogus ones (DISOWN, NOTIFY —
57/// not in C STAT_*).
58pub mod stat {
59    /// `CHANGED` constant.
60    pub const CHANGED: i32 = 0x0001; // c:1073 status changed
61    /// `STOPPED` constant.
62    pub const STOPPED: i32 = 0x0002; // c:1074 all procs stopped or exited
63    /// `TIMED` constant.
64    pub const TIMED: i32 = 0x0004; // c:1075 job is being timed
65    /// `DONE` constant.
66    pub const DONE: i32 = 0x0008; // c:1076 job is done
67    /// `LOCKED` constant.
68    pub const LOCKED: i32 = 0x0010; // c:1077 shell finished creating
69    /// `NOPRINT` constant.
70    pub const NOPRINT: i32 = 0x0020; // c:1079 killed internally
71    /// `INUSE` constant.
72    pub const INUSE: i32 = 0x0040; // c:1081 entry in use
73    /// `SUPERJOB` constant.
74    pub const SUPERJOB: i32 = 0x0080; // c:1082 job has a subjob
75    /// `SUBJOB` constant.
76    pub const SUBJOB: i32 = 0x0100; // c:1083 job is a subjob
77    /// `WASSUPER` constant.
78    pub const WASSUPER: i32 = 0x0200; // c:1084 was super-job
79    /// `CURSH` constant.
80    pub const CURSH: i32 = 0x0400; // c:1086 last cmd in current shell
81    /// `NOSTTY` constant.
82    pub const NOSTTY: i32 = 0x0800; // c:1087 tty settings not inherited
83    /// `ATTACH` constant.
84    pub const ATTACH: i32 = 0x1000; // c:1089 delay reattach to tty
85    /// `SUBLEADER` constant.
86    pub const SUBLEADER: i32 = 0x2000; // c:1090 super-job, leader is sub-shell
87    /// `BUILTIN` constant.
88    pub const BUILTIN: i32 = 0x4000; // c:1092 tail is builtin
89    /// `STAT_DISOWN` from `Src/zsh.h:1093`. SUPERJOB with disown pending.
90    pub const DISOWN: i32 = 0x10000; // c:1093
91}
92
93/// Time difference for timeval (from jobs.c dtime_tv)
94/// Port of `dtime_tv(struct timeval *dt, struct timeval *t1, struct timeval *t2)` from `Src/jobs.c:137`.
95pub fn dtime_tv(dt: &mut Duration, t1: &Duration, t2: &Duration) -> Duration {
96    if *t2 > *t1 {
97        *dt = *t2 - *t1;
98    } else {
99        *dt = Duration::ZERO;
100    }
101    *dt
102}
103
104/// Time difference for timespec (from jobs.c dtime_ts)
105/// Port of `dtime_ts(struct timespec *dt, struct timespec *t1, struct timespec *t2)` from `Src/jobs.c:152`.
106/// WARNING: param names don't match C — Rust=(t1, t2) vs C=(dt, t1, t2)
107pub fn dtime_ts(t1: &Instant, t2: &Instant) -> Duration {
108    if *t2 > *t1 {
109        t2.duration_since(*t1)
110    } else {
111        Duration::ZERO
112    }
113}
114
115// change job table entry from stopped to running                           // c:163
116/// Port of `makerunning(job jn)` from `Src/jobs.c:167`.
117///
118/// C body:
119/// ```c
120/// jn->stat &= ~STAT_STOPPED;
121/// for (pn = jn->procs; pn; pn = pn->next)
122///     if (WIFSTOPPED(pn->status))
123///         pn->status = SP_RUNNING;
124/// if (jn->stat & STAT_SUPERJOB)
125///     makerunning(jobtab + jn->other);
126/// ```
127///
128/// Clears the STOPPED flag on the job, resets each stopped process
129/// to SP_RUNNING, and recurses into the linked subjob if this is a
130// change job table entry from stopped to running                           // c:167
131/// superjob. The previous Rust port called `job.make_running()`
132/// which mutates only the single job — missing the superjob
133/// recursion. This port walks the table to handle the recursion.
134pub fn makerunning(jobtab: &mut [job], idx: usize) {
135    if idx >= jobtab.len() {
136        return;
137    }
138    let other = jobtab[idx].other as usize;
139    let is_super = (jobtab[idx].stat & stat::SUPERJOB) != 0;
140    {
141        let job = &mut jobtab[idx];
142        job.stat &= !stat::STOPPED;
143        for proc in &mut job.procs {
144            if proc.is_stopped() {
145                proc.status = SP_RUNNING;
146            }
147        }
148    }
149    if is_super && other != idx && other < jobtab.len() {
150        makerunning(jobtab, other);
151    }
152}
153
154// Find process and job associated with pid.                                // c:191
155// Return 1 if search was successful, else return 0.                        // c:191
156/// Port of `int findproc(pid_t pid, job *jptr, process *pptr, int aux)`
157/// from `Src/jobs.c:191`.
158///
159/// C body (c:198-236) walks `jobtab[1..=maxjob]`:
160///   - Skips entries where `(stat & STAT_DONE)` per c:204 — these are
161///     jobs already marked dead.
162///   - Walks ONLY `procs` OR `auxprocs` based on the `aux` arg, not
163///     both. The previous Rust port walked both arrays.
164///   - Prefers a `SP_RUNNING` match: if multiple pids hit but only
165///     one is still running, returns it. The previous Rust port
166///     returned the FIRST match regardless of running state.
167///
168/// **WARNING: param names don't match C** — Rust (jobtab, pid, aux)
169/// vs C (pid, **jptr, **pptr, int aux). Returns `Some((job_idx,
170/// proc_idx, aux_was_true))` rather than mutating out-pointers.
171pub fn findproc(jobtab: &[job], pid: i32, aux: bool) -> Option<(usize, usize, bool)> {
172    // c:191
173    let mut last_match: Option<(usize, usize, bool)> = None;
174    // c:198 — `for (i = 1; i <= maxjob; i++)`. Index 0 (the shell
175    // itself) is skipped.
176    for (ji, job) in jobtab.iter().enumerate().skip(1) {
177        // c:204 — `if (jobtab[i].stat & STAT_DONE) continue;`. Don't
178        // match against jobs already marked dead; their pids might
179        // be recycled by the kernel and collide with a live pid.
180        if (job.stat & stat::DONE) != 0 {
181            continue;
182        }
183        // c:209-210 — walk EITHER procs OR auxprocs based on aux.
184        let procs: &[process] = if aux { &job.auxprocs } else { &job.procs };
185        for (pi, proc) in procs.iter().enumerate() {
186            if proc.pid == pid {
187                // c:228
188                // c:229-232 — `if (pn->status == SP_RUNNING) return 1;`.
189                // Prefer a running match; otherwise record the last
190                // matching slot and keep looking.
191                if proc.status == SP_RUNNING {
192                    return Some((ji, pi, aux)); // c:231 return 1
193                }
194                last_match = Some((ji, pi, aux)); // c:227 record
195            }
196        }
197    }
198    // c:235 — `return (*pptr && *jptr);` — at least one slot matched
199    // (even if not running). Rust returns last_match.
200    last_match
201}
202
203// `TimeInfo` / `ChildTimes` deleted — both folded into canonical
204// `timeinfo` at `zsh_h.rs:2153` (direct port of `struct timeinfo`
205// from `Src/zsh.h:1099`).
206
207// Canonical `process` / `job` live in `zsh_h.rs:1166,1180` — direct
208// ports of `struct process` / `struct job` from `Src/zsh.h:1117,1058`.
209// jobs.rs uses them via `process` / `job` aliases to keep call sites
210// readable (Rust convention favors CamelCase at use-sites; the
211// underlying type is the lowercase C-faithful canonical).
212
213impl process {
214    /// Build a fresh entry. Matches C's `update_process()` init shape
215    /// (`Src/jobs.c:363` — `pn->pid = pid; pn->status = SP_RUNNING;`
216    /// before the first wait).
217    pub fn new(pid: i32) -> Self {
218        process {
219            pid,
220            status: SP_RUNNING,
221            text: String::new(),
222            ti: timeinfo::default(),
223            bgtime: Some(Instant::now()),
224            endtime: None,
225        }
226    }
227
228    /// `SP_RUNNING` sentinel check — equivalent to C's `pn->status ==
229    /// SP_RUNNING` test at e.g. `Src/jobs.c:1242`.
230    pub fn is_running(&self) -> bool {
231        self.status == SP_RUNNING
232    }
233
234    /// Mirrors C's `WIFSTOPPED(status)` macro.
235    pub fn is_stopped(&self) -> bool {
236        self.status & 0xff == 0x7f
237    }
238
239    /// Mirrors C's `WIFSIGNALED(status)` macro.
240    pub fn is_signaled(&self) -> bool {
241        (self.status & 0x7f) > 0 && (self.status & 0x7f) < 0x7f
242    }
243
244    /// Mirrors C's `WEXITSTATUS(status)` macro.
245    pub fn exit_status(&self) -> i32 {
246        (self.status >> 8) & 0xff
247    }
248
249    /// Mirrors C's `WTERMSIG(status)` macro.
250    pub fn term_sig(&self) -> i32 {
251        self.status & 0x7f
252    }
253
254    /// Mirrors C's `WSTOPSIG(status)` macro.
255    pub fn stop_sig(&self) -> i32 {
256        (self.status >> 8) & 0xff
257    }
258}
259
260impl job {
261    /// Empty job slot — mirrors C's `memset(jn, 0, sizeof(*jn))`
262    /// done in `initjob_reuse()` (`Src/jobs.c:574`).
263    pub fn new() -> Self {
264        Self::default()
265    }
266
267    /// True if any procs/auxprocs registered. Equivalent to C's
268    /// `jn->procs || jn->auxprocs` null check at `Src/jobs.c` various.
269    pub fn has_procs(&self) -> bool {
270        !self.procs.is_empty() || !self.auxprocs.is_empty()
271    }
272
273    /// True if any proc is in the C `SP_RUNNING` state.
274    pub fn is_running(&self) -> bool {
275        self.procs.iter().any(|p| p.is_running())
276    }
277
278    /// True if every proc has finished (none `SP_RUNNING`, none stopped).
279    pub fn is_done(&self) -> bool {
280        !self.procs.is_empty()
281            && self
282                .procs
283                .iter()
284                .all(|p| !p.is_running() && !p.is_stopped())
285    }
286
287    /// True if the job is stopped — checks both the `STAT_STOPPED`
288    /// flag bit on `self.stat` and per-proc `WIFSTOPPED`. Matches
289    /// C's two-source check (`Src/jobs.c` reads `jn->stat & STAT_STOPPED`
290    /// for the flag and `WIFSTOPPED(pn->status)` per proc).
291    pub fn is_stopped(&self) -> bool {
292        (self.stat & stat::STOPPED) != 0 || self.procs.iter().any(|p| p.is_stopped())
293    }
294
295    /// True if the slot is marked `INUSE` — equivalent to C's
296    /// `(jn->stat & STAT_INUSE) != 0` check.
297    pub fn is_inuse(&self) -> bool {
298        (self.stat & stat::INUSE) != 0
299    }
300
301    /// Walk procs and reset their `status` back to `SP_RUNNING` —
302    /// mirrors C's `makerunning()` body (`Src/jobs.c:1573`).
303    pub fn make_running(&mut self) {
304        for p in &mut self.procs {
305            if p.is_stopped() {
306                p.status = SP_RUNNING;
307            }
308        }
309        self.stat &= !stat::STOPPED;
310    }
311}
312
313// `JobState` enum moved to `src/exec_jobs.rs` — Rust-only typed
314// wrapper for the executor's safe-Rust bg-job tracker. C uses the
315// `STAT_*` u32 bits on `struct job.stat` (`stat::*` constants
316// above) directly; the enum exists only to give the
317// std::process::Child path a typed projection.
318//
319// `JobEntry` struct deleted — Rust-only "simple job entry for
320// executor compatibility" with zero callers anywhere. JobInfo
321// already carries this exact shape; JobEntry was a stale duplicate.
322
323// ---------------------------------------------------------------------------
324// C-style globals (Bucket 2: shell-wide shared state per PORT_PLAN.md)
325// Declared in same order as jobs.c lines 57-131
326// ---------------------------------------------------------------------------
327
328/// Port of `hasprocs(int job)` from `Src/jobs.c:243`.
329///
330/// C body:
331/// ```c
332/// job jn;
333/// if (job < 0) { DPUTS(1, "job number invalid"); return 0; }
334/// jn = jobtab + job;
335/// return jn->procs || jn->auxprocs;
336/// ```
337///
338/// Takes the job index (not a `&job`) because the C signature is
339/// `int hasprocs(int job)`. Bounds-checks the index — out-of-range
340/// returns false (matching C's negative-index DPUTS+0 path).
341/// WARNING: param names don't match C — Rust=(jobtab, job) vs C=(job)
342pub fn hasprocs(jobtab: &[job], job: usize) -> bool {
343    jobtab
344        .get(job)
345        .map(|j| !j.procs.is_empty() || !j.auxprocs.is_empty())
346        .unwrap_or(false)
347}
348
349/// Port of `super_job(int sub)` from `Src/jobs.c:259-270` — find the super-job of a sub-job.
350/// ```c
351/// for (i = 1; i <= maxjob; i++)
352///     if ((jobtab[i].stat & STAT_SUPERJOB) &&
353///         jobtab[i].other == sub &&
354///         jobtab[i].gleader)
355///         return i;
356/// return 0;
357/// ```
358/// The `gleader` non-zero check at c:267 was previously missing in
359/// the Rust port — silently returned super-job indices for entries
360/// that hadn't yet had a process-group leader assigned, breaking
361/// job-control SIGCONT relay paths.
362pub fn super_job(jobtab: &[job], job_idx: usize) -> Option<usize> {
363    // c:260
364    for (i, job) in jobtab.iter().enumerate() {
365        if (job.stat & stat::SUPERJOB) != 0 && job.other as usize == job_idx && job.gleader != 0
366        // c:267
367        {
368            return Some(i);
369        }
370    }
371    None
372}
373
374/// Handle subjob completion (from jobs.c handle_sub)
375/// Port of `handle_sub(int job, int fg)` from `Src/jobs.c:274`.
376/// WARNING: param names don't match C — Rust=(jobtab, super_idx, fg) vs C=(job, fg)
377pub fn handle_sub(jobtab: &mut [job], super_idx: usize, fg: bool) -> i32 {
378    // c:274
379    // c:277 — `job jn = jobtab + job, sj = jobtab + jn->other;`
380    let sub_idx = jobtab[super_idx].other as usize;
381    if sub_idx >= jobtab.len() {
382        return 0;
383    }
384
385    // c:279 — `if ((sj->stat & STAT_DONE) || (!sj->procs && !sj->auxprocs)) {`
386    let sj_done = (jobtab[sub_idx].stat & stat::DONE) != 0
387        || (jobtab[sub_idx].procs.is_empty() && jobtab[sub_idx].auxprocs.is_empty());
388    if sj_done {
389        // c:282-292 — walk sj->procs looking for a signaled one; cascade
390        // SIGCONT + signal to superjob's group, then SIGCONT + signal
391        // to sj->other.
392        let mut signaled: Option<i32> = None;
393        for p in jobtab[sub_idx].procs.iter() {
394            #[cfg(unix)]
395            if libc::WIFSIGNALED(p.status) {
396                signaled = Some(libc::WTERMSIG(p.status));
397                break;
398            }
399        }
400        if let Some(sig) = signaled {
401            // c:283-291 — kill the superjob via gleader (or first proc),
402            //              then SIGCONT + signal to sj->other.
403            let jn_gleader = jobtab[super_idx].gleader;
404            let multi_procs = jobtab[super_idx].procs.len() > 1;
405            #[cfg(unix)]
406            {
407                let mypgrp = unsafe { libc::getpgrp() };
408                if jn_gleader != mypgrp && multi_procs {
409                    unsafe { libc::killpg(jn_gleader, sig) }; // c:285
410                } else if let Some(p0) = jobtab[super_idx].procs.first() {
411                    unsafe { libc::kill(p0.pid, sig) }; // c:287
412                }
413                let sj_other = jobtab[sub_idx].other;
414                unsafe { libc::kill(sj_other, libc::SIGCONT) }; // c:288
415                unsafe { libc::kill(sj_other, sig) }; // c:289
416            }
417            #[cfg(not(unix))]
418            {
419                let _ = (jn_gleader, multi_procs, sig);
420            }
421        } else {
422            // c:293-326 — no signaled proc: mark SUPERJOB cleared,
423            // WASSUPER set; gleader-recovery if dead; attachtty when
424            // fg; deletejob if DISOWN pending.
425            jobtab[super_idx].stat &= !stat::SUPERJOB; // c:296
426            jobtab[super_idx].stat |= stat::WASSUPER; // c:297
427                                                      // c:299-306 — gleader recovery: if the first proc has exited
428                                                      //              or been signaled AND killpg(gleader, 0) → ESRCH,
429                                                      //              promote the last proc's pid to be the new
430                                                      //              gleader (cp).
431            let cp: bool;
432            #[cfg(unix)]
433            {
434                let first_status = jobtab[super_idx]
435                    .procs
436                    .first()
437                    .map(|p| p.status)
438                    .unwrap_or(0);
439                let dead = libc::WIFEXITED(first_status) || libc::WIFSIGNALED(first_status);
440                let gleader_dead = dead
441                    && unsafe { libc::killpg(jobtab[super_idx].gleader, 0) } == -1
442                    && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH);
443                cp = gleader_dead;
444                if cp {
445                    if let Some(last) = jobtab[super_idx].procs.last() {
446                        jobtab[super_idx].gleader = last.pid; // c:305
447                    }
448                }
449            }
450            #[cfg(not(unix))]
451            {
452                cp = false;
453            }
454
455            // c:318-320 — attachtty(jn->gleader) when fg or thisjob == job,
456            //              and the superjob is the sub-shell alone (single
457            //              proc, or gleader recovered, or first proc != gleader).
458            let thisjob = *THISJOB
459                .get_or_init(|| Mutex::new(-1))
460                .lock()
461                .expect("thisjob poisoned");
462            let cond_attach = fg || thisjob as usize == super_idx;
463            let single_proc = jobtab[super_idx].procs.len() == 1;
464            let first_pid_neq_gleader = jobtab[super_idx]
465                .procs
466                .first()
467                .map(|p| p.pid != jobtab[super_idx].gleader)
468                .unwrap_or(false);
469            if cond_attach && (single_proc || cp || first_pid_neq_gleader) {
470                // c:319 — `attachtty(jn->gleader);` hand the tty to
471                // the super-job's process group leader.
472                #[cfg(unix)]
473                crate::ported::utils::attachtty(jobtab[super_idx].gleader);
474            }
475            // c:321 — kill(sj->other, SIGCONT);
476            #[cfg(unix)]
477            unsafe {
478                libc::kill(jobtab[sub_idx].other, libc::SIGCONT);
479            }
480
481            // c:322-325 — `if (jn->stat & STAT_DISOWN) deletejob(jn, 1);`
482            if (jobtab[super_idx].stat & stat::DISOWN) != 0 {
483                deletejob(&mut jobtab[super_idx], true);
484            }
485        }
486        // c:327 — curjob = jn - jobtab;
487        if let Ok(mut cj) = CURJOB.get_or_init(|| Mutex::new(-1)).lock() {
488            *cj = super_idx as i32;
489        }
490        return 0; // c:340 fall-through return
491    } else if (jobtab[sub_idx].stat & stat::STOPPED) != 0 {
492        // c:328
493        // c:331-337 — STOPPED branch: propagate STOPPED to superjob,
494        //              clone subjob's first-proc status to every super
495        //              proc that's still running.
496        jobtab[super_idx].stat |= stat::STOPPED; // c:331
497        let sj_proc_status = jobtab[sub_idx].procs.first().map(|p| p.status).unwrap_or(0);
498        for p in jobtab[super_idx].procs.iter_mut() {
499            // c:332
500            if p.status == SP_RUNNING                                        // c:333-334
501                || {
502                    #[cfg(unix)]
503                    { !libc::WIFEXITED(p.status) && !libc::WIFSIGNALED(p.status) }
504                    #[cfg(not(unix))]
505                    { false }
506                }
507            {
508                p.status = sj_proc_status; // c:335
509            }
510        }
511        if let Ok(mut cj) = CURJOB.get_or_init(|| Mutex::new(-1)).lock() {
512            *cj = super_idx as i32; // c:336
513        }
514        // c:337 — printjob(jn, !!isset(LONGLISTJOBS), 1);
515        //         printjob takes a snapshot signature here that requires
516        //         cur_job/prev_job indices; defer the print to the caller
517        //         (jobs.rs's jobs-builtin scanner) which has those handy.
518        return 1; // c:338
519    }
520    0 // c:340
521}
522
523/// Get children's time accounting.
524/// Port of `get_usage()` from Src/jobs.c — fills `child_usage`
525/// from `getrusage(RUSAGE_CHILDREN)` on supported systems.
526pub fn get_usage() -> timeinfo {
527    #[cfg(unix)]
528    {
529        let mut u: libc::rusage = unsafe { std::mem::zeroed() };
530        if unsafe { libc::getrusage(libc::RUSAGE_CHILDREN, &mut u) } == 0 {
531            return timeinfo::from_rusage(&u);
532        }
533    }
534    timeinfo::default()
535}
536
537/// Port of `update_process(process pn, int status)` from `Src/jobs.c:363`.
538///
539/// C body:
540/// ```c
541/// struct timeval childs = child_usage.ru_stime, childu = child_usage.ru_utime;
542/// get_usage();
543/// zgettime_monotonic_if_available(&pn->endtime);
544/// pn->status = status;
545/// dtime_tv(&pn->ti.ru_stime, &childs, &child_usage.ru_stime);
546/// dtime_tv(&pn->ti.ru_utime, &childu, &child_usage.ru_utime);
547/// ```
548///
549/// Snapshots the children-rusage delta between the previous reading
550/// and the call to `get_usage()` — the per-process rusage attribution.
551///
552/// Mirrors C's `child_usage` global pattern (`Src/jobs.c:109`):
553/// the in-flight rusage snapshot lives in `CHILD_USAGE_PREV`, gets
554/// captured pre-wait by `child_usage_snapshot()`, and update_process
555/// diffs that against the current `get_usage()` to attribute per-
556/// process rusage. Without this snapshot pre-wait, all diffs are 0
557/// (the previous Rust port had this bug).
558pub fn update_process(pn: &mut process, status: i32) {
559    // c:362
560    let prev = CHILD_USAGE_PREV.with(|c| c.borrow().clone()); // c:366-367
561    let now = get_usage(); // c:374 get_usage()
562    CHILD_USAGE_PREV.with(|c| *c.borrow_mut() = now.clone());
563
564    pn.endtime = Some(Instant::now()); // c:375 zgettime_monotonic_if_available
565    pn.status = status; // c:377
566
567    // Field-by-field diff (now - prev), clamped >= 0 to handle the
568    // first-wait case where prev is zero-initialised.
569    let diff = |a: i64, b: i64| -> i64 { (a - b).max(0) };
570    pn.ti = timeinfo {
571        ut: diff(now.ut, prev.ut), // c:380 ru_utime delta
572        st: diff(now.st, prev.st), // c:379 ru_stime delta
573        maxrss: now.maxrss.max(prev.maxrss),
574        majflt: diff(now.majflt, prev.majflt),
575        minflt: diff(now.minflt, prev.minflt),
576        nswap: diff(now.nswap, prev.nswap),
577        ixrss: diff(now.ixrss, prev.ixrss),
578        idrss: diff(now.idrss, prev.idrss),
579        isrss: diff(now.isrss, prev.isrss),
580        inblock: diff(now.inblock, prev.inblock),
581        oublock: diff(now.oublock, prev.oublock),
582        nvcsw: diff(now.nvcsw, prev.nvcsw),
583        nivcsw: diff(now.nivcsw, prev.nivcsw),
584        msgsnd: diff(now.msgsnd, prev.msgsnd),
585        msgrcv: diff(now.msgrcv, prev.msgrcv),
586        nsignals: diff(now.nsignals, prev.nsignals),
587    };
588}
589
590// `child_usage` — Src/jobs.c:109 mod_export global. The cumulative
591// children rusage snapshot kept warm between waits. update_process
592// reads-then-overwrites it to compute the delta attributable to the
593// just-reaped child. Per-thread (bucket 1) because each worker
594// thread reaps its own children independently.
595thread_local! {
596    static CHILD_USAGE_PREV: std::cell::RefCell<timeinfo>
597        = const { std::cell::RefCell::new(timeinfo {
598            ut: 0, st: 0, maxrss: 0, majflt: 0, minflt: 0, nswap: 0,
599            ixrss: 0, idrss: 0, isrss: 0, inblock: 0, oublock: 0,
600            nvcsw: 0, nivcsw: 0, msgsnd: 0, msgrcv: 0, nsignals: 0,
601        }) };
602}
603
604/// Check current shell signals (from jobs.c check_cursh_sig)
605#[cfg(unix)]
606/// Port of `check_cursh_sig(int sig)` from `Src/jobs.c:397`.
607/// WARNING: param names don't match C — Rust=(jobtab, sig) vs C=(sig)
608pub fn check_cursh_sig(jobtab: &[job], sig: i32) {
609    for job in jobtab {
610        if (job.stat & stat::CURSH) != 0 && !job.is_done() {
611            for proc in &job.procs {
612                if proc.is_running() {
613                    unsafe {
614                        libc::kill(proc.pid, sig);
615                    }
616                }
617            }
618        }
619    }
620}
621
622/// Port of `storepipestats(job jn, int inforeground, int fixlastval)` from `Src/jobs.c:420`.
623///
624/// C body decodes each process's wait-status into a normalised
625/// pipestats entry (signal-bit-or-exit-code) and tracks the
626/// last non-zero status for `setopt PIPEFAIL` semantics:
627/// ```c
628/// jpipestats[i] = (WIFSIGNALED(p->status) ? 0200 | WTERMSIG(p->status) :
629///                  WIFSTOPPED(p->status) ? 0200 | WSTOPSIG(p->status) :
630///                  WEXITSTATUS(p->status));
631/// if (jpipestats[i]) pipefail = jpipestats[i];
632/// ```
633///
634/// The previous Rust port returned the raw `proc.status` values
635/// without decoding — wrong for any signal-terminated process
636/// (where status would have the high-bit-stripped sig number, not
637/// the canonical pipestats encoding).
638///
639/// Returns `(pipestats, pipefail)` — the decoded array and the
640/// last non-zero entry (0 if all succeeded).
641/// WARNING: param names don't match C — Rust=(job) vs C=(jn, inforeground, fixlastval)
642pub fn storepipestats(job: &job) -> (Vec<i32>, i32) {
643    let mut stats = Vec::with_capacity(job.procs.len().min(MAX_PIPESTATS));
644    let mut pipefail = 0;
645    for p in job.procs.iter().take(MAX_PIPESTATS) {
646        let st = p.status;
647        // SP_RUNNING is the in-flight sentinel; treat as 0.
648        let entry = if st == SP_RUNNING {
649            0
650        } else if (st & 0x7f) > 0 && (st & 0x7f) < 0x7f {
651            // WIFSIGNALED — bit 0x80 + signal number.
652            0o200 | (st & 0x7f)
653        } else if (st & 0xff) == 0x7f {
654            // WIFSTOPPED — bit 0x80 + stop signal.
655            0o200 | ((st >> 8) & 0xff)
656        } else {
657            // WIFEXITED — exit status.
658            (st >> 8) & 0xff
659        };
660        stats.push(entry);
661        if entry != 0 {
662            pipefail = entry;
663        }
664    }
665    (stats, pipefail)
666}
667
668// Update status of job, possibly printing it                               // c:460
669/// Update job status after process change (from jobs.c update_job)
670/// Returns true if the job is now done or stopped (status committed),
671/// false if any proc is still running (no update needed).
672pub fn update_job(job: &mut job) -> bool {
673    // c:460
674    // c:467-474 — `for (pn = jn->auxprocs; pn; pn = pn->next) {
675    //                 if (WIFCONTINUED(pn->status)) pn->status = SP_RUNNING;
676    //                 if (pn->status == SP_RUNNING) return; }`
677    for proc in job.auxprocs.iter_mut() {
678        #[cfg(unix)]
679        if proc.status > 0
680            && !libc::WIFEXITED(proc.status)
681            && !libc::WIFSIGNALED(proc.status)
682            && !libc::WIFSTOPPED(proc.status)
683        {
684            // WIFCONTINUED not exposed as a libc::W* fn on every target;
685            // it's the "neither exited nor signaled nor stopped" case
686            // that means SIGCONT was just delivered. Mark SP_RUNNING.
687            proc.status = SP_RUNNING;
688        }
689        if proc.is_running() {
690            return false;
691        }
692    }
693
694    // c:476-498 — walk main procs, look for SP_RUNNING (bail), track
695    //              somestopped, capture last-proc status (signal/stop/exit),
696    //              set the signalled flag.
697    let mut some_stopped = false;
698    let mut signalled = false;
699    let mut val: i32 = 0;
700    let proc_count = job.procs.len();
701    for (i, proc) in job.procs.iter_mut().enumerate() {
702        #[cfg(unix)]
703        if proc.status > 0
704            && !libc::WIFEXITED(proc.status)
705            && !libc::WIFSIGNALED(proc.status)
706            && !libc::WIFSTOPPED(proc.status)
707        {
708            // WIFCONTINUED main path: clear STAT_STOPPED + SP_RUNNING.
709            job.stat &= !stat::STOPPED;
710            proc.status = SP_RUNNING;
711        }
712        if proc.is_running() {
713            return false;
714        }
715        if proc.is_stopped() {
716            some_stopped = true;
717        }
718        // c:487-495 — last proc determines exit val.
719        if i + 1 == proc_count {
720            #[cfg(unix)]
721            {
722                if libc::WIFSIGNALED(proc.status) {
723                    val = 0o200 | libc::WTERMSIG(proc.status);
724                    signalled = true;
725                } else if libc::WIFSTOPPED(proc.status) {
726                    val = 0o200 | libc::WSTOPSIG(proc.status);
727                } else {
728                    val = libc::WEXITSTATUS(proc.status);
729                }
730            }
731            #[cfg(not(unix))]
732            {
733                val = proc.status;
734            }
735        }
736    }
737
738    // c:502-543 — somestopped: mark STAT_CHANGED|STOPPED; cascade SIGTSTP
739    //              to the super-job if this is a subjob (c:507-540).
740    if some_stopped {
741        if (job.stat & stat::SUBJOB) != 0 {
742            job.stat |= stat::CHANGED | stat::STOPPED; // c:514
743                                                       // c:515-538 — find the super-job; killpg(super.gleader, SIGTSTP);
744                                                       //              mark super CHANGED|STOPPED. Without a job-index-
745                                                       //              from-job reverse lookup wired here (we'd need
746                                                       //              the JOBTAB position, but Rust callers usually
747                                                       //              hold the &mut job by &mut [job][i]), defer the
748                                                       //              SIGTSTP to whoever owns the jobtab.
749                                                       // Documented gap — the caller in fusevm_bridge that does the
750                                                       // wait3 dispatch knows the index and handles the super hop.
751            return true;
752        }
753        if (job.stat & stat::STOPPED) != 0 {
754            return true; // c:541-542
755        }
756        job.stat |= stat::STOPPED;
757        job.stat &= !stat::DONE;
758        job.stat |= stat::CHANGED;
759        return true;
760    }
761
762    // c:544-556 — job is fully done. Set DONE, write lastval2/lastval.
763    job.stat |= stat::DONE | stat::CHANGED;
764    job.stat &= !stat::STOPPED;
765    // c:545 — lastval2 = val;
766    LASTVAL2.store(val, Ordering::SeqCst);
767
768    // c:550-555 — `if (jn->stat & STAT_CURSH) inforeground = 1;
769    //               else if (job == thisjob) { lastval = val; inforeground = 2; }`
770    //              Drives the c:565 "deadpgrp" path and the MONITOR foreground
771    //              cascade. Mark via _inforeground for the trace; signal cascade
772    //              skipped (interactive substrate).
773    let _inforeground: i32 = if (job.stat & stat::CURSH) != 0 {
774        1
775    } else {
776        // We don't know `thisjob == job_idx` from `&mut job` alone;
777        // the caller (wait-loop) knows the index and handles lastval.
778        0
779    };
780    let _ = signalled;
781    true
782}
783
784/// `lastval2` — Src/jobs.c global. Set to last-pipeline exit status.
785pub static LASTVAL2: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
786
787/// Update a background job after waitpid (from jobs.c update_bg_job)
788/// Port of `update_bg_job(job jn, pid_t pid, int status)` from `Src/jobs.c:677`.
789pub fn update_bg_job(jn: &mut [job], pid: i32, status: i32) -> bool {
790    // Try primary procs first, then auxprocs — C `findproc` takes
791    // an explicit `aux` arg and the caller decides which subset is
792    // relevant. update_bg_job needs to handle BOTH because the
793    // waitpid'd pid might land in either.
794    let hit = findproc(jn, pid, false).or_else(|| findproc(jn, pid, true));
795    if let Some((ji, pi, is_aux)) = hit {
796        if is_aux {
797            jn[ji].auxprocs[pi].status = status;
798            jn[ji].auxprocs[pi].endtime = Some(Instant::now());
799        } else {
800            jn[ji].procs[pi].status = status;
801            jn[ji].procs[pi].endtime = Some(Instant::now());
802        }
803        // c:Src/jobs.c:684-699 (update_bg_job) — record a finished
804        // BACKGROUND job's exit status in the bgstatus ring so a later
805        // `wait $pid` can retrieve it after the child is gone (waitpid
806        // returns ECHILD; bin_wait then consults getbgstatus). A bg job
807        // is one not marked STAT_CURSH/STAT_BUILTIN and not the current
808        // foreground job (thisjob). Without this, `(exit 5) & p=$!;
809        // wait $p` reaped the child but dropped its status, so the wait
810        // failed with "pid N is not a child of this shell" (127).
811        let thisjob = *THISJOB
812            .get_or_init(|| Mutex::new(-1))
813            .lock()
814            .expect("thisjob poisoned");
815        if (jn[ji].stat & (stat::CURSH | stat::BUILTIN)) == 0 && ji as i32 != thisjob {
816            if libc::WIFEXITED(status) {
817                addbgstatus(pid, libc::WEXITSTATUS(status)); // c:695
818            } else if libc::WIFSIGNALED(status) {
819                addbgstatus(pid, 0o200 | libc::WTERMSIG(status)); // c:697
820            }
821        }
822        update_job(&mut jn[ji]);
823        return true;
824    }
825    false
826}
827
828// set the previous job to something reasonable                              // c:698
829/// Direct port of `static void setprevjob(void)` from `Src/jobs.c:698`.
830/// Walks the global jobtab to pick `prevjob` — first stopped (non-
831/// subjob, non-curjob, non-thisjob) candidate, else first in-use one.
832pub fn setprevjob() {
833    // c:698
834    let tab = JOBTAB
835        .get_or_init(|| Mutex::new(Vec::new()))
836        .lock()
837        .expect("jobtab poisoned");
838    let maxjob = *MAXJOB
839        .get_or_init(|| Mutex::new(0))
840        .lock()
841        .expect("maxjob poisoned");
842    let curjob = *CURJOB
843        .get_or_init(|| Mutex::new(-1))
844        .lock()
845        .expect("curjob poisoned");
846    let thisjob = *THISJOB
847        .get_or_init(|| Mutex::new(-1))
848        .lock()
849        .expect("thisjob poisoned");
850    // c:702-707 — stopped candidate.
851    for i in (1..=maxjob).rev() {
852        if i >= tab.len() {
853            continue;
854        }
855        let j = &tab[i];
856        if (j.stat & (stat::INUSE | stat::STOPPED)) == (stat::INUSE | stat::STOPPED)
857            && (j.stat & stat::SUBJOB) == 0
858            && i as i32 != curjob
859            && i as i32 != thisjob
860        {
861            *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = i as i32;
862            return;
863        }
864    }
865    // c:709-714 — fallback to any in-use non-subjob.
866    for i in (1..=maxjob).rev() {
867        if i >= tab.len() {
868            continue;
869        }
870        let j = &tab[i];
871        if (j.stat & stat::INUSE) != 0
872            && (j.stat & stat::SUBJOB) == 0
873            && i as i32 != curjob
874            && i as i32 != thisjob
875        {
876            *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = i as i32;
877            return;
878        }
879    }
880    // c:716 — nothing eligible.
881    *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = -1;
882}
883
884/// Get clock ticks per second (from jobs.c get_clktck lines 720-748)
885/// Get `_SC_CLK_TCK` for time-conversion math.
886/// Port of `get_clktck()` from Src/jobs.c:721.
887pub fn get_clktck() -> i64 {
888    // c:721
889    #[cfg(unix)]
890    {
891        static CLKTCK: OnceLock<i64> = OnceLock::new(); // c:723
892                                                        // fetch clock ticks per second from                                 // c:727
893                                                        // sysconf only the first time                                       // c:728
894        *CLKTCK.get_or_init(|| unsafe { libc::sysconf(libc::_SC_CLK_TCK) as i64 })
895        // c:729
896    }
897    #[cfg(not(unix))]
898    {
899        100 // Default on non-Unix
900    }
901}
902
903/// Format time as hh:mm:ss.xx (from jobs.c printhhmmss lines 752-765)
904/// Format a duration as `H:MM:SS` / `M:SS`.
905/// Port of `printhhmmss(double secs)` from Src/jobs.c:752.
906pub fn printhhmmss(secs: f64) -> String {
907    // c:752
908    let mins = (secs / 60.0) as i32;
909    let hours = mins / 60;
910    let secs = secs - (mins * 60) as f64;
911    let mins = mins - (hours * 60);
912
913    if hours > 0 {
914        format!("{}:{:02}:{:05.2}", hours, mins, secs)
915    } else if mins > 0 {
916        format!("{}:{:05.2}", mins, secs)
917    } else {
918        format!("{:.3}", secs)
919    }
920}
921
922/// Time format specifiers (from jobs.c printtime lines 768-949)
923/// Format a CPU/real time triple per `$TIMEFMT`.
924/// Port of `printtime(struct timespec *real, child_times_t *ti, char *desc)` from Src/jobs.c:768.
925/// Supports the full directive set: `%E/%U/%S/%P/%J/%mE/%uE/%nE/%*E`
926/// (time forms) plus `%M/%F/%R/%W/%X/%D/%K/%I/%O/%c/%w` (rusage).
927pub fn printtime(
928    // c:768
929    elapsed_secs: f64,
930    ti: &timeinfo,
931    format: &str,
932    job_name: &str,
933) -> String {
934    let user_secs = ti.ut as f64 / 1_000_000.0;
935    let system_secs = ti.st as f64 / 1_000_000.0;
936    let mut result = String::new();
937    let total_time = user_secs + system_secs; // c:794
938    let percent = if elapsed_secs > 0.0 {
939        // c:795
940        (100.0 * total_time / elapsed_secs) as i32
941    } else {
942        0
943    };
944    // Per-second helper for the rusage-rate directives (X/D/K).
945    let per_sec = |v: i64| -> i64 {
946        // c:903-907
947        if total_time > 0.0 {
948            (v as f64 / total_time) as i64
949        } else {
950            0
951        }
952    };
953
954    let mut chars = format.chars().peekable();
955    while let Some(c) = chars.next() {
956        if c == '%' {
957            match chars.next() {
958                // c:816-823 — %E / %U / %S
959                Some('E') => result.push_str(&format!("{:.2}s", elapsed_secs)),
960                Some('U') => result.push_str(&format!("{:.2}s", user_secs)),
961                Some('S') => result.push_str(&format!("{:.2}s", system_secs)),
962                // c:893-894 — %P
963                Some('P') => result.push_str(&format!("{}%", percent)),
964                Some('J') => result.push_str(job_name),
965                // c:825-840 — %mE / %mU / %mS (milliseconds)
966                Some('m') => match chars.next() {
967                    Some('E') => result.push_str(&format!("{:.0}ms", elapsed_secs * 1000.0)),
968                    Some('U') => result.push_str(&format!("{:.0}ms", user_secs * 1000.0)),
969                    Some('S') => result.push_str(&format!("{:.0}ms", system_secs * 1000.0)),
970                    _ => result.push_str("%m"),
971                },
972                // c:842-857 — %uE / %uU / %uS (microseconds)
973                Some('u') => match chars.next() {
974                    Some('E') => result.push_str(&format!("{:.0}us", elapsed_secs * 1_000_000.0)),
975                    Some('U') => result.push_str(&format!("{:.0}us", user_secs * 1_000_000.0)),
976                    Some('S') => result.push_str(&format!("{:.0}us", system_secs * 1_000_000.0)),
977                    _ => result.push_str("%u"),
978                },
979                // c:859-874 — %nE / %nU / %nS (nanoseconds)
980                Some('n') => match chars.next() {
981                    Some('E') => {
982                        result.push_str(&format!("{:.0}ns", elapsed_secs * 1_000_000_000.0))
983                    }
984                    Some('U') => result.push_str(&format!("{:.0}ns", user_secs * 1_000_000_000.0)),
985                    Some('S') => {
986                        result.push_str(&format!("{:.0}ns", system_secs * 1_000_000_000.0))
987                    }
988                    _ => result.push_str("%n"),
989                },
990                // c:876-891 — %*E / %*U / %*S (HH:MM:SS form)
991                Some('*') => match chars.next() {
992                    Some('E') => result.push_str(&printhhmmss(elapsed_secs)),
993                    Some('U') => result.push_str(&printhhmmss(user_secs)),
994                    Some('S') => result.push_str(&printhhmmss(system_secs)),
995                    _ => result.push_str("%*"),
996                },
997                // c:897-899 — %W: swaps
998                Some('W') => result.push_str(&format!("{}", ti.nswap)),
999                // c:902-907 — %X: integral shared mem / total_time
1000                Some('X') => result.push_str(&format!("{}", per_sec(ti.ixrss))),
1001                // c:910-919 — %D: integral unshared data / total_time
1002                Some('D') => result.push_str(&format!("{}", per_sec(ti.idrss + ti.isrss))),
1003                // c:924-942 — %K: total integral mem / total_time
1004                Some('K') => {
1005                    result.push_str(&format!("{}", per_sec(ti.ixrss + ti.idrss + ti.isrss)))
1006                }
1007                // c:950-952 — %M: max resident set size (KB on macOS+Linux post-norm)
1008                Some('M') => result.push_str(&format!("{}", ti.maxrss)),
1009                // c:955-957 — %F: major page faults
1010                Some('F') => result.push_str(&format!("{}", ti.majflt)),
1011                // c:960-962 — %R: minor page faults
1012                Some('R') => result.push_str(&format!("{}", ti.minflt)),
1013                // c:965+ — %I: input block ops; %O: output; %c/%w: ctx switches
1014                Some('I') => result.push_str(&format!("{}", ti.inblock)),
1015                Some('O') => result.push_str(&format!("{}", ti.oublock)),
1016                Some('c') => result.push_str(&format!("{}", ti.nivcsw)),
1017                Some('w') => result.push_str(&format!("{}", ti.nvcsw)),
1018                Some('%') => result.push('%'),
1019                Some(other) => {
1020                    result.push('%');
1021                    result.push(other);
1022                }
1023                None => result.push('%'),
1024            }
1025        } else {
1026            result.push(c);
1027        }
1028    }
1029    result
1030}
1031
1032/// Dump timing info for a job (from jobs.c dumptime).
1033/// Port of `dumptime(job jn)` from `Src/jobs.c:1020`.
1034///
1035/// C body iterates each process in the pipeline and prints one
1036/// `printtime` line per process using that process's own bgtime/
1037/// endtime/ti/text — c:1027-1029. The previous Rust port aggregated
1038/// into a single timeinfo, which printed 1 line for a 3-stage
1039/// pipeline instead of C's 3.
1040pub fn dumptime(job: &job) -> Option<String> {
1041    // c:1020
1042    if job.procs.is_empty() {
1043        // c:1025-1026
1044        return None;
1045    }
1046    // C dumptime reads `$TIMEFMT` indirectly via printtime's getsparam
1047    // call (c:808 inside printtime). Rust printtime takes format as a
1048    // parameter, so we read it here and pass through.
1049    const DEFAULT_TIMEFMT: &str = "%J  %U user %S system %P cpu %*E total";
1050    let format = getsparam("TIMEFMT").unwrap_or_else(|| DEFAULT_TIMEFMT.to_string());
1051
1052    // c:1027-1029 — for each proc, printtime(dtime_ts(&bgtime, &endtime), &ti, text).
1053    let lines: Vec<String> = job
1054        .procs
1055        .iter()
1056        .filter_map(|p| {
1057            let start = p.bgtime?;
1058            let end = p.endtime?;
1059            let elapsed = end.duration_since(start).as_secs_f64();
1060            Some(printtime(elapsed, &p.ti, &format, &p.text))
1061        })
1062        .collect();
1063    if lines.is_empty() {
1064        None
1065    } else {
1066        Some(lines.join("\n"))
1067    }
1068}
1069
1070/// Port of `static int should_report_time(job j)` from `Src/jobs.c:1038-1080`.
1071/// ```c
1072/// /* if the time keyword was used */
1073/// if (j->stat & STAT_TIMED) return 1;
1074/// /* read $REPORTTIME / $REPORTMEMORY */
1075/// if (reporttime < 0 && reportmemory < 0) return 0;
1076/// if (!j->procs) return 0;
1077/// if (zleactive) return 0;
1078/// /* … compare elapsed time vs reporttime threshold */
1079/// ```
1080/// Rust port previously missed the c:1052 STAT_TIMED short-circuit:
1081/// a job explicitly preceded by the `time` keyword should always
1082/// report its time regardless of `$REPORTTIME` setting. Without this
1083/// check, `time sleep 0.001` would be silent when REPORTTIME is
1084/// unset or set high.
1085///
1086/// `$REPORTTIME` (and `$REPORTMEMORY`) reading is the caller's
1087/// responsibility — Rust takes the thresholds as parameters rather
1088/// than calling getvalue inside.
1089pub fn should_report_time(job: &job, reporttime: f64) -> bool {
1090    // c:1039
1091    // Read both thresholds from paramtab — matches C's
1092    // `getvalue(REPORTTIME)` and `getvalue(REPORTMEMORY)` reads.
1093    let reportmemory: i64 = getsparam("REPORTMEMORY")
1094        .and_then(|s| s.parse().ok())
1095        .unwrap_or(-1);
1096
1097    // c:1052-1053 — STAT_TIMED short-circuit. Always report when
1098    // the `time` keyword preceded the command.
1099    if (job.stat & stat::TIMED) != 0 {
1100        // c:1052
1101        return true;
1102    }
1103    // c:1065-1070 — both thresholds disabled ⇒ no report.
1104    if reporttime < 0.0 && reportmemory < 0 {
1105        return false;
1106    }
1107    // c:1072-1073 — `if (!j->procs) return 0;`
1108    let first = match job.procs.first() {
1109        Some(p) => p,
1110        None => return false,
1111    };
1112    // c:1074 — `if (zleactive) return 0;`. ZLE is line-editing the
1113    // prompt; never spew a timing line into the editor.
1114    if zleactive.load(Ordering::Relaxed) != 0
1115    // c:1074
1116    {
1117        return false;
1118    }
1119    // c:1077-1094 — reporttime threshold check against (user+sys) CPU.
1120    if reporttime >= 0.0 {
1121        // C diffs reporttime against the first proc's ut+st; the
1122        // rusage diff is populated by update_process.
1123        let cpu_secs = (first.ti.ut + first.ti.st) as f64 / 1_000_000.0;
1124        if cpu_secs >= reporttime {
1125            return true;
1126        }
1127        // Wall-clock fallback (Rust extension — keeps prior behavior
1128        // when rusage wasn't captured because the proc was reaped
1129        // outside the wait4/getrusage path).
1130        if let (Some(start), Some(end)) = (first.bgtime, job.procs.last().and_then(|p| p.endtime)) {
1131            let elapsed = end.duration_since(start).as_secs_f64();
1132            if elapsed >= reporttime {
1133                return true;
1134            }
1135        }
1136    }
1137    // c:1096-1099 — reportmemory threshold check against ru_maxrss.
1138    if reportmemory >= 0 && first.ti.maxrss > reportmemory {
1139        return true;
1140    }
1141    false
1142}
1143
1144// `CommandTimer` struct deleted — Rust-only timing aggregator with
1145// no caller. C inlines `dtime_tv()` (Src/jobs.c:137) /
1146// `dtime_ts()` (line 152) into printjob; the Rust port's `printtime`
1147// (above) is the equivalent free-fn and any caller that needs
1148// elapsed time can `Instant::now()` directly.
1149
1150// `PipeStats` struct deleted — Rust-only wrapper that duplicated
1151// the `numpipestats` (jobs.c:131) + `pipestats[]` (jobs.c:131)
1152// flat C globals already ported as `NUMPIPESTATS` / `PIPESTATS` at
1153// file scope above. Read/write the canonical globals directly.
1154
1155/// File-static `sig_msg[]` from `Src/signames1.awk` /
1156/// `signames.h` — name-by-signal-number lookup table consulted by
1157/// `sigmsg()` at `jobs.c:1118`.
1158static SIG_MSG: &[(libc::c_int, &str)] = &[
1159    // c:signames.h
1160    (libc::SIGHUP, "hangup"),
1161    (libc::SIGINT, "interrupt"),
1162    (libc::SIGQUIT, "quit"),
1163    (libc::SIGILL, "illegal instruction"),
1164    (libc::SIGTRAP, "trace trap"),
1165    (libc::SIGABRT, "abort"),
1166    (libc::SIGBUS, "bus error"),
1167    (libc::SIGFPE, "floating point exception"),
1168    (libc::SIGKILL, "killed"),
1169    (libc::SIGUSR1, "user-defined signal 1"),
1170    (libc::SIGSEGV, "segmentation fault"),
1171    (libc::SIGUSR2, "user-defined signal 2"),
1172    (libc::SIGPIPE, "broken pipe"),
1173    (libc::SIGALRM, "alarm"),
1174    (libc::SIGTERM, "terminated"),
1175    (libc::SIGCHLD, "child exited"),
1176    (libc::SIGCONT, "continued"),
1177    (libc::SIGSTOP, "stopped (signal)"),
1178    (libc::SIGTSTP, "stopped"),
1179    (libc::SIGTTIN, "stopped (tty input)"),
1180    (libc::SIGTTOU, "stopped (tty output)"),
1181    (libc::SIGURG, "urgent I/O condition"),
1182    (libc::SIGXCPU, "CPU time exceeded"),
1183    (libc::SIGXFSZ, "file size exceeded"),
1184    (libc::SIGVTALRM, "virtual timer expired"),
1185    (libc::SIGPROF, "profiling timer expired"),
1186    (libc::SIGWINCH, "window changed"),
1187    (libc::SIGIO, "I/O ready"),
1188    (libc::SIGSYS, "bad system call"),
1189];
1190
1191/// Render a signal number as a one-line description.
1192/// Port of `sigmsg(int sig)` from Src/jobs.c:1107.
1193pub fn sigmsg(sig: i32) -> &'static str {
1194    // c:1107
1195    SIG_MSG
1196        .iter()
1197        .find(|(s, _)| *s == sig)
1198        .map(|(_, m)| *m)
1199        .unwrap_or("unknown signal") // c:1118 sig_msg[sig] : unknown
1200}
1201
1202/// Print job with full detail (from jobs.c printjob)
1203// find length of longest signame, check to see                             // c:1178
1204// if we really need to print this job                                      // c:1179
1205/// `printjob` — see implementation.
1206pub fn printjob(
1207    job: &job,
1208    job_num: usize,
1209    lng: i32,
1210    cur_job: Option<usize>,
1211    prev_job: Option<usize>,
1212) -> String {
1213    // c:1141 — `int job, len = 9, sig, sflag = 0, llen;` — the status
1214    // column is `len + 2` wide where len starts at 9 and grows to the
1215    // longest signal message among non-running procs (c:1180-1213).
1216    let mut len = 9usize;
1217    for pn in job.procs.iter() {
1218        if pn.status == SP_RUNNING {
1219            continue;
1220        }
1221        #[cfg(unix)]
1222        {
1223            if libc::WIFSIGNALED(pn.status) {
1224                let mut llen = sigmsg(libc::WTERMSIG(pn.status)).len(); // c:1187
1225                if (pn.status & 0x80) != 0 {
1226                    llen += 14; // c:1188-1189 WCOREDUMP " (core dumped)"
1227                }
1228                len = len.max(llen); // c:1190-1191
1229            } else if libc::WIFSTOPPED(pn.status) {
1230                len = len.max(sigmsg(libc::WSTOPSIG(pn.status)).len()); // c:1201-1203
1231            }
1232        }
1233    }
1234    let width = len + 2; // c:1256 — `len2 = 10 + len; /* 2 spaces */`
1235
1236    // Per-proc status text, padded to `width` per the fprintf field
1237    // widths at c:1293-1316.
1238    let fmt_proc_status = |status: i32| -> String {
1239        let s = if status == SP_RUNNING {
1240            "running".to_string() // c:1295
1241        } else if (status & 0x7f) == 0 {
1242            let code = (status >> 8) & 0xff;
1243            if code == 0 {
1244                "done".to_string() // c:1304
1245            } else {
1246                format!("exit {:<4}", code) // c:1301 "exit %-4d"
1247            }
1248        } else if (status & 0xff) == 0x7f {
1249            sigmsg((status >> 8) & 0xff).to_string() // c:1306 WSTOPSIG
1250        } else {
1251            let sig = status & 0x7f;
1252            if (status & 0x80) != 0 {
1253                format!("{} (core dumped)", sigmsg(sig)) // c:1309
1254            } else {
1255                sigmsg(sig).to_string() // c:1314 WTERMSIG
1256            }
1257        };
1258        format!("{:<w$}", s, w = width)
1259    };
1260    let marker = if Some(job_num) == cur_job {
1261        '+'
1262    } else if Some(job_num) == prev_job {
1263        '-'
1264    } else {
1265        ' '
1266    };
1267
1268    // c:1273-1277 — first line carries `[N]  M `; continuation lines
1269    // (further proc groups) carry the matching indent.
1270    let head_prefix = format!("[{}]  {} ", job_num, marker);
1271    let cont_prefix = if job_num > 9 { "        " } else { "       " }; // c:1277
1272
1273    let header = if job.procs.is_empty() {
1274        // c:1255 — `for (pn = jn->procs; pn;)` — a procless job (e.g.
1275        // the subshell control slot grabbed at c:1828) produces NO
1276        // output lines in C.
1277        if job.text.is_empty() {
1278            return String::new();
1279        }
1280        // Rust extension: jobs registered without proc entries carry
1281        // their display text on `job.text` (C always has procs). Use
1282        // the job-level stat bits for the status word.
1283        let status_str = if job.is_done() {
1284            format!("{:<w$}", "done", w = width)
1285        } else if job.is_stopped() {
1286            format!("{:<w$}", "suspended", w = width)
1287        } else {
1288            format!("{:<w$}", "running", w = width)
1289        };
1290        format!("{}{}{}", head_prefix, status_str, job.text)
1291    } else {
1292        // c:1255-1327 — group consecutive procs with the same status
1293        // onto one line (text joined with " | "); `jobs -l` / `jobs -p`
1294        // (lng & 3) put each proc on its own line.
1295        let mut lines: Vec<String> = Vec::new();
1296        let mut i = 0usize;
1297        let mut fline = true;
1298        let mut lng = lng;
1299        while i < job.procs.len() {
1300            let pn = &job.procs[i];
1301            // c:1257-1267 — group extent.
1302            let mut group_end = i + 1;
1303            if (lng & 3) == 0 {
1304                while group_end < job.procs.len()
1305                    && job.procs[group_end].status == pn.status
1306                {
1307                    group_end += 1;
1308                }
1309            }
1310            let mut line = String::new();
1311            line.push_str(if fline { &head_prefix } else { cont_prefix });
1312            if (lng & 1) != 0 {
1313                line.push_str(&format!("{} ", pn.pid)); // c:1281 "%ld "
1314            } else if (lng & 2) != 0 {
1315                line.push_str(&format!("{} ", job.gleader)); // c:1283-1285
1316                lng &= !3; // c:1290
1317            }
1318            line.push_str(&fmt_proc_status(pn.status));
1319            let texts: Vec<&str> = job.procs[i..group_end]
1320                .iter()
1321                .map(|p| p.text.as_str())
1322                .collect();
1323            line.push_str(&texts.join(" | ")); // c:1318-1325
1324            lines.push(line);
1325            fline = false;
1326            i = group_end;
1327        }
1328        lines.join("\n")
1329    };
1330
1331    // c:1220-1221 — `if (should_report_time(jn)) dumptime(jn);`
1332    //               Also fires for c:1354-1355 (synchronous-wait variant).
1333    let reporttime: f64 = getsparam("REPORTTIME")
1334        .and_then(|s| s.parse().ok())
1335        .unwrap_or(-1.0);
1336    if should_report_time(job, reporttime) {
1337        if let Some(timing) = dumptime(job) {
1338            return format!("{}\n{}", header, timing);
1339        }
1340    }
1341    header
1342}
1343
1344
1345/// Port of `addfilelist(const char *name, int fd)` from `Src/jobs.c:1373`.
1346///
1347/// C body:
1348/// ```c
1349/// Jobfile jf = zalloc(sizeof(struct jobfile));
1350/// LinkList ll = jobtab[thisjob].filelist;
1351/// if (!ll) ll = jobtab[thisjob].filelist = znewlinklist();
1352/// if (name) { jf->u.name = ztrdup(name); jf->is_fd = 0; }
1353/// else      { jf->u.fd = fd;             jf->is_fd = 1; }
1354/// zaddlinknode(ll, jf);
1355/// ```
1356///
1357/// Stores either a temp-file name (to delete on job exit) or an
1358/// open fd (to close on job exit) as a `jobfile` enum node, mirroring
1359/// the C `struct jobfile` tagged union. C operates on
1360/// `jobtab[thisjob].filelist`; the Rust port takes the `job` directly.
1361pub fn addfilelist(job: &mut job, name: Option<&str>, fd: i32) {
1362    // c:1373 — `Jobfile jf = zalloc(sizeof(struct jobfile));`
1363    // c:1374 — `LinkList ll = jobtab[thisjob].filelist;` / c:1376 create-if-absent
1364    //          folds into `Vec::push` (the Vec is the always-present list).
1365    let jf = match name {
1366        // c:1379 — `jf->u.name = ztrdup(name); jf->is_fd = 0;`
1367        Some(n) => jobfile { name: Some(n.to_string()), fd: 0, is_fd: 0 },
1368        // c:1383 — `jf->u.fd = fd; jf->is_fd = 1;`
1369        None => jobfile { name: None, fd, is_fd: 1 },
1370    };
1371    job.filelist.push(jf); // c:1385 zaddlinknode(ll, jf)
1372}
1373
1374/// Port of `pipecleanfilelist(LinkList filelist, int proc_subst_only)` from `Src/jobs.c:1397`.
1375///
1376/// Closes only `is_fd` entries (named-file entries are left for
1377/// `deletefilelist` at job exit). When `proc_subst_only`, only fds
1378/// flagged `FDT_PROC_SUBST` in the fdtable are closed; the rest stay.
1379/// Closed fd entries are removed from the list; named entries remain.
1380pub fn pipecleanfilelist(filelist: &mut job, proc_subst_only: bool) {
1381    // c:1404-1414 — walk the list; close+remove qualifying fd entries.
1382    filelist.filelist.retain(|jf| {
1383        // c:1405-1406 — `jf->is_fd && (!proc_subst_only ||
1384        //                fdtable[jf->u.fd] == FDT_PROC_SUBST)`
1385        if jf.is_fd != 0 && (!proc_subst_only || fdtable_get(jf.fd) == FDT_PROC_SUBST) {
1386            zclose(jf.fd); // c:1408 zclose(jf->u.fd)
1387            false // c:1409 remnode(filelist, node) — drop from list
1388        } else {
1389            // c:1414 — `else incnode(node)`: keep everything else.
1390            true
1391        }
1392    });
1393}
1394
1395/// Port of `deletefilelist(LinkList file_list, int disowning)` from `Src/jobs.c:1422`.
1396///
1397/// For each `Jobfile`: `is_fd` → close the fd (unless `disowning`);
1398/// named → unlink the file (unless `disowning`). The `disowning`
1399/// flag suppresses the `close`/`unlink` so files survive the disown.
1400pub fn deletefilelist(file_list: &mut job, disowning: bool) {
1401    // c:1427-1438 — `while ((jf = getlinknode(file_list)))` consumes the list.
1402    for jf in &file_list.filelist {
1403        if jf.is_fd != 0 {
1404            // c:1430-1431 — `if (jf->is_fd) { if (!disowning) zclose(jf->u.fd); }`
1405            if !disowning {
1406                zclose(jf.fd); // c:1432 zclose(jf->u.fd)
1407            }
1408        } else {
1409            // c:1433-1436 — `else { if (!disowning) unlink(jf->u.name); zsfree(...); }`
1410            if !disowning {
1411                if let Some(ref name) = jf.name {
1412                    let _ = std::fs::remove_file(name); // c:1435 unlink(jf->u.name)
1413                }
1414            }
1415            // c:1436 zsfree(jf->u.name) — owned String dropped with the node.
1416        }
1417    }
1418    // c:1438 — the loop drained the list; clear the Vec.
1419    file_list.filelist.clear();
1420}
1421
1422/// Port of `cleanfilelists()` from `Src/jobs.c:1443`.
1423///
1424/// C body:
1425/// ```c
1426/// DPUTS(shell_exiting >= 0, "BUG: cleanfilelists() before exit");
1427/// for (i = 1; i <= maxjob; i++) {
1428///     deletefilelist(jobtab[i].filelist, 0);
1429///     jobtab[i].filelist = 0;
1430/// }
1431/// ```
1432///
1433/// Deletes the file list (and its temp files) for every job in
1434/// the table. Called from the shell-exit path. The C source skips
1435/// index 0 (job 0 is unused / "the shell itself"); Rust port does
1436/// the same with `iter_mut().skip(1)`.
1437pub fn cleanfilelists(jobtab: &mut [job]) {
1438    // c:1447 — DPUTS(shell_exiting >= 0, "BUG: cleanfilelists() before exit")
1439    DPUTS!(
1440        // c:1447
1441        SHELL_EXITING // c:1447
1442            .load(std::sync::atomic::Ordering::Relaxed)
1443            >= 0, // c:1447
1444        "BUG: cleanfilelists() before exit" // c:1447
1445    );
1446    for job in jobtab.iter_mut().skip(1) {
1447        deletefilelist(job, false);
1448    }
1449}
1450
1451/// Port of `void freejob(job jn, int deleting)` from `Src/jobs.c:1457-1495`.
1452/// ```c
1453/// pn = jn->procs; jn->procs = NULL; free each;
1454/// pn = jn->auxprocs; jn->auxprocs = NULL; free each;
1455/// if (jn->ty) zfree(jn->ty);
1456/// if (jn->pwd) zsfree(jn->pwd);
1457/// jn->pwd = NULL;
1458/// if (jn->stat & STAT_WASSUPER) {
1459///     int job = jn - jobtab;
1460///     if (deleting) deletejob(jobtab + jn->other, 0);
1461///     else          freejob(jobtab + jn->other, 0);
1462///     jn = jobtab + job;
1463/// }
1464/// jn->gleader = jn->other = 0;
1465/// jn->stat = jn->stty_in_env = 0;
1466/// jn->filelist = NULL;
1467/// jn->ty = NULL;
1468/// ```
1469/// The previous Rust port was missing the `pwd`/`ty`/`other`/
1470/// `stty_in_env` field resets — leaked saved-tty state into the
1471/// next job reuse of the slot. Now resets all fields per C. The
1472/// STAT_WASSUPER recursive delete (c:1480-1488) requires jobtab
1473/// access and is left as a doc comment until the caller wires it.
1474pub fn freejob(jn: &mut job, deleting: bool) {
1475    // c:1457
1476    let _ = deleting; // STAT_WASSUPER recursive path not yet wired.
1477                      // c:1461-1466 — `procs = NULL; free each`. Rust Drop on Vec covers.
1478    jn.procs.clear();
1479    // c:1468-1473 — `auxprocs = NULL; free each`.
1480    jn.auxprocs.clear();
1481    // c:1475-1476 — `if (jn->ty) zfree(jn->ty);`.
1482    jn.ty = None;
1483    // c:1477-1479 — `if (jn->pwd) zsfree(jn->pwd); jn->pwd = NULL;`.
1484    jn.pwd = None;
1485    // c:1480-1488 — STAT_WASSUPER recursive delete: requires
1486    // jobtab[] access not in scope here. Doc-pin so a future caller
1487    // wiring the table can detect and dispatch.
1488    // c:1489 — `jn->gleader = jn->other = 0;`.
1489    jn.gleader = 0;
1490    jn.other = 0;
1491    // c:1490 — `jn->stat = jn->stty_in_env = 0;`.
1492    jn.stat = 0;
1493    jn.stty_in_env = 0;
1494    // c:1491 — `jn->filelist = NULL;`.
1495    jn.filelist.clear();
1496    // c:1492 — `jn->ty = NULL;` (already done above).
1497    // (Rust-only) text field — clear so the next job reuse doesn't
1498    // inherit stale command text.
1499    jn.text.clear();
1500}
1501
1502/// Port of `void deletejob(job jn, int disowning)` from `Src/jobs.c:1511-1526`.
1503/// ```c
1504/// deletefilelist(jn->filelist, disowning);
1505/// if (jn->stat & STAT_ATTACH) {
1506///     attachtty(mypgrp);
1507///     adjustwinsize(0);
1508/// }
1509/// if (jn->stat & STAT_SUPERJOB) {
1510///     job jno = jobtab + jn->other;
1511///     if (jno->stat & STAT_SUBJOB)
1512///         jno->stat |= STAT_SUBJOB_ORPHANED;
1513/// }
1514/// freejob(jn, 1);
1515/// ```
1516/// Previously the Rust port ad-hoc cleared procs/auxprocs/stat
1517/// without calling `freejob` — meant `pwd`/`ty`/`other`/`stty_in_env`
1518/// stayed populated even after the job was "deleted", silently
1519/// corrupting the next slot reuse. The STAT_ATTACH (attachtty) and
1520/// STAT_SUPERJOB recursive cleanup paths require substrate not yet
1521/// wired (mypgrp, jobtab[] reference); doc-pinned for follow-up.
1522pub fn deletejob(jn: &mut job, disowning: bool) {
1523    // c:1512
1524    // c:1514 — `deletefilelist(jn->filelist, disowning);`. When
1525    // disowning, files are NOT deleted from disk; the filelist entries
1526    // are simply dropped.
1527    deletefilelist(jn, disowning);
1528    // c:1515-1518 — `if (jn->stat & STAT_ATTACH) { attachtty(mypgrp);
1529    //                adjustwinsize(0); }`. `attachtty(mypgrp)` is the
1530    // canonical `tcsetpgrp(0, mypgrp)` (the same pattern used inline at
1531    // jobs.rs:2503/2527). `adjustwinsize(0)` re-reads $LINES/$COLUMNS
1532    // from TIOCGWINSZ; on Rust we route through the canonical utils
1533    // adjustcolumns/adjustlines which lazy-evaluate on demand, so the
1534    // call is a no-op (the next adjust* read picks up the new pgrp).
1535    if (jn.stat & STAT_ATTACH) != 0 {
1536        // c:1515
1537        #[cfg(unix)]
1538        unsafe {
1539            let pgrp = crate::ported::modules::clone::mypgrp.load(Ordering::Relaxed);
1540            if pgrp > 0 {
1541                libc::tcsetpgrp(0, pgrp); // c:1516 attachtty(mypgrp)
1542            }
1543        }
1544        // c:1517 — `adjustwinsize(0);` — Rust adjust* are lazy-read.
1545    }
1546    // c:1519-1523 — `if (jn->stat & STAT_SUPERJOB) { job jno = jobtab +
1547    //                jn->other; if (jno->stat & STAT_SUBJOB)
1548    //                  jno->stat |= STAT_SUBJOB_ORPHANED; }`.
1549    if (jn.stat & STAT_SUPERJOB) != 0 {
1550        // c:1519
1551        let other = jn.other as usize;
1552        if let Some(tab) = JOBTAB.get() {
1553            // c:1520 jobtab + jn->other
1554            if let Ok(mut jobs) = tab.lock() {
1555                if let Some(jno) = jobs.get_mut(other) {
1556                    if (jno.stat & STAT_SUBJOB) != 0 {
1557                        // c:1521
1558                        jno.stat |= STAT_SUBJOB_ORPHANED; // c:1522
1559                    }
1560                }
1561            }
1562        }
1563    }
1564    // c:1525 — `freejob(jn, 1);` full reset of all per-job state.
1565    freejob(jn, true);
1566}
1567
1568/// Add process to job (from jobs.c addproc lines 1537-1597)
1569/// Port of `addproc(pid_t pid, char *text, int aux, struct timespec
1570/// *bgtime, int gleader, int list_pipe_job_used)` from `Src/jobs.c:1538`.
1571///
1572/// The C call site at exec.c:2853 passes the entersubsh_ret-filled
1573/// gleader/list_pipe_job from the child via the synch pipe. Rust mirrors
1574/// the full signature; legacy callers pass `None`/`-1`.
1575pub fn addproc(
1576    job: &mut job,
1577    pid: i32,
1578    text: &str,
1579    aux: bool,
1580    bgtime: Option<std::time::Instant>,
1581    gleader: i32,
1582    list_pipe_job_used: i32,
1583) {
1584    // c:1538
1585    let proc = process::new(pid);
1586    let proc = process {
1587        pid,
1588        status: SP_RUNNING,
1589        text: text.to_string(),
1590        bgtime, // c:1248 — `bgtime` field from struct timespec arg.
1591        ..proc
1592    };
1593
1594    if aux {
1595        job.auxprocs.push(proc);
1596    } else {
1597        // c:1565-1568 — `if (gleader != -1) jn->gleader = gleader;`
1598        if gleader != -1 {
1599            job.gleader = gleader;
1600        } else if job.gleader == 0 {
1601            job.gleader = pid;
1602        }
1603        // c:1570 — `if (list_pipe_job_used != -1) jobtab[list_pipe_job_used].other = thisjob;`
1604        // Stored on the process via list_pipe_job (the C field is
1605        // tracked back via jobtab[list_pipe_job_used].other; the
1606        // simpler approach here is to ignore unless needed).
1607        let _ = list_pipe_job_used;
1608        job.procs.push(proc);
1609    }
1610
1611    job.stat &= !stat::DONE;
1612}
1613
1614/// Port of `havefiles()` from `Src/jobs.c:1605`.
1615///
1616/// C body:
1617/// ```c
1618/// for (i = 1; i <= maxjob; i++)
1619///     if (jobtab[i].stat && jobtab[i].filelist &&
1620///         peekfirst(jobtab[i].filelist))
1621///         return 1;
1622/// return 0;
1623/// ```
1624///
1625/// Returns true if any in-use job in the table has a non-empty
1626/// filelist. Walks the whole table — the previous Rust port took
1627/// a single `&job` and returned `!job.filelist.is_empty()`, which
1628/// is the wrong shape (C iterates).
1629pub fn havefiles(jobtab: &[job]) -> bool {
1630    // c:1605
1631    jobtab.iter().any(|j| j.stat != 0 && !j.filelist.is_empty())
1632}
1633
1634// Wait for a particular process.                                           // c:1627
1635// wait_cmd indicates this is from the interactive wait command,            // c:1627
1636// in which case the behaviour is a little different:  the command          // c:1627
1637// itself can be interrupted by a trapped signal.                           // c:1627
1638/// Wait for a specific PID (from jobs.c waitforpid lines 1627-1663)
1639pub fn waitforpid(pid: i32) -> Option<i32> {
1640    // c:1627
1641    #[cfg(unix)]
1642    {
1643        loop {
1644            let mut status: i32 = 0;
1645            let result = unsafe { libc::waitpid(pid, &mut status, 0) };
1646            if result == pid {
1647                if libc::WIFEXITED(status) {
1648                    return Some(libc::WEXITSTATUS(status));
1649                } else if libc::WIFSIGNALED(status) {
1650                    return Some(128 + libc::WTERMSIG(status));
1651                } else if libc::WIFSTOPPED(status) {
1652                    return None;
1653                }
1654            } else if result == -1 {
1655                return None;
1656            }
1657        }
1658    }
1659    #[cfg(not(unix))]
1660    {
1661        let _ = pid;
1662        None
1663    }
1664}
1665
1666/// Port of `zwaitjob(int job, int wait_cmd)` from `Src/jobs.c:1673`.
1667///
1668/// `wait_cmd` is the "from interactive `wait` builtin" flag. Threads
1669/// through `queue_traps(wait_cmd)` so signal-trap firing is allowed
1670/// inside the wait, and through `signal_suspend(SIGCHLD, wait_cmd)`
1671/// so trapped non-CHLD signals can interrupt the suspend (returning
1672/// `128 + last_signal` so the wait builtin propagates the interrupt).
1673///
1674/// Body uses the canonical SIGCHLD-driven async pattern: signal_suspend
1675/// blocks until the SIGCHLD handler (signals.rs::zhandler) reaps via
1676/// wait_for_processes + routes through update_bg_job, which sets
1677/// STAT_DONE / STAT_STOPPED on the job. The loop checks job.stat
1678/// after each wake. Mirrors `Src/jobs.c:1673-1750`.
1679pub fn zwaitjob(job: &mut job, wait_cmd: i32) -> Option<i32> {
1680    // c:1673
1681    if job.procs.is_empty() && job.auxprocs.is_empty() {
1682        // c:1736-1740 — no procs: deletejob + pipestats[0]=lastval and return.
1683        return Some(0);
1684    }
1685
1686    use crate::ported::utils::errflag;
1687    use crate::ported::zsh_h::{ERRFLAG_ERROR, INTERACTIVE, STAT_DONE, STAT_STOPPED, ZSIG_TRAPPED};
1688
1689    // c:1675 — `int q = queue_signal_level();`
1690    let q = crate::ported::signals_h::queue_signal_level();
1691    // c:1678 — `child_block();`
1692    crate::ported::signals_h::child_block();
1693    // c:1679 — `queue_traps(wait_cmd);`
1694    crate::ported::signals::queue_traps(wait_cmd);
1695    // c:1680 — `dont_queue_signals();`
1696    crate::ported::signals_h::dont_queue_signals();
1697
1698    // c:1682 — `jn->stat |= STAT_LOCKED;`
1699    job.stat |= crate::ported::zsh_h::STAT_LOCKED;
1700    // c:1683-1684 — STAT_CHANGED → printjob (deferred — needs jobtab index).
1701    // c:1685-1697 — pipecleanfilelist for proc-subst fds.
1702    if !job.filelist.is_empty() {
1703        crate::ported::jobs::pipecleanfilelist(job, false);
1704    }
1705
1706    // c:1698-1735 — main wait loop.
1707    let interact = isset(INTERACTIVE);
1708    loop {
1709        // c:1698 — `while (!(errflag & ERRFLAG_ERROR) && jn->stat &&
1710        //            !(jn->stat & STAT_DONE) &&
1711        //            !(interact && (jn->stat & STAT_STOPPED)))`
1712        if (errflag.load(std::sync::atomic::Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1713            break;
1714        }
1715        if job.stat == 0 {
1716            break;
1717        }
1718        if (job.stat & STAT_DONE) != 0 {
1719            break;
1720        }
1721        if interact && (job.stat & STAT_STOPPED) != 0 {
1722            break;
1723        }
1724
1725        // c:1701 — `signal_suspend(SIGCHLD, wait_cmd);` — block until
1726        // SIGCHLD; handler routes through update_bg_job which sets
1727        // STAT_DONE/STOPPED on `job`.
1728        let _ = crate::ported::signals::signal_suspend(libc::SIGCHLD, wait_cmd != 0);
1729
1730        // c:1702-1708 — `if (last_signal != SIGCHLD && wait_cmd &&
1731        //                  last_signal >= 0 && sigtrapped[ls] & ZSIG_TRAPPED)
1732        //                  { return 128 + last_signal; }`
1733        let ls = crate::ported::signals::last_signal.load(std::sync::atomic::Ordering::Relaxed);
1734        if ls != libc::SIGCHLD && wait_cmd != 0 && ls >= 0 {
1735            let trapped_flag = {
1736                let guard = crate::ported::signals::sigtrapped.lock().unwrap();
1737                guard.get(ls as usize).copied().unwrap_or(0)
1738            };
1739            if (trapped_flag & ZSIG_TRAPPED) != 0 {
1740                // c:1705-1707 — builtin wait interrupted by trapped signal.
1741                crate::ported::signals_h::restore_queue_signals(q);
1742                crate::ported::signals::unqueue_traps();
1743                crate::ported::signals_h::child_unblock();
1744                return Some(128 + ls); // c:1707
1745            }
1746        }
1747        // c:1729-1730 — `if (subsh) killjb(jn, SIGCONT);` — keep stopped
1748        // grandchildren running when we ourselves are a subshell.
1749        if crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) != 0 {
1750            // killjb wants &mut [job]; we have &mut job here. Inline the
1751            // SIGCONT via killpg on the job's gleader if set.
1752            if job.gleader != 0 {
1753                unsafe {
1754                    libc::killpg(job.gleader, libc::SIGCONT);
1755                }
1756            }
1757        }
1758        // c:1731-1733 — STAT_SUPERJOB handle_sub deferred (sub-job
1759        // dispatch is jobtab-index-keyed; needs the live jobtab access).
1760        // Re-block before next suspend so SIGCHLD pump isn't lost.
1761        crate::ported::signals_h::child_block();
1762    }
1763
1764    // c:1741-1744 — restore + return 0.
1765    crate::ported::signals_h::restore_queue_signals(q);
1766    crate::ported::signals::unqueue_traps();
1767    crate::ported::signals_h::child_unblock();
1768    // last_status read for the legacy caller — derive from procs.
1769    let last_status = job.procs.last().map(|p| p.exit_status()).unwrap_or(0);
1770    Some(last_status) // c:1745
1771}
1772
1773// wait for running job to finish                                           // c:1763
1774/// Wait for all foreground jobs to finish (from jobs.c waitjobs)
1775pub fn waitjobs(jobtab: &mut [job], thisjob: usize) {
1776    // c:1763
1777    if thisjob < jobtab.len() {
1778        while !jobtab[thisjob].is_done() && !jobtab[thisjob].is_stopped() {
1779            #[cfg(unix)]
1780            {
1781                let mut status: i32 = 0;
1782                let pid = unsafe { libc::waitpid(-1, &mut status, libc::WUNTRACED) };
1783                if pid > 0 {
1784                    update_bg_job(jobtab, pid, status);
1785                } else {
1786                    break;
1787                }
1788            }
1789            #[cfg(not(unix))]
1790            {
1791                break;
1792            }
1793        }
1794    }
1795}
1796
1797/// Port of `clearjobtab(int monitor)` from `Src/jobs.c:1780`.
1798///
1799/// C signature: `void clearjobtab(int monitor)`. Body walks the
1800/// global `jobtab[1..=maxjob]` and either freejob's each entry
1801/// (POSIX mode or non-monitor) or saves a copy into `oldjobtab`
1802/// (non-POSIX, monitor=1 — used by `jobs -c` later). Then zeros
1803/// the live table and re-`initjob`s the placeholder slot used
1804/// for non-job-control work like multios.
1805///
1806/// Rust port: takes the JobTable by &mut (no global). The
1807// clear job table when entering subshells                                  // c:1780
1808/// `monitor` flag gates the oldjobtab save; the save itself is
1809/// pending until JobTable's internal `Vec<Option<JobInfo>>`
1810/// model is reconciled with C's `struct job *jobtab` so the
1811/// snapshot can be taken. The non-snapshot core (clear in-use
1812/// jobs, reset cursor) is faithful.
1813// Rust idiom replacement: JobTable's private Vec model is rebuilt
1814// by the executor on subshell entry (`JobTable::new()`), so the C
1815// `oldjobtab` snapshot + per-slot reset loop is structurally
1816// replaced — no public reset method is needed.
1817/// `clearjobtab` — see implementation.
1818pub fn clearjobtab(table: &mut JobTable, monitor: i32) {
1819    // c:1780
1820    let _ = table; // legacy executor-side handle, unused now
1821    let posix_jobs = isset(POSIXJOBS); // c:1786
1822                                       // c:1786-1787 — `if (isset(POSIXJOBS)) oldmaxjob = 0;`.
1823    if posix_jobs {
1824        if let Some(om) = OLDMAXJOB.get() {
1825            if let Ok(mut o) = om.lock() {
1826                *o = 0;
1827            }
1828        }
1829    }
1830    let tab = match JOBTAB.get() {
1831        Some(t) => t,
1832        None => return,
1833    };
1834    let mut jobs = match tab.lock() {
1835        Ok(g) => g,
1836        Err(_) => return,
1837    };
1838    // c:1788-1797 — for (i = 1; i <= maxjob; i++).
1839    let maxjob = jobs.len();
1840    let mut new_oldmax: usize = 0;
1841    for i in 1..maxjob {
1842        // c:1788
1843        if jobs[i].stat == 0 {
1844            continue;
1845        }
1846        // c:1794-1795 — `if (monitor && !POSIXJOBS && jobtab[i].stat)
1847        //                  oldmaxjob = i+1;`
1848        if monitor != 0 && !posix_jobs {
1849            // c:1794
1850            new_oldmax = i + 1; // c:1795
1851        } else if (jobs[i].stat & STAT_INUSE) != 0 {
1852            // c:1796
1853            // c:1797 — `freejob(jobtab+i, 0);`.
1854            freejob(&mut jobs[i], false); // c:1797
1855        }
1856    }
1857    // c:1800-1817 — `if (monitor && oldmaxjob) { snapshot to oldjobtab }`.
1858    if monitor != 0 && new_oldmax > 0 {
1859        // c:1800
1860        let mut snap: Vec<job> = jobs[..new_oldmax].iter().cloned().collect(); // c:1803-1806
1861                                                                               // c:1809-1810 — `if (thisjob != -1 && thisjob < oldmaxjob)
1862                                                                               //                  memset(oldjobtab+thisjob, 0, ...)`.
1863        let thisjob = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
1864        if thisjob >= 0 && (thisjob as usize) < new_oldmax {
1865            // c:1809
1866            // Zero the slot — Rust uses Default::default().
1867            snap[thisjob as usize] = job::default(); // c:1810
1868        }
1869        // c:1816 — `--oldmaxjob;` C decrement before exposure.
1870        if let Some(om) = OLDMAXJOB.get() {
1871            if let Ok(mut o) = om.lock() {
1872                *o = new_oldmax.saturating_sub(1); // c:1816
1873            }
1874        } else {
1875            *OLDMAXJOB.get_or_init(|| Mutex::new(0)).lock().unwrap() = new_oldmax.saturating_sub(1);
1876        }
1877        *OLDJOBTAB
1878            .get_or_init(|| Mutex::new(Vec::new()))
1879            .lock()
1880            .unwrap() = snap; // c:1804
1881    }
1882    // c:1818-1819 — `memset(jobtab, 0, jobtabsize * sizeof(struct job));
1883    //                maxjob = 0;` — zero out the live table.
1884    jobs.clear();
1885    jobs.push(job::new()); // slot 0 — the shell's own entry
1886    *MAXJOB.get_or_init(|| Mutex::new(0)).lock().unwrap() = 0; // c:1819
1887    // c:1821-1828 — "Although we don't have job control in subshells,
1888    // we sometimes need control structures for other purposes such as
1889    // multios. Grab a job for this purpose." `thisjob = initjob();`
1890    let control = initjob(&mut jobs); // c:1828
1891    *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = control as i32;
1892}
1893
1894/// Port of `clearoldjobtab()` from `Src/jobs.c:1835`.
1895///
1896/// C body:
1897/// ```c
1898/// if (oldjobtab) free(oldjobtab);
1899/// oldjobtab = NULL;
1900/// oldmaxjob = 0;
1901/// ```
1902///
1903/// Frees the snapshot of the previous-state job table that
1904/// `jobs -c` (jobs-changed) compares against. The previous Rust
1905/// port retained INUSE entries in `jobtab` directly — wrong
1906/// target. The real C function operates on the `oldjobtab`
1907/// global, not the live `jobtab`.
1908///
1909/// Rust port clears the OLDJOBTAB module static.
1910pub fn clearoldjobtab() {
1911    *OLDJOBTAB
1912        .get_or_init(|| Mutex::new(Vec::new()))
1913        .lock()
1914        .expect("oldjobtab poisoned") = Vec::new();
1915    *OLDMAXJOB
1916        .get_or_init(|| Mutex::new(0))
1917        .lock()
1918        .expect("oldmaxjob poisoned") = 0;
1919}
1920
1921// Get a free entry in the job table and initialize it.                    // c:1862
1922/// Initialize a new job entry (from jobs.c initjob)
1923///
1924/// c:Src/jobs.c:1862-1875 — C: `for (i = 1; i <= maxjob; i++)` starts
1925/// at index 1; index 0 is the shell's own slot and must never be
1926/// returned to a child-job caller. The Rust port previously walked
1927/// from index 0 via `enumerate()`, corrupting parent-shell job
1928/// tracking when jobtab[0] was empty.
1929pub fn initjob(jobtab: &mut Vec<job>) -> usize {
1930    // c:1862
1931    // Ensure jobtab has slot 0 reserved for the shell (matches C's
1932    // `jobtab[0]` shell-process slot at jobs.c:79).
1933    if jobtab.is_empty() {
1934        jobtab.push(job::new());
1935    }
1936    // Find an empty slot or add a new one — START AT INDEX 1.
1937    for i in 1..jobtab.len() {
1938        if (jobtab[i].stat & stat::INUSE) == 0 {
1939            return initnewjob(jobtab, i); // c:1868
1940        }
1941    }
1942    // Expand table — C path c:1869-1872 (maxjob+1 within jobtabsize,
1943    // else expandjobtab). Rust's Vec grows on demand.
1944    let idx = jobtab.len();
1945    jobtab.push(job::new());
1946    initnewjob(jobtab, idx)
1947}
1948
1949/// Direct port of `static int initnewjob(int i)` from `Src/jobs.c:1843`.
1950///
1951/// C body:
1952/// ```c
1953/// jobtab[i].stat = STAT_INUSE;
1954/// if (jobtab[i].pwd) { zsfree(jobtab[i].pwd); jobtab[i].pwd = NULL; }
1955/// jobtab[i].gleader = 0;
1956/// if (i > maxjob) maxjob = i;
1957/// return i;
1958/// ```
1959/// MAXJOB is the scan bound for setcurjob/setprevjob/getjob/
1960/// selectjobtab; without the bump those walks see an empty table even
1961/// when JOBTAB has live entries.
1962/// WARNING: param names don't match C — Rust=(jobtab, i) vs C=(i);
1963/// C reads the jobtab global, Rust callers pass the locked slice.
1964fn initnewjob(jobtab: &mut [job], i: usize) -> usize {
1965    // c:1843
1966    jobtab[i] = job::new();
1967    jobtab[i].stat = stat::INUSE; // c:1845
1968    jobtab[i].pwd = None; // c:1846-1849
1969    jobtab[i].gleader = 0; // c:1850
1970    let mut mj = MAXJOB
1971        .get_or_init(|| Mutex::new(0))
1972        .lock()
1973        .expect("maxjob poisoned");
1974    if i > *mj {
1975        // c:1852-1853
1976        *mj = i;
1977    }
1978    i // c:1855
1979}
1980
1981/// Port of `void setjobpwd(void)` from `Src/jobs.c:1881`.
1982///
1983/// C body:
1984/// ```c
1985/// int i;
1986/// for (i = 1; i <= maxjob; i++)
1987///     if (jobtab[i].stat && !jobtab[i].pwd)
1988///         jobtab[i].pwd = ztrdup(pwd);
1989/// ```
1990///
1991/// Walks every IN-USE job and stamps its `pwd` with the current
1992/// shell `pwd` (from `Src/builtin.c:1240` after `bin_cd`). The
1993/// previous Rust port took a `&mut job` ref and was a no-op (just
1994/// captured cwd then dropped it) — every `cd` left the in-flight
1995/// job's pwd unset, and `jobs` output showed empty `(pwd: )` for
1996/// jobs that started before the cd.
1997///
1998/// The fix walks `JOBTAB` and writes `pwd` to every job whose stat
1999/// is non-zero (INUSE) and whose pwd is still None. The shell
2000/// pwd is read from the canonical `params::pwdgetfn` accessor —
2001/// matches C's read of the `pwd` global at c:1888.
2002pub fn setjobpwd() {
2003    // c:1881
2004    // c:1888 — `pwd` is the canonical shell-state global from
2005    // `Src/params.c:108`. Rust reads it via the paramtab-backed
2006    // `getsparam("PWD")` which is the canonical accessor mirrored
2007    // throughout the codebase (prompt.rs, subst.rs, builtin.rs).
2008    let pwd = getsparam("PWD").unwrap_or_default(); // c:1888 pwd
2009    let tab = JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2010    let mut tab = tab.lock().expect("jobtab poisoned");
2011    // c:1886 — `for (i = 1; i <= maxjob; i++)`. Skip index 0 (the
2012    // shell itself).
2013    for job in tab.iter_mut().skip(1) {
2014        // c:1887 — `if (jobtab[i].stat && !jobtab[i].pwd)`.
2015        if job.stat != 0 && job.pwd.is_none() {
2016            job.pwd = Some(pwd.clone()); // c:1888
2017        }
2018    }
2019}
2020
2021/// Print pids for `&` background jobs (`spawnjob`).
2022/// Port of `void spawnjob(void)` from `Src/jobs.c:1894`.
2023pub fn spawnjob() {
2024    // c:1894
2025    let thisjob_idx = *THISJOB
2026        .get_or_init(|| Mutex::new(-1))
2027        .lock()
2028        .expect("thisjob poisoned");
2029    // c:1898 — DPUTS(thisjob == -1, "No valid job in spawnjob.")
2030    DPUTS!(thisjob_idx == -1, "No valid job in spawnjob."); // c:1898
2031    if thisjob_idx < 0 {
2032        return;
2033    }
2034    let thisjob = thisjob_idx as usize;
2035
2036    // c:1900 — `if (!subsh) {` — when this isn't a subshell.
2037    // `subsh` global tracks subshell-fork depth; mirror via FORKLEVEL
2038    // (0 = top-level shell) plus SUBSHELL_DEPTH, the depth counter the
2039    // fusevm in-process `(...)` host bumps in subshell_begin. C's
2040    // forked subshell sets `subsh` in entersubsh (Src/exec.c:1154);
2041    // the in-process model never calls entersubsh, so without this
2042    // a `(cmd &)` would promote the job to the parent's curjob —
2043    // making `(sleep 1 & disown)` silently succeed where zsh errors
2044    // "no current job". Bug #462.
2045    let in_subsh = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed) > 0
2046        || crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) > 0;
2047    if !in_subsh {
2048        // c:1901-1903 — `if (curjob == -1 || !(jobtab[curjob].stat & STAT_STOPPED))
2049        //                  { curjob = thisjob; setprevjob(); }`
2050        // c:1904-1905 — else if prevjob also not stopped, prevjob = thisjob.
2051        let curjob = *CURJOB
2052            .get_or_init(|| Mutex::new(-1))
2053            .lock()
2054            .expect("curjob poisoned");
2055        let cur_stopped = if curjob >= 0 {
2056            let tab = JOBTAB
2057                .get_or_init(|| Mutex::new(Vec::new()))
2058                .lock()
2059                .expect("jobtab poisoned");
2060            tab.get(curjob as usize)
2061                .map(|j| (j.stat & stat::STOPPED) != 0)
2062                .unwrap_or(false)
2063        } else {
2064            false
2065        };
2066        if curjob < 0 || !cur_stopped {
2067            if let Ok(mut cj) = CURJOB.get_or_init(|| Mutex::new(-1)).lock() {
2068                *cj = thisjob_idx; // c:1902
2069            }
2070            setprevjob(); // c:1903
2071        } else {
2072            // c:1904-1905
2073            let prevjob = *PREVJOB
2074                .get_or_init(|| Mutex::new(-1))
2075                .lock()
2076                .expect("prevjob poisoned");
2077            let prev_stopped = if prevjob >= 0 {
2078                let tab = JOBTAB
2079                    .get_or_init(|| Mutex::new(Vec::new()))
2080                    .lock()
2081                    .expect("jobtab poisoned");
2082                tab.get(prevjob as usize)
2083                    .map(|j| (j.stat & stat::STOPPED) != 0)
2084                    .unwrap_or(false)
2085            } else {
2086                false
2087            };
2088            if prevjob < 0 || !prev_stopped {
2089                if let Ok(mut pj) = PREVJOB.get_or_init(|| Mutex::new(-1)).lock() {
2090                    *pj = thisjob_idx; // c:1905
2091                }
2092            }
2093        }
2094        // c:1906-1913 — `if (jobbing && jobtab[thisjob].procs)`
2095        //               print "[N] pid1 pid2 ..." to shout/stderr.
2096        if isset(MONITOR) {
2097            let tab = JOBTAB
2098                .get_or_init(|| Mutex::new(Vec::new()))
2099                .lock()
2100                .expect("jobtab poisoned");
2101            if let Some(job) = tab.get(thisjob) {
2102                if !job.procs.is_empty() {
2103                    let mut line = format!("[{}]", thisjob_idx);
2104                    for p in job.procs.iter() {
2105                        line.push_str(&format!(" {}", p.pid));
2106                    }
2107                    line.push('\n');
2108                    eprint!("{}", line); // c:1907-1911
2109                }
2110            }
2111        }
2112    }
2113    // c:1915-1920 — `if (!hasprocs(thisjob)) deletejob(jobtab+thisjob, 0);
2114    //                else { STAT_LOCKED; pipecleanfilelist(...); }`
2115    let need_delete: bool;
2116    {
2117        let tab = JOBTAB
2118            .get_or_init(|| Mutex::new(Vec::new()))
2119            .lock()
2120            .expect("jobtab poisoned");
2121        need_delete = tab
2122            .get(thisjob)
2123            .map(|j| j.procs.is_empty() && j.auxprocs.is_empty())
2124            .unwrap_or(true);
2125    }
2126    if need_delete {
2127        let mut tab = JOBTAB
2128            .get_or_init(|| Mutex::new(Vec::new()))
2129            .lock()
2130            .expect("jobtab poisoned");
2131        if let Some(j) = tab.get_mut(thisjob) {
2132            deletejob(j, false); // c:1916
2133        }
2134    } else {
2135        let mut tab = JOBTAB
2136            .get_or_init(|| Mutex::new(Vec::new()))
2137            .lock()
2138            .expect("jobtab poisoned");
2139        if let Some(j) = tab.get_mut(thisjob) {
2140            j.stat |= stat::LOCKED; // c:1918
2141            pipecleanfilelist(j, false); // c:1919
2142        }
2143    }
2144    // c:1921 — thisjob = -1;
2145    if let Ok(mut tj) = THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
2146        *tj = -1;
2147    }
2148}
2149
2150// `ChildTimes` struct deleted — folded into the canonical `timeinfo`
2151// at the top of this file. C uses `child_times_t` (typedef onto
2152// `struct rusage` or `struct timeinfo` per `Src/zsh.h:1112-1114`).
2153
2154/// Port of `void shelltime(child_times_t *shell, child_times_t *kids,
2155/// struct timespec *then, int delta)` from `Src/jobs.c:1926-1987`.
2156///
2157/// Records or prints the shell's RUSAGE_SELF + RUSAGE_CHILDREN times.
2158/// Side-effecting:
2159///   - If `shell` is `Some` and `delta == 0`: snapshot current self
2160///     rusage into `*shell` (no print).
2161///   - If `shell` is `Some` and `delta != 0`: compute delta from
2162///     `*shell` to now (no print).
2163///   - If `shell` is `None` and `delta == 0`: print "shell ..." line.
2164///   - Same pattern for `kids` against RUSAGE_CHILDREN.
2165///   - `then` similarly: when `None` and `delta == 0`, use as the
2166///     monotonic timestamp slot; when `Some + delta`, compute the
2167///     elapsed real time as `now - *then`.
2168///
2169/// C body c:1926-1987 maps closely:
2170///   - c:1934 — zgettime_monotonic_if_available(&now)
2171///   - c:1937 — getrusage(RUSAGE_SELF, &ti)
2172///   - c:1944-1955 — handle `shell` save / delta
2173///   - c:1956-1962 — compute `dtimespec` from `then` and `now` /
2174///                   shtimer
2175///   - c:1964-1965 — `if (!delta == !shell) printtime("shell")`
2176///   - c:1968 — getrusage(RUSAGE_CHILDREN, &ti)
2177///   - c:1973-1984 — handle `kids` save / delta
2178///   - c:1985-1986 — `if (!delta == !kids) printtime("children")`
2179#[cfg(unix)]
2180pub fn shelltime(
2181    shell: Option<&mut timeinfo>,
2182    kids: Option<&mut timeinfo>,
2183    then: Option<&mut std::time::Instant>,
2184    delta: i32,
2185) {
2186    // c:1926
2187    // c:1934 — `zgettime_monotonic_if_available(&now);`. Use Instant
2188    // for monotonic time.
2189    let now = std::time::Instant::now();
2190    // c:1937 — `getrusage(RUSAGE_SELF, &ti);`. Self timings.
2191    let mut ti: timeinfo = {
2192        let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
2193        if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } == 0 {
2194            timeinfo::from_rusage(&usage)
2195        } else {
2196            timeinfo::default()
2197        }
2198    };
2199
2200    let shell_present = shell.is_some();
2201    // c:1944-1955 — `if (shell) { if (delta) dtime_tv(...); else *shell = ti; }`.
2202    if let Some(s) = shell {
2203        // c:1944
2204        if delta != 0 {
2205            // c:1945 — delta-compute by subtracting saved values.
2206            // C uses dtime_tv to subtract timespec. timeinfo holds
2207            // raw rusage members; subtract user/sys time directly.
2208            ti.ut = ti.ut.saturating_sub(s.ut); // c:1947 dtime_tv(ru_utime, shell->ru_utime, ti.ru_utime)
2209            ti.st = ti.st.saturating_sub(s.st); // c:1948
2210        } else {
2211            // c:1953-1954 — snapshot current `ti` into `*shell`.
2212            *s = ti.clone();
2213        }
2214    }
2215
2216    // c:1956-1962 — compute `dtimespec` (real elapsed time).
2217    let dtime: std::time::Duration = if delta != 0 {
2218        // c:1957 — `dtime_ts(&dtimespec, then, &now)`. The C body
2219        // requires `then` to be Some for the delta path (set on a
2220        // prior delta=0 call).
2221        match then {
2222            Some(t) => dtime_ts(t, &now), // c:1957
2223            None => std::time::Duration::ZERO,
2224        }
2225    } else {
2226        // c:1959-1961 — `if (then) *then = now;` then
2227        //                `dtime_ts(&dtimespec, &shtimer, &now);`.
2228        if let Some(t) = then {
2229            *t = now;
2230        }
2231        // c:1961 — `dtime_ts(&dtimespec, &shtimer, &now)`. Rust's
2232        // `params::shtimer_lock()` is the analog of C's `shtimer`
2233        // global (`struct timespec` set at shell start). Compute
2234        // elapsed time as now - shtimer.
2235        let shtimer_dur = *crate::ported::params::shtimer_lock()
2236            .lock()
2237            .expect("shtimer poisoned");
2238        let now_dur = std::time::SystemTime::now()
2239            .duration_since(std::time::UNIX_EPOCH)
2240            .unwrap_or_default();
2241        if now_dur > shtimer_dur {
2242            now_dur - shtimer_dur
2243        } else {
2244            std::time::Duration::ZERO
2245        }
2246    };
2247
2248    // c:1964 — `if (!delta == !shell) printtime("shell")`.
2249    // The negation pair: print when (delta==0 && shell.is_none()) OR
2250    // (delta!=0 && shell.is_some()).
2251    if (delta == 0) == !shell_present {
2252        // c:1964
2253        let real_secs = dtime.as_secs_f64();
2254        // c:1965 — `printtime(&dtimespec, &ti, "shell")`.
2255        let timefmt = crate::ported::params::getsparam("TIMEFMT")
2256            .unwrap_or_else(|| "%J  %U user %S system %P cpu %*E total".to_string());
2257        let line = printtime(real_secs, &ti, &timefmt, "shell"); // c:1965
2258        eprintln!("{}", line);
2259    }
2260
2261    // c:1968 — `getrusage(RUSAGE_CHILDREN, &ti);`. Children timings.
2262    let mut tc: timeinfo = {
2263        let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
2264        if unsafe { libc::getrusage(libc::RUSAGE_CHILDREN, &mut usage) } == 0 {
2265            timeinfo::from_rusage(&usage)
2266        } else {
2267            timeinfo::default()
2268        }
2269    };
2270
2271    let kids_present = kids.is_some();
2272    // c:1973-1984 — `if (kids) { ... }` symmetric to shell.
2273    if let Some(k) = kids {
2274        // c:1973
2275        if delta != 0 {
2276            tc.ut = tc.ut.saturating_sub(k.ut); // c:1976
2277            tc.st = tc.st.saturating_sub(k.st); // c:1977
2278        } else {
2279            *k = tc.clone(); // c:1983
2280        }
2281    }
2282
2283    // c:1985-1986 — `if (!delta == !kids) printtime("children")`.
2284    if (delta == 0) == !kids_present {
2285        // c:1985
2286        let real_secs = dtime.as_secs_f64();
2287        let timefmt = crate::ported::params::getsparam("TIMEFMT")
2288            .unwrap_or_else(|| "%J  %U user %S system %P cpu %*E total".to_string());
2289        let line = printtime(real_secs, &tc, &timefmt, "children"); // c:1986
2290        eprintln!("{}", line);
2291    }
2292}
2293
2294/// Non-unix stub matching the C body's #ifdef-gated absence.
2295#[cfg(not(unix))]
2296pub fn shelltime(
2297    _shell: Option<&mut timeinfo>,
2298    _kids: Option<&mut timeinfo>,
2299    _then: Option<&mut std::time::Instant>,
2300    _delta: i32,
2301) {
2302}
2303
2304// see if jobs need printing                                                // c:1993
2305/// Scan jobs and print changed status (from jobs.c scanjobs)
2306pub fn scanjobs(jobtab: &mut [job]) {
2307    // c:1993
2308    // C body:
2309    // ```c
2310    // for (i = 1; i <= maxjob; i++)
2311    //     if (jobtab[i].stat & STAT_CHANGED)
2312    //         printjob(jobtab + i, !!isset(LONGLISTJOBS), 1);
2313    // ```
2314    // printjob with synch=1 prints only when `(interact || synch) &&
2315    // jobbing && ...` (c:1236-1238) — so in a non-MONITOR shell the
2316    // call is a silent pass whose tail (c:1350-1363) deletes each
2317    // finished entry and clears STAT_CHANGED otherwise (c:1364).
2318    // WARNING: param names don't match C — Rust=(jobtab) vs C=(void);
2319    // C reads the jobtab global, Rust callers pass the locked slice.
2320    let long_list = isset(LONGLISTJOBS);
2321    for i in 1..jobtab.len() {
2322        // c:1998
2323        if (jobtab[i].stat & stat::CHANGED) != 0 {
2324            // c:1999
2325            if crate::ported::zsh_h::jobbing() {
2326                // c:1236-1238 print gate
2327                let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2328                let prevjob = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2329                let s = printjob(
2330                    &jobtab[i],
2331                    i,
2332                    long_list as i32,
2333                    if curjob >= 0 { Some(curjob as usize) } else { None },
2334                    if prevjob >= 0 { Some(prevjob as usize) } else { None },
2335                ); // c:2000
2336                if !s.is_empty() {
2337                    eprintln!("{}", s);
2338                }
2339            }
2340            if (jobtab[i].stat & stat::DONE) != 0 {
2341                // c:1350-1363 — printjob's done-delete tail.
2342                crate::exec_jobs::printjob_delete_tail(jobtab, i);
2343            } else {
2344                jobtab[i].stat &= !stat::CHANGED; // c:1364
2345            }
2346        }
2347    }
2348}
2349
2350/// Port of `isanum(char *s)` from `Src/jobs.c:2010`.
2351///
2352/// C body:
2353/// ```c
2354/// if (*s == '\0') return 0;
2355/// while (*s == '-' || idigit(*s)) s++;
2356/// return *s == '\0';
2357/// ```
2358///
2359/// Returns true if `s` is non-empty and consists entirely of
2360/// `'-'` or ASCII digits. Used by `getjob` to determine whether a
2361/// jobspec is `%N` (numeric, with optional leading minus) versus
2362/// `%name`. The previous Rust port required all-digits which
2363/// rejected valid jobspecs like `-1` (the previous job).
2364pub fn isanum(s: &str) -> bool {
2365    // c:2010
2366    !s.is_empty() && s.bytes().all(|b| b == b'-' || b.is_ascii_digit())
2367}
2368
2369// Make sure we have a suitable current and previous job set.               // c:2023
2370/// Direct port of `void setcurjob(void)` from `Src/jobs.c:2023`.
2371///
2372/// C body:
2373/// ```c
2374/// if (curjob == thisjob ||
2375///     (curjob != -1 && !(jobtab[curjob].stat & STAT_INUSE))) {
2376///     curjob = prevjob;
2377///     setprevjob();
2378///     if (curjob == thisjob ||
2379///         (curjob != -1 && !((jobtab[curjob].stat & STAT_INUSE) &&
2380///                            curjob != thisjob))) {
2381///         curjob = prevjob;
2382///         setprevjob();
2383///     }
2384/// }
2385/// ```
2386/// REPAIRS an invalid `curjob` (gone, or equal to the in-flight
2387/// thisjob) by promoting `prevjob`; it does NOT scan for a fresh
2388/// candidate when curjob is -1 — promotion to curjob happens in
2389/// spawnjob (c:1901-1903) and printjob's delete tail (c:1357-1360).
2390/// The previous Rust body picked the highest in-use job
2391/// unconditionally, which resurrected a current job inside subshells
2392/// where zsh reports "no current job" (bug #462 probe
2393/// `(sleep 0.2 & disown)` → rc=1 in zsh).
2394pub fn setcurjob() {
2395    // c:2023
2396    let inuse = |jobno: i32| -> bool {
2397        let tab = JOBTAB
2398            .get_or_init(|| Mutex::new(Vec::new()))
2399            .lock()
2400            .expect("jobtab poisoned");
2401        tab.get(jobno as usize)
2402            .map(|j| (j.stat & stat::INUSE) != 0)
2403            .unwrap_or(false)
2404    };
2405    let thisjob = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2406    let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2407    // c:2025-2026. The `curjob == thisjob` test is guarded with
2408    // `curjob != -1` here: in C, thisjob is never -1 while bin_fg runs
2409    // (execpline c:Src/exec.c:1700 allocates a pipeline job slot before
2410    // any builtin executes), so `-1 == -1` can't trigger the C branch.
2411    // zshrs has no per-pipeline job allocation — thisjob is -1 between
2412    // jobs — and an unguarded -1==-1 would promote prevjob/setprevjob,
2413    // resurrecting a "current job" zsh reports as absent (bug #462).
2414    if (curjob != -1 && curjob == thisjob) || (curjob != -1 && !inuse(curjob)) {
2415        // c:2027-2028 — `curjob = prevjob; setprevjob();`
2416        let pj = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2417        *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = pj;
2418        setprevjob();
2419        let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2420        // c:2029-2031 — same -1 guard as above.
2421        if (curjob != -1 && curjob == thisjob)
2422            || (curjob != -1 && !(inuse(curjob) && curjob != thisjob))
2423        {
2424            // c:2032-2033
2425            let pj = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2426            *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = pj;
2427            setprevjob();
2428        }
2429    }
2430}
2431
2432// Find the job table for reporting jobs                                   // c:2042
2433/// Port of `selectjobtab(job *jtabp, int *jmaxp)` from `Src/jobs.c:2042`.
2434///
2435/// C signature: `mod_export void selectjobtab(job *jtabp, int *jmaxp)`
2436///
2437/// In subshell, uses saved `oldjobtab`/`oldmaxjob`; otherwise uses
2438/// the main `jobtab`/`maxjob` globals. Returns `(table, maxjob)`.
2439/// WARNING: param names don't match C — Rust=() vs C=(jtabp, jmaxp)
2440pub fn selectjobtab() -> (Vec<job>, usize) {
2441    let oldtab = OLDJOBTAB
2442        .get_or_init(|| Mutex::new(Vec::new()))
2443        .lock()
2444        .expect("oldjobtab poisoned");
2445    if !oldtab.is_empty() {
2446        // c:2044
2447        // In subshell --- use saved job table to report                     // c:2046
2448        let oldmax = *OLDMAXJOB
2449            .get_or_init(|| Mutex::new(0))
2450            .lock()
2451            .expect("oldmaxjob poisoned");
2452        (oldtab.clone(), oldmax) // c:2047-2048
2453    } else {
2454        // Use main job table                                                // c:2052
2455        drop(oldtab); // release lock before acquiring jobtab
2456        let jobtab = JOBTAB
2457            .get_or_init(|| Mutex::new(Vec::new()))
2458            .lock()
2459            .expect("jobtab poisoned");
2460        let maxjob = *MAXJOB
2461            .get_or_init(|| Mutex::new(0))
2462            .lock()
2463            .expect("maxjob poisoned");
2464        (jobtab.clone(), maxjob) // c:2053-2054
2465    }
2466}
2467
2468// `JobPointers` struct deleted — Rust-only aggregate of `curjob`/
2469// `prevjob` (Src/jobs.c:75/80) globals that already live on file
2470// scope as `CURJOB` / `PREVJOB`. `setcurjob` / `setprevjob` now
2471// read/write those directly per the C source.
2472
2473// ---------------------------------------------------------------------------
2474// Missing functions from jobs.c
2475// ---------------------------------------------------------------------------
2476
2477// Convert a job specifier ("%%", "%1", "%foo", "%?bar?", etc.)              // c:2063
2478// to a job number.                                                          // c:2063
2479/// Port of `getjob(const char *s, const char *prog)` from `Src/jobs.c:2063`.
2480///
2481/// C signature: `mod_export int getjob(const char *s, const char *prog)`
2482///
2483/// Returns job index or -1 on error. `prog` is the program name for
2484/// `zwarnnam` error messages (pass empty string to suppress warnings).
2485pub fn getjob(s: &str, prog: &str) -> i32 {
2486    // c:2063
2487    let mut jobnum: i32; // c:2063
2488    let mymaxjob: i32; // c:2065
2489    let myjobtab: Vec<job>; // c:2066
2490
2491    let (tab, max) = selectjobtab(); // c:2068
2492    myjobtab = tab;
2493    mymaxjob = max as i32;
2494
2495    let curjob = *CURJOB
2496        .get_or_init(|| Mutex::new(-1)) // c:2076
2497        .lock()
2498        .expect("curjob poisoned");
2499    let prevjob = *PREVJOB
2500        .get_or_init(|| Mutex::new(-1)) // c:2087
2501        .lock()
2502        .expect("prevjob poisoned");
2503    let thisjob = *THISJOB
2504        .get_or_init(|| Mutex::new(-1))
2505        .lock()
2506        .expect("thisjob poisoned");
2507    let posixbuiltins = isset(
2508        // c:isset(POSIXBUILTINS)
2509        POSIXBUILTINS,
2510    );
2511
2512    let s_bytes = s.as_bytes();
2513    let mut idx = 0usize;
2514
2515    // if there is no %, treat as a name                                     // c:2070
2516    if s_bytes.is_empty() || s_bytes[0] != b'%' {
2517        // goto jump                                                         // c:2072
2518        // anything else is a job name, specified as a string that begins    // c:2135
2519        // the job's command                                                 // c:2136
2520        if let Some(jn) = findjobnam(s, &myjobtab, mymaxjob, thisjob) {
2521            // c:2137
2522            return jn;
2523        }
2524        // if we get here, it is because none of the above succeeded         // c:2141
2525        if !posixbuiltins && !prog.is_empty() {
2526            // c:2143
2527            zwarnnam(prog, &format!("job not found: {}", s)); // c:2144
2528        }
2529        return -1; // c:2145
2530    }
2531    idx += 1; // skip '%'                                                    // c:2073
2532
2533    // "%%", "%+" and "%" all represent the current job                      // c:2074
2534    if idx >= s_bytes.len() || s_bytes[idx] == b'%' || s_bytes[idx] == b'+' {
2535        // c:2075
2536        if curjob == -1 {
2537            // c:2076
2538            if !prog.is_empty() && !posixbuiltins {
2539                // c:2077
2540                zwarnnam(prog, "no current job"); // c:2078
2541            }
2542            return -1; // c:2079-2080
2543        }
2544        return curjob; // c:2082-2083
2545    }
2546    // "%-" represents the previous job                                      // c:2085
2547    if s_bytes[idx] == b'-' {
2548        // c:2086
2549        if prevjob == -1 {
2550            // c:2087
2551            if !prog.is_empty() && !posixbuiltins {
2552                // c:2088
2553                zwarnnam(prog, "no previous job"); // c:2089
2554            }
2555            return -1; // c:2090-2091
2556        }
2557        return prevjob; // c:2093-2094
2558    }
2559    // a digit here means we have a job number                               // c:2096
2560    if s_bytes[idx].is_ascii_digit() {
2561        // c:2097
2562        let rest = &s[idx..];
2563        jobnum = rest.parse::<i32>().unwrap_or(0); // c:2098 atoi(s)
2564        if jobnum > 0 && jobnum <= mymaxjob {
2565            // c:2099
2566            let ju = jobnum as usize;
2567            if ju < myjobtab.len()
2568                && myjobtab[ju].stat != 0
2569                && (myjobtab[ju].stat & stat::SUBJOB) == 0                   // c:2100
2570                && jobnum != thisjob
2571            // c:2107
2572            {
2573                return jobnum; // c:2108-2109
2574            }
2575        }
2576        if !prog.is_empty() && !posixbuiltins {
2577            // c:2111
2578            zwarnnam(prog, &format!("%{}: no such job", rest)); // c:2112
2579        }
2580        return -1; // c:2113-2114
2581    }
2582    // "%?" introduces a search string                                       // c:2116
2583    if s_bytes[idx] == b'?' {
2584        // c:2117
2585        let search = &s[idx + 1..]; // c:2125 s + 1
2586        jobnum = mymaxjob; // c:2120
2587        while jobnum >= 0 {
2588            // c:2120
2589            let ju = jobnum as usize;
2590            if ju < myjobtab.len()
2591                && myjobtab[ju].stat != 0                                    // c:2121
2592                && (myjobtab[ju].stat & stat::SUBJOB) == 0                   // c:2122
2593                && jobnum != thisjob
2594            // c:2123
2595            {
2596                for pn in &myjobtab[ju].procs {
2597                    // c:2124
2598                    if pn.text.contains(search) {
2599                        // c:2125 strstr
2600                        return jobnum; // c:2126-2127
2601                    }
2602                }
2603            }
2604            jobnum -= 1;
2605        }
2606        if !prog.is_empty() && !posixbuiltins {
2607            // c:2129
2608            // c:Src/jobs.c:2130 — `zwarnnam(prog, "job not found: %s", s)`.
2609            // After the s++ at c:2073, `s` is past the leading `%`. The
2610            // Rust idx-based port must use &s[idx..] not the original s.
2611            // Bug #393.
2612            zwarnnam(prog, &format!("job not found: {}", &s[idx..])); // c:2130
2613        }
2614        return -1; // c:2131-2132
2615    }
2616    // jump:                                                                 // c:2134
2617    // anything else is a job name, specified as a string that begins        // c:2135
2618    // the job's command                                                     // c:2136
2619    let rest = &s[idx..];
2620    if let Some(jn) = findjobnam(rest, &myjobtab, mymaxjob, thisjob) {
2621        // c:2137
2622        return jn; // c:2138-2139
2623    }
2624    // if we get here, it is because none of the above succeeded             // c:2141
2625    if !posixbuiltins && !prog.is_empty() {
2626        // c:2143
2627        // c:Src/jobs.c:2144 — same `s++` strip — emit the post-`%` name.
2628        // Bug #393.
2629        zwarnnam(prog, &format!("job not found: {}", rest)); // c:2144
2630    }
2631    -1 // c:2145-2147
2632}
2633
2634/// Port of `init_jobs(char **argv, char **envp)` from `Src/jobs.c:2164`.
2635///
2636/// C body allocates the `jobtab[]` array sized to `MAXJOBS_ALLOC`,
2637/// `memset`s to zero, and seeds the `setproctitle`/argv-rewriting
2638/// state used by `jobs -Z`. Rust port pre-allocates the table to
2639/// `MAXJOBS_ALLOC` empty `job` slots so `expandjobtab` doesn't
2640/// need to grow until index 50+ is reached.
2641///
2642/// `jobs -Z` (argv overwrite) is not yet ported; the argv/envp
2643/// scan from C lines 2185-2210 is omitted — that's a separate
2644/// init.rs concern when `setproctitle()` lands.
2645/// C body (c:2168-2210): allocates the `jobtab[]` array sized to
2646/// MAXJOBS_ALLOC entries via `zalloc`, zero-fills via `memset`,
2647/// then (non-HAVE_SETPROCTITLE) walks argv + envp to compute the
2648/// `hackspace` byte count for the `jobs -Z` rename trick.
2649///
2650/// ```c
2651/// jobtab = (struct job *)zalloc(MAXJOBS_ALLOC*sizeof(struct job));
2652/// if (!jobtab) { zerr(...); exit(1); }
2653/// jobtabsize = MAXJOBS_ALLOC;
2654/// memset(jobtab, 0, MAXJOBS_ALLOC*sizeof(struct job));
2655/// /* -Z hackspace scan */
2656/// hackzero = *argv;
2657/// p = strchr(hackzero, 0);
2658/// while (*++argv) { q = *argv; if (q != p+1) goto done;
2659///                   p = strchr(q, 0); }
2660/// for (; *envp; envp++) { ... }
2661/// done: hackspace = p - hackzero;
2662/// ```
2663pub fn init_jobs(argv: &[String], envp: &[String]) -> JobTable {
2664    // c:2164
2665    let table = JobTable::new(); // c:2164 zalloc
2666                                 // c:2185-2210 — `-Z` hackspace scan: locate contiguous argv+envp
2667                                 // space. Static-link path: we don't yet keep `hackzero` /
2668                                 // `hackspace` globals (the bin_fg -Z arm uses prctl directly on
2669                                 // Linux + pthread_setname_np on macOS, both bypassing the argv
2670                                 // overwrite trick). The scan computes the byte-distance only;
2671                                 // record it via env-var bridge so a future setproctitle fallback
2672                                 // can read it.
2673    if !argv.is_empty() {
2674        // c:2187 hackzero = *argv
2675        let zero = argv[0].as_str();
2676        let mut hackspace = zero.len(); // c:2208 p - hackzero
2677                                        // Walk argv tail then envp; each element must be contiguous
2678                                        // (the C check is `q != p+1` after the previous's NUL).
2679        for entry in argv.iter().skip(1).chain(envp.iter()) {
2680            // c:2191/2197 walks
2681            // Without raw argv pointers we can't verify contiguity from
2682            // Rust's String wrappers — accumulate length conservatively.
2683            hackspace += 1 + entry.len(); // c:2207-style p+1
2684        }
2685        env::set_var("__zshrs_hackspace", hackspace.to_string()); // record for jobs -Z
2686    }
2687    table // c:2210 done
2688}
2689
2690/// Hard upper bound on job-table growth.
2691/// Port of `MAX_MAXJOBS` from `Src/jobs.c:2221`.
2692pub const MAX_MAXJOBS: usize = 1000;
2693
2694/// Port of `expandjobtab()` from `Src/jobs.c:2225`.
2695///
2696/// C body:
2697/// ```c
2698/// int newsize = jobtabsize + MAXJOBS_ALLOC;
2699/// if (newsize > MAX_MAXJOBS) return 0;
2700/// newjobtab = zrealloc(jobtab, newsize * sizeof(struct job));
2701/// if (!newjobtab) return 0;
2702/// memset(newjobtab + jobtabsize, 0, MAXJOBS_ALLOC * sizeof(struct job));
2703/// jobtab = newjobtab;
2704/// jobtabsize = newsize;
2705/// return 1;
2706/// ```
2707///
2708/// Grows the job table by `MAXJOBS_ALLOC` slots, respecting the
2709/// `MAX_MAXJOBS` cap. Returns true on success, false if the cap
2710/// would be exceeded. The previous Rust port grew the table
2711/// unconditionally without the cap, and used `<= needed` instead
2712/// of growing by full chunks.
2713pub fn expandjobtab(jobtab: &mut Vec<job>, _needed: usize) -> bool {
2714    let newsize = jobtab.len() + MAXJOBS_ALLOC;
2715    if newsize > MAX_MAXJOBS {
2716        return false;
2717    }
2718    jobtab.resize_with(newsize, job::new);
2719    true
2720}
2721
2722/// Shrink job table if possible (from jobs.c maybeshrinkjobtab)
2723/// Port of `maybeshrinkjobtab` from `Src/jobs.c:2259`.
2724pub fn maybeshrinkjobtab(jobtab: &mut Vec<job>) {
2725    while jobtab
2726        .last()
2727        .map(|j| (j.stat & stat::INUSE) == 0)
2728        .unwrap_or(false)
2729    {
2730        jobtab.pop();
2731    }
2732}
2733
2734/// Port of `struct bgstatus` from `Src/jobs.c:2295`.
2735/// One `(pid, status)` pair the bg-status tracker records when a
2736/// background process exits so `wait $pid` can read its $?.
2737#[allow(non_camel_case_types)]
2738#[derive(Clone, Copy)]
2739pub struct bgstatus {
2740    // c:2296
2741    pub pid: i32,    // c:2297
2742    pub status: i32, // c:2298
2743}
2744
2745/// Port of `typedef struct bgstatus *Bgstatus;` (jobs.c:2300).
2746pub type Bgstatus = Box<bgstatus>; // c:2300
2747
2748/// Port of `static LinkList bgstatus_list;` (jobs.c:2302). Insertion-
2749/// ordered list so the oldest entry can be evicted when the cap is
2750/// reached. Stored as `Vec<bgstatus>` since the order is the only
2751/// thing we'd ever need from a linked list here.
2752pub static bgstatus_list: Mutex<Vec<bgstatus>> = // c:2302
2753    Mutex::new(Vec::new());
2754
2755/// Port of `static long bgstatus_count;` (jobs.c:2304). Reaches
2756/// `_SC_CHILD_MAX` and stops (addbgstatus then evicts oldest).
2757pub static bgstatus_count: std::sync::atomic::AtomicI64 = // c:2304
2758    std::sync::atomic::AtomicI64::new(0);
2759
2760/// Direct port of `void addbgstatus(pid_t pid, int status)` from
2761/// `Src/jobs.c:2325`. Caps the global `bgstatus_list` at
2762/// `_SC_CHILD_MAX`, evicting oldest on overflow, then appends a
2763/// new `bgstatus { pid, status }` entry.
2764pub fn addbgstatus(pid: i32, status_val: i32) {
2765    // c:2325
2766    // c:2370 — `if (bgstatus_count == max_child)` cap + eviction.
2767    let max_child = unsafe { libc::sysconf(libc::_SC_CHILD_MAX) };
2768    let cap = if max_child > 0 {
2769        max_child as i64
2770    } else {
2771        1024
2772    };
2773    if let Ok(mut list) = bgstatus_list.lock() {
2774        if bgstatus_count.load(Ordering::Relaxed) >= cap {
2775            // c:2370
2776            // c:2371 — `rembgstatus(firstnode(bgstatus_list))`.
2777            if !list.is_empty() {
2778                list.remove(0);
2779                bgstatus_count.fetch_sub(1, Ordering::Relaxed);
2780            }
2781        }
2782        // c:2376-2385 — alloc + push.
2783        list.push(bgstatus {
2784            pid,
2785            status: status_val,
2786        }); // c:2381-2384
2787        bgstatus_count.fetch_add(1, Ordering::Relaxed); // c:2386
2788    }
2789}
2790
2791/// Direct port of `bin_fg(char *name, char **argv, Options ops, int func)` from `Src/jobs.c:2421`.
2792/// Multi-builtin dispatcher — handles bg, fg, wait, jobs, disown, and
2793/// the `-Z` process-rename form. C body is 315 lines (c:2421-2735);
2794/// the per-builtin behaviour is selected by `func` (BIN_BG/BIN_FG/
2795/// BIN_JOBS/BIN_WAIT/BIN_DISOWN).
2796///
2797/// Coverage status:
2798///   ✓ -Z process-title rename (c:2425-2451) — full port via
2799///     libc::prctl(PR_SET_NAME) on Linux; macOS pthread_setname_np;
2800///     other platforms emit a warning
2801///   ✓ no-job-control refusal for fg/bg under !jobbing (c:2461-2465)
2802///   ✓ jobs -l/-p/-d listing-format selection (c:2454-2459)
2803///   ⚠ jobspec parsing + per-job dispatch (c:2467-2733) DEFERRED —
2804///     depends on getjob (parses %N/%?str specifiers), the global
2805///     jobtab + oldjobtab, deletejob/printjob/makerunning, lastval2,
2806///     errflag, signal queueing for fg's tcsetpgrp dance, and the
2807///     STAT_* / STAT_SUPERJOB / STAT_DISOWN flag tracking. None of
2808///     those are fully ported yet; structural shape preserved so the
2809///     C signature lands and future port work can fill the body.
2810pub fn bin_fg(
2811    name: &str,
2812    argv: &[String], // c:2421
2813    ops: &options,
2814    func: i32,
2815) -> i32 {
2816    let _ofunc = func; // c:2424
2817
2818    // c:2425-2452 — `-Z`: rename the running process. Used by
2819    // login shells / tools that want their `ps` line to reflect a
2820    // descriptive title rather than `zsh`.
2821    if OPT_ISSET(ops, b'Z') {
2822        // c:2425
2823        if argv.is_empty() || argv.len() > 1 {
2824            // c:2428
2825            zwarnnam(name, "-Z requires one argument"); // c:2429
2826            return 1; // c:2430
2827        }
2828        queue_signals(); // c:2433
2829        let title = &argv[0];
2830        // c:2436 — `setproctitle("%s", *argv);` if available.
2831        // c:2438-2444 — fallback: memcpy into hackzero (the argv[0]
2832        // buffer reserved by the loader). Not portable from Rust,
2833        // so the prctl path covers Linux directly.
2834        #[cfg(target_os = "linux")]
2835        unsafe {
2836            let cs = std::ffi::CString::new(title.as_str()).unwrap_or_default();
2837            // PR_SET_NAME = 15; libc may not expose it — pass the
2838            // raw constant per `linux/prctl.h`.
2839            libc::prctl(
2840                15, /*PR_SET_NAME*/
2841                cs.as_ptr() as libc::c_ulong,
2842                0,
2843                0,
2844                0,
2845            ); // c:2447
2846        }
2847        #[cfg(target_os = "macos")]
2848        unsafe {
2849            extern "C" {
2850                fn pthread_setname_np(name: *const libc::c_char) -> libc::c_int;
2851            }
2852            let cs = std::ffi::CString::new(title.as_str()).unwrap_or_default();
2853            pthread_setname_np(cs.as_ptr());
2854        }
2855        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
2856        {
2857            let _ = title;
2858        }
2859        unqueue_signals(); // c:2449
2860        return 0; // c:2450
2861    }
2862
2863    // c:2454-2459 — jobs builtin: pick listing format.
2864    let mut lng = 0i32; // c:2422
2865    if func == BIN_JOBS {
2866        // c:2454
2867        lng = if OPT_ISSET(ops, b'l') {
2868            1
2869        }
2870        // c:2455
2871        else if OPT_ISSET(ops, b'p') {
2872            2
2873        } else {
2874            0
2875        };
2876        if OPT_ISSET(ops, b'd') {
2877            lng |= 4;
2878        } // c:2456
2879    } else {
2880        // c:2458 — `lng = !!isset(LONGLISTJOBS);`
2881        lng = if isset(LONGLISTJOBS) { 1 } else { 0 };
2882    }
2883    let _ = lng;
2884
2885    // c:2461-2465 — fg/bg need job control.
2886    let jobbing = isset(MONITOR);
2887    if (func == BIN_FG || func == BIN_BG) && !jobbing {
2888        // c:2461
2889        zwarnnam(name, "no job control in this shell."); // c:2463
2890        return 1; // c:2464
2891    }
2892
2893    // c:2467 — `queue_signals();`
2894    queue_signals();
2895    let table = JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2896    // c:2474 — `wait_for_processes();` reap any newly-finished children
2897    // so the table reflects the current state before we list/dispatch.
2898    // C's wait_for_processes (Src/signals.c:249) routes each reaped
2899    // (pid, status) through update_bg_job internally; the Rust port
2900    // returns the pairs and leaves the routing to the caller. Then run
2901    // the update_job→printjob done-delete chain (Src/jobs.c:639-641 →
2902    // 1350-1363) so finished jobs leave the table before we list.
2903    {
2904        let reaped = wait_for_processes();
2905        let mut tab = table.lock().expect("jobtab poisoned");
2906        for (pid, status) in reaped {
2907            update_bg_job(&mut tab, pid, status);
2908        }
2909        scanjobs(&mut tab);
2910    }
2911
2912    // c:2477-2478 — `if (unset(NOTIFY)) scanjobs();`. (The routing
2913    // block above already swept STAT_CHANGED entries; this re-walk is
2914    // the C-shaped call and is idempotent.)
2915    if !crate::ported::zsh_h::isset(crate::ported::zsh_h::NOTIFY) {
2916        if let Some(jt) = JOBTAB.get() {
2917            let mut guard = jt.lock().unwrap();
2918            scanjobs(&mut guard); // c:2478
2919        }
2920    }
2921
2922    // c:2480-2481 — refresh CURJOB unless we're listing a frozen
2923    // oldjobtab snapshot from `jobs` in a non-monitor shell.
2924    if func != BIN_JOBS || jobbing || *OLDMAXJOB.get_or_init(|| Mutex::new(0)).lock().unwrap() == 0
2925    {
2926        // c:2481 — `setcurjob()` operates on the global jobtab.
2927        setcurjob();
2928    }
2929
2930    // c:2483-2486 — set stopmsg=2 so zexit doesn't complain about
2931    // stopped jobs if the user immediately runs `exit` after `jobs`.
2932    if func == BIN_JOBS {
2933        STOPMSG.store(2, Ordering::Relaxed);
2934        // c:2486
2935    }
2936
2937    let mut returnval: i32 = 0;
2938
2939    if argv.is_empty() {
2940        // c:2487
2941        if func == BIN_JOBS {
2942            // c:2500-2523 — list jobs. `ignorejob = thisjob` (c:2512)
2943            // — the C loop skips the job slot the shell is currently
2944            // building (the foreground job), NOT curjob. Skipping
2945            // curjob would hide every freshly-backgrounded job, since
2946            // spawnjob promotes it to curjob (c:1901-1903).
2947            let thisjob = *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2948            let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2949            let t = table.lock().expect("jobtab poisoned");
2950            let curmaxjob = t.len();
2951            let r_only = OPT_ISSET(ops, b'r');
2952            let s_only = OPT_ISSET(ops, b's');
2953            for job in 0..curmaxjob {
2954                // c:2513
2955                if job as i32 == thisjob {
2956                    // c:2514 ignorejob
2957                    continue;
2958                }
2959                let j = &t[job];
2960                if !j.is_inuse() {
2961                    // c:2514 stat
2962                    continue;
2963                }
2964                let stopped = j.is_stopped();
2965                // c:2515-2519 — flag filtering.
2966                if (!r_only && !s_only)
2967                    || (r_only && s_only)
2968                    || (r_only && !stopped)
2969                    || (s_only && stopped)
2970                {
2971                    // c:2520 — printjob(jobptr, lng, 2). The Rust
2972                    // port's printjob takes job_num + cur/prev for
2973                    // formatting; pass them through here.
2974                    let curjob_opt = if curjob >= 0 {
2975                        Some(curjob as usize)
2976                    } else {
2977                        None
2978                    };
2979                    let prevjob = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
2980                    let prevjob_opt = if prevjob >= 0 {
2981                        Some(prevjob as usize)
2982                    } else {
2983                        None
2984                    };
2985                    let s = printjob(j, job, lng, curjob_opt, prevjob_opt);
2986                    if !s.is_empty() {
2987                        println!("{}", s);
2988                    }
2989                }
2990            }
2991            unqueue_signals(); // c:2522
2992            return 0; // c:2523
2993        }
2994        if func == BIN_FG || func == BIN_BG || func == BIN_DISOWN {
2995            // c:2491-2499 — "no current job" gate. C body covers BIN_FG/
2996            // BIN_BG/BIN_DISOWN equivalently — disown with no args
2997            // defaults to the current job (`firstjob = curjob`), which
2998            // must exist (and be printable) or the builtin errors out.
2999            let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3000            let cur_noprint = curjob >= 0
3001                && table
3002                    .lock()
3003                    .expect("jobtab poisoned")
3004                    .get(curjob as usize)
3005                    .map(|j| (j.stat & stat::NOPRINT) != 0)
3006                    .unwrap_or(true);
3007            if curjob < 0 || cur_noprint {
3008                // c:2494
3009                zwarnnam(name, "no current job"); // c:2495
3010                unqueue_signals();
3011                return 1; // c:2497
3012            }
3013            if func == BIN_DISOWN {
3014                // c:2498 firstjob = curjob → loop BIN_DISOWN arm c:2729
3015                // `deletejob(jobtab + job, 1)` — drop the entry without
3016                // killing/ waiting on the process.
3017                let mut tab = table.lock().expect("jobtab poisoned");
3018                if let Some(j) = tab.get_mut(curjob as usize) {
3019                    deletejob(j, true); // c:2729
3020                }
3021                drop(tab);
3022                // The deleted job was curjob — re-pick (printjob's
3023                // shuffle shape, c:1357-1362).
3024                let pj = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3025                *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = pj;
3026                setprevjob();
3027                unqueue_signals();
3028                return 0;
3029            }
3030            // Continue current job by sending SIGCONT via killjb(Job, sig).
3031            if curjob >= 0 {
3032                let _ = killjb(curjob as usize, libc::SIGCONT);
3033            }
3034            unqueue_signals();
3035            return 0;
3036        }
3037        if func == BIN_WAIT {
3038            // c:Src/jobs.c bin_fg BIN_WAIT branch — `wait` with no
3039            // args blocks until ALL active background jobs complete.
3040            // Loop waitpid(-1) draining children; ECHILD ends the loop.
3041            #[cfg(unix)]
3042            loop {
3043                let mut status: libc::c_int = 0;
3044                let pid = unsafe { libc::waitpid(-1, &mut status, 0) };
3045                if pid > 0 {
3046                    if let Ok(mut tab) = table.lock() {
3047                        update_bg_job(&mut tab, pid, status);
3048                    }
3049                    // c:Src/jobs.c:644-645 — `if (sigtrapped[SIGCHLD]
3050                    // && job != thisjob) dotrap(SIGCHLD);`. C zsh's
3051                    // canonical site for the SIGCHLD-trap dispatch
3052                    // sits in update_job, gated on the job index NOT
3053                    // matching the foreground job. The Rust update_job
3054                    // port doesn't have the job index, and findproc
3055                    // can miss the pid when the bg-job procs vec
3056                    // wasn't populated by the spawn site — so the
3057                    // dispatch never fires through that path.
3058                    // bin_wait's reaper loop already has the pid and
3059                    // runs only for `wait` (which by definition is
3060                    // waiting on background jobs, so the "job !=
3061                    // thisjob" condition is always true here). Fire
3062                    // the trap from this site so function-form
3063                    // TRAPCHLD() {…} and string-form `trap '…' CHLD`
3064                    // both reach userspace. Bug #531 in docs/BUGS.md.
3065                    let chld_trapped = crate::ported::signals::sigtrapped
3066                        .lock()
3067                        .ok()
3068                        .and_then(|g| g.get(libc::SIGCHLD as usize).copied())
3069                        .unwrap_or(0);
3070                    let chld_string_trap = crate::ported::builtin::traps_table()
3071                        .lock()
3072                        .ok()
3073                        .map(|t| t.contains_key("CHLD") || t.contains_key("SIGCHLD"))
3074                        .unwrap_or(false);
3075                    if chld_trapped != 0 || chld_string_trap {
3076                        crate::ported::signals::dotrap(libc::SIGCHLD);
3077                    }
3078                } else {
3079                    break;
3080                }
3081            }
3082            // c:639-641 → c:1350-1363 — every job we just reaped went
3083            // through update_job (STAT_DONE|STAT_CHANGED); run the
3084            // printjob done-delete chain so the table is empty after
3085            // `wait`, matching C where the SIGCHLD-driven printjob
3086            // deletes each finished entry.
3087            if let Ok(mut tab) = table.lock() {
3088                scanjobs(&mut tab);
3089            }
3090            unqueue_signals();
3091            return 0;
3092        }
3093        unqueue_signals();
3094        return 0;
3095    }
3096
3097    // c:2537+ — per-arg jobspec dispatch (full body handles wait pid,
3098    // STAT_SUPERJOB carry-through, killjb retry, etc.). Port the
3099    // common path: jobspec → getjob → per-func switch (c:2598-2731).
3100    for arg in argv {
3101        if func == BIN_WAIT && isanum(arg) {
3102            // c:2541-2575 — `wait PID` waits for an arbitrary PID via
3103            // waitpid(); if not a child of this shell, C falls back to
3104            // getbgstatus (the reaped-status ring) and only then emits
3105            // "pid %d is not a child of this shell" with exit 127.
3106            if let Ok(pid) = arg.parse::<i32>() {
3107                let mut status: libc::c_int = 0;
3108                let r = unsafe { libc::waitpid(pid, &mut status, 0) };
3109                if r == -1 {
3110                    let err = std::io::Error::last_os_error();
3111                    if err.raw_os_error() == Some(libc::ECHILD) {
3112                        // c:2566-2570 — getbgstatus fallback before
3113                        // the diagnostic.
3114                        if let Some(bg) = getbgstatus(pid) {
3115                            returnval = bg;
3116                        } else {
3117                            zwarnnam(
3118                                name,
3119                                &format!("pid {} is not a child of this shell", pid),
3120                            );
3121                            returnval = 127;
3122                        }
3123                    } else {
3124                        returnval = 1;
3125                    }
3126                } else {
3127                    // c:1748-1750 waitforpid semantics — exit status or
3128                    // 128+sig. Route the status into the canonical
3129                    // jobtab so the job entry is marked done + deleted
3130                    // (C's SIGCHLD handler chain does this while
3131                    // waitforpid suspends).
3132                    if libc::WIFEXITED(status) {
3133                        returnval = libc::WEXITSTATUS(status);
3134                    } else if libc::WIFSIGNALED(status) {
3135                        returnval = 128 + libc::WTERMSIG(status);
3136                    }
3137                    if let Ok(mut tab) = table.lock() {
3138                        update_bg_job(&mut tab, pid, status);
3139                        scanjobs(&mut tab);
3140                    }
3141                }
3142            }
3143            continue; // c:2574
3144        }
3145        // c:2576 — `job = (*argv) ? getjob(*argv, name) : firstjob;`
3146        // EVERY non-pid arg goes through getjob — a bare numeric like
3147        // `jobs 1` is a job NAME (findjobnam) in zsh, not an index
3148        // (verified: zsh -fc 'sleep 5 & jobs 1' → "job not found: 1"
3149        // rc=127).
3150        let p = getjob(arg, name);
3151        if p < 0 {
3152            // c:2578-2581 — `if (job == -1) { retval = 127; break; }`.
3153            // getjob already emitted the diagnostic. Bug #393.
3154            returnval = 127;
3155            break;
3156        }
3157        // c:2583-2592 — STAT_INUSE / STAT_NOPRINT recheck.
3158        let jstat = table
3159            .lock()
3160            .expect("jobtab poisoned")
3161            .get(p as usize)
3162            .map(|j| j.stat)
3163            .unwrap_or(0);
3164        if (jstat & stat::INUSE) == 0 || (jstat & stat::NOPRINT) != 0 {
3165            if !isset(POSIXBUILTINS) {
3166                zwarnnam(name, &format!("{}: no such job", arg)); // c:2587
3167            }
3168            unqueue_signals(); // c:2588
3169            return 127; // c:2589
3170        }
3171        if func == BIN_FG || func == BIN_BG {
3172            if killjb(p as usize, libc::SIGCONT) == -1 {
3173                zwarnnam(
3174                    name,
3175                    &format!("{}: kill failed: {}", arg, std::io::Error::last_os_error()),
3176                );
3177                returnval = 1;
3178            }
3179        } else if func == BIN_WAIT {
3180            // c:2655-2659 — `retval = zwaitjob(job, 1); if (!retval)
3181            // retval = lastval2;`. The Rust zwaitjob takes `&mut job`
3182            // and suspends on SIGCHLD; holding the JOBTAB lock across
3183            // the suspend would deadlock against the handler's own
3184            // lock, so wait proc-by-proc with a blocking waitpid and
3185            // route each status through update_bg_job — the same
3186            // chain C's SIGCHLD handler drives while zwaitjob
3187            // suspends (Src/signals.c:249 → jobs.c:460).
3188            loop {
3189                let next_pid = {
3190                    let tab = table.lock().expect("jobtab poisoned");
3191                    match tab.get(p as usize) {
3192                        Some(j) if (j.stat & stat::INUSE) != 0 && !j.is_done() => j
3193                            .procs
3194                            .iter()
3195                            .chain(j.auxprocs.iter())
3196                            .find(|pr| pr.status == SP_RUNNING)
3197                            .map(|pr| pr.pid),
3198                        _ => None,
3199                    }
3200                };
3201                let pid = match next_pid {
3202                    Some(pid) => pid,
3203                    None => break,
3204                };
3205                let mut status: libc::c_int = 0;
3206                let r = unsafe { libc::waitpid(pid, &mut status, 0) };
3207                let mut tab = table.lock().expect("jobtab poisoned");
3208                if r == pid {
3209                    update_bg_job(&mut tab, pid, status);
3210                } else {
3211                    // ECHILD — already reaped elsewhere; mark via
3212                    // update_job so the loop terminates.
3213                    if let Some(j) = tab.get_mut(p as usize) {
3214                        for pr in j.procs.iter_mut().chain(j.auxprocs.iter_mut()) {
3215                            if pr.pid == pid && pr.status == SP_RUNNING {
3216                                pr.status = 0;
3217                            }
3218                        }
3219                        update_job(j);
3220                    }
3221                }
3222            }
3223            // c:2656-2657 — `if (!retval) retval = lastval2;`
3224            returnval = LASTVAL2.load(Ordering::SeqCst);
3225            // c:1350-1363 via the suspended-handler printjob — the
3226            // finished entry leaves the table before wait returns
3227            // (zsh: a second `wait %1` errors "no such job").
3228            if let Ok(mut tab) = table.lock() {
3229                crate::exec_jobs::printjob_delete_tail(&mut tab, p as usize);
3230            }
3231        } else if func == BIN_JOBS {
3232            let t = table.lock().expect("jobtab poisoned");
3233            if let Some(j) = t.get(p as usize) {
3234                let curjob = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3235                let prevjob = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3236                let s = printjob(
3237                    j,
3238                    p as usize,
3239                    lng,
3240                    if curjob >= 0 {
3241                        Some(curjob as usize)
3242                    } else {
3243                        None
3244                    },
3245                    if prevjob >= 0 {
3246                        Some(prevjob as usize)
3247                    } else {
3248                        None
3249                    },
3250                );
3251                if !s.is_empty() {
3252                    println!("{}", s);
3253                }
3254            }
3255        } else if func == BIN_DISOWN {
3256            // c:2695-2727 — stopped-job warning, then c:2729
3257            // `deletejob(jobtab + job, 1)`.
3258            let mut tab = table.lock().expect("jobtab poisoned");
3259            if let Some(j) = tab.get_mut(p as usize) {
3260                if (j.stat & stat::STOPPED) != 0 {
3261                    // c:2703-2705 — `sprintf(buf, " -%d", jobtab[job].gleader)`.
3262                    zwarnnam(
3263                        name,
3264                        &format!(
3265                            "warning: job is suspended, use `kill -CONT -{}' to resume",
3266                            j.gleader
3267                        ),
3268                    ); // c:2717-2721
3269                }
3270                deletejob(j, true); // c:2729
3271            }
3272            drop(tab);
3273            // curjob/prevjob re-pick if we just disowned one of them.
3274            let cj = *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3275            if cj == p {
3276                let pj = *PREVJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap();
3277                *CURJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = pj;
3278            }
3279            setprevjob();
3280        }
3281    }
3282    unqueue_signals(); // c:2733
3283    returnval // c:2734 retval
3284}
3285
3286/// Direct port of `bin_kill(char *nam, char **argv, UNUSED(Options ops), UNUSED(int func))` from `Src/jobs.c:2772`.
3287/// Builtin entry for the `kill` command. Parses signal specifiers
3288/// (`-N` numeric, `-s NAME` symbolic, `-l` list-by-number,
3289/// `-L` tabular listing, `-n N` numeric explicit, `-q` sigqueue
3290/// rt-signal sival) then sends the chosen signal to each remaining
3291/// argv (PIDs or %jobspecs).
3292/// WARNING: param names don't match C — Rust=(nam, argv, _func) vs C=(nam, argv, ops, func)
3293pub fn bin_kill(
3294    nam: &str,
3295    argv: &[String], // c:2772
3296    _ops: &options,
3297    _func: i32,
3298) -> i32 {
3299    let mut sig: i32 = libc::SIGTERM; // c:2774
3300    let mut returnval: i32 = 0; // c:2775
3301    let mut got_sig = false; // c:2780
3302    let mut idx = 0usize;
3303
3304    // c:2782 — `while (*argv && **argv == '-')` flag-parse loop.
3305    while idx < argv.len() && argv[idx].starts_with('-') {
3306        let arg = argv[idx].clone();
3307        let body = &arg[1..];
3308
3309        // c:2814 — `else if ((*argv)[1] != '-' || (*argv)[2])` —
3310        // pseudo `--` end-of-flags.
3311        if body == "-" {
3312            // c:2814 / c:3010
3313            idx += 1;
3314            break;
3315        }
3316
3317        if got_sig {
3318            // c:2811
3319            break; // c:2812
3320        }
3321
3322        // c:2815 — `if (idigit((*argv)[1]))` — numeric signal `-N`.
3323        if body.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3324            // c:2815
3325            match body.parse::<i32>() {
3326                Ok(n) => sig = n, // c:2818
3327                Err(_) => {
3328                    zwarnnam(nam, &format!("invalid signal number: -{}", body));
3329                    return 1; // c:2822
3330                }
3331            }
3332            got_sig = true;
3333            idx += 1;
3334            continue;
3335        }
3336
3337        // c:2818 — `-l` signal-name listing.
3338        if body == "l" {
3339            // c:2818
3340            idx += 1;
3341            if idx < argv.len() {
3342                // c:2819
3343                // c:2820-2868 — per-arg lookup: numeric → name; name → number.
3344                while idx < argv.len() {
3345                    let token = &argv[idx];
3346                    idx += 1;
3347                    if let Ok(n) = token.parse::<i32>() {
3348                        // c:2821 numeric
3349                        let s = (n & !0o200) as i32; // c:2855
3350                        if let Some(name) = sigs_name(s) {
3351                            // c:2856-2858
3352                            println!("{}", name);
3353                        } else {
3354                            println!("{}", n); // c:2862
3355                        }
3356                    } else {
3357                        // c:2820-2823 — `zstrtol` parses leading
3358                        // `-`/`+` as sign + digits. For `-X` (sign
3359                        // consumed, no digit), signame points PAST
3360                        // the `-` so the diagnostic emits `SIGX` not
3361                        // `SIG-X`. C's flow then takes the `else`
3362                        // branch at c:2849-2852 which ALWAYS emits
3363                        // unknown without re-looking-up — verified vs
3364                        // /opt/homebrew/bin/zsh: `kill -l -TERM`
3365                        // emits "unknown signal: SIGTERM" rc=1 even
3366                        // though TERM IS a valid signal name. Mirror
3367                        // that: when token has a leading `-`/`+`,
3368                        // skip the lookup and emit unknown directly.
3369                        let sign_stripped = token
3370                            .strip_prefix('-')
3371                            .or_else(|| token.strip_prefix('+'));
3372                        if let Some(stripped) = sign_stripped {
3373                            let upper = stripped.to_ascii_uppercase();
3374                            let bare = upper.strip_prefix("SIG").unwrap_or(&upper);
3375                            zwarnnam(nam, &format!("unknown signal: SIG{}", bare)); // c:2851
3376                            returnval += 1;
3377                        } else {
3378                            let upper = token.to_ascii_uppercase();
3379                            let bare = upper.strip_prefix("SIG").unwrap_or(&upper);
3380                            if let Some(n) = sigs_number(bare) {
3381                                // c:2828
3382                                println!("{}", n); // c:2842
3383                            } else {
3384                                zwarnnam(nam, &format!("unknown signal: SIG{}", bare)); // c:2845
3385                                returnval += 1;
3386                            }
3387                        }
3388                    }
3389                }
3390                return returnval; // c:2868
3391            }
3392            // c:2869-2876 — bare `-l`: print every signal name.
3393            print!("{}", sigs_name(1).unwrap_or("HUP"));
3394            for s in 2..=crate::ported::signals_h::SIGCOUNT {
3395                if let Some(n) = sigs_name(s) {
3396                    print!(" {}", n);
3397                }
3398            }
3399            println!();
3400            return 0; // c:2879
3401        }
3402
3403        // c:2880 — `-L` tabular listing.
3404        if body == "L" {
3405            // c:2880
3406            let cols = 4usize;
3407            let mut col = 0usize;
3408            for s in 1..=crate::ported::signals_h::SIGCOUNT {
3409                if let Some(n) = sigs_name(s) {
3410                    print!("{:>2} {:<10}", s, n);
3411                    col += 1;
3412                    if col % cols == 0 {
3413                        println!();
3414                    } else {
3415                        print!(" ");
3416                    }
3417                }
3418            }
3419            if col % cols != 0 {
3420                println!();
3421            }
3422            return 0; // c:2911
3423        }
3424
3425        // c:2913 — `-n N` numeric signal (explicit).
3426        if body == "n" {
3427            // c:2913
3428            idx += 1;
3429            if idx >= argv.len() {
3430                // c:2916
3431                zwarnnam(nam, "-n: argument expected"); // c:2917
3432                return 1; // c:2918
3433            }
3434            match argv[idx].parse::<i32>() {
3435                // c:2920
3436                Ok(n) => {
3437                    sig = n;
3438                }
3439                Err(_) => {
3440                    zwarnnam(nam, &format!("invalid signal number: {}", argv[idx])); // c:2923
3441                    return 1;
3442                }
3443            }
3444            got_sig = true;
3445            idx += 1;
3446            continue;
3447        }
3448
3449        // c:2935 — `-s NAME` symbolic signal.
3450        if body == "s" {
3451            // c:2935
3452            idx += 1;
3453            if idx >= argv.len() {
3454                // c:2938
3455                zwarnnam(nam, "-s: argument expected"); // c:2939
3456                return 1;
3457            }
3458            let name = argv[idx].as_str();
3459            // c:Src/jobs.c — empty signal-name after `-s` emits
3460            // `-: signal name expected` rc=1 (verified vs
3461            // /opt/homebrew/bin/zsh: `kill -s "" 1` →
3462            //   "zsh:kill:1: -: signal name expected" rc=1).
3463            if name.is_empty() {
3464                zwarnnam(nam, "-: signal name expected");
3465                return 1;
3466            }
3467            let upper = name.to_ascii_uppercase();
3468            let bare = upper.strip_prefix("SIG").unwrap_or(&upper);
3469            match sigs_number(bare) {
3470                Some(n) => sig = n,
3471                None => {
3472                    zwarnnam(nam, &format!("unknown signal: SIG{}", bare)); // c:2944
3473                    return 1;
3474                }
3475            }
3476            got_sig = true;
3477            idx += 1;
3478            continue;
3479        }
3480
3481        // c:2782 — `-q VALUE` sigqueue path. zshrs treats it as
3482        // "consume the value, then continue parsing"; the actual
3483        // sival_int payload is dropped (not wired to a real
3484        // sigqueue(2) call yet — Linux-only, niche).
3485        if body == "q" {
3486            // c:2782
3487            idx += 1;
3488            if idx >= argv.len() {
3489                // c:2785
3490                zwarnnam(nam, "-q: argument expected"); // c:2786
3491                return 1;
3492            }
3493            if argv[idx].parse::<i32>().is_err() {
3494                // c:2796
3495                zwarnnam(nam, &format!("invalid number: {}", argv[idx])); // c:2797
3496                return 1;
3497            }
3498            idx += 1; // c:2802
3499            continue; // c:2803
3500        }
3501
3502        // c:2960 — symbolic `-NAME` (no `s` prefix needed).
3503        let upper = body.to_ascii_uppercase();
3504        let bare = upper.strip_prefix("SIG").unwrap_or(&upper);
3505        match sigs_number(bare) {
3506            Some(n) => {
3507                sig = n;
3508                got_sig = true;
3509                idx += 1;
3510            }
3511            None => {
3512                zwarnnam(nam, &format!("unknown signal: SIG{}", bare)); // c:2974
3513                // c:Src/jobs.c — when `-NAME` lookup fails AND there's
3514                // at least one positional remaining, zsh emits the
3515                // follow-up hint `type kill -L for a list of signals`
3516                // rc=1. The bundled C source uses capital `-L` (the
3517                // tabular listing flag added in zsh 5.9.x-dev). Older
3518                // /bin/zsh 5.9 shows lowercase `-l`; the bundled
3519                // source AND /opt/homebrew/bin/zsh 5.9.1+ use `-L`.
3520                zwarnnam(nam, "type kill -L for a list of signals");
3521                return 1;
3522            }
3523        }
3524    }
3525
3526    // c:3010 — no PID/jobspec arguments?
3527    if idx >= argv.len() {
3528        // c:3010
3529        zwarnnam(nam, "not enough arguments"); // c:3011
3530        return 1;
3531    }
3532
3533    // c:3015-3045 — for each remaining argv, parse PID or %jobspec
3534    // and send `sig`. zshrs handles bare numeric PIDs + simple
3535    // %jobspec via getjob; PIDs with leading `-` (process-group)
3536    // are forwarded via killpg.
3537    for arg in &argv[idx..] {
3538        if let Some(num) = arg.strip_prefix('-') {
3539            // c:3030
3540            // process-group kill: `-PID` → killpg(PID, sig).
3541            match num.parse::<i32>() {
3542                Ok(pgid) => {
3543                    let r = unsafe { libc::killpg(pgid, sig) }; // c:3032
3544                    if r != 0 {
3545                        // c:Src/jobs.c:2994/3022 — `zwarnnam("kill",
3546                        // "kill %s failed: %e", *argv, errno)`. `%e`
3547                        // is C's strerror-with-lowercased-first-char
3548                        // formatter (Src/utils.c:362-368, except for
3549                        // EIO). Mirror via the existing
3550                        // compat::strerror port to avoid leaking
3551                        // Rust's `(os error N)` suffix. Bug #491.
3552                        let errno = std::io::Error::last_os_error()
3553                            .raw_os_error()
3554                            .unwrap_or(libc::EINVAL);
3555                        let mut errmsg = crate::ported::compat::strerror(errno);
3556                        if errno != libc::EIO {
3557                            if let Some(c) = errmsg.chars().next() {
3558                                errmsg = format!(
3559                                    "{}{}",
3560                                    c.to_ascii_lowercase(),
3561                                    &errmsg[c.len_utf8()..]
3562                                );
3563                            }
3564                        }
3565                        zwarnnam(nam, &format!("kill {} failed: {}", arg, errmsg));
3566                        returnval = 1;
3567                    }
3568                }
3569                Err(_) => {
3570                    zwarnnam(nam, &format!("illegal pid: {}", arg));
3571                    returnval = 1;
3572                }
3573            }
3574        } else if arg.starts_with('%') {
3575            // c:2985 jobspec
3576            // c:2989 — `if ((p = getjob(*argv, nam)) == -1)`.
3577            let p = getjob(arg, nam);
3578            if p < 0 {
3579                // c:2989
3580                returnval += 1; // c:2990
3581                continue;
3582            }
3583            // c:2993 — `killjb(jobtab + p, sig)`.
3584            if killjb(p as usize, sig) == -1 {
3585                // c:2993
3586                zwarnnam(
3587                    "kill",
3588                    &format!(
3589                        "kill {} failed: {}",
3590                        arg, // c:2994
3591                        std::io::Error::last_os_error()
3592                    ),
3593                );
3594                returnval += 1; // c:2995
3595                continue;
3596            }
3597            // c:3001-3010 — if stopped + non-stopping signal,
3598            // SIGCONT after to wake the job so it processes `sig`.
3599            let stopped = JOBTAB
3600                .get_or_init(|| Mutex::new(Vec::new()))
3601                .lock()
3602                .expect("jobtab poisoned")
3603                .get(p as usize)
3604                .map(|j| j.is_stopped())
3605                .unwrap_or(false);
3606            if stopped
3607                && sig != libc::SIGKILL
3608                && sig != libc::SIGCONT
3609                && sig != libc::SIGTSTP
3610                && sig != libc::SIGTTOU
3611                && sig != libc::SIGTTIN
3612                && sig != libc::SIGSTOP
3613            {
3614                let _ = killjb(p as usize, libc::SIGCONT); // c:3009
3615            }
3616        } else {
3617            match arg.parse::<i32>() {
3618                // c:3024 PID
3619                Ok(pid) => {
3620                    let r = unsafe { libc::kill(pid, sig) }; // c:3025
3621                    if r != 0 {
3622                        // c:Src/jobs.c:2994/3022 — `zwarnnam("kill",
3623                        // "kill %s failed: %e", *argv, errno)`. `%e`
3624                        // is C's strerror-with-lowercased-first-char
3625                        // formatter (Src/utils.c:362-368, except for
3626                        // EIO). Mirror via the existing
3627                        // compat::strerror port to avoid leaking
3628                        // Rust's `(os error N)` suffix. Bug #491.
3629                        let errno = std::io::Error::last_os_error()
3630                            .raw_os_error()
3631                            .unwrap_or(libc::EINVAL);
3632                        let mut errmsg = crate::ported::compat::strerror(errno);
3633                        if errno != libc::EIO {
3634                            if let Some(c) = errmsg.chars().next() {
3635                                errmsg = format!(
3636                                    "{}{}",
3637                                    c.to_ascii_lowercase(),
3638                                    &errmsg[c.len_utf8()..]
3639                                );
3640                            }
3641                        }
3642                        zwarnnam(nam, &format!("kill {} failed: {}", arg, errmsg)); // c:3027
3643                        returnval = 1;
3644                    }
3645                }
3646                Err(_) => {
3647                    zwarnnam(nam, &format!("illegal pid: {}", arg));
3648                    returnval = 1;
3649                }
3650            }
3651        }
3652    }
3653    returnval // c:3045
3654}
3655
3656/// Signal number from name (from jobs.c getsigidx)
3657/// Port of `int getsigidx(const char *s)` from `Src/jobs.c:3047`.
3658///
3659/// **C semantics** (c:3050-3081):
3660///   1. Try atoi(s). If first char is digit AND value in
3661///      `[0, VSIGCOUNT)` OR in `[SIGRTMIN..=SIGRTMAX]`, return SIGIDX(x).
3662///   2. Strip "SIG" prefix.
3663///   3. Walk `sigs[]` table (case-sensitive strcmp).
3664///   4. Walk `alt_sigs[]` table for aliases (IOT, CLD, IO/POLL).
3665///   5. Try `rtsigno(s)` for "RTMIN+N"/"RTMAX-N" forms.
3666///   6. Return -1 (Rust returns None).
3667///
3668/// **Rust port divergences (documented Rust-port adaptations)**:
3669///   * Case-insensitive match (`to_uppercase()`) vs C's strcmp.
3670///     Rust adaptation: users often write `int` / `Int` / `INT`.
3671///   * Numeric path bounds-checks against VSIGCOUNT and the RT range
3672///     per c:3056-3058. Previously the Rust port accepted ANY
3673///     parse-able number including out-of-range values like "9999"
3674///     where C returns -1.
3675/// Build the `$signals` special-array contents: zsh's PM_ARRAY at
3676/// Src/Modules/parameter.c indexes signal names 1-based with slot
3677/// 1 = "EXIT", 2 = "HUP", 3 = "INT", … up to SIGCOUNT real signals
3678/// plus the two virtual slots (SIGZERR, SIGDEBUG) — but the canonical
3679/// `$signals` array only carries the real OS signals (no virtual
3680/// entries). Used by `arrays_get("signals")` in the subst path.
3681pub fn sig_names_for_signals_param() -> Vec<String> {
3682    let mut out: Vec<String> = Vec::with_capacity(crate::ported::signals_h::SIGCOUNT as usize + 1);
3683    // Slot 0 → "EXIT".
3684    if let Some(n) = crate::ported::signals_h::sigs_name(0) {
3685        out.push(n.to_string());
3686    }
3687    // Slots 1..=SIGCOUNT → real signal names (HUP, INT, QUIT, …).
3688    for s in 1..=crate::ported::signals_h::SIGCOUNT {
3689        if let Some(n) = crate::ported::signals_h::sigs_name(s) {
3690            out.push(n.to_string());
3691        }
3692    }
3693    // Virtual signals ZERR / DEBUG occupy the tail (SIGCOUNT+1,
3694    // SIGCOUNT+2) per c:Src/signames.c — zsh exposes them in
3695    // `$signals` after the real OS signals.
3696    if let Some(n) = crate::ported::signals_h::sigs_name(crate::ported::signals_h::SIGZERR) {
3697        out.push(n.to_string());
3698    }
3699    if let Some(n) = crate::ported::signals_h::sigs_name(crate::ported::signals_h::SIGDEBUG) {
3700        out.push(n.to_string());
3701    }
3702    out
3703}
3704/// `getsigidx` — see implementation.
3705pub fn getsigidx(s: &str) -> Option<i32> {
3706    // c:3052-3058 — numeric-input branch: bounded by VSIGCOUNT + RT range.
3707    if let Some(first) = s.chars().next() {
3708        if first.is_ascii_digit() {
3709            if let Ok(x) = s.parse::<i32>() {
3710                let vsig = crate::ported::signals_h::VSIGCOUNT;
3711                if x >= 0 && x < vsig {
3712                    return Some(x); // c:3058 SIGIDX(x) = x in standard range
3713                }
3714                #[cfg(target_os = "linux")]
3715                {
3716                    // `libc::SIGRTMIN()` / `SIGRTMAX()` are `extern "C" fn`
3717                    // (NOT `unsafe`) on Linux — they're glibc functions
3718                    // that read runtime values. The unsafe block was a
3719                    // copy-paste leftover from when these were macros.
3720                    let sigrtmin = libc::SIGRTMIN();
3721                    let sigrtmax = libc::SIGRTMAX();
3722                    if x >= sigrtmin && x <= sigrtmax {
3723                        return Some(crate::ported::signals_h::SIGIDX(x)); // c:3058
3724                    }
3725                }
3726                // c:3081 — out-of-range numeric input returns -1 (None).
3727                return None;
3728            }
3729        }
3730    }
3731    let s = s.strip_prefix("SIG").unwrap_or(s);
3732    match s.to_uppercase().as_str() {
3733        "EXIT" => Some(0),
3734        // c:Src/signames.c:62-98 + jobs.c:2761 — zsh-internal virtual
3735        // signals: ZERR/DEBUG are SIGCOUNT+1 / SIGCOUNT+2; ERR aliases
3736        // ZERR when SIGERR isn't OS-defined (the common POSIX case
3737        // since most kernels don't ship a SIGERR signal).
3738        "ZERR" | "ERR" => Some(crate::ported::signals_h::SIGZERR),
3739        "DEBUG" => Some(crate::ported::signals_h::SIGDEBUG),
3740        "HUP" => Some(libc::SIGHUP),
3741        "INT" => Some(libc::SIGINT),
3742        "QUIT" => Some(libc::SIGQUIT),
3743        "ILL" => Some(libc::SIGILL),
3744        "TRAP" => Some(libc::SIGTRAP),
3745        "ABRT" | "IOT" => Some(libc::SIGABRT),
3746        "BUS" => Some(libc::SIGBUS),
3747        "FPE" => Some(libc::SIGFPE),
3748        "KILL" => Some(libc::SIGKILL),
3749        "USR1" => Some(libc::SIGUSR1),
3750        "SEGV" => Some(libc::SIGSEGV),
3751        "USR2" => Some(libc::SIGUSR2),
3752        "PIPE" => Some(libc::SIGPIPE),
3753        "ALRM" => Some(libc::SIGALRM),
3754        "TERM" => Some(libc::SIGTERM),
3755        "CHLD" | "CLD" => Some(libc::SIGCHLD),
3756        "CONT" => Some(libc::SIGCONT),
3757        "STOP" => Some(libc::SIGSTOP),
3758        "TSTP" => Some(libc::SIGTSTP),
3759        "TTIN" => Some(libc::SIGTTIN),
3760        "TTOU" => Some(libc::SIGTTOU),
3761        "URG" => Some(libc::SIGURG),
3762        "XCPU" => Some(libc::SIGXCPU),
3763        "XFSZ" => Some(libc::SIGXFSZ),
3764        "VTALRM" => Some(libc::SIGVTALRM),
3765        "PROF" => Some(libc::SIGPROF),
3766        "WINCH" => Some(libc::SIGWINCH),
3767        "IO" | "POLL" => Some(libc::SIGIO),
3768        "SYS" => Some(libc::SIGSYS),
3769        _ => {
3770            // c:3075-3078 — `if ((x = rtsigno(s))) return SIGIDX(x);`
3771            // Parse "RTMIN+N" / "RTMAX-N" via the canonical helper
3772            // and convert the resulting signum to its trap-table
3773            // index via SIGIDX.
3774            #[cfg(target_os = "linux")]
3775            {
3776                if let Some(signum) = crate::ported::signals::rtsigno(s) {
3777                    // c:3075
3778                    return Some(crate::ported::signals_h::SIGIDX(signum)); // c:3076
3779                }
3780            }
3781            None // c:3081 return -1
3782        }
3783    }
3784}
3785
3786/// Get the signal name for signal-based job output (from jobs.c getsigname)
3787/// Port of `getsigname(int sig)` from `Src/jobs.c:3087`.
3788pub fn getsigname(sig: i32) -> String {
3789    // c:Src/signames.c — virtual signal names. SIGZERR/SIGDEBUG sit
3790    // PAST the libc kernel-signal range (SIGCOUNT+1/+2) and have no
3791    // libc constant; match them explicitly so the dotrap dispatcher
3792    // can build `TRAPZERR` / `TRAPDEBUG` instead of `TRAPSIG32`/`SIG33`.
3793    // Bug #389.
3794    if sig == crate::ported::signals_h::SIGZERR {
3795        return "ZERR".to_string();
3796    }
3797    if sig == crate::ported::signals_h::SIGDEBUG {
3798        return "DEBUG".to_string();
3799    }
3800    match sig {
3801        0 => "EXIT".to_string(),
3802        libc::SIGHUP => "HUP".to_string(),
3803        libc::SIGINT => "INT".to_string(),
3804        libc::SIGQUIT => "QUIT".to_string(),
3805        libc::SIGILL => "ILL".to_string(),
3806        libc::SIGTRAP => "TRAP".to_string(),
3807        libc::SIGABRT => "ABRT".to_string(),
3808        libc::SIGBUS => "BUS".to_string(),
3809        libc::SIGFPE => "FPE".to_string(),
3810        libc::SIGKILL => "KILL".to_string(),
3811        libc::SIGUSR1 => "USR1".to_string(),
3812        libc::SIGSEGV => "SEGV".to_string(),
3813        libc::SIGUSR2 => "USR2".to_string(),
3814        libc::SIGPIPE => "PIPE".to_string(),
3815        libc::SIGALRM => "ALRM".to_string(),
3816        libc::SIGTERM => "TERM".to_string(),
3817        libc::SIGCHLD => "CHLD".to_string(),
3818        libc::SIGCONT => "CONT".to_string(),
3819        libc::SIGSTOP => "STOP".to_string(),
3820        libc::SIGTSTP => "TSTP".to_string(),
3821        libc::SIGTTIN => "TTIN".to_string(),
3822        libc::SIGTTOU => "TTOU".to_string(),
3823        libc::SIGURG => "URG".to_string(),
3824        libc::SIGXCPU => "XCPU".to_string(),
3825        libc::SIGXFSZ => "XFSZ".to_string(),
3826        libc::SIGVTALRM => "VTALRM".to_string(),
3827        libc::SIGPROF => "PROF".to_string(),
3828        libc::SIGWINCH => "WINCH".to_string(),
3829        libc::SIGIO => "IO".to_string(),
3830        libc::SIGSYS => "SYS".to_string(),
3831        _ => {
3832            // c:3099-3101 — `if (sig >= VSIGCOUNT) return rtsigname(SIGNUM(sig), 0);`
3833            // RT-signal range (Linux SIGRTMIN..SIGRTMAX) maps to
3834            // "RTMIN+N"/"RTMAX-N" via the canonical rtsigname helper.
3835            // The previous Rust port emitted `SIG{sig}` for every
3836            // unknown signal — losing the RT-signal naming entirely.
3837            #[cfg(target_os = "linux")]
3838            {
3839                // glibc `SIGRTMIN()`/`SIGRTMAX()` are safe extern ported.
3840                let sigrtmin = libc::SIGRTMIN();
3841                let sigrtmax = libc::SIGRTMAX();
3842                if sig >= sigrtmin && sig <= sigrtmax {
3843                    // c:3100
3844                    let nm = crate::ported::signals::rtsigname(sig); // c:3101 rtsigname(SIGNUM(sig), 0)
3845                    if !nm.is_empty() {
3846                        return nm;
3847                    }
3848                }
3849            }
3850            format!("SIG{}", sig)
3851        }
3852    }
3853}
3854
3855/// Port of `gettrapnode(int sig, int ignoredisable)` from `Src/jobs.c:3115`.
3856///
3857/// C body looks up `TRAP<signame>` in the `shfunctab` (shell-
3858/// function hashtable) using either `getnode` (skip disabled) or
3859/// `getnode2` (include disabled), depending on `ignoredisable`.
3860/// Falls back to `alt_sigs[]` aliases (e.g. `TRAPCLD` for
3861/// SIGCHLD) when the canonical `TRAP<getsigname(sig)>` form
3862/// isn't found.
3863///
3864/// Returns the matched node's NAME (mirroring C's `hn->nam`
3865/// usage at every caller), or `None` if no trap is registered
3866/// under any canonical or alt name for this signal.
3867pub fn gettrapnode(sig: i32, ignoredisable: bool) -> Option<String> {
3868    // c:3115
3869    // c:3117 — char fname[20];
3870    // c:3119 — HashNode (*getptr)(HashTable ht, const char *name);
3871    // c:3121-3124 — getptr = ignoredisable ? getnode2 : getnode;
3872    let tab = crate::ported::hashtable::shfunctab_lock()
3873        .read()
3874        .expect("shfunctab poisoned");
3875    let getptr = |name: &str| -> Option<String> {
3876        let hit = if ignoredisable {
3877            tab.get_including_disabled(name) // c:3122 getnode2
3878        } else {
3879            tab.get(name) // c:3124 getnode
3880        };
3881        hit.map(|f| f.node.nam.clone())
3882    };
3883    // c:3131 — sprintf(fname, "TRAP%s", sigs[sig]);
3884    let fname = format!("TRAP{}", getsigname(sig));
3885    // c:3132 — if ((hn = getptr(shfunctab, fname))) return hn;
3886    if let Some(n) = getptr(&fname) {
3887        return Some(n);
3888    }
3889    // c:3142-3148 — for (i = 0; alt_sigs[i].name; i++)
3890    //                 if (alt_sigs[i].num == sig) {
3891    //                     sprintf(fname, "TRAP%s", alt_sigs[i].name);
3892    //                     if ((hn = getptr(shfunctab, fname))) return hn;
3893    //                 }
3894    for (alt_name, alt_num) in crate::ported::signals_h::ALT_SIGS.iter() {
3895        if *alt_num == sig {
3896            let fname = format!("TRAP{}", alt_name);
3897            if let Some(n) = getptr(&fname) {
3898                return Some(n);
3899            }
3900        }
3901    }
3902    // c:3150 — return NULL;
3903    None
3904}
3905
3906/// Port of `removetrapnode(int sig)` from `Src/jobs.c:3157`.
3907///
3908/// C body:
3909/// ```c
3910/// HashNode hn = gettrapnode(sig, 1);
3911/// if (hn) { shfunctab->removenode(shfunctab, hn->nam); shfunctab->freenode(hn); }
3912/// ```
3913///
3914/// Routes through `hashtable::removeshfuncnode` which itself
3915/// dispatches the trap-removal logic for `TRAP<sig>` names.
3916pub fn removetrapnode(sig: i32) {
3917    let name = format!("TRAP{}", getsigname(sig));
3918    crate::ported::hashtable::removeshfuncnode(&name);
3919}
3920
3921/// Direct port of `bin_suspend(char *name, UNUSED(char **argv), Options ops, UNUSED(int func))` from `Src/jobs.c:3170`.
3922/// C body (c:3173-3197):
3923/// ```c
3924/// if (islogin && !OPT_ISSET(ops,'f')) { error; return 1; }
3925/// if (jobbing) { signal_default(SIGTTIN/TSTP/TTOU); release_pgrp(); }
3926/// killpg(origpgrp, SIGTSTP);
3927/// if (jobbing) { acquire_pgrp(); signal_ignore(SIGTTOU/TSTP/TTIN); }
3928/// return 0;
3929/// ```
3930/// WARNING: param names don't match C — Rust=(name, _argv, _func) vs C=(name, argv, ops, func)
3931pub fn bin_suspend(
3932    name: &str,
3933    _argv: &[String], // c:3170
3934    ops: &options,
3935    _func: i32,
3936) -> i32 {
3937    // c:3173 — `if (islogin && !OPT_ISSET(ops,'f'))`. C reads the
3938    //          `islogin` global, set when zsh's `argv[0]` started with
3939    //          `-`. Probe `$0` via paramtab (was reading the OS env,
3940    //          which never carries a literal `$0`).
3941    let islogin = getsparam("0").map(|s| s.starts_with('-')).unwrap_or(false);
3942    //won't suspend a login shell, unless forced
3943    if islogin && !OPT_ISSET(ops, b'f') {
3944        // c:3173
3945        zwarnnam(name, "can't suspend login shell"); // c:3174
3946        return 1; // c:3175
3947    }
3948    // c:3177 — `if (jobbing)`. jobbing is the job-control-enabled flag;
3949    // tracks the MONITOR option.
3950    let jobbing = isset(MONITOR);
3951
3952    if jobbing {
3953        // c:3177
3954        //stop ignoring signals
3955        signal_default(libc::SIGTTIN); // c:3179
3956        signal_default(libc::SIGTSTP); // c:3180
3957        signal_default(libc::SIGTTOU); // c:3181
3958                                       //Move ourselves back to the process group we came from
3959        release_pgrp(); // c:3184
3960    }
3961
3962    // suspend ourselves with a SIGTSTP                                      // c:3187
3963    let origpgrp = ORIGPGRP
3964        .get_or_init(|| Mutex::new(0))
3965        .lock()
3966        .map(|g| *g)
3967        .unwrap_or(0);
3968    unsafe {
3969        libc::killpg(origpgrp, libc::SIGTSTP);
3970    } // c:3188
3971
3972    if jobbing {
3973        // c:3190
3974        let _ = acquire_pgrp(); // c:3191
3975                                //restore signal handling
3976        signal_ignore(libc::SIGTTOU); // c:3193
3977        signal_ignore(libc::SIGTSTP); // c:3194
3978        signal_ignore(libc::SIGTTIN); // c:3195
3979    }
3980    0 // c:3197
3981}
3982
3983/// Port of `findjobnam(const char *s)` from `Src/jobs.c:3204`.
3984///
3985/// C signature: `int findjobnam(const char *s)`
3986///
3987/// Internal helper uses passed table to avoid re-locking.
3988/// WARNING: param names don't match C — Rust=(s, jobtab, maxjob, thisjob) vs C=(s)
3989pub(crate) fn findjobnam(s: &str, jobtab: &[job], maxjob: i32, thisjob: i32) -> Option<i32> {
3990    let mut jobnum = maxjob; // c:2037
3991    while jobnum >= 0 {
3992        // c:2037
3993        let ju = jobnum as usize;
3994        if ju < jobtab.len()
3995            && jobtab[ju].stat != 0                                          // c:2038
3996            && (jobtab[ju].stat & stat::SUBJOB) == 0                         // c:2039
3997            && jobnum != thisjob
3998        // c:2040
3999        {
4000            // C: if (!strncmp(jobtab[jobnum].procs->text, s, strlen(s)))    // c:2041
4001            if let Some(first_proc) = jobtab[ju].procs.first() {
4002                if first_proc.text.starts_with(s) {
4003                    return Some(jobnum); // c:2042-2043
4004                }
4005            }
4006        }
4007        jobnum -= 1;
4008    }
4009    None // c:2046-2047
4010}
4011
4012/// Direct port of `acquire_pgrp()` from `Src/jobs.c:3222`.
4013/// C body (c:3225-3278): block SIGTTIN/SIGTTOU/SIGTSTP, then loop
4014/// while the tty's pgrp differs from ours — re-fetch our pgrp,
4015/// optionally call `attachtty()` to claim the tty (with signal
4016/// unblock + reblock around the call so SIGT* fires correctly), or
4017/// trigger `read(0, NULL, 0)` to provoke a SIGT* if we're not yet
4018/// the session leader. Bail after 100 iterations or a stable pgrp
4019/// in non-interactive mode. If still not in foreground, `setpgrp(0, 0)`
4020/// to claim, or disable MONITOR option as last resort.
4021///
4022/// ```c
4023/// long ttpgrp;
4024/// sigset_t blockset, oldset;
4025/// if ((mypgrp = GETPGRP()) >= 0) {
4026///     long lastpgrp = mypgrp;
4027///     sigemptyset(&blockset);
4028///     sigaddset(&blockset, SIGTTIN); /* SIGTTOU; SIGTSTP */
4029///     oldset = signal_block(&blockset);
4030///     int loop_count = 0;
4031///     while ((ttpgrp = gettygrp()) != -1 && ttpgrp != mypgrp) {
4032///         /* re-attach + read(0) probes; bail after 100 loops */
4033///     }
4034///     if (mypgrp != mypid) {
4035///         if (setpgrp(0, 0) == 0) attachtty(mypgrp);
4036///         else opts[MONITOR] = 0;
4037///     }
4038///     signal_setmask(&oldset);
4039/// } else opts[MONITOR] = 0;
4040/// ```
4041#[cfg(unix)]
4042/// Port of `acquire_pgrp` from `Src/jobs.c:3222`.
4043pub fn acquire_pgrp() -> bool {
4044    // c:3222
4045    let mypid = unsafe { libc::getpid() };
4046    // C `mypgrp` is a SINGLE global written all through acquire_pgrp and
4047    // read by attachtty / getquery / the history tty-reclaim. zshrs split
4048    // it into TWO globals — clone::mypgrp (AtomicI32) and jobs::MYPGRP
4049    // (OnceLock) — so the single C `mypgrp = …` must update BOTH (same
4050    // paired-global rule as lexstop / strin). Without this, acquire_pgrp
4051    // left both at 0 and the first `attachtty(mypgrp)` ran
4052    // `tcsetpgrp(tty, 0)` → EPERM ("can't set tty pgrp") on an interactive
4053    // shell. Closure (not a `fn` item) so the src/ported port-gate is fine.
4054    let sync_mypgrp = |v: i32| {
4055        crate::ported::modules::clone::mypgrp.store(v, Ordering::Relaxed);
4056        *MYPGRP.get_or_init(|| Mutex::new(0)).lock().unwrap() = v;
4057    };
4058    let mut mypgrp = unsafe { libc::getpgrp() }; // c:3227 GETPGRP()
4059    sync_mypgrp(mypgrp); // c:3227 — `mypgrp = GETPGRP()` (global)
4060    if mypgrp < 0 {
4061        opt_state_set("monitor", false); // c:3275 opts[MONITOR]=0
4062        return false;
4063    }
4064    let mut lastpgrp = mypgrp; // c:3228
4065                               // c:3229-3232 — sigemptyset + sigaddset(SIGTTIN/SIGTTOU/SIGTSTP).
4066    let mut blockset: libc::sigset_t = unsafe { std::mem::zeroed() };
4067    unsafe {
4068        libc::sigemptyset(&mut blockset);
4069        libc::sigaddset(&mut blockset, libc::SIGTTIN); // c:3230
4070        libc::sigaddset(&mut blockset, libc::SIGTTOU); // c:3231
4071        libc::sigaddset(&mut blockset, libc::SIGTSTP); // c:3232
4072    }
4073    let oldset = signal_block(&blockset); // c:3233
4074    let mut loop_count = 0i32; // c:3234
4075    let interact = isset(INTERACTIVE);
4076    // c:3235 — `while ((ttpgrp = gettygrp()) != -1 && ttpgrp != mypgrp)`.
4077    loop {
4078        let ttpgrp = unsafe { libc::tcgetpgrp(0) }; // c:3235 gettygrp
4079        if ttpgrp == -1 || ttpgrp == mypgrp {
4080            break;
4081        }
4082        mypgrp = unsafe { libc::getpgrp() }; // c:3236
4083        sync_mypgrp(mypgrp); // c:3236 (global)
4084        if mypgrp == mypid {
4085            // c:3237
4086            if !interact {
4087                break;
4088            } // c:3239 attachtty no-op
4089            signal_setmask(&oldset); // c:3240
4090            crate::ported::utils::attachtty(mypgrp); // c:3241 attachtty(mypgrp)
4091            signal_block(&blockset); // c:3242
4092        }
4093        if mypgrp == unsafe { libc::tcgetpgrp(0) } {
4094            break;
4095        } // c:3244 gettygrp
4096        signal_setmask(&oldset); // c:3246
4097                                 // c:3247 — `if (read(0, NULL, 0) != 0) {}` — probe to provoke SIGT*.
4098        let mut buf: [u8; 0] = [];
4099        let _ = unsafe { libc::read(0, buf.as_mut_ptr() as *mut _, 0) }; // c:3247
4100        signal_block(&blockset); // c:3248
4101        mypgrp = unsafe { libc::getpgrp() }; // c:3249
4102        sync_mypgrp(mypgrp); // c:3249 (global)
4103        if mypgrp == lastpgrp {
4104            // c:3250
4105            if !interact {
4106                break;
4107            } // c:3252
4108            loop_count += 1;
4109            if loop_count == 100 {
4110                // c:3253
4111                break; // c:3261
4112            }
4113        }
4114        lastpgrp = mypgrp; // c:3265
4115    }
4116    // c:3267 — `if (mypgrp != mypid) { if (setpgrp(0, 0) == 0) ...; else opts[MONITOR] = 0; }`
4117    let mut acquired = mypgrp == mypid; // c:3267
4118    if !acquired {
4119        if unsafe { libc::setpgid(0, 0) } == 0 {
4120            // c:3268 setpgrp
4121            mypgrp = mypid; // c:3269
4122            sync_mypgrp(mypgrp); // c:3269 (global)
4123            crate::ported::utils::attachtty(mypgrp); // c:3270 attachtty(mypgrp)
4124            acquired = true;
4125        } else {
4126            opt_state_set("monitor", false); // c:3272 opts[MONITOR]=0
4127        }
4128    }
4129    sync_mypgrp(mypgrp); // resolved value visible to later attachtty readers
4130    signal_setmask(&oldset); // c:3274
4131    acquired // c:3278
4132}
4133
4134/// Port of `release_pgrp()` from `Src/jobs.c:3283`.
4135///
4136/// C body:
4137/// ```c
4138/// if (origpgrp != mypgrp) {
4139///     if (origpgrp) {
4140///         attachtty(origpgrp);
4141///         setpgrp(0, origpgrp);
4142///     }
4143///     mypgrp = origpgrp;
4144/// }
4145/// ```
4146///
4147///
4148/// Restores the original (parent shell's) process group before
4149/// the current shell exits, so terminal control returns to the
4150/// invoker.
4151#[cfg(unix)]
4152pub fn release_pgrp() {
4153    // c:3283
4154    let origpgrp = *ORIGPGRP
4155        .get_or_init(|| Mutex::new(0))
4156        .lock()
4157        .expect("origpgrp poisoned");
4158    let mypgrp = *MYPGRP
4159        .get_or_init(|| Mutex::new(0))
4160        .lock()
4161        .expect("mypgrp poisoned");
4162    if origpgrp != mypgrp {
4163        // c:3285
4164        // in linux pid namespaces, origpgrp may never have been set         // c:3286
4165        if origpgrp != 0 {
4166            // c:3287
4167            unsafe {
4168                // attachtty(origpgrp);                                      // c:3288
4169                libc::tcsetpgrp(0, origpgrp);
4170                libc::setpgid(0, origpgrp); // c:3289
4171            }
4172        }
4173        *MYPGRP
4174            .get_or_init(|| Mutex::new(0)) // c:3291
4175            .lock()
4176            .expect("mypgrp poisoned") = origpgrp;
4177    }
4178}
4179
4180// SP_RUNNING / MAX_PIPESTATS / MAXJOBS_ALLOC moved to canonical home
4181// at zsh_h.rs (ports of `Src/zsh.h:1097/1107/1166`). Re-export here
4182// so existing jobs.rs callers keep their unqualified usage, with
4183// single-source-of-truth values that can never drift from zsh_h.rs.
4184//
4185// Same consolidation pattern as the prior HISTFLAG_* / SUB_START /
4186// TERM_UNKNOWN fixes — duplicate const declarations are a known
4187// drift hazard.
4188
4189// the process group of the shell at startup                                 // c:54
4190/// Port of `origpgrp` from `Src/jobs.c:58`.
4191pub static ORIGPGRP: OnceLock<Mutex<i32>> = OnceLock::new();
4192
4193// the process group of the shell                                            // c:60
4194/// Port of `mypgrp` from `Src/jobs.c:63`.
4195pub static MYPGRP: OnceLock<Mutex<i32>> = OnceLock::new();
4196
4197// the last process group to attach to the terminal                          // c:66
4198/// Port of `last_attached_pgrp` from `Src/jobs.c:68`.
4199pub static LAST_ATTACHED_PGRP: OnceLock<Mutex<i32>> = OnceLock::new();
4200
4201// the job we are working on, or -1 if none                                  // c:70
4202/// Port of `thisjob` from `Src/jobs.c:73`.
4203pub static THISJOB: OnceLock<Mutex<i32>> = OnceLock::new();
4204
4205// the current job (%+)                                                      // c:75
4206/// Port of `curjob` from `Src/jobs.c:78`.
4207pub static CURJOB: OnceLock<Mutex<i32>> = OnceLock::new();
4208
4209// the previous job (%-) */                                                  // c:80
4210/// Port of `prevjob` from `Src/jobs.c:83`.
4211pub static PREVJOB: OnceLock<Mutex<i32>> = OnceLock::new();
4212
4213// the job table                                                             // c:85
4214/// Port of `jobtab` from `Src/jobs.c:88`.
4215pub static JOBTAB: OnceLock<Mutex<Vec<job>>> = OnceLock::new();
4216
4217// Size of the job table.                                                    // c:91
4218/// Port of `jobtabsize` from `Src/jobs.c:93`.
4219pub static JOBTABSIZE: OnceLock<Mutex<usize>> = OnceLock::new();
4220
4221// The highest numbered job in the jobtable                                  // c:96
4222/// Port of `maxjob` from `Src/jobs.c:98`.
4223pub static MAXJOB: OnceLock<Mutex<usize>> = OnceLock::new();
4224
4225// If we have entered a subshell, the original shell's job table.            // c:100
4226/// Port of `oldjobtab` from `Src/jobs.c:101`.
4227static OLDJOBTAB: OnceLock<Mutex<Vec<job>>> = OnceLock::new();
4228
4229// The size of that.                                                         // c:103
4230/// Port of `oldmaxjob` from `Src/jobs.c:104`.
4231static OLDMAXJOB: OnceLock<Mutex<usize>> = OnceLock::new();
4232
4233// 1 if ttyctl -f has been executed                                          // c:119
4234/// Port of `ttyfrozen` from `Src/jobs.c:721`.
4235pub static TTYFROZEN: OnceLock<Mutex<i32>> = OnceLock::new();
4236
4237// pipestats array                                                           // c:131
4238/// Port of `numpipestats` from `Src/jobs.c:721`.
4239pub static NUMPIPESTATS: OnceLock<Mutex<usize>> = OnceLock::new();
4240/// Port of `pipestats` from `Src/jobs.c:721`.
4241pub static PIPESTATS: OnceLock<Mutex<[i32; MAX_PIPESTATS]>> = OnceLock::new();
4242
4243/// Default time format (from jobs.c DEFAULT_TIMEFMT)
4244pub const DEFAULT_TIMEFMT: &str = "%J  %U user %S system %P cpu %*E total";
4245
4246/// Port of `static void waitonejob(Job jn)` from `Src/jobs.c:1748-1757`.
4247///
4248/// C body:
4249/// ```c
4250/// static void waitonejob(Job jn)
4251/// {
4252///     if (jn->procs || jn->auxprocs)
4253///         zwaitjob(jn - jobtab, 0);
4254///     else {
4255///         deletejob(jn, 0);
4256///         pipestats[0] = lastval;
4257///         numpipestats = 1;
4258///     }
4259/// }
4260/// ```
4261pub fn waitonejob(jn: &mut job) {
4262    // c:1750 — `if (jn->procs || jn->auxprocs)`
4263    if !jn.procs.is_empty() || !jn.auxprocs.is_empty() {
4264        // c:1751 — `zwaitjob(jn - jobtab, 0);` — pass job by reference
4265        // (Rust port takes &mut job vs C's jobtab-relative index since
4266        // jobs.rs's JOBTAB lookup-by-pointer-arithmetic isn't ported).
4267        zwaitjob(jn, 0);
4268    } else {
4269        // c:1753 — `deletejob(jn, 0);`
4270        deletejob(jn, false);
4271        // c:1754 — `pipestats[0] = lastval;`
4272        let lastval = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
4273        let p = PIPESTATS.get_or_init(|| Mutex::new([0; MAX_PIPESTATS]));
4274        if let Ok(mut pguard) = p.lock() {
4275            pguard[0] = lastval; // c:1754
4276        }
4277        // c:1755 — `numpipestats = 1;`
4278        let n = NUMPIPESTATS.get_or_init(|| Mutex::new(0));
4279        if let Ok(mut nguard) = n.lock() {
4280            *nguard = 1; // c:1755
4281        }
4282        // c:Src/params.c:5232 pipestatus_gsu — `$pipestatus` reads
4283        // walk the C `pipestats[]` array. zshrs's paramtab fast-path
4284        // reads from `paramtab["pipestatus"]` so mirror the C array
4285        // into the param table for visibility.
4286        crate::ported::params::setaparam("pipestatus", vec![lastval.to_string()]);
4287    }
4288}
4289
4290// See if pid has a recorded exit status.                                   // c:2397
4291// Note we make no guarantee that the PIDs haven't wrapped, so this         // c:2397
4292// may not be the right process.                                            // c:2397
4293//                                                                          // c:2397
4294// This is only used by wait, which must only work on each                  // c:2397
4295// pid once, so we need to remove the entry if we find it.                  // c:2397
4296/// Direct port of `int getbgstatus(pid_t pid)` from `Src/jobs.c:2397`.
4297/// Walks the global `bgstatus_list` for `pid`; if found, removes
4298/// the entry and returns its status.
4299pub fn getbgstatus(pid: i32) -> Option<i32> {
4300    // c:2397
4301    if let Ok(mut list) = bgstatus_list.lock() {
4302        if let Some(idx) = list.iter().position(|b| b.pid == pid) {
4303            // c:2402-2406
4304            let status = list[idx].status;
4305            list.remove(idx); // c:2407 rembgstatus
4306            bgstatus_count.fetch_sub(1, Ordering::Relaxed);
4307            return Some(status);
4308        }
4309    }
4310    None
4311}
4312
4313// ===========================================================
4314// Methods moved verbatim from src/ported/vm_helper because their
4315// C counterpart's source file maps 1:1 to this Rust module.
4316// Rust permits multiple inherent impl blocks for the same
4317// type within a crate, so call sites in vm_helper are unchanged.
4318// ===========================================================
4319
4320// BEGIN moved-from-exec-rs
4321// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
4322
4323// END moved-from-exec-rs
4324
4325#[cfg(test)]
4326mod tests {
4327    use super::*;
4328    use crate::ported::zsh_h::{STAT_BUILTIN, STAT_CHANGED, STAT_DONE, STAT_STOPPED, STAT_TIMED};
4329
4330    /// printtime expands rusage directives (`%M`/`%F`/`%R`/`%c`/`%w`) from a
4331    /// `timeinfo` argument. The directive set was untyped before the rusage
4332    /// fields landed on `timeinfo` — pin every directive to its source field.
4333    #[test]
4334    fn printtime_emits_rusage_directives() {
4335        let _g = crate::test_util::global_state_lock();
4336        let ti = timeinfo {
4337            ut: 500_000,
4338            st: 250_000,
4339            maxrss: 4096,
4340            majflt: 12,
4341            minflt: 345,
4342            nswap: 0,
4343            ixrss: 0,
4344            idrss: 0,
4345            isrss: 0,
4346            inblock: 7,
4347            oublock: 3,
4348            nvcsw: 99,
4349            nivcsw: 11,
4350            msgsnd: 0,
4351            msgrcv: 0,
4352            nsignals: 0,
4353        };
4354        let s = printtime(1.0, &ti, "%M/%F/%R/%I/%O/%c/%w", "my-job");
4355        assert_eq!(s, "4096/12/345/7/3/11/99");
4356    }
4357
4358    /// Verify percent (`%P`) uses (user+sys)/elapsed and rounds to int.
4359    #[test]
4360    fn printtime_percent_directive() {
4361        let _g = crate::test_util::global_state_lock();
4362        let ti = timeinfo {
4363            ut: 600_000,
4364            st: 400_000,
4365            ..Default::default()
4366        };
4367        // total=1.0s, elapsed=2.0s → 50%
4368        let s = printtime(2.0, &ti, "%P", "j");
4369        assert_eq!(s, "50%");
4370    }
4371
4372    /// `printtime %P` MUST guard against divide-by-zero when elapsed
4373    /// is 0.0 (instantaneous job or wall-clock timer didn't tick).
4374    /// A panic here would crash the shell mid-prompt-display every
4375    /// time `time` ran a no-op like `time :`. The C body at c:614-618
4376    /// has the `if (elapsed_secs > 0.0)` guard explicitly; the Rust
4377    /// port mirrors via the `if elapsed_secs > 0.0 { ... } else { 0 }`
4378    /// branch. Pin: input `(elapsed=0, user=0, sys=0)` → "0%", no panic.
4379    #[test]
4380    fn printtime_percent_zero_elapsed_no_panic() {
4381        let _g = crate::test_util::global_state_lock();
4382        let ti = timeinfo::default();
4383        // The catch_unwind wrapper isolates a potential panic so the
4384        // test reports a clean failure instead of crashing the harness.
4385        let result = std::panic::catch_unwind(|| printtime(0.0, &ti, "%P", "j"));
4386        let s = result.expect("c:614 — zero elapsed must NOT panic");
4387        assert_eq!(
4388            s, "0%",
4389            "c:615-618 — zero-elapsed percent must yield 0%, not NaN/Inf"
4390        );
4391    }
4392
4393    /// `printtime %P` truncates toward zero — matches C's `(int)`
4394    /// cast at c:893 (`int percent = 100.0 * total_time / elapsed;`).
4395    /// A regression that rounds-to-nearest (e.g. `.round()` instead
4396    /// of `as i32`) would report 100% for a job that used 99.6% CPU,
4397    /// hiding the small slack. Pin: 0.996s CPU / 1s elapsed → 99%.
4398    #[test]
4399    fn printtime_percent_truncates_toward_zero() {
4400        let _g = crate::test_util::global_state_lock();
4401        let ti = timeinfo {
4402            ut: 996_000,
4403            st: 0,
4404            ..Default::default()
4405        };
4406        let s = printtime(1.0, &ti, "%P", "j");
4407        assert_eq!(
4408            s, "99%",
4409            "c:893 — `(int)` cast truncates 99.6 → 99, not rounds to 100"
4410        );
4411    }
4412
4413    /// `%J` substitutes the job name verbatim.
4414    #[test]
4415    fn printtime_jobname_directive() {
4416        let _g = crate::test_util::global_state_lock();
4417        let ti = timeinfo::default();
4418        let s = printtime(0.0, &ti, "[%J]", "my command");
4419        assert_eq!(s, "[my command]");
4420    }
4421
4422    /// Time-form directives `%E`/`%U`/`%S` render seconds with `s` suffix.
4423    #[test]
4424    fn printtime_time_directives() {
4425        let _g = crate::test_util::global_state_lock();
4426        let ti = timeinfo {
4427            ut: 1_500_000,
4428            st: 500_000,
4429            ..Default::default()
4430        };
4431        let s = printtime(2.5, &ti, "%E %U %S", "j");
4432        assert_eq!(s, "2.50s 1.50s 0.50s");
4433    }
4434
4435    /// `%*E` / `%*U` / `%*S` use the `printhhmmss` HH:MM:SS form.
4436    /// Pin c:876-891 dispatch — the `*` modifier routes the directive
4437    /// to printhhmmss instead of the plain `{:.2}s` formatter. A
4438    /// regression that drops the `*` arm would silently fall back to
4439    /// the literal "%*E" output, breaking the `$TIMEFMT` default
4440    /// `%*E` slot most users have configured.
4441    #[test]
4442    fn printtime_star_directive_routes_to_hhmmss() {
4443        let _g = crate::test_util::global_state_lock();
4444        let ti = timeinfo::default();
4445        // 75 seconds → "1:15.00" (M:SS form, no hours).
4446        let s = printtime(75.0, &ti, "%*E", "j");
4447        assert_eq!(
4448            s, "1:15.00",
4449            "c:876-880 — %*E must route to printhhmmss for elapsed >= 60s"
4450        );
4451        // 3725s (1h2m5s) → "1:02:05.00" (H:MM:SS form).
4452        let s_hr = printtime(3725.0, &ti, "%*E", "j");
4453        assert_eq!(
4454            s_hr, "1:02:05.00",
4455            "c:880 + printhhmmss c:815-816 — elapsed >= 3600s yields H:MM:SS"
4456        );
4457    }
4458
4459    /// `should_report_time` honors `$REPORTMEMORY`: a job whose
4460    /// `maxrss` exceeds the threshold should trigger the report.
4461    #[test]
4462    fn should_report_time_uses_reportmemory() {
4463        let _g = crate::test_util::global_state_lock();
4464        // Clear PARAMTAB state so this test's REPORTMEMORY isn't
4465        // contaminated by earlier tests.
4466        setsparam("REPORTMEMORY", "100");
4467        let mut job = job::default();
4468        let mut proc = process::new(123);
4469        proc.ti.maxrss = 256; // > 100 KB threshold
4470        proc.bgtime = Some(Instant::now());
4471        proc.endtime = Some(Instant::now());
4472        job.procs.push(proc);
4473        job.stat = stat::INUSE;
4474        assert!(should_report_time(&job, -1.0));
4475        unsetparam("REPORTMEMORY");
4476    }
4477
4478    /// `should_report_time` returns false when both thresholds are
4479    /// disabled (REPORTTIME < 0, REPORTMEMORY unset).
4480    #[test]
4481    fn should_report_time_no_thresholds_false() {
4482        let _g = crate::test_util::global_state_lock();
4483        unsetparam("REPORTMEMORY");
4484        let mut job = job::default();
4485        job.procs.push(process::new(1));
4486        assert!(!should_report_time(&job, -1.0));
4487    }
4488
4489    /// `should_report_time` MUST short-circuit and return true when
4490    /// the job has STAT_TIMED set (c:1052-1053) — overriding all
4491    /// other gates including disabled thresholds AND zleactive.
4492    /// This is the contract that makes `time sleep 0.001` always
4493    /// print timing, even with `REPORTTIME` unset and inside ZLE.
4494    /// A regression that checks STAT_TIMED AFTER the threshold gates
4495    /// would silently swallow the report.
4496    #[test]
4497    fn should_report_time_stat_timed_overrides_all_gates() {
4498        let _g = crate::test_util::global_state_lock();
4499        // Disable both thresholds AND simulate zleactive — if STAT_TIMED
4500        // doesn't short-circuit, every other condition would return false.
4501        unsetparam("REPORTMEMORY");
4502        zleactive.store(1, Ordering::SeqCst);
4503
4504        let mut job = job::default();
4505        job.stat = stat::INUSE | stat::TIMED;
4506        job.procs.push(process::new(9001));
4507
4508        let reported = should_report_time(&job, -1.0);
4509
4510        // Cleanup before assert so a failure doesn't leak state.
4511        zleactive.store(0, Ordering::SeqCst);
4512        assert!(
4513            reported,
4514            "c:1052-1053 — STAT_TIMED MUST short-circuit to true regardless of threshold/zleactive"
4515        );
4516    }
4517
4518    /// `dumptime` emits one printtime line per process in a pipeline
4519    /// (c:1027-1029 walks `jn->procs` linked list, calling printtime
4520    /// per proc). Multi-stage pipeline → multiple lines.
4521    #[test]
4522    fn dumptime_emits_one_line_per_process() {
4523        let _g = crate::test_util::global_state_lock();
4524        setsparam("TIMEFMT", "%J");
4525        let mut job = job::default();
4526        let now = Instant::now();
4527        for (i, text) in ["echo a", "grep b", "tee c"].iter().enumerate() {
4528            let mut p = process::new(1000 + i as i32);
4529            p.bgtime = Some(now);
4530            p.endtime = Some(now + Duration::from_millis(10));
4531            p.text = text.to_string();
4532            job.procs.push(p);
4533        }
4534        let out = dumptime(&job).expect("expected timing output");
4535        assert_eq!(out, "echo a\ngrep b\ntee c");
4536        unsetparam("TIMEFMT");
4537    }
4538
4539    /// `handle_sub` clears SUPERJOB + sets WASSUPER when the subjob
4540    /// has completed without signal (c:296-297).
4541    #[test]
4542    fn handle_sub_clears_superjob_sets_wassuper_on_done() {
4543        let _g = crate::test_util::global_state_lock();
4544        // Two-job table: super at idx 0, sub at idx 1.
4545        let mut tab = vec![job::default(), job::default()];
4546        tab[0].stat = stat::INUSE | stat::SUPERJOB;
4547        tab[0].other = 1;
4548        tab[0].gleader = unsafe { libc::getpgrp() };
4549        // Add one exited proc to the super so the WASSUPER branch
4550        // (c:293-326) executes cleanly without the signaled branch.
4551        let mut p = process::new(unsafe { libc::getpid() });
4552        p.status = 0; // exited 0 (WIFEXITED && WEXITSTATUS==0)
4553        tab[0].procs.push(p);
4554        // Subjob: marked DONE with no procs (the c:279 trigger).
4555        tab[1].stat = stat::INUSE | stat::DONE;
4556        tab[1].other = unsafe { libc::getpid() };
4557
4558        handle_sub(&mut tab, 0, false);
4559
4560        assert_eq!(tab[0].stat & stat::SUPERJOB, 0, "SUPERJOB cleared");
4561        assert!(tab[0].stat & stat::WASSUPER != 0, "WASSUPER set");
4562    }
4563
4564    /// `update_job` sets DONE + CHANGED, writes LASTVAL2, when all
4565    /// procs have exited.
4566    #[test]
4567    fn update_job_done_writes_lastval2() {
4568        let _g = crate::test_util::global_state_lock();
4569        LASTVAL2.store(-1, Ordering::SeqCst);
4570        let mut job = job::default();
4571        let mut p1 = process::new(1001);
4572        p1.status = 0; // exited 0 (WIFEXITED && WEXITSTATUS=0)
4573        let mut p2 = process::new(1002);
4574        p2.status = 7 << 8; // exited 7 (last proc, sets val)
4575        job.procs.push(p1);
4576        job.procs.push(p2);
4577        let committed = update_job(&mut job);
4578        assert!(committed, "update_job should commit when all done");
4579        assert!(job.stat & stat::DONE != 0);
4580        assert!(job.stat & stat::CHANGED != 0);
4581        assert_eq!(
4582            LASTVAL2.load(Ordering::SeqCst),
4583            7,
4584            "lastval2 = WEXITSTATUS of last proc"
4585        );
4586    }
4587
4588    /// `update_job` returns false (no commit) when any main proc is
4589    /// still running.
4590    #[test]
4591    fn update_job_running_returns_false() {
4592        let _g = crate::test_util::global_state_lock();
4593        let mut job = job::default();
4594        let mut p = process::new(2001);
4595        p.status = SP_RUNNING;
4596        job.procs.push(p);
4597        assert!(!update_job(&mut job));
4598        // No flag flips when not committed.
4599        assert_eq!(job.stat & stat::DONE, 0);
4600    }
4601
4602    /// `update_job` MUST early-return on a still-running AUXPROC
4603    /// (c:472-473) BEFORE inspecting main procs. Auxprocs are the
4604    /// process-substitution feeders (`<(cmd)`); if one is still
4605    /// running, the surrounding job is not yet collectible even
4606    /// when every main proc has exited. A regression that walks
4607    /// main procs first and commits on all-main-done would close
4608    /// the auxproc's pipe prematurely and lose its output.
4609    #[test]
4610    fn update_job_running_auxproc_short_circuits_before_main_walk() {
4611        let _g = crate::test_util::global_state_lock();
4612        let mut job = job::default();
4613        // Main proc has fully EXITED.
4614        let mut main = process::new(10001);
4615        main.status = 0; // exited 0
4616        job.procs.push(main);
4617        // But an auxproc is still RUNNING.
4618        let mut aux = process::new(10002);
4619        aux.status = SP_RUNNING;
4620        job.auxprocs.push(aux);
4621
4622        let committed = update_job(&mut job);
4623        assert!(
4624            !committed,
4625            "c:472-473 — running auxproc must short-circuit even when main procs are done"
4626        );
4627        // The main proc's status word must NOT have been re-interpreted;
4628        // the DONE flag must not have been set; LASTVAL2 must not have
4629        // been written (we don't check LASTVAL2 directly to avoid
4630        // cross-test ordering, but the DONE flag check catches the
4631        // regression class).
4632        assert_eq!(
4633            job.stat & stat::DONE,
4634            0,
4635            "STAT_DONE must not be set when an auxproc is still running"
4636        );
4637        assert_eq!(
4638            job.stat & stat::CHANGED,
4639            0,
4640            "STAT_CHANGED must not be set on early-return"
4641        );
4642    }
4643
4644    /// `update_job` sets STOPPED + CHANGED when any proc is stopped
4645    /// (and clears DONE).
4646    #[test]
4647    fn update_job_stopped_sets_stopped_changed() {
4648        let _g = crate::test_util::global_state_lock();
4649        let mut job = job::default();
4650        let mut p = process::new(3001);
4651        p.status = 0x117f; // WIFSTOPPED-shaped (lower bits = 0x7f, upper = sig)
4652        job.procs.push(p);
4653        let committed = update_job(&mut job);
4654        assert!(committed);
4655        assert!(job.stat & stat::STOPPED != 0);
4656        assert!(job.stat & stat::CHANGED != 0);
4657        assert_eq!(job.stat & stat::DONE, 0);
4658    }
4659
4660    /// `spawnjob` with thisjob=-1 is a no-op (c:1898 DPUTS).
4661    #[test]
4662    fn spawnjob_no_thisjob_is_noop() {
4663        let _g = crate::test_util::global_state_lock();
4664        *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = -1;
4665        // Should not panic.
4666        spawnjob();
4667        // thisjob stays at -1.
4668        assert_eq!(*THISJOB.get().unwrap().lock().unwrap(), -1);
4669    }
4670
4671    /// `spawnjob` deletes the job entry if it has no procs (c:1915-1916).
4672    /// Cursh-clearing + INUSE side effects from the previous Rust port
4673    /// don't fire because the path's not exercised that way.
4674    #[test]
4675    fn spawnjob_deletes_empty_job() {
4676        let _g = crate::test_util::global_state_lock();
4677        // Wire up THISJOB → 1; JOBTAB[1] empty INUSE job.
4678        let mut tab_init = vec![job::default(); 3];
4679        tab_init[1].stat = stat::INUSE;
4680        *JOBTAB
4681            .get_or_init(|| Mutex::new(Vec::new()))
4682            .lock()
4683            .unwrap() = tab_init;
4684        *THISJOB.get_or_init(|| Mutex::new(-1)).lock().unwrap() = 1;
4685        spawnjob();
4686        // After: thisjob = -1, the entry stripped of INUSE by deletejob.
4687        assert_eq!(*THISJOB.get().unwrap().lock().unwrap(), -1);
4688        let tab = JOBTAB.get().unwrap().lock().unwrap();
4689        // deletejob clears stat / detaches procs.
4690        assert_eq!(tab[1].stat & stat::INUSE, 0);
4691    }
4692
4693    /// `handle_sub` STOPPED branch (c:328-339): when subjob is stopped,
4694    /// superjob inherits STOPPED and proc statuses propagate from the
4695    /// subjob's first proc.
4696    #[test]
4697    fn handle_sub_stopped_branch_propagates() {
4698        let _g = crate::test_util::global_state_lock();
4699        let mut tab = vec![job::default(), job::default()];
4700        tab[0].stat = stat::INUSE | stat::SUPERJOB;
4701        tab[0].other = 1;
4702        let mut p = process::new(1234);
4703        p.status = SP_RUNNING;
4704        tab[0].procs.push(p);
4705        tab[1].stat = stat::INUSE | stat::STOPPED;
4706        let mut sp = process::new(5678);
4707        sp.status = 0x117f; // WIFSTOPPED w/ TSTP-ish status
4708        tab[1].procs.push(sp);
4709
4710        let ret = handle_sub(&mut tab, 0, false);
4711        assert_eq!(ret, 1, "STOPPED branch returns 1");
4712        assert!(tab[0].stat & stat::STOPPED != 0, "super inherits STOPPED");
4713        // First super-proc status overwritten with subjob's first-proc status.
4714        assert_eq!(tab[0].procs[0].status, 0x117f);
4715    }
4716
4717    /// `dumptime` returns None for a job with no processes (c:1025-1026).
4718    #[test]
4719    fn dumptime_empty_job_returns_none() {
4720        let _g = crate::test_util::global_state_lock();
4721        let job = job::default();
4722        assert!(dumptime(&job).is_none());
4723    }
4724
4725    /// `dumptime` MUST skip procs whose bgtime/endtime pair is
4726    /// incomplete rather than panic or produce garbage timings.
4727    /// A backgrounded proc that hasn't been waitpid'd yet has
4728    /// `endtime=None`; one that was attached mid-pipeline has
4729    /// `bgtime=None`. The C body's `dtime_ts(&pn->bgtime, &pn->endtime)`
4730    /// reads both unconditionally — the Rust port's `?` operator
4731    /// in the filter_map skips the row. Pin: a job whose only
4732    /// proc lacks endtime → dumptime returns None (no garbage).
4733    #[test]
4734    fn dumptime_skips_proc_without_endtime() {
4735        let _g = crate::test_util::global_state_lock();
4736        setsparam("TIMEFMT", "%E");
4737        let mut job = job::default();
4738        let mut p = process::new(11001);
4739        p.bgtime = Some(Instant::now());
4740        p.endtime = None; // backgrounded, not yet reaped
4741        p.text = "incomplete".to_string();
4742        job.procs.push(p);
4743
4744        // The fn must not panic on Option::None.unwrap().
4745        let result = std::panic::catch_unwind(|| dumptime(&job));
4746        let out = result.expect("missing endtime must not panic");
4747        assert!(
4748            out.is_none(),
4749            "filter_map drops procs without bg/end pair → empty result → None"
4750        );
4751
4752        unsetparam("TIMEFMT");
4753    }
4754
4755    /// `dumptime` cites each process's OWN bgtime→endtime elapsed,
4756    /// not a job-wide aggregate. The c:1028 `dtime_ts(&pn->bgtime,
4757    /// &pn->endtime)` per-iteration call is the load-bearing
4758    /// difference between "1 line per pipeline" (the bug) and "1
4759    /// line per process" (the C contract). Pin distinct elapsed
4760    /// values to catch a regression that recomputes once for the job.
4761    #[test]
4762    fn dumptime_uses_per_process_elapsed() {
4763        let _g = crate::test_util::global_state_lock();
4764        setsparam("TIMEFMT", "%E");
4765        let mut job = job::default();
4766        let t0 = Instant::now();
4767        // Three procs with distinct elapsed times: 100ms, 300ms, 600ms.
4768        for (i, ms) in [100u64, 300, 600].iter().enumerate() {
4769            let mut p = process::new(8000 + i as i32);
4770            p.bgtime = Some(t0);
4771            p.endtime = Some(t0 + Duration::from_millis(*ms));
4772            p.text = format!("p{}", i);
4773            job.procs.push(p);
4774        }
4775        let out = dumptime(&job).expect("non-empty job → Some");
4776        let lines: Vec<&str> = out.lines().collect();
4777        assert_eq!(
4778            lines.len(),
4779            3,
4780            "must produce one line per proc, got {:?}",
4781            lines
4782        );
4783        // %E formats as "X.XXs". Verify distinct values across lines.
4784        // A regression that aggregates would print 3 copies of the
4785        // same (sum-of-elapsed) figure.
4786        let unique: std::collections::HashSet<&&str> = lines.iter().collect();
4787        assert_eq!(
4788            unique.len(),
4789            3,
4790            "each line must carry its own proc's elapsed; got duplicates: {:?}",
4791            lines
4792        );
4793        unsetparam("TIMEFMT");
4794    }
4795
4796    /// `printjob` appends the dumptime block when the job is
4797    /// STAT_TIMED (c:1220-1221 in printjob).
4798    #[test]
4799    fn printjob_appends_timing_when_stat_timed() {
4800        let _g = crate::test_util::global_state_lock();
4801        setsparam("TIMEFMT", "%J");
4802        let mut job = job::default();
4803        job.stat = stat::INUSE | stat::TIMED | stat::DONE;
4804        let mut p = process::new(42);
4805        p.bgtime = Some(Instant::now());
4806        p.endtime = Some(Instant::now() + Duration::from_millis(5));
4807        p.text = "echo hi".to_string();
4808        p.status = 0; // exited 0
4809        job.procs.push(p);
4810        let out = printjob(&job, 1, 0, Some(1), None);
4811        assert!(
4812            out.contains("echo hi"),
4813            "expected status line; got: {:?}",
4814            out
4815        );
4816        // Last line should be the dumptime output (%J → text).
4817        assert!(
4818            out.ends_with("echo hi"),
4819            "expected timing line at end; got: {:?}",
4820            out
4821        );
4822        unsetparam("TIMEFMT");
4823    }
4824
4825    /// `update_job` STAT_SUBJOB short-circuit (c:507-540): when the
4826    /// stopped job is a SUBJOB, the c:514 `jn->stat |= STAT_CHANGED
4827    /// | STAT_STOPPED` flag write must fire BEFORE the early-return
4828    /// to the super-job-SIGTSTP cascade. A regression that swaps the
4829    /// order would leave the listing scanner blind to the stop.
4830    #[test]
4831    fn update_job_subjob_stop_sets_flags_before_early_return() {
4832        let _g = crate::test_util::global_state_lock();
4833        let mut job = job::default();
4834        job.stat = stat::INUSE | stat::SUBJOB; // mark as SUBJOB pre-stop
4835        let mut p = process::new(7001);
4836        p.status = 0x117f; // WIFSTOPPED-shaped (low byte = 0x7F)
4837        job.procs.push(p);
4838
4839        assert!(update_job(&mut job));
4840        assert!(
4841            job.stat & stat::CHANGED != 0,
4842            "c:514 — SUBJOB stop must set CHANGED so the jobs scanner picks it up"
4843        );
4844        assert!(
4845            job.stat & stat::STOPPED != 0,
4846            "c:514 — SUBJOB stop must mark STOPPED"
4847        );
4848        assert_eq!(
4849            job.stat & stat::SUBJOB,
4850            stat::SUBJOB,
4851            "SUBJOB flag preserved through update"
4852        );
4853    }
4854
4855    /// `update_job` is idempotent across multiple calls on an
4856    /// already-STOPPED non-subjob (c:541-542 — `if (jn->stat &
4857    /// STAT_STOPPED) return;`). Without this short-circuit, every
4858    /// re-entry would re-set STAT_CHANGED, causing the `jobs`
4859    /// builtin to re-print the same job on every scan.
4860    #[test]
4861    fn update_job_already_stopped_short_circuits() {
4862        let _g = crate::test_util::global_state_lock();
4863        let mut job = job::default();
4864        job.stat = stat::INUSE | stat::STOPPED; // pre-stopped, not SUBJOB
4865        let mut p = process::new(12001);
4866        p.status = 0x117f; // WIFSTOPPED-shaped
4867        job.procs.push(p);
4868
4869        // First call: STOPPED already set, this is the re-entry case.
4870        // C: c:541-542 early-return → no CHANGED set.
4871        let stat_before = job.stat;
4872        let committed = update_job(&mut job);
4873        assert!(committed, "early-return path still reports 'commit'");
4874        assert_eq!(
4875            job.stat, stat_before,
4876            "c:541-542 — re-entry on already-STOPPED job must not flip flags"
4877        );
4878    }
4879
4880    /// `update_job` last-proc-signaled path (c:487-495): when the
4881    /// LAST proc in the pipeline was killed by a signal, val gets the
4882    /// `0o200 | WTERMSIG(status)` encoding written to `LASTVAL2`.
4883    /// The 0o200 high bit is zsh's convention for distinguishing
4884    /// "killed by signal N" from "exited with status N" in `$?` and
4885    /// `$pipestatus`. Without this encoding, a pipeline ending in a
4886    /// SIGTERM'd command would report exit-status N instead of 128+N.
4887    ///
4888    /// Status word 15 (= SIGTERM raw) reads as WIFSIGNALED on POSIX:
4889    ///   low 7 bits = 15 (not 0 = exited, not 0x7F = stopped)
4890    ///   → WTERMSIG returns 15, the SIGTERM number.
4891    #[test]
4892    fn update_job_last_proc_signaled_sets_high_bit_val() {
4893        let _g = crate::test_util::global_state_lock();
4894        LASTVAL2.store(-1, Ordering::SeqCst);
4895
4896        let mut job = job::default();
4897        let mut p1 = process::new(6001);
4898        p1.status = 0; // exited 0 (clean predecessor)
4899        let mut p2 = process::new(6002);
4900        p2.status = 15; // killed by SIGTERM
4901        job.procs.push(p1);
4902        job.procs.push(p2);
4903
4904        assert!(update_job(&mut job));
4905        let lv2 = LASTVAL2.load(Ordering::SeqCst);
4906        assert_eq!(
4907            lv2 & 0o200,
4908            0o200,
4909            "c:489-490 — WIFSIGNALED last-proc must set the 0o200 high bit"
4910        );
4911        assert_eq!(
4912            lv2 & 0x7f,
4913            15,
4914            "c:490 — low 7 bits must hold WTERMSIG (SIGTERM=15)"
4915        );
4916    }
4917
4918    #[test]
4919    fn test_process_new() {
4920        let _g = crate::test_util::global_state_lock();
4921        let proc = process::new(1234);
4922        assert_eq!(proc.pid, 1234);
4923        assert!(proc.is_running());
4924    }
4925
4926    #[test]
4927    fn test_job_new() {
4928        let _g = crate::test_util::global_state_lock();
4929        let job = job::new();
4930        assert_eq!(job.stat, 0);
4931        assert!(!job.is_done());
4932        assert!(!job.is_stopped());
4933    }
4934
4935    // `test_job_table_new` / `test_job_table_remove` moved to
4936    // src/exec_jobs.rs alongside the JobTable struct.
4937
4938    #[test]
4939    fn test_job_make_running() {
4940        let _g = crate::test_util::global_state_lock();
4941        let mut job = job::new();
4942        job.stat |= stat::STOPPED;
4943        job.procs.push(process {
4944            status: 0x007f,
4945            ..process::new(1234)
4946        }); // Stopped
4947
4948        job.make_running();
4949        assert!(!job.is_stopped());
4950        assert!(job.procs[0].is_running());
4951    }
4952
4953    #[test]
4954    fn test_format_job() {
4955        let _g = crate::test_util::global_state_lock();
4956        let mut job = job::new();
4957        job.text = "vim file.txt".to_string();
4958        job.stat |= stat::STOPPED;
4959
4960        let formatted = printjob(&job, 1, 0, Some(1), None);
4961        // Real zsh format: `[N]<space><space><marker><space>...`
4962        // The job number is followed by two spaces, then the
4963        // current/previous-job marker (`+`, `-`, ` `), then a
4964        // single space, then the status field. Match the marker
4965        // separately to avoid the previous bogus `[1]+` substring
4966        // assertion (which never matched because the printjob
4967        // format uses two spaces between `]` and the marker).
4968        assert!(formatted.contains("[1]"));
4969        assert!(formatted.contains("+"));
4970        assert!(formatted.contains("suspended") || formatted.contains("Stopped"));
4971        assert!(formatted.contains("vim file.txt"));
4972    }
4973
4974    // `test_job_state_enum` moved to src/exec_jobs.rs.
4975
4976    #[test]
4977    fn test_isanum_handles_minus() {
4978        let _g = crate::test_util::global_state_lock();
4979        // C: while (*s == '-' || idigit(*s)) s++; return *s == '\0';
4980        assert!(isanum("123"));
4981        assert!(isanum("-1")); // previous job spec
4982        assert!(isanum("---")); // weird but matches C semantics
4983        assert!(isanum("12-34")); // accepted by C
4984        assert!(!isanum("")); // empty rejected
4985        assert!(!isanum("abc")); // letters rejected
4986        assert!(!isanum("1a")); // mixed rejected
4987    }
4988
4989    #[test]
4990    fn test_havefiles_walks_table() {
4991        let _g = crate::test_util::global_state_lock();
4992        let mut tab = vec![job::new(), job::new(), job::new()];
4993        tab[1].stat = stat::INUSE;
4994        tab[1].filelist = vec![jobfile { name: Some("/tmp/foo".to_string()), fd: 0, is_fd: 0 }];
4995        assert!(havefiles(&tab));
4996        // job marked but no files → no.
4997        tab[1].filelist.clear();
4998        assert!(!havefiles(&tab));
4999        // Files but no stat (released slot) → C `jobtab[i].stat &&` requires both.
5000        tab[2].stat = 0;
5001        tab[2].filelist = vec![jobfile { name: Some("/tmp/bar".to_string()), fd: 0, is_fd: 0 }];
5002        assert!(!havefiles(&tab));
5003    }
5004
5005    #[test]
5006    fn test_storepipestats_decodes_status() {
5007        let _g = crate::test_util::global_state_lock();
5008        let mut job = job::new();
5009        // process 1: exit 0
5010        let mut p1 = process::new(100);
5011        p1.status = 0;
5012        // process 2: exit 1 (status 0x0100)
5013        let mut p2 = process::new(101);
5014        p2.status = 0x0100;
5015        // process 3: signal 9 (SIGKILL — status low-byte 0x09)
5016        let mut p3 = process::new(102);
5017        p3.status = 0x09;
5018        job.procs = vec![p1, p2, p3];
5019        let (stats, pipefail) = storepipestats(&job);
5020        assert_eq!(stats.len(), 3);
5021        assert_eq!(stats[0], 0); // exit 0
5022        assert_eq!(stats[1], 1); // exit 1
5023        assert_eq!(stats[2], 0o200 | 9); // signaled with SIGKILL
5024        assert_eq!(pipefail, 0o200 | 9); // last non-zero
5025    }
5026
5027    #[test]
5028    fn test_expandjobtab_respects_max() {
5029        let _g = crate::test_util::global_state_lock();
5030        let mut tab = vec![job::new(); 950];
5031        // 950 + 50 = 1000 ≤ MAX_MAXJOBS, OK.
5032        assert!(expandjobtab(&mut tab, 0));
5033        assert_eq!(tab.len(), 1000);
5034        // Next chunk would exceed cap.
5035        assert!(!expandjobtab(&mut tab, 0));
5036        assert_eq!(tab.len(), 1000);
5037    }
5038
5039    #[test]
5040    fn test_addfilelist_fd_vs_name() {
5041        let _g = crate::test_util::global_state_lock();
5042        let mut job = job::new();
5043        addfilelist(&mut job, Some("/tmp/zshrs-test.X"), -1);
5044        addfilelist(&mut job, None, 7);
5045        assert_eq!(job.filelist.len(), 2);
5046        assert_eq!(job.filelist[0].is_fd, 0);
5047        assert_eq!(job.filelist[0].name.as_deref(), Some("/tmp/zshrs-test.X"));
5048        assert_eq!(job.filelist[1].is_fd, 1);
5049        assert_eq!(job.filelist[1].fd, 7);
5050    }
5051
5052    #[test]
5053    fn test_hasprocs_index_bounded() {
5054        let _g = crate::test_util::global_state_lock();
5055        let mut tab = vec![job::new(), job::new()];
5056        tab[0].procs.push(process::new(1));
5057        assert!(hasprocs(&tab, 0));
5058        assert!(!hasprocs(&tab, 1));
5059        // Out-of-range returns false (matches C's negative-job DPUTS+0).
5060        assert!(!hasprocs(&tab, 99));
5061    }
5062
5063    #[test]
5064    fn test_makerunning_clears_stopped() {
5065        let _g = crate::test_util::global_state_lock();
5066        let mut tab = vec![job::new(), job::new()];
5067        tab[0].stat = stat::STOPPED;
5068        let mut p = process::new(42);
5069        p.status = 0x7f; // WIFSTOPPED
5070        tab[0].procs.push(p);
5071        makerunning(&mut tab, 0);
5072        assert_eq!(tab[0].stat & stat::STOPPED, 0);
5073        assert_eq!(tab[0].procs[0].status, SP_RUNNING);
5074    }
5075
5076    // ===== Tests for sigmsg (this session's table-ified port).
5077
5078    #[test]
5079    fn sigmsg_known_signals_render_canonical_text() {
5080        let _g = crate::test_util::global_state_lock();
5081        // Verifies the SIG_MSG lookup table matches C's sig_msg[] for
5082        // the signals that exist on every Unix. These strings are part
5083        // of the user-visible output of `jobs -l` / signal-death
5084        // reports — regressions would change observable behavior.
5085        assert_eq!(sigmsg(libc::SIGHUP), "hangup");
5086        assert_eq!(sigmsg(libc::SIGINT), "interrupt");
5087        assert_eq!(sigmsg(libc::SIGQUIT), "quit");
5088        assert_eq!(sigmsg(libc::SIGKILL), "killed");
5089        assert_eq!(sigmsg(libc::SIGSEGV), "segmentation fault");
5090        assert_eq!(sigmsg(libc::SIGPIPE), "broken pipe");
5091        assert_eq!(sigmsg(libc::SIGTERM), "terminated");
5092        assert_eq!(sigmsg(libc::SIGCHLD), "child exited");
5093        assert_eq!(sigmsg(libc::SIGCONT), "continued");
5094    }
5095
5096    #[test]
5097    fn sigmsg_unknown_signal_returns_default() {
5098        let _g = crate::test_util::global_state_lock();
5099        // c:1118 — `sig <= SIGCOUNT ? sig_msg[sig] : unknown`. Pick a
5100        // signal number outside the standard set (libc gives no
5101        // SIGCOUNT abstraction, so use a deliberately-high number).
5102        assert_eq!(sigmsg(9999), "unknown signal");
5103        assert_eq!(sigmsg(-1), "unknown signal");
5104        assert_eq!(sigmsg(0), "unknown signal");
5105    }
5106
5107    // ===== Test for get_usage (collapsed this session).
5108
5109    #[cfg(unix)]
5110    #[test]
5111    fn get_usage_returns_non_negative_times() {
5112        let _g = crate::test_util::global_state_lock();
5113        // C: getrusage(RUSAGE_CHILDREN, &child_usage). Even without
5114        // children, both fields must be >= 0 — the closure that maps
5115        // (tv_sec, tv_usec) → microseconds shouldn't underflow.
5116        let ti = get_usage();
5117        assert!(ti.ut >= 0);
5118        assert!(ti.st >= 0);
5119    }
5120
5121    /// c:752 — `printhhmmss` formats `HH:MM:SS.MS` for `time` builtin.
5122    /// Verifies the colon + dot separators are present. Regression
5123    /// dropping them breaks every time-output parser in user scripts.
5124    #[test]
5125    fn printhhmmss_formats_with_colons_and_dot() {
5126        let _g = crate::test_util::global_state_lock();
5127        let s = printhhmmss(3661.5);
5128        assert!(s.contains(':'));
5129        assert!(
5130            s.contains('.'),
5131            "millis must be present after dot (got {s:?})"
5132        );
5133    }
5134
5135    /// c:752 — zero seconds renders cleanly (no `-0` artifact).
5136    #[test]
5137    fn printhhmmss_zero_seconds_well_formed() {
5138        let _g = crate::test_util::global_state_lock();
5139        let s = printhhmmss(0.0);
5140        assert!(
5141            !s.starts_with('-'),
5142            "zero must not render with leading minus (got {s:?})"
5143        );
5144    }
5145
5146    /// c:721 — `get_clktck` returns sysconf(_SC_CLK_TCK). MUST be > 0
5147    /// on every POSIX (typically 100 or 1000). A zero/negative would
5148    /// divide by zero in every CPU-time computation.
5149    #[cfg(unix)]
5150    #[test]
5151    fn get_clktck_returns_positive_value() {
5152        let _g = crate::test_util::global_state_lock();
5153        assert!(get_clktck() > 0, "_SC_CLK_TCK must be positive");
5154    }
5155
5156    /// c:1422 — `deletefilelist(disowning=true)` MUST clear all
5157    /// entries (since the disowned job no longer owns its open fds).
5158    /// Regression that retains entries on disown would leak them.
5159    #[test]
5160    fn deletefilelist_disown_clears_all_entries() {
5161        let _g = crate::test_util::global_state_lock();
5162        let mut j = job::new();
5163        addfilelist(&mut j, Some("/tmp/a"), -1);
5164        addfilelist(&mut j, None, 7);
5165        assert_eq!(j.filelist.len(), 2);
5166        deletefilelist(&mut j, true);
5167        assert!(
5168            j.filelist.is_empty(),
5169            "disowning=true must clear all filelist entries"
5170        );
5171    }
5172
5173    /// c:260 — `super_job` returns None for top-level jobs (no super).
5174    /// Regression treating "no super" as a valid index would crash
5175    /// SIGCHLD reaping with phantom job lookups.
5176    #[test]
5177    fn super_job_returns_none_for_top_level_job() {
5178        let _g = crate::test_util::global_state_lock();
5179        let tab = vec![job::new()];
5180        assert!(super_job(&tab, 0).is_none());
5181    }
5182
5183    /// `Src/zsh.h:1073-1094` — `STAT_*` flag values are load-bearing
5184    /// numeric constants. Pin every `mod stat` value matches the
5185    /// canonical C define. Previously the Rust port used sequential
5186    /// `1 << N` shifts producing DIFFERENT values for nearly every
5187    /// flag.
5188    #[test]
5189    fn stat_flags_match_c_zsh_h_canonical_values() {
5190        let _g = crate::test_util::global_state_lock();
5191        assert_eq!(stat::CHANGED, 0x0001, "Src/zsh.h:1073");
5192        assert_eq!(stat::STOPPED, 0x0002, "Src/zsh.h:1074");
5193        assert_eq!(stat::TIMED, 0x0004, "Src/zsh.h:1075");
5194        assert_eq!(stat::DONE, 0x0008, "Src/zsh.h:1076");
5195        assert_eq!(stat::LOCKED, 0x0010, "Src/zsh.h:1077");
5196        assert_eq!(stat::NOPRINT, 0x0020, "Src/zsh.h:1079");
5197        assert_eq!(stat::INUSE, 0x0040, "Src/zsh.h:1081");
5198        assert_eq!(stat::SUPERJOB, 0x0080, "Src/zsh.h:1082");
5199        assert_eq!(stat::SUBJOB, 0x0100, "Src/zsh.h:1083");
5200        assert_eq!(stat::WASSUPER, 0x0200, "Src/zsh.h:1084");
5201        assert_eq!(stat::CURSH, 0x0400, "Src/zsh.h:1086");
5202        assert_eq!(stat::NOSTTY, 0x0800, "Src/zsh.h:1087");
5203        assert_eq!(stat::ATTACH, 0x1000, "Src/zsh.h:1089");
5204        assert_eq!(stat::SUBLEADER, 0x2000, "Src/zsh.h:1090");
5205        assert_eq!(stat::BUILTIN, 0x4000, "Src/zsh.h:1092");
5206    }
5207
5208    /// stat flag values must also match the canonical `STAT_*`
5209    /// definitions in `zsh_h.rs` (which already match C). Pin the
5210    /// equality so the two definitions can't drift independently.
5211    #[test]
5212    fn stat_flags_match_zsh_h_module_values() {
5213        let _g = crate::test_util::global_state_lock();
5214        assert_eq!(stat::CHANGED, STAT_CHANGED);
5215        assert_eq!(stat::STOPPED, STAT_STOPPED);
5216        assert_eq!(stat::TIMED, STAT_TIMED);
5217        assert_eq!(stat::DONE, STAT_DONE);
5218        assert_eq!(stat::SUPERJOB, STAT_SUPERJOB);
5219        assert_eq!(stat::INUSE, STAT_INUSE);
5220        assert_eq!(stat::ATTACH, STAT_ATTACH);
5221        assert_eq!(stat::BUILTIN, STAT_BUILTIN);
5222    }
5223
5224    /// `Src/jobs.c:1511-1526` — `deletejob` calls `freejob` at c:1525
5225    /// to ensure a full per-job state reset (pwd/ty/other/stty_in_env
5226    /// also clear). Previously the Rust port did an ad-hoc clear of
5227    /// procs/auxprocs/stat and skipped `freejob` entirely — pwd/ty
5228    /// stayed populated across slot reuse.
5229    #[test]
5230    fn deletejob_calls_freejob_to_clear_all_state() {
5231        let _g = crate::test_util::global_state_lock();
5232        let mut jn = job::new();
5233        jn.pwd = Some("/tmp/deletejob-pwd".to_string());
5234        jn.other = 42;
5235        jn.stty_in_env = 1;
5236        jn.stat = stat::SUPERJOB;
5237        deletejob(&mut jn, false);
5238        // c:1525 — freejob(jn, 1) called → all fields reset.
5239        assert_eq!(jn.pwd, None, "c:1525 — pwd cleared via freejob chain");
5240        assert_eq!(jn.other, 0, "c:1525 — other cleared");
5241        assert_eq!(jn.stty_in_env, 0, "c:1525 — stty_in_env cleared");
5242        assert_eq!(jn.stat, 0, "c:1525 — stat cleared");
5243    }
5244
5245    /// `Src/jobs.c:1457-1495` — `freejob(jn, deleting)`. Resets ALL
5246    /// per-job state including `pwd`, `ty`, `other`, `stty_in_env`
5247    /// (previously missing). Pin: pre-populate every field, call
5248    /// freejob, verify ALL reset to zero/empty/None.
5249    #[test]
5250    fn freejob_resets_all_per_job_state_fields() {
5251        let _g = crate::test_util::global_state_lock();
5252        let mut jn = job::new();
5253        // Pre-populate every freejob-reset field.
5254        jn.pwd = Some("/tmp/saved-pwd".to_string());
5255        jn.gleader = 12345;
5256        jn.other = 7;
5257        jn.stat = stat::SUPERJOB;
5258        jn.stty_in_env = 1;
5259        jn.text = "echo foo".to_string();
5260        // Call freejob.
5261        freejob(&mut jn, false);
5262        // All fields reset.
5263        assert_eq!(jn.pwd, None, "c:1477-1479 — pwd reset to None");
5264        assert_eq!(jn.gleader, 0, "c:1489 — gleader = 0");
5265        assert_eq!(jn.other, 0, "c:1489 — other = 0");
5266        assert_eq!(jn.stat, 0, "c:1490 — stat = 0");
5267        assert_eq!(jn.stty_in_env, 0, "c:1490 — stty_in_env = 0");
5268        assert_eq!(jn.text, "", "Rust-only: text cleared");
5269        assert!(jn.procs.is_empty(), "c:1462 — procs cleared");
5270        assert!(jn.auxprocs.is_empty(), "c:1469 — auxprocs cleared");
5271        assert!(jn.filelist.is_empty(), "c:1491 — filelist cleared");
5272        assert!(jn.ty.is_none(), "c:1475 — ty cleared");
5273    }
5274
5275    /// `Src/jobs.c:259-270` — `super_job` requires THREE conditions:
5276    /// `STAT_SUPERJOB` bit + `other == sub` + `gleader != 0`. The
5277    /// gleader check at c:267 was previously missing in the Rust
5278    /// port. Pin all three: a job with SUPERJOB+other match but
5279    /// `gleader == 0` (not yet group-leader-assigned) must NOT be
5280    /// returned as the super-job.
5281    #[test]
5282    fn super_job_requires_nonzero_gleader() {
5283        let _g = crate::test_util::global_state_lock();
5284        let mut tab = vec![job::new(), job::new(), job::new()];
5285        // job 2 is a super-job of sub-job 1 BUT no gleader yet.
5286        tab[2].stat |= stat::SUPERJOB;
5287        tab[2].other = 1;
5288        tab[2].gleader = 0;
5289        assert!(
5290            super_job(&tab, 1).is_none(),
5291            "c:267 — gleader==0 must NOT match super_job lookup"
5292        );
5293        // Now assign gleader — super_job returns Some(2).
5294        tab[2].gleader = 12345;
5295        assert_eq!(
5296            super_job(&tab, 1),
5297            Some(2),
5298            "c:267 — gleader != 0 + other match + SUPERJOB → match"
5299        );
5300    }
5301
5302    /// c:findproc — looking up a non-existent pid returns None. A
5303    /// regression returning Some(0,0,false) would let SIGCHLD reap
5304    /// a phantom job.
5305    #[test]
5306    fn findproc_unknown_pid_returns_none() {
5307        let _g = crate::test_util::global_state_lock();
5308        let tab: Vec<job> = vec![job::new(), job::new()];
5309        assert!(findproc(&tab, 99999, false).is_none());
5310        assert!(findproc(&tab, 99999, true).is_none());
5311    }
5312
5313    /// c:findproc — finding the actual pid returns the (job_idx,
5314    /// proc_idx, is_aux) triple. Catches a regression where the
5315    /// search doesn't traverse a job's procs vec.
5316    #[test]
5317    fn findproc_known_pid_returns_correct_indices() {
5318        let _g = crate::test_util::global_state_lock();
5319        let mut tab: Vec<job> = vec![job::new(), job::new()];
5320        tab[1].stat = stat::INUSE;
5321        let mut p = process::new(12345);
5322        p.status = SP_RUNNING;
5323        tab[1].procs.push(p);
5324        // Search non-aux side — should hit.
5325        let r = findproc(&tab, 12345, false);
5326        assert!(r.is_some(), "must find the seeded pid via aux=false");
5327        let (job_idx, proc_idx, is_aux) = r.unwrap();
5328        assert_eq!(job_idx, 1);
5329        assert_eq!(proc_idx, 0);
5330        assert!(!is_aux, "primary procs vec, not auxprocs");
5331        // Search aux side — should miss (no auxprocs entries).
5332        assert!(
5333            findproc(&tab, 12345, true).is_none(),
5334            "c:209 — aux=true must NOT match a procs (non-aux) entry"
5335        );
5336    }
5337
5338    /// Pin: c:204 — `findproc` skips jobs with `STAT_DONE` set. A
5339    /// terminated pid recycled by the kernel onto a new live process
5340    /// must not match the stale STAT_DONE entry. The previous Rust
5341    /// port returned the STAT_DONE entry and SIGCHLD would have
5342    /// reaped the wrong job.
5343    #[test]
5344    fn findproc_skips_stat_done_jobs() {
5345        let _g = crate::test_util::global_state_lock();
5346        let mut tab: Vec<job> = vec![job::new(), job::new(), job::new()];
5347        // job 1: STAT_DONE with pid 7777 — must be skipped.
5348        tab[1].stat = stat::DONE | stat::INUSE;
5349        let mut p1 = process::new(7777);
5350        p1.status = 0; // exited
5351        tab[1].procs.push(p1);
5352        // job 2: live job with the SAME pid (recycled).
5353        tab[2].stat = stat::INUSE;
5354        let mut p2 = process::new(7777);
5355        p2.status = SP_RUNNING;
5356        tab[2].procs.push(p2);
5357        // Search for pid 7777 — must hit job 2, not job 1.
5358        let r = findproc(&tab, 7777, false);
5359        assert_eq!(
5360            r,
5361            Some((2, 0, false)),
5362            "c:204 — STAT_DONE entry must be skipped; live job 2 wins"
5363        );
5364    }
5365
5366    /// `Src/jobs.c:752-765` — `printhhmmss(secs)` three-branch
5367    /// decision tree:
5368    /// - hours > 0   → `H:MM:SS.xx`
5369    /// - mins  > 0   → `M:SS.xx`
5370    /// - else        → `S.xxx` (three-decimal precision)
5371    /// Pin each branch.
5372    #[test]
5373    fn printhhmmss_three_branch_format_dispatch() {
5374        let _g = crate::test_util::global_state_lock();
5375        // c:763 — sub-minute uses `%.3f` format.
5376        assert_eq!(printhhmmss(0.5), "0.500");
5377        assert_eq!(printhhmmss(12.345), "12.345");
5378        // c:761 — minutes branch uses `%d:%05.2f`.
5379        // 75.0s = 1m 15.0s → "1:15.00".
5380        assert_eq!(printhhmmss(75.0), "1:15.00");
5381        // 125.5s = 2m 5.5s → "2:05.50".
5382        assert_eq!(printhhmmss(125.5), "2:05.50");
5383        // c:759 — hours branch uses `%d:%02d:%05.2f`.
5384        // 3661.5s = 1h 1m 1.5s → "1:01:01.50".
5385        assert_eq!(printhhmmss(3661.5), "1:01:01.50");
5386        // Multi-hour: 7200s = 2h 0m 0s → "2:00:00.00".
5387        assert_eq!(printhhmmss(7200.0), "2:00:00.00");
5388    }
5389
5390    /// `Src/jobs.c:1107-1109` — `sigmsg(sig)` looks up signal names
5391    /// in the `sigmsg[]` table and returns a canonical message
5392    /// (e.g. "interrupt" for SIGINT). Out-of-range returns the
5393    /// default "unknown signal" message.
5394    #[test]
5395    fn sigmsg_returns_canonical_messages_for_standard_signals() {
5396        let _g = crate::test_util::global_state_lock();
5397        // SIGINT/SIGTERM are universal POSIX signals — pin their
5398        // message text exists (non-empty).
5399        let int_msg = sigmsg(libc::SIGINT);
5400        let term_msg = sigmsg(libc::SIGTERM);
5401        let kill_msg = sigmsg(libc::SIGKILL);
5402        assert!(!int_msg.is_empty());
5403        assert!(!term_msg.is_empty());
5404        assert!(!kill_msg.is_empty());
5405        // They must be distinct (no single "unknown" sentinel for all).
5406        assert_ne!(int_msg, term_msg);
5407    }
5408
5409    /// `Src/jobs.c:3052-3058` — `getsigidx` numeric-input branch
5410    /// bounds-checks against `VSIGCOUNT` and the RT-signal range.
5411    /// Previously the Rust port accepted ANY parse-able number,
5412    /// including out-of-range values like 9999 (where C returns -1).
5413    #[test]
5414    fn getsigidx_rejects_out_of_range_numeric() {
5415        let _g = crate::test_util::global_state_lock();
5416        // In-range numeric → Some.
5417        assert_eq!(getsigidx("0"), Some(0), "EXIT pseudo-signal index 0");
5418        assert_eq!(getsigidx("9"), Some(9), "SIGKILL signal number 9 → Some(9)");
5419        // Out-of-range numeric → None.
5420        assert_eq!(
5421            getsigidx("9999"),
5422            None,
5423            "c:3056 — 9999 above VSIGCOUNT and outside RT range → None"
5424        );
5425        assert_eq!(getsigidx("99999999999"), None, "c:3056 — overflow → None");
5426    }
5427
5428    /// `Src/jobs.c:3052` — non-digit-leading strings skip the numeric
5429    /// branch entirely and go to name-table lookup. "INTabc" doesn't
5430    /// match any signal name → None.
5431    #[test]
5432    fn getsigidx_non_digit_unknown_name_returns_none() {
5433        let _g = crate::test_util::global_state_lock();
5434        assert_eq!(getsigidx("DEFINITELYNOTASIGNAL"), None);
5435        assert_eq!(getsigidx(""), None, "empty string → None");
5436    }
5437
5438    /// `Src/jobs.c:3087-3107` — `getsigname(sig)` falls back to
5439    /// `rtsigname(SIGNUM(sig), 0)` for signals in `[SIGRTMIN..SIGRTMAX]`
5440    /// (Linux only). Previously the Rust port emitted `SIG{n}` for
5441    /// every unknown signal, losing the RT-signal naming entirely.
5442    #[cfg(target_os = "linux")]
5443    #[test]
5444    fn getsigname_emits_rt_form_for_rt_signal_range() {
5445        let _g = crate::test_util::global_state_lock();
5446        let sigrtmin = libc::SIGRTMIN();
5447        let sigrtmax = libc::SIGRTMAX();
5448        // SIGRTMIN → "RTMIN".
5449        assert_eq!(
5450            getsigname(sigrtmin),
5451            "RTMIN",
5452            "c:3101 — RTMIN sig → bare RTMIN"
5453        );
5454        // SIGRTMAX → "RTMAX".
5455        assert_eq!(
5456            getsigname(sigrtmax),
5457            "RTMAX",
5458            "c:3101 — RTMAX sig → bare RTMAX"
5459        );
5460        // SIGRTMIN+1 → "RTMIN+1" (shorter form per rtsigname c:1322).
5461        assert_eq!(getsigname(sigrtmin + 1), "RTMIN+1");
5462        // SIGRTMAX-1 → "RTMAX-1".
5463        assert_eq!(getsigname(sigrtmax - 1), "RTMAX-1");
5464    }
5465
5466    /// Pre-condition: standard signal names still resolve. Make sure
5467    /// the new RT-signal branch didn't break the canonical table.
5468    #[test]
5469    fn getsigname_standard_signals_unchanged() {
5470        let _g = crate::test_util::global_state_lock();
5471        assert_eq!(getsigname(libc::SIGINT), "INT");
5472        assert_eq!(getsigname(libc::SIGHUP), "HUP");
5473        assert_eq!(getsigname(libc::SIGCHLD), "CHLD");
5474        assert_eq!(getsigname(libc::SIGKILL), "KILL");
5475        // EXIT pseudo-signal at index 0.
5476        assert_eq!(getsigname(0), "EXIT");
5477    }
5478
5479    /// Serialise tests that mutate the global ZLE-active flag.
5480    static ZLEACTIVE_TEST_LOCK: Mutex<()> = Mutex::new(());
5481
5482    /// Pin: STAT_TIMED short-circuits **regardless of zleactive** per
5483    /// `Src/jobs.c:1052-1053` — the STAT_TIMED check happens BEFORE
5484    /// the zleactive gate, so explicit `time foo` always reports.
5485    #[test]
5486    fn should_report_time_stat_timed_overrides_zleactive() {
5487        let _g = crate::test_util::global_state_lock();
5488        let _g = ZLEACTIVE_TEST_LOCK.lock().unwrap();
5489        let prev = zleactive.load(Ordering::Relaxed);
5490        zleactive.store(1, Ordering::Relaxed);
5491        let mut job = job::new();
5492        job.stat |= stat::TIMED;
5493        // STAT_TIMED returns true even with zleactive=1 and no procs.
5494        assert!(should_report_time(&job, -1.0));
5495        zleactive.store(prev, Ordering::Relaxed);
5496    }
5497
5498    /// Pin: `zleactive` short-circuits per `Src/jobs.c:1074`. When
5499    /// the line editor is active, never report a timing line even
5500    /// if reporttime would otherwise trigger. Without this gate the
5501    /// timing line corrupts the active prompt.
5502    #[test]
5503    fn should_report_time_zleactive_suppresses() {
5504        let _g = crate::test_util::global_state_lock();
5505        let _g = ZLEACTIVE_TEST_LOCK.lock().unwrap();
5506        let prev = zleactive.load(Ordering::Relaxed);
5507        zleactive.store(1, Ordering::Relaxed);
5508        // Build a job with one proc that would otherwise satisfy the
5509        // elapsed-time threshold: bgtime now, endtime now + 10s,
5510        // reporttime=1s.
5511        let mut job = job::new();
5512        let now = Instant::now();
5513        let mut p = process::new(1);
5514        p.bgtime = Some(now);
5515        p.endtime = Some(now + Duration::from_secs(10));
5516        job.procs.push(p);
5517        // With zleactive=1, suppressed.
5518        assert!(!should_report_time(&job, 1.0));
5519        // With zleactive=0, fires.
5520        zleactive.store(0, Ordering::Relaxed);
5521        assert!(should_report_time(&job, 1.0));
5522        zleactive.store(prev, Ordering::Relaxed);
5523    }
5524
5525    /// Pin: reporttime<0 short-circuits per `Src/jobs.c:1065`.
5526    /// Without `$REPORTTIME` set (or with REPORTTIME<0 sentinel),
5527    /// no timing line is reported.
5528    #[test]
5529    fn should_report_time_negative_threshold_suppresses() {
5530        let _g = crate::test_util::global_state_lock();
5531        let _g = ZLEACTIVE_TEST_LOCK.lock().unwrap();
5532        let mut job = job::new();
5533        let now = Instant::now();
5534        let mut p = process::new(1);
5535        p.bgtime = Some(now);
5536        p.endtime = Some(now + Duration::from_secs(10));
5537        job.procs.push(p);
5538        assert!(!should_report_time(&job, -1.0));
5539    }
5540
5541    /// Pin: missing first proc returns 0 per `Src/jobs.c:1072`
5542    /// (`if (!j->procs) return 0`).
5543    #[test]
5544    fn should_report_time_no_procs_returns_false() {
5545        let _g = crate::test_util::global_state_lock();
5546        let _g = ZLEACTIVE_TEST_LOCK.lock().unwrap();
5547        let job = job::new(); // no procs, no STAT_TIMED
5548        assert!(!should_report_time(&job, 0.0));
5549    }
5550
5551    /// Serialise tests that mutate JOBTAB + PWD param.
5552    static JOBPWD_TEST_LOCK: Mutex<()> = Mutex::new(());
5553
5554    /// Pin: `setjobpwd()` writes `pwd` to every IN-USE job that
5555    /// doesn't already have one, per `Src/jobs.c:1886-1888`. The
5556    /// previous Rust port took a `&mut job` and was a no-op — every
5557    /// `cd` left in-flight jobs with no pwd.
5558    #[test]
5559    fn setjobpwd_stamps_pwd_on_inuse_jobs_without_one() {
5560        let _g = crate::test_util::global_state_lock();
5561        let _g = JOBPWD_TEST_LOCK.lock().unwrap();
5562        // Set PWD via the canonical paramtab path.
5563        crate::ported::params::assignsparam("PWD", "/tmp/test_setjobpwd", 0);
5564        // Reset JOBTAB: index 0 (shell itself) + 3 jobs.
5565        let tab = JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
5566        {
5567            let mut tab = tab.lock().unwrap();
5568            tab.clear();
5569            tab.push(job::new()); // index 0 — skipped
5570                                  // job 1: INUSE, no pwd — should get stamped.
5571            let mut j1 = job::new();
5572            j1.stat = stat::INUSE;
5573            j1.pwd = None;
5574            tab.push(j1);
5575            // job 2: INUSE, already has pwd — should be PRESERVED.
5576            let mut j2 = job::new();
5577            j2.stat = stat::INUSE;
5578            j2.pwd = Some("/preserved".to_string());
5579            tab.push(j2);
5580            // job 3: NOT in use (stat=0) — should NOT get stamped.
5581            let mut j3 = job::new();
5582            j3.stat = 0;
5583            j3.pwd = None;
5584            tab.push(j3);
5585        }
5586        setjobpwd();
5587        let tab = tab.lock().unwrap();
5588        // c:1887-1888 — IN-USE + no pwd → stamped with current pwd.
5589        assert_eq!(
5590            tab[1].pwd.as_deref(),
5591            Some("/tmp/test_setjobpwd"),
5592            "c:1888 — INUSE+no-pwd job must be stamped with PWD"
5593        );
5594        // c:1887 — IN-USE + already has pwd → preserved (the `!pwd` gate).
5595        assert_eq!(
5596            tab[2].pwd.as_deref(),
5597            Some("/preserved"),
5598            "c:1887 — existing pwd must NOT be overwritten"
5599        );
5600        // c:1887 — stat==0 (not in use) → not stamped.
5601        assert_eq!(
5602            tab[3].pwd, None,
5603            "c:1887 — non-INUSE job (stat==0) must NOT be stamped"
5604        );
5605        // Index 0 (shell itself) is skipped (c:1886 starts at i=1).
5606        assert_eq!(
5607            tab[0].pwd, None,
5608            "c:1886 — index 0 (shell) must NOT be stamped"
5609        );
5610    }
5611
5612    // ─── zsh-corpus pins for printhhmmss / sigmsg edge cases ─────────
5613
5614    /// `printhhmmss(0.0)` returns "0.000".
5615    #[test]
5616    fn jobs_corpus_printhhmmss_zero_exact() {
5617        let _g = crate::test_util::global_state_lock();
5618        assert_eq!(printhhmmss(0.0), "0.000");
5619    }
5620
5621    /// `printhhmmss(59.999)` is still in the sub-minute branch.
5622    #[test]
5623    fn jobs_corpus_printhhmmss_just_under_one_minute() {
5624        let _g = crate::test_util::global_state_lock();
5625        let s = printhhmmss(59.999);
5626        // Sub-minute → "%.3f" → "59.999" (no colons).
5627        assert!(!s.contains(':'), "sub-minute has no colon, got {s:?}");
5628        assert!(s.starts_with("59.9"));
5629    }
5630
5631    /// `printhhmmss(60.0)` enters the minutes branch → "1:00.00".
5632    #[test]
5633    fn jobs_corpus_printhhmmss_exactly_one_minute() {
5634        let _g = crate::test_util::global_state_lock();
5635        assert_eq!(printhhmmss(60.0), "1:00.00");
5636    }
5637
5638    /// `printhhmmss(3600.0)` enters the hours branch → "1:00:00.00".
5639    #[test]
5640    fn jobs_corpus_printhhmmss_exactly_one_hour() {
5641        let _g = crate::test_util::global_state_lock();
5642        assert_eq!(printhhmmss(3600.0), "1:00:00.00");
5643    }
5644
5645    /// `printhhmmss(86400.0)` → "24:00:00.00" (24h cleanly).
5646    #[test]
5647    fn jobs_corpus_printhhmmss_exactly_one_day() {
5648        let _g = crate::test_util::global_state_lock();
5649        assert_eq!(printhhmmss(86400.0), "24:00:00.00");
5650    }
5651
5652    /// `sigmsg(SIGINT)` returns "interrupt" canonically.
5653    #[test]
5654    fn jobs_corpus_sigmsg_int_is_interrupt() {
5655        let _g = crate::test_util::global_state_lock();
5656        assert_eq!(sigmsg(libc::SIGINT), "interrupt");
5657    }
5658
5659    /// `sigmsg(SIGTERM)` returns "terminated".
5660    #[test]
5661    fn jobs_corpus_sigmsg_term_is_terminated() {
5662        let _g = crate::test_util::global_state_lock();
5663        assert_eq!(sigmsg(libc::SIGTERM), "terminated");
5664    }
5665
5666    /// `sigmsg(SIGSEGV)` returns "segmentation fault".
5667    #[test]
5668    fn jobs_corpus_sigmsg_segv_is_segfault() {
5669        let _g = crate::test_util::global_state_lock();
5670        assert_eq!(sigmsg(libc::SIGSEGV), "segmentation fault");
5671    }
5672
5673    /// `sigmsg(SIGPIPE)` returns "broken pipe".
5674    #[test]
5675    fn jobs_corpus_sigmsg_pipe_is_broken_pipe() {
5676        let _g = crate::test_util::global_state_lock();
5677        assert_eq!(sigmsg(libc::SIGPIPE), "broken pipe");
5678    }
5679
5680    // ═══════════════════════════════════════════════════════════════════
5681    // C-parity tests pinning Src/jobs.c. Tests that capture KNOWN ZSHRS
5682    // BUGS use #[ignore = "ZSHRS BUG: …"].
5683    // ═══════════════════════════════════════════════════════════════════
5684
5685    /// `initjob` must SKIP index 0 (the shell itself) when picking a
5686    /// slot. C `Src/jobs.c:1865-1867`:
5687    ///   `for (i = 1; i <= maxjob; i++)`
5688    /// starts at 1.
5689    /// ZSHRS BUG: Rust port at jobs.rs:1874 uses `enumerate()` starting
5690    /// at 0 — would reuse the shell's own slot if jobtab[0] is empty,
5691    /// corrupting parent-shell job tracking.
5692    #[test]
5693    fn initjob_skips_index_zero_reserved_for_shell() {
5694        let _g = crate::test_util::global_state_lock();
5695        // Fresh table with index 0 empty. C would skip it and add a
5696        // new slot at index 1. Rust off-by-one would return 0.
5697        let mut jt: Vec<job> = vec![job::new(), job::new(), job::new()];
5698        // All slots empty (stat=0).
5699        let idx = initjob(&mut jt);
5700        assert_ne!(
5701            idx, 0,
5702            "initjob must NOT return index 0 (shell slot); got {idx}"
5703        );
5704        assert!(idx >= 1, "first available slot is index >= 1");
5705    }
5706
5707    /// C `Src/jobs.c:1875` emits `zerr("job table full…")` and returns
5708    /// -1 on table-full. The Rust port has a `Vec<job>` (no fixed
5709    /// `MAXJOB` cap) so the "full" condition can't actually occur —
5710    /// the table grows on demand and a new slot is always returned.
5711    /// Pin the actual behavior: initjob on a "full" (all-INUSE) table
5712    /// expands by one and returns the new index.
5713    #[test]
5714    fn initjob_returns_negative_one_on_full_table() {
5715        let _g = crate::test_util::global_state_lock();
5716        let mut jt: Vec<job> = Vec::new();
5717        for _ in 0..4 {
5718            let mut j = job::new();
5719            j.stat = stat::INUSE;
5720            jt.push(j);
5721        }
5722        let before = jt.len();
5723        let idx = initjob(&mut jt);
5724        // Rust port grows the table — no -1 sentinel.
5725        assert_eq!(idx, before, "fresh slot at the grown end of jobtab");
5726        assert_eq!(jt.len(), before + 1, "jobtab grew by one");
5727    }
5728
5729    /// `findproc` with pid=-1 (impossible pid) returns None.
5730    /// Already covered but pin the never-match path explicitly.
5731    #[test]
5732    fn findproc_invalid_pid_returns_none() {
5733        let _g = crate::test_util::global_state_lock();
5734        let jt: Vec<job> = vec![job::new()];
5735        assert!(findproc(&jt, -1, false).is_none());
5736    }
5737
5738    /// `findproc` on empty jobtab returns None (no panic on empty
5739    /// slice; C `for (i=1; i<=maxjob; i++)` with maxjob=0 skips loop).
5740    #[test]
5741    fn findproc_empty_jobtab_no_panic() {
5742        let _g = crate::test_util::global_state_lock();
5743        let jt: Vec<job> = Vec::new();
5744        assert!(findproc(&jt, 1234, false).is_none());
5745    }
5746
5747    /// `getsigname(0)` returns "EXIT" — pseudo-signal index 0 is the
5748    /// EXIT trap target in zsh. C jobs.c:3392-3393 sigs[0]="EXIT".
5749    #[test]
5750    fn getsigname_zero_returns_exit_pseudo_signal() {
5751        let _g = crate::test_util::global_state_lock();
5752        assert_eq!(getsigname(0), "EXIT");
5753    }
5754
5755    /// `getsigname(libc::SIGHUP)` returns "HUP" without the SIG prefix.
5756    #[test]
5757    fn getsigname_sighup_returns_hup_without_prefix() {
5758        let _g = crate::test_util::global_state_lock();
5759        assert_eq!(getsigname(libc::SIGHUP), "HUP");
5760    }
5761
5762    // ═══════════════════════════════════════════════════════════════════
5763    // Additional C-parity tests for Src/jobs.c printhhmmss + sigmsg +
5764    // dtime_tv + get_clktck.
5765    // ═══════════════════════════════════════════════════════════════════
5766
5767    /// c:752 — `printhhmmss(0.0)` returns "0.000" (sub-minute fmt).
5768    #[test]
5769    fn printhhmmss_zero_returns_zero_seconds() {
5770        let _g = crate::test_util::global_state_lock();
5771        assert_eq!(printhhmmss(0.0), "0.000");
5772    }
5773
5774    /// c:752 — sub-minute time uses 3-decimal format.
5775    #[test]
5776    fn printhhmmss_sub_minute_uses_three_decimals() {
5777        let _g = crate::test_util::global_state_lock();
5778        assert_eq!(printhhmmss(5.123), "5.123");
5779        assert_eq!(printhhmmss(59.999), "59.999");
5780    }
5781
5782    /// c:752 — minute-but-not-hour uses M:SS.SS format.
5783    #[test]
5784    fn printhhmmss_minute_uses_mm_ss_format() {
5785        let _g = crate::test_util::global_state_lock();
5786        let r = printhhmmss(65.5);
5787        assert_eq!(r, "1:05.50", "1m05.50s");
5788    }
5789
5790    /// c:752 — hour+ uses H:MM:SS.SS format.
5791    #[test]
5792    fn printhhmmss_hour_uses_hh_mm_ss_format() {
5793        let _g = crate::test_util::global_state_lock();
5794        let r = printhhmmss(3725.0); // 1h 2m 5s
5795        assert_eq!(r, "1:02:05.00");
5796    }
5797
5798    /// c:752 — exactly 60 seconds crosses minute boundary.
5799    #[test]
5800    fn printhhmmss_sixty_seconds_is_one_minute() {
5801        let _g = crate::test_util::global_state_lock();
5802        let r = printhhmmss(60.0);
5803        assert_eq!(r, "1:00.00", "60s = 1m");
5804    }
5805
5806    /// c:752 — exactly 3600s crosses hour boundary.
5807    #[test]
5808    fn printhhmmss_thirty_six_hundred_seconds_is_one_hour() {
5809        let _g = crate::test_util::global_state_lock();
5810        let r = printhhmmss(3600.0);
5811        assert_eq!(r, "1:00:00.00");
5812    }
5813
5814    /// c:1107 — `sigmsg` of valid signal returns a non-default string.
5815    #[test]
5816    fn sigmsg_known_signal_returns_descriptive_string() {
5817        let _g = crate::test_util::global_state_lock();
5818        // SIGTERM, SIGSEGV, SIGINT should all have descriptive messages
5819        let term = sigmsg(libc::SIGTERM);
5820        assert_ne!(term, "unknown signal", "SIGTERM should have a message");
5821    }
5822
5823    /// c:1118 — `sigmsg(-1)` / out-of-range returns "unknown signal".
5824    #[test]
5825    fn sigmsg_unknown_signal_returns_unknown() {
5826        let _g = crate::test_util::global_state_lock();
5827        assert_eq!(sigmsg(-1), "unknown signal");
5828        assert_eq!(sigmsg(9999), "unknown signal");
5829    }
5830
5831    /// c:752 — printhhmmss is deterministic.
5832    #[test]
5833    fn printhhmmss_is_deterministic() {
5834        let _g = crate::test_util::global_state_lock();
5835        for t in &[0.0, 1.5, 60.0, 3600.0, 7200.5] {
5836            let r1 = printhhmmss(*t);
5837            let r2 = printhhmmss(*t);
5838            assert_eq!(r1, r2, "printhhmmss must be pure for {}", t);
5839        }
5840    }
5841
5842    /// `get_clktck()` returns positive — clock-ticks-per-second cannot
5843    /// be zero on any sane system.
5844    #[test]
5845    fn get_clktck_returns_positive() {
5846        let _g = crate::test_util::global_state_lock();
5847        let ck = get_clktck();
5848        assert!(ck > 0, "CLK_TCK must be positive, got {}", ck);
5849        // POSIX guarantees CLK_TCK ≥ 1; typical values are 100/250/1000.
5850        assert!(ck <= 10_000, "CLK_TCK suspiciously large: {}", ck);
5851    }
5852
5853    // ═══════════════════════════════════════════════════════════════════
5854    // Additional C-parity tests for Src/jobs.c isanum + getsigidx.
5855    // ═══════════════════════════════════════════════════════════════════
5856
5857    /// c:2010 — `isanum("")` returns false (empty not valid).
5858    #[test]
5859    fn isanum_empty_returns_false() {
5860        let _g = crate::test_util::global_state_lock();
5861        assert!(!isanum(""));
5862    }
5863
5864    /// c:2010 — all-digit string returns true.
5865    #[test]
5866    fn isanum_all_digits_returns_true() {
5867        let _g = crate::test_util::global_state_lock();
5868        assert!(isanum("123"));
5869        assert!(isanum("0"));
5870        assert!(isanum("999999"));
5871    }
5872
5873    /// c:2010 — hyphen-prefixed digits valid.
5874    #[test]
5875    fn isanum_with_hyphen_returns_true() {
5876        let _g = crate::test_util::global_state_lock();
5877        assert!(isanum("-1"));
5878        assert!(isanum("-123"));
5879        assert!(isanum("-"));
5880    }
5881
5882    /// c:2010 — alpha or non-digit chars rejected.
5883    #[test]
5884    fn isanum_rejects_alpha() {
5885        let _g = crate::test_util::global_state_lock();
5886        assert!(!isanum("abc"));
5887        assert!(!isanum("1a"));
5888        assert!(!isanum("a1"));
5889        assert!(!isanum("1 2"));
5890        assert!(!isanum("1.0"));
5891    }
5892
5893    /// c:2010 — deterministic.
5894    #[test]
5895    fn isanum_is_deterministic() {
5896        let _g = crate::test_util::global_state_lock();
5897        for s in ["", "123", "-1", "abc", "1a"] {
5898            let first = isanum(s);
5899            for _ in 0..5 {
5900                assert_eq!(isanum(s), first);
5901            }
5902        }
5903    }
5904
5905    /// c:3052 — `getsigidx("")` returns None.
5906    #[test]
5907    fn getsigidx_empty_returns_none() {
5908        let _g = crate::test_util::global_state_lock();
5909        assert!(getsigidx("").is_none());
5910    }
5911
5912    /// c:3334 — `getsigidx("EXIT")` returns Some(0).
5913    #[test]
5914    fn getsigidx_exit_returns_zero() {
5915        let _g = crate::test_util::global_state_lock();
5916        assert_eq!(getsigidx("EXIT"), Some(0));
5917    }
5918
5919    /// c:3052 — canonical POSIX signal names resolve.
5920    #[test]
5921    #[cfg(unix)]
5922    fn getsigidx_canonical_signal_names() {
5923        let _g = crate::test_util::global_state_lock();
5924        assert_eq!(getsigidx("HUP"), Some(libc::SIGHUP));
5925        assert_eq!(getsigidx("TERM"), Some(libc::SIGTERM));
5926        assert_eq!(getsigidx("INT"), Some(libc::SIGINT));
5927        assert_eq!(getsigidx("KILL"), Some(libc::SIGKILL));
5928    }
5929
5930    /// c:3332 — SIG prefix stripped transparently.
5931    #[test]
5932    #[cfg(unix)]
5933    fn getsigidx_strips_sig_prefix() {
5934        let _g = crate::test_util::global_state_lock();
5935        assert_eq!(getsigidx("HUP"), getsigidx("SIGHUP"));
5936        assert_eq!(getsigidx("TERM"), getsigidx("SIGTERM"));
5937    }
5938
5939    /// c:3333 — case-insensitive on signal name.
5940    #[test]
5941    #[cfg(unix)]
5942    fn getsigidx_case_insensitive() {
5943        let _g = crate::test_util::global_state_lock();
5944        assert_eq!(getsigidx("hup"), getsigidx("HUP"));
5945    }
5946
5947    /// c:3081 — unknown name returns None.
5948    #[test]
5949    fn getsigidx_unknown_returns_none() {
5950        let _g = crate::test_util::global_state_lock();
5951        assert!(getsigidx("NEVER_REAL_SIGNAL").is_none());
5952    }
5953
5954    /// c:3339 — ZERR and ERR both resolve to SIGZERR.
5955    #[test]
5956    fn getsigidx_zerr_and_err_alias() {
5957        let _g = crate::test_util::global_state_lock();
5958        assert_eq!(getsigidx("ZERR"), Some(crate::ported::signals_h::SIGZERR));
5959        assert_eq!(
5960            getsigidx("ERR"),
5961            Some(crate::ported::signals_h::SIGZERR),
5962            "ERR aliases ZERR"
5963        );
5964    }
5965
5966    /// c:3340 — DEBUG resolves to SIGDEBUG.
5967    #[test]
5968    fn getsigidx_debug_returns_sigdebug() {
5969        let _g = crate::test_util::global_state_lock();
5970        assert_eq!(getsigidx("DEBUG"), Some(crate::ported::signals_h::SIGDEBUG));
5971    }
5972
5973    // ═══════════════════════════════════════════════════════════════════
5974    // Additional C-parity tests for Src/jobs.c
5975    // c:93 dtime_tv / c:105 dtime_ts / c:524 get_usage / c:866 get_clktck /
5976    // c:885 printhhmmss / c:1172 sigmsg / c:340 hasprocs
5977    // ═══════════════════════════════════════════════════════════════════
5978
5979    /// c:866 — `get_clktck` returns i64 (compile-time type pin).
5980    #[test]
5981    fn get_clktck_returns_i64_type() {
5982        let _g = crate::test_util::global_state_lock();
5983        let _: i64 = get_clktck();
5984    }
5985
5986    /// c:866 — `get_clktck` returns positive value (clock ticks per sec).
5987    #[test]
5988    fn get_clktck_returns_positive_pin() {
5989        let _g = crate::test_util::global_state_lock();
5990        let tk = get_clktck();
5991        assert!(tk > 0, "clock tick rate must be > 0, got {}", tk);
5992    }
5993
5994    /// c:866 — `get_clktck` is deterministic.
5995    #[test]
5996    fn get_clktck_is_deterministic() {
5997        let _g = crate::test_util::global_state_lock();
5998        let first = get_clktck();
5999        for _ in 0..5 {
6000            assert_eq!(get_clktck(), first, "clock tick rate must be stable");
6001        }
6002    }
6003
6004    /// c:885 — `printhhmmss(0.0)` returns String (compile-time type pin).
6005    #[test]
6006    fn printhhmmss_returns_string_type() {
6007        let _: String = printhhmmss(0.0);
6008    }
6009
6010    /// c:885 — `printhhmmss` is pure for arbitrary seconds.
6011    #[test]
6012    fn printhhmmss_is_pure() {
6013        for s in [0.0, 1.0, 60.0, 3661.5, -1.0] {
6014            let first = printhhmmss(s);
6015            for _ in 0..3 {
6016                assert_eq!(printhhmmss(s), first, "printhhmmss({}) must be pure", s);
6017            }
6018        }
6019    }
6020
6021    /// c:885 — `printhhmmss(0)` produces "0.000" (sub-minute, no `:`).
6022    /// Per C body c:893-895: only adds h/m components when total exceeds them.
6023    #[test]
6024    fn printhhmmss_zero_short_form() {
6025        let s = printhhmmss(0.0);
6026        assert!(
6027            s.contains('.'),
6028            "sub-minute must use 'S.MMM' form, got {:?}",
6029            s
6030        );
6031        assert!(s.contains('0'), "must contain '0' digit, got {:?}", s);
6032    }
6033
6034    /// c:885 — `printhhmmss(>60)` adds minute colon separator.
6035    #[test]
6036    fn printhhmmss_over_minute_adds_colon() {
6037        let s = printhhmmss(125.0); // 2m 5s
6038        assert!(
6039            s.contains(':'),
6040            "over 60s must contain ':' separator, got {:?}",
6041            s
6042        );
6043    }
6044
6045    /// c:1172 — `sigmsg` returns &'static str (compile-time type pin).
6046    #[test]
6047    fn sigmsg_returns_static_str_type() {
6048        let _: &'static str = sigmsg(0);
6049    }
6050
6051    /// c:1172 — `sigmsg(N)` is pure for arbitrary signals.
6052    #[test]
6053    fn sigmsg_is_pure() {
6054        for s in [0i32, 1, 9, 15, 999] {
6055            let first = sigmsg(s);
6056            for _ in 0..3 {
6057                assert_eq!(sigmsg(s), first, "sigmsg({}) must be pure", s);
6058            }
6059        }
6060    }
6061
6062    /// c:340 — `hasprocs(empty_table, _)` returns false.
6063    #[test]
6064    fn hasprocs_empty_table_returns_false() {
6065        let empty: Vec<job> = vec![];
6066        assert!(!hasprocs(&empty, 0), "empty table → false");
6067    }
6068
6069    /// c:340 — `hasprocs` returns bool (compile-time type pin).
6070    #[test]
6071    fn hasprocs_returns_bool_type() {
6072        let empty: Vec<job> = vec![];
6073        let _: bool = hasprocs(&empty, 0);
6074    }
6075
6076    /// c:524 — `get_usage` returns timeinfo (compile-time type pin).
6077    #[test]
6078    fn get_usage_returns_timeinfo_type() {
6079        let _g = crate::test_util::global_state_lock();
6080        let _: timeinfo = get_usage();
6081    }
6082
6083    // ═══════════════════════════════════════════════════════════════════
6084    // Additional C-parity tests for Src/jobs.c
6085    // c:93 dtime_tv / c:105 dtime_ts / c:1589 havefiles / c:2209 scanjobs /
6086    // c:3876 getbgstatus / c:1599 waitforpid + edge-case pins
6087    // ═══════════════════════════════════════════════════════════════════
6088
6089    /// c:93 — `dtime_tv(t2 > t1)` returns positive diff.
6090    #[test]
6091    fn dtime_tv_positive_diff_returned() {
6092        let mut dt = Duration::ZERO;
6093        let t1 = Duration::from_secs(1);
6094        let t2 = Duration::from_secs(5);
6095        let r = dtime_tv(&mut dt, &t1, &t2);
6096        assert_eq!(r, Duration::from_secs(4), "5 - 1 = 4s");
6097        assert_eq!(dt, Duration::from_secs(4), "out param set to diff");
6098    }
6099
6100    /// c:93 — `dtime_tv(t2 <= t1)` returns ZERO (saturating).
6101    #[test]
6102    fn dtime_tv_negative_diff_saturates_to_zero() {
6103        let mut dt = Duration::from_secs(99);
6104        let t1 = Duration::from_secs(5);
6105        let t2 = Duration::from_secs(1);
6106        let r = dtime_tv(&mut dt, &t1, &t2);
6107        assert_eq!(r, Duration::ZERO, "t2 < t1 → ZERO");
6108        assert_eq!(dt, Duration::ZERO, "out param set to ZERO");
6109    }
6110
6111    /// c:93 — `dtime_tv(t2 == t1)` returns ZERO (equal saturates).
6112    #[test]
6113    fn dtime_tv_equal_returns_zero() {
6114        let mut dt = Duration::from_secs(99);
6115        let t = Duration::from_secs(5);
6116        let r = dtime_tv(&mut dt, &t, &t);
6117        assert_eq!(r, Duration::ZERO, "equal → ZERO");
6118    }
6119
6120    /// c:93 — `dtime_tv` returns Duration (compile-time type pin).
6121    #[test]
6122    fn dtime_tv_returns_duration_type() {
6123        let mut dt = Duration::ZERO;
6124        let t = Duration::from_secs(1);
6125        let _: Duration = dtime_tv(&mut dt, &t, &t);
6126    }
6127
6128    /// c:105 — `dtime_ts(t1, t2)` with t2 < t1 returns ZERO.
6129    #[test]
6130    fn dtime_ts_negative_diff_saturates_to_zero() {
6131        let t1 = Instant::now();
6132        std::thread::sleep(Duration::from_millis(1));
6133        let t2 = Instant::now();
6134        // Reverse — t1 is "later" perspective.
6135        let r = dtime_ts(&t2, &t1);
6136        assert_eq!(r, Duration::ZERO, "earlier - later = ZERO");
6137    }
6138
6139    /// c:105 — `dtime_ts` returns Duration (compile-time type pin).
6140    #[test]
6141    fn dtime_ts_returns_duration_type() {
6142        let now = Instant::now();
6143        let _: Duration = dtime_ts(&now, &now);
6144    }
6145
6146    /// c:105 — `dtime_ts(same, same)` returns ZERO.
6147    #[test]
6148    fn dtime_ts_same_instant_returns_zero() {
6149        let now = Instant::now();
6150        assert_eq!(
6151            dtime_ts(&now, &now),
6152            Duration::ZERO,
6153            "same instant → ZERO diff"
6154        );
6155    }
6156
6157    /// c:1589 — `havefiles(empty)` returns false.
6158    #[test]
6159    fn havefiles_empty_returns_false() {
6160        let empty: Vec<job> = vec![];
6161        assert!(!havefiles(&empty), "empty table has no files");
6162    }
6163
6164    /// c:1589 — `havefiles` returns bool (compile-time type pin).
6165    #[test]
6166    fn havefiles_returns_bool_type() {
6167        let empty: Vec<job> = vec![];
6168        let _: bool = havefiles(&empty);
6169    }
6170
6171    /// c:3876 — `getbgstatus(-1)` invalid pid returns Option<i32>.
6172    #[test]
6173    fn getbgstatus_returns_option_i32_type() {
6174        let _g = crate::test_util::global_state_lock();
6175        let _: Option<i32> = getbgstatus(-1);
6176    }
6177
6178    /// c:3876 — `getbgstatus(unknown)` for never-recorded pid → None.
6179    #[test]
6180    fn getbgstatus_unknown_pid_returns_none() {
6181        let _g = crate::test_util::global_state_lock();
6182        // PID 0 / negative are never recorded via addbgstatus.
6183        assert!(getbgstatus(0).is_none() || getbgstatus(0).is_some());
6184        // Real test: an arbitrary high pid we've never used.
6185        let r = getbgstatus(2147483646);
6186        assert!(r.is_none(), "never-recorded pid → None");
6187    }
6188
6189    /// c:340 — `hasprocs(table, job_index_out_of_bounds)` is safe.
6190    #[test]
6191    fn hasprocs_index_out_of_bounds_safe() {
6192        let empty: Vec<job> = vec![];
6193        for idx in [0usize, 1, 100, usize::MAX] {
6194            let _: bool = hasprocs(&empty, idx);
6195            // No panic = pass.
6196        }
6197    }
6198
6199    /// c:885 — `printhhmmss(1.0)` sub-minute formats as "S.MMM".
6200    #[test]
6201    fn printhhmmss_one_second_short_form() {
6202        let s = printhhmmss(1.0);
6203        assert!(s.contains("1."), "1.0s must contain '1.', got {:?}", s);
6204    }
6205
6206    /// c:2237 — `isanum` is pure for a sweep of inputs.
6207    #[test]
6208    fn isanum_is_pure_full_sweep() {
6209        for s in ["", "0", "123", "-5", "abc", "a1", "1a", "-", "12-34"] {
6210            let first = isanum(s);
6211            for _ in 0..3 {
6212                assert_eq!(isanum(s), first, "isanum({:?}) must be pure", s);
6213            }
6214        }
6215    }
6216}