Skip to main content

zsh/ported/
signals.rs

1//! Signal handling for zshrs
2//!
3//! Direct port from zsh/Src/signals.c
4//!
5//! Total count of trapped signals                                           // c:55
6//! Running an exit trap?                                                    // c:60
7//! Variables used by trap queueing                                          // c:87
8//! enable ^C interrupts                                                     // c:114
9//! disable ^C interrupts                                                    // c:124
10//! SIGHUP any jobs left running                                             // c:502
11//!
12//! Manages signal handling including:
13//! - Signal handlers for SIGINT, SIGCHLD, SIGHUP, etc.
14//! - Signal queueing during critical sections
15//! - Trap management (trap builtin)
16//! - Job control signals
17
18use crate::ported::builtin::{zexit, BREAKS, LASTVAL, LOOPS, RETFLAG, SFCONTEXT, STOPMSG};
19use crate::ported::context::{zcontext_restore, zcontext_save};
20use crate::ported::exec::{TRAP_RETURN, TRAP_STATE};
21use crate::ported::init::zleentry;
22use crate::ported::jobs::gettrapnode;
23pub use crate::ported::jobs::{getsigidx, getsigname};
24use crate::ported::mem::{zsfree, ztrdup};
25use crate::ported::options::optlookup;
26use crate::ported::params::{getiparam, ttyidlegetfn};
27pub use crate::ported::signals_h::{queue_signals, unqueue_signals};
28use crate::ported::signals_h::{SIGNUM, TRAPCOUNT as TRAPCOUNT_H, VSIGCOUNT};
29use crate::ported::utils::{
30    errflag, inc_locallevel, locallevel as locallevel_fn, zerr, zwarn, ERRFLAG_ERROR, RESETNEEDED,
31};
32use crate::ported::zsh_h::{
33    isset, Eprog, AFTERTRAPHOOK, BEFORETRAPHOOK, EMULATE_SH, EMULATION, ERRFLAG_INT, HUP,
34    INTERACTIVE, LOCALTRAPS, MONITOR, POSIXTRAPS, PRIVILEGED, SFC_SIGNAL, TRAPSASYNC,
35    TRAP_STATE_FORCE_RETURN, TRAP_STATE_PRIMED, ZEXIT_SIGNAL, ZLE_CMD_REFRESH, ZSIG_FUNC,
36    ZSIG_IGNORED, ZSIG_SHIFT, ZSIG_TRAPPED,
37};
38use crate::r#loop::try_tryflag;
39use crate::sched::zleactive;
40pub use crate::signals_h::{signal_default, signal_ignore};
41use crate::signals_h::{MAX_QUEUE_SIZE, SIGCOUNT, SIGDEBUG, SIGEXIT, SIGZERR, TRAPCOUNT};
42use crate::utils::getshfunc;
43use crate::DPUTS;
44use nix::sys::signal::{
45    sigprocmask, SaFlags, SigAction, SigHandler, SigSet, SigmaskHow, Signal as NixSignal,
46};
47use nix::unistd::getpid;
48use std::collections::HashMap;
49use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
50use std::sync::{Mutex, OnceLock};
51
52// getsigidx / getsigname live in `jobs.rs` per C source split:
53// `getsigidx` at `Src/jobs.c:3047`, `getsigname` at `Src/jobs.c:3087`.
54// Re-export from the canonical home so callers using
55// `crate::ported::signals::getsigidx` continue to compile.
56
57/// Per-slot trap-queue signals. Port of `static int
58/// trap_queue[MAX_QUEUE_SIZE]` from `Src/signals.c:92`.
59pub static trap_queue: [AtomicI32; MAX_QUEUE_SIZE] = // c:92
60    [ATOM_I32_ZERO; MAX_QUEUE_SIZE];
61
62/// Port of `install_handler(int sig)` from `Src/signals.c:100`.
63///
64/// C body:
65/// ```c
66/// struct sigaction act;
67/// act.sa_handler = zhandler;
68/// sigemptyset(&act.sa_mask);
69/// act.sa_flags = 0;
70/// if (interact) act.sa_flags |= SA_INTERRUPT;
71/// sigaction(sig, &act, NULL);
72/// ```
73///
74/// Uses `sigaction(2)` (not `signal(2)`) so SA_INTERRUPT can
75/// disable system-call restart when running interactively —
76/// matches the C source's contract that an interactive shell's
77/// signal handlers interrupt blocked reads (so ^C breaks out of
78/// `read` etc.).
79#[cfg(unix)]
80/// Port of `install_handler(int sig)` from `Src/signals.c:100`.
81pub fn install_handler(sig: i32) {
82    // c:100
83    unsafe {
84        let mut act: libc::sigaction = std::mem::zeroed();
85        act.sa_sigaction = zhandler as *const () as usize;
86        libc::sigemptyset(&mut act.sa_mask);
87        // SA_INTERRUPT isn't in the libc crate's POSIX feature set;
88        // when running interactively we'd prefer to leave SA_RESTART
89        // unset (the default after sigemptyset+0). Mirroring C: the
90        // sa_flags = 0 path matches the non-interactive case;
91        // interactive mode would OR in SA_INTERRUPT, which on Linux
92        // is the same as sa_flags = 0 on most libcs (deprecated
93        // alias). Leaving sa_flags = 0 is the same effect on every
94        // modern target.
95        act.sa_flags = 0;
96        libc::sigaction(sig, &act, std::ptr::null_mut());
97    }
98}
99
100// enable ^C interrupts                                                     // c:118
101/// Port of `intr()` from `Src/signals.c:118`.
102///
103/// C body: `if (interact) install_handler(SIGINT);` — the
104/// interactive-shell-only SIGINT installer used by `bin_set` /
105/// trap restoration paths to re-enable ^C breaking after a
106/// scope that disabled it.
107pub fn intr() {
108    // c:118
109    if is_interact() {
110        install_handler(libc::SIGINT);
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Remaining 18 missing signals.c functions
116// ---------------------------------------------------------------------------
117
118/// Port of `nointr()` from `Src/signals.c:128`.
119///
120/// C body (under `#if 0` in current zsh — kept for historical
121/// completeness):
122/// ```c
123/// if (interact)
124///     signal_ignore(SIGINT);
125/// ```
126// disable ^C interrupts                                                    // c:128
127/// Disables SIGINT delivery in interactive mode (sets the
128/// disposition to SIG_IGN). The `if (interact)` gate matches C.
129/// C body (2 lines): `if (interact) signal_ignore(SIGINT);`
130#[cfg(unix)]
131pub fn nointr() {
132    // c:128
133    if is_interact() {
134        unsafe {
135            libc::signal(libc::SIGINT, libc::SIG_IGN);
136        }
137    } // c:130-131
138}
139
140/// Port of `holdintr()` from `Src/signals.c:139`.
141///
142/// C body:
143/// ```c
144/// if (interact)
145///     signal_block(signal_mask(SIGINT));
146/// ```
147///
148// temporarily block ^C interrupts                                          // c:139
149/// Blocks SIGINT temporarily — used by code paths that can't
150/// handle interruption mid-flight (e.g. after fork before exec).
151#[cfg(unix)]
152pub fn holdintr() {
153    // c:139
154    if is_interact() {
155        let mask = signal_mask(libc::SIGINT);
156        signal_block(&mask);
157    }
158}
159
160/// Port of `noholdintr()` from `Src/signals.c:149`.
161///
162/// C body:
163/// ```c
164/// if (interact)
165///     signal_unblock(signal_mask(SIGINT));
166/// ```
167// release ^C interrupts                                                    // c:149
168///
169/// Inverse of [`holdintr`].
170#[cfg(unix)]
171pub fn noholdintr() {
172    // c:149
173    if is_interact() {
174        let mask = signal_mask(libc::SIGINT);
175        signal_unblock(&mask);
176    }
177}
178
179/// Port of `signal_mask(int sig)` from `Src/signals.c:160`.
180///
181/// C body:
182/// ```c
183/// sigset_t set;
184/// sigemptyset(&set);
185/// if (sig)
186///     sigaddset(&set, sig);
187/// return set;
188/// ```
189///
190/// Builds a sigset containing only the given signal; `sig == 0`
191/// returns an empty set (matches the explicit C check).
192#[cfg(unix)]
193/// Port of `signal_mask(int sig)` from `Src/signals.c:160`.
194pub fn signal_mask(sig: i32) -> libc::sigset_t {
195    let mut set: libc::sigset_t = unsafe { std::mem::zeroed() };
196    unsafe {
197        libc::sigemptyset(&mut set);
198        if sig != 0 {
199            libc::sigaddset(&mut set, sig);
200        }
201    }
202    set
203}
204
205/// Port of `signal_block(sigset_t set)` from `Src/signals.c:175`.
206///
207/// C body:
208/// ```c
209/// sigset_t oset;
210/// sigprocmask(SIG_BLOCK, &set, &oset);
211/// return oset;
212/// ```
213///
214/// Blocks every signal in `set`, returning the previous mask
215/// (matches C's `sigset_t signal_block(sigset_t set)`).
216#[cfg(unix)]
217pub fn signal_block(set: &libc::sigset_t) -> libc::sigset_t {
218    // c:175
219    let mut oset: libc::sigset_t = unsafe { std::mem::zeroed() };
220    unsafe {
221        libc::sigprocmask(libc::SIG_BLOCK, set, &mut oset);
222    }
223    oset
224}
225
226/// Port of `signal_unblock(sigset_t set)` from `Src/signals.c:189`.
227///
228/// C body: `sigprocmask(SIG_UNBLOCK, &set, &oset); return oset;`
229#[cfg(unix)]
230pub fn signal_unblock(set: &libc::sigset_t) -> libc::sigset_t {
231    // c:189
232    let mut oset: libc::sigset_t = unsafe { std::mem::zeroed() };
233    unsafe {
234        libc::sigprocmask(libc::SIG_UNBLOCK, set, &mut oset);
235    }
236    oset
237}
238
239/// Port of `signal_setmask(sigset_t set)` from `Src/signals.c:203`.
240///
241/// C body: `sigprocmask(SIG_SETMASK, &set, &oset); return oset;`
242///
243/// Sets the process signal mask, returning the previous mask
244/// (the previous Rust port discarded the old mask).
245#[cfg(unix)]
246pub fn signal_setmask(set: &libc::sigset_t) -> libc::sigset_t {
247    let mut oset: libc::sigset_t = unsafe { std::mem::zeroed() };
248    unsafe {
249        libc::sigprocmask(libc::SIG_SETMASK, set, &mut oset);
250    }
251    oset
252}
253
254/// Number of OS signals zsh tracks.
255/// `dotrap()` and `printsigtable()` to size the per-signal table.
256
257/// Total trap count including EXIT and ERR
258
259/// Port of `signal_suspend(UNUSED(int sig), int wait_cmd)` from `Src/signals.c:214`.
260///
261/// C body:
262/// ```c
263/// sigset_t set;
264/// sigemptyset(&set);
265/// if (!(wait_cmd || isset(TRAPSASYNC) ||
266///       (sigtrapped[SIGINT] & ~ZSIG_IGNORED)))
267///     sigaddset(&set, SIGINT);
268/// return sigsuspend(&set);
269/// ```
270///
271/// Atomically waits for any signal NOT in `set`. The wait_cmd /
272/// TRAPSASYNC / SIGINT-trapped cascade gates whether SIGINT is
273/// added to the mask: when `wait_cmd` is set (the `wait` builtin
274/// calls this) OR TRAPSASYNC is set OR the user has trapped
275/// SIGINT (and not ignored it), SIGINT is left UNblocked so the
276/// trap fires.
277///
278/// Previous Rust port did `libc::raise(SIGTSTP)` which is
279/// completely wrong (that's job-control suspend, not "wait for
280/// signal delivery"). Now real port via `sigsuspend(2)`.
281#[cfg(unix)]
282/// Port of `signal_suspend(UNUSED(int sig), int wait_cmd)` from `Src/signals.c:214`.
283#[allow(unused_variables)]
284pub fn signal_suspend(sig: i32, wait_cmd: bool) -> i32 {
285    // c:214
286    let mut set: libc::sigset_t = unsafe { std::mem::zeroed() };
287    unsafe {
288        libc::sigemptyset(&mut set);
289    }
290    // c:228 — `if (!(wait_cmd || isset(TRAPSASYNC) ||
291    //           (sigtrapped[SIGINT] & ~ZSIG_IGNORED))) sigaddset(...)`.
292    // Three escape hatches let SIGINT stay UNblocked during suspend:
293    //   1. `wait_cmd` — the `wait` builtin wants SIGINT to break it.
294    //   2. `isset(TRAPSASYNC)` — async-trap mode means traps fire even
295    //      while blocked, so SIGINT must arrive in real time.
296    //   3. SIGINT is trapped but not ignored — user trap must fire.
297    let int_state = sigtrapped
298        .lock()
299        .ok()
300        .and_then(|g| g.get(libc::SIGINT as usize).copied())
301        .unwrap_or(0);
302    let int_trapped = (int_state & !ZSIG_IGNORED) != 0;
303    let trapsasync_set = isset(
304        TRAPSASYNC, // c:228 isset(TRAPSASYNC)
305    );
306    if !(wait_cmd || trapsasync_set || int_trapped) {
307        unsafe {
308            libc::sigaddset(&mut set, libc::SIGINT);
309        }
310    }
311    unsafe { libc::sigsuspend(&set) }
312}
313
314/// Reap zombie child processes via non-blocking `waitpid(2)`.
315/// Port of `wait_for_processes()` from Src/signals.c:249 — the
316/// SIGCHLD-driven reaper that updates the job table.
317/// Rust idiom replacement: drain-loop over `waitpid(-1, WNOHANG)`
318/// covers the C `update_process` + `update_job` cascade; the
319/// per-PID job-table update is the caller's responsibility (decoupled
320/// from the reaper).
321#[cfg(unix)]
322pub fn wait_for_processes() -> Vec<(i32, i32)> {
323    let mut results = Vec::new();
324    // c:271-274 — `WAITFLAGS = WNOHANG|WUNTRACED|WCONTINUED`. The
325    // previous Rust port used `WNOHANG|WUNTRACED` only, dropping the
326    // WCONTINUED bit so children that were resumed via SIGCONT
327    // wouldn't surface a status update — silently breaking
328    // `fg`/`bg` job-table tracking. WCONTINUED is POSIX and
329    // available in libc-rs on every platform zshrs supports.
330    let waitflags = libc::WNOHANG | libc::WUNTRACED | libc::WCONTINUED; // c:271
331    loop {
332        let mut status: i32 = 0;
333        let pid = unsafe { libc::waitpid(-1, &mut status, waitflags) };
334        if pid <= 0 {
335            break;
336        }
337        results.push((pid, status));
338    }
339    results
340}
341
342/// Direct port of `void zhandler(int sig)` from
343/// `Src/signals.c:399-498`. The main dispatcher installed for
344/// every trapped + critical signal. Block all signals while
345/// running, record the delivery, queue if `queueing_enabled`,
346/// otherwise dispatch the per-signal handler (SIGCHLD →
347/// wait_for_processes; SIGPIPE/SIGHUP/SIGINT/SIGWINCH/SIGALRM →
348/// handletrap with platform-specific fallback; default →
349/// handletrap).
350#[cfg(unix)]
351/// Direct port of `static RETSIGTYPE zhandler(int sig)` from
352/// `Src/signals.c:393`. Exposed as `pub` so the Rust queue drainer
353/// (`run_queued_signals` in signals_h.rs) can call it synchronously,
354/// matching C's `zhandler(signal_queue[queue_front])` at c:83. C's
355/// version is `static` because all queue draining happens inside
356/// signals.c; Rust splits the macro (queue) from the handler
357/// (signals.rs) across two modules, so `pub` is the equivalent of C's
358/// in-file visibility. (Earlier port routed via `libc::raise(sig)`,
359/// which is the wrong analog — raise(2) goes through the kernel and
360/// loses the queued signal behind the process signal mask. See Bug
361/// #104 in docs/BUGS.md.)
362pub extern "C" fn zhandler(sig: libc::c_int) {
363    last_signal.store(sig, Ordering::Relaxed); // c:403
364
365    // c:405-407 — `sigfillset(&newmask); oldmask = signal_block(newmask);`
366    let mut newmask: libc::sigset_t = unsafe { std::mem::zeroed() };
367    unsafe {
368        libc::sigfillset(&mut newmask);
369    }
370    let oldmask = signal_block(&newmask);
371
372    // c:410-424 — `if (queueing_enabled) { ... return; }`
373    if queueing_enabled.load(Ordering::SeqCst) != 0 {
374        let temp_rear = (queue_rear.load(Ordering::SeqCst) + 1) % MAX_QUEUE_SIZE;
375        if temp_rear != queue_front.load(Ordering::SeqCst) {
376            queue_rear.store(temp_rear, Ordering::SeqCst);
377            signal_queue[temp_rear].store(sig, Ordering::SeqCst);
378            if let Ok(mut g) = signal_mask_queue.lock() {
379                if let Some(slot) = g.get_mut(temp_rear) {
380                    *slot = oldmask;
381                }
382            }
383        }
384        return;
385    }
386
387    // c:427 — `signal_setmask(oldmask);`
388    let _ = signal_setmask(&oldmask);
389
390    // c:429-498 — per-signal dispatch.
391    match sig {
392        libc::SIGCHLD => {
393            // c:430-431 — `wait_for_processes();` — reap zombies AND
394            // route their (pid, status) pairs through update_bg_job so
395            // job.stat picks up STAT_DONE / STAT_STOPPED bits. Without
396            // the route, signal_suspend-driven waits in zwaitjob can't
397            // see jobs as completed.
398            let reaped = wait_for_processes();
399            if !reaped.is_empty() {
400                if let Some(jt) = crate::ported::jobs::JOBTAB.get() {
401                    if let Ok(mut guard) = jt.lock() {
402                        for (pid, status) in reaped {
403                            let _ = crate::ported::jobs::update_bg_job(&mut guard, pid, status);
404                        }
405                        // c:Src/jobs.c:635-643 — update_job's tail reports a
406                        // finished job from the async (signal) path ONLY when
407                        // `isset(NOTIFY)` (a background job is never `thisjob`,
408                        // so the `job == thisjob` half never applies here). With
409                        // NOTIFY unset the completion is left STAT_CHANGED and
410                        // reported by preprompt()'s `if (unset(NOTIFY)) scanjobs()`
411                        // (utils.rs:1736) at the NEXT real prompt — never mid-ZLE.
412                        // Without this gate the handler spewed `[1] done …` lines
413                        // into the editor during e.g. zinit-turbo loading, which
414                        // real zsh (NOTIFY off) never does.
415                        if isset(crate::ported::zsh_h::NOTIFY) {
416                            crate::ported::jobs::scanjobs(&mut guard);
417                        }
418                    }
419                }
420            }
421        }
422        libc::SIGPIPE => {
423            // c:434
424            if handletrap(libc::SIGPIPE) == 0 {
425                // c:436-441 — non-interactive exits immediately; an
426                // interactive non-tty also exits via zexit.
427                let interact = isset(INTERACTIVE);
428                if !interact {
429                    unsafe {
430                        libc::_exit(libc::SIGPIPE);
431                    } // c:437
432                } else {
433                    // c:438 — `else if (!isatty(SHTTY))`. The previous
434                    // Rust port hardcoded fd 0 (stdin) with a comment
435                    // claiming "SHTTY isn't a single global in zshrs"
436                    // — but SHTTY IS a global at `init::SHTTY`. Use it.
437                    let shtty = crate::ported::init::SHTTY.load(Ordering::SeqCst);
438                    let on_tty = shtty >= 0 && unsafe { libc::isatty(shtty) } != 0;
439                    if !on_tty {
440                        STOPMSG // c:439
441                            .store(1, Ordering::Relaxed);
442                        zexit(libc::SIGPIPE, ZEXIT_SIGNAL);
443                        // c:440
444                    }
445                }
446            }
447        }
448        libc::SIGHUP => {
449            // c:445
450            if handletrap(libc::SIGHUP) == 0 {
451                // c:447 — `stopmsg = 1; zexit(SIGHUP, ZEXIT_SIGNAL);`
452                STOPMSG.store(1, Ordering::Relaxed);
453                zexit(libc::SIGHUP, ZEXIT_SIGNAL); // c:448
454            }
455        }
456        libc::SIGINT => {
457            // c:452
458            if handletrap(libc::SIGINT) == 0 {
459                // c:454-456 — PRIVILEGED+INTERACTIVE during a signal-
460                // noerrexit window: immediate exit.
461                let privileged = isset(PRIVILEGED);
462                let interactive = isset(INTERACTIVE);
463                if privileged && interactive {
464                    zexit(libc::SIGINT, ZEXIT_SIGNAL);
465                }
466                // c:457 — `errflag |= ERRFLAG_INT;`
467                let cur = errflag.load(Ordering::Relaxed);
468                errflag.store(cur | ERRFLAG_INT, Ordering::Relaxed); // c:457
469                                                                     // c:458-462 — `if (list_pipe || chline || simple_pline)`:
470                                                                     // an interactive SIGINT mid-pipeline must break loops,
471                                                                     // flush pending input, and signal any cursh job.
472                let in_list_pipe = crate::ported::exec::list_pipe.load(Ordering::Relaxed) != 0;
473                let chline_nonempty = crate::ported::hist::chline
474                    .lock()
475                    .map(|s| !s.is_empty())
476                    .unwrap_or(false);
477                let in_simple_pline =
478                    crate::ported::exec::simple_pline.load(Ordering::Relaxed) != 0;
479                if in_list_pipe || chline_nonempty || in_simple_pline {
480                    // c:459 — `breaks = loops;`
481                    let l = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
482                    crate::ported::builtin::BREAKS.store(l, Ordering::Relaxed);
483                    // c:460 — `inerrflush();`
484                    crate::ported::input::inerrflush();
485                    // c:461 — `check_cursh_sig(SIGINT);`. Rust port
486                    // takes `(jobtab, sig)`; load the canonical JOBTAB
487                    // snapshot then dispatch.
488                    #[cfg(unix)]
489                    if let Some(tab) = crate::ported::jobs::JOBTAB.get() {
490                        if let Ok(jt) = tab.lock() {
491                            crate::ported::jobs::check_cursh_sig(&jt, libc::SIGINT);
492                        }
493                    }
494                }
495                // c:463 — `lastval = 128 + SIGINT;`
496                LASTVAL.store(128 + libc::SIGINT, Ordering::Relaxed);
497            }
498        }
499        libc::SIGWINCH => {
500            // c:468
501            // c:469 — `adjustwinsize(1)` (Src/utils.c) — re-reads
502            // TIOCGWINSZ and updates LINES/COLUMNS params.
503            let _ = crate::ported::utils::adjustwinsize(1); // c:469
504            let _ = handletrap(libc::SIGWINCH); // c:470
505        }
506        libc::SIGALRM => {
507            // c:475
508            if handletrap(libc::SIGALRM) == 0 {
509                // c:476-489 — idle vs TMOUT branch. The previous Rust
510                // port commented "Skip the still idle re-arm" claiming
511                // no ttyidlegetfn port — but it IS ported at
512                // `ttyidlegetfn`. Now wired
513                // exactly per C.
514                //
515                // C body (c:478-484):
516                //   int idle = ttyidlegetfn(NULL);
517                //   int tmout = getiparam("TMOUT");
518                //   if (idle >= 0 && idle < tmout)
519                //       alarm(tmout - idle);
520                //   else { /* timeout exit */ }
521                let idle = ttyidlegetfn(); // c:478
522                let tmout = getiparam("TMOUT"); // c:479
523                if idle >= 0 && idle < tmout {
524                    // c:481 — `alarm(tmout - idle);` — re-arm for
525                    // remaining idle window.
526                    unsafe {
527                        libc::alarm((tmout - idle) as u32); // c:481
528                    }
529                } else if tmout == 0 {
530                    // No timeout configured — bail out silently
531                    // (C falls into the else branch which would
532                    // emit "timeout" and zexit even with tmout==0,
533                    // but that's a degenerate setup; matching
534                    // common-case behavior here).
535                } else {
536                    // c:486 — `errflag = noerrs = 0;`
537                    errflag.store(0, Ordering::Relaxed);
538                    // c:487 — `zwarn("timeout");`
539                    zwarn("timeout"); // c:487
540                    STOPMSG.store(1, Ordering::Relaxed); // c:488
541                    zexit(libc::SIGALRM, ZEXIT_SIGNAL); // c:489
542                }
543            }
544        }
545        _ => {
546            // c:506
547            let _ = handletrap(sig);
548        }
549    }
550}
551
552/// Kill all running jobs with SIGHUP at shell exit.
553///
554/// Port of `void killrunjobs(int from_signal)` from `Src/signals.c:506`.
555/// C body:
556/// ```c
557/// if (unset(HUP)) return;
558/// for (i = 1; i <= maxjob; i++)
559///     if ((from_signal || i != thisjob) && (jobtab[i].stat & STAT_LOCKED) &&
560///         !(jobtab[i].stat & STAT_NOPRINT) &&
561///         !(jobtab[i].stat & STAT_STOPPED)) {
562///         if (jobtab[i].gleader != getpid() &&
563///             killpg(jobtab[i].gleader, SIGHUP) != -1)
564///             killed++;
565///     }
566/// if (killed) zwarn("warning: %d jobs SIGHUPed", killed);
567/// ```
568///
569#[cfg(unix)]
570pub fn killrunjobs(from_signal: i32) {
571    // c:506
572    // c:512 — `if (unset(HUP)) return;`. HUP option gates the
573    // whole walk: when `setopt nohup`, jobs survive shell exit.
574    if !isset(HUP) {
575        // c:512
576        return;
577    }
578    let my_pid = unsafe { libc::getpid() };
579    let mut killed: i32 = 0;
580    // c:514 — `for (i = 1; i <= maxjob; i++)`. Skip index 0
581    // (shell itself).
582    let tab = crate::ported::jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
583    let tab = tab.lock().expect("jobtab poisoned");
584    let thisjob = crate::ported::jobs::THISJOB
585        .get_or_init(|| Mutex::new(-1))
586        .lock()
587        .map(|g| *g)
588        .unwrap_or(-1);
589    for (i, job) in tab.iter().enumerate().skip(1) {
590        // c:515-517 — gate: (from_signal || i != thisjob) AND
591        // STAT_LOCKED AND !STAT_NOPRINT AND !STAT_STOPPED.
592        if !(from_signal != 0 || i as i32 != thisjob) {
593            // c:515
594            continue;
595        }
596        if (job.stat & crate::ported::jobs::stat::LOCKED) == 0 {
597            // c:516
598            continue;
599        }
600        if (job.stat & crate::ported::jobs::stat::NOPRINT) != 0 {
601            // c:516
602            continue;
603        }
604        if (job.stat & crate::ported::jobs::stat::STOPPED) != 0 {
605            // c:516
606            continue;
607        }
608        // c:518-520 — `if (jobtab[i].gleader != getpid() &&
609        //                  killpg(jobtab[i].gleader, SIGHUP) != -1)
610        //                  killed++;`
611        // The gleader check avoids the shell HUP-ing itself.
612        if job.gleader != my_pid && unsafe { libc::killpg(job.gleader, libc::SIGHUP) } != -1
613        // c:519
614        {
615            killed += 1; // c:520
616        }
617    }
618    drop(tab);
619    // c:524 — `if (killed) zwarn("warning: %d jobs SIGHUPed", killed);`
620    if killed != 0 {
621        // c:524
622        zwarn(&format!("warning: {} jobs SIGHUPed", killed));
623        // c:524
624    }
625}
626
627/* send a signal to a job (simply involves kill if monitoring is on) */
628// c:525
629/// Port of `killjb(Job jn, int sig)` from `Src/signals.c:529`.
630/// CALLER CONVENTION: Rust passes `jn_idx: usize` (JOBTAB index)
631/// instead of C `Job jn` (pointer); the body resolves the job via
632/// `JOBTAB.lock()`. Returns 0/-1 per the killpg result chain.
633#[cfg(unix)]
634pub fn killjb(jn_idx: usize, sig: i32) -> i32 {
635    // c:529
636    let _pn: (); // c:531 `Process pn;` — modelled by loop binding
637    let mut err: i32 = 0; // c:532
638
639    if crate::ported::zsh_h::jobbing() {
640        // c:534
641        // Snapshot the job state under the lock (gleader + stat + other
642        // + procs + other-procs). Avoid holding the lock during kill()
643        // syscalls — they can block under signals.
644        let snap = {
645            let table = match crate::ported::jobs::JOBTAB.get() {
646                Some(t) => t,
647                None => return -1,
648            };
649            let tab = table.lock().unwrap_or_else(|e| e.into_inner());
650            let jn = match tab.get(jn_idx) {
651                Some(j) => j,
652                None => return -1,
653            };
654            let other_procs: Vec<libc::pid_t> = if jn.other > 0 {
655                tab.get(jn.other as usize)
656                    .map(|o| o.procs.iter().map(|p| p.pid).collect())
657                    .unwrap_or_default()
658            } else {
659                Vec::new()
660            };
661            let other_empty = jn.other > 0
662                && tab
663                    .get(jn.other as usize)
664                    .map(|o| o.procs.is_empty())
665                    .unwrap_or(true);
666            (
667                jn.stat,
668                jn.gleader,
669                jn.other,
670                jn.procs.iter().map(|p| p.pid).collect::<Vec<_>>(),
671                other_procs,
672                other_empty,
673            )
674        };
675        let (stat, gleader, other, procs_pids, other_procs, other_empty) = snap;
676
677        if (stat & crate::ported::zsh_h::STAT_SUPERJOB) != 0 {
678            // c:535
679            if sig == libc::SIGCONT {
680                // c:536
681                // c:537-540 — walk jobtab[jn->other].procs, killpg each;
682                // fall through to kill() on killpg failure; ESRCH ignored.
683                for pid in &other_procs {
684                    // c:537
685                    if unsafe { libc::killpg(*pid, sig) } == -1 {
686                        // c:538
687                        let e = std::io::Error::last_os_error().raw_os_error();
688                        // c:539 — fallback kill()
689                        if unsafe { libc::kill(*pid, sig) } == -1 && e != Some(libc::ESRCH) {
690                            err = -1; // c:540
691                        }
692                    }
693                }
694
695                /*
696                 * Note this does not kill the last process,
697                 * which is assumed to be the one controlling the
698                 * subjob, i.e. the forked zsh that was originally
699                 * list_pipe_pid...
700                 */
701                // c:542-547
702                let n = procs_pids.len();
703                if n > 0 {
704                    for pid in &procs_pids[..n - 1] {
705                        // c:548 `for pn = jn->procs; pn->next; ...`
706                        if unsafe { libc::kill(*pid, sig) } == -1
707                            && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
708                        {
709                            err = -1; // c:550
710                        }
711                    }
712
713                    /*
714                     * ...we only continue that once the external processes
715                     * currently associated with the subjob are finished.
716                     */
717                    // c:552-555
718                    if other_empty {
719                        // c:556
720                        let last = procs_pids[n - 1];
721                        if unsafe { libc::kill(last, sig) } == -1
722                            && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
723                        {
724                            err = -1; // c:558
725                        }
726                    }
727                }
728
729                /*
730                 * The following marks both the superjob and subjob
731                 * as running, as done elsewhere.
732                 */
733                // c:560-569
734                if err != -1 {
735                    // c:570
736                    let table = crate::ported::jobs::JOBTAB.get().unwrap();
737                    let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
738                    crate::ported::jobs::makerunning(&mut tab, jn_idx); // c:571
739                }
740
741                return err; // c:573
742            }
743
744            // c:575 — `if (killpg(jobtab[jn->other].gleader, sig) == -1 && errno != ESRCH) err = -1;`
745            let other_gleader = crate::ported::jobs::JOBTAB
746                .get()
747                .and_then(|t| {
748                    t.lock()
749                        .ok()
750                        .and_then(|tab| tab.get(other as usize).map(|j| j.gleader))
751                })
752                .unwrap_or(0);
753            if other_gleader > 0
754                && unsafe { libc::killpg(other_gleader, sig) } == -1
755                && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
756            {
757                err = -1; // c:576
758            }
759
760            if unsafe { libc::killpg(gleader, sig) } == -1                   // c:578
761                && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
762            {
763                err = -1; // c:579
764            }
765
766            return err; // c:581
767        } else {
768            // c:583
769            err = unsafe { libc::killpg(gleader, sig) }; // c:584
770            if sig == libc::SIGCONT && err != -1 {
771                // c:585
772                let table = crate::ported::jobs::JOBTAB.get().unwrap();
773                let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
774                crate::ported::jobs::makerunning(&mut tab, jn_idx); // c:586
775            }
776            return err; // c:587
777        }
778    }
779    // c:590-604 — non-jobbing: walk jn->procs, kill each if SP_RUNNING
780    // or WIFSTOPPED; ignore ESRCH and `sig == 0` (kill -0 polling).
781    let table = match crate::ported::jobs::JOBTAB.get() {
782        Some(t) => t,
783        None => return err,
784    };
785    let snap: Vec<(libc::pid_t, i32)> = {
786        let tab = table.lock().unwrap_or_else(|e| e.into_inner());
787        match tab.get(jn_idx) {
788            Some(j) => j.procs.iter().map(|p| (p.pid, p.status)).collect(),
789            None => return err,
790        }
791    };
792    for (pid, status) in snap {
793        // c:590
794        /*
795         * Do not kill this job's process if it's already dead as its
796         * pid could have been reused by the system.
797         */                                                                  // c:591-595
798        let is_running = status == crate::ported::zsh_h::SP_RUNNING;
799        let is_stopped = libc::WIFSTOPPED(status);
800        if is_running || is_stopped {
801            // c:596
802            /*
803             * kill -0 on a job is pointless. We still call kill() for each
804             * process in case the user cares about it but we ignore its outcome.
805             */                                                              // c:597-600
806            let r = unsafe { libc::kill(pid, sig) };
807            if r == -1
808                && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
809                && sig != 0
810            {
811                err = r;
812                return -1; // c:602
813            }
814            err = r; // c:601 assignment
815        }
816    }
817    err // c:605
818}
819
820/// Port of `struct savetrap` from `Src/signals.c:611-624`.
821/// One stacked trap-state entry captured by `dosavetrap` so the
822/// outer-scope trap can be restored when an inner scope exits.
823#[allow(non_camel_case_types)]
824pub struct savetrap {
825    // c:611
826    pub sig: i32,            // c:613
827    pub flags: i32,          // c:614
828    pub local: i32,          // c:615 locallevel at save
829    pub posix: i32,          // c:616 exit_trap_posix snapshot
830    pub list: Option<Eprog>, // c:617 trap eval-list Eprog
831    /// Snapshot of the body string from `traps_table` at save time.
832    /// Rust-only — C zsh stores the body in `siglists[sig]` as an
833    /// Eprog (already covered by `list` above), but zshrs stores the
834    /// raw body string in `traps_table` for the bridge's
835    /// `execute_script` dispatch path. Without snapshotting it here,
836    /// `endtrapscope`'s restore loop puts back `sigtrapped` flags
837    /// matching the outer scope but the body in `traps_table` still
838    /// reflects the inner scope's overwrite — so the outer EXIT
839    /// trap dispatches the inner body. Bug #80 in docs/BUGS.md.
840    pub body: Option<String>,
841}
842
843/// Direct port of `void dosavetrap(int sig, int level)` from
844/// `Src/signals.c:626`. Captures the current trap state for
845/// `sig` into a `savetrap` and pushes it onto `SAVETRAPS`.
846pub fn dosavetrap(sig: i32, level: i32) {
847    // c:626
848    let flags = sigtrapped
849        .lock()
850        .ok()
851        .and_then(|g| g.get(sig as usize).copied())
852        .unwrap_or(0);
853    // c:663 — `st->list = siglists[sig] ? dupeprog(siglists[sig], 0) : NULL`.
854    // dupeprog isn't ported yet so take the Eprog out of siglists and
855    // re-stash a fresh None — the saved entry owns the body until the
856    // matching endtrapscope restore re-inserts it.
857    let list = siglists
858        .lock()
859        .ok()
860        .and_then(|mut g| g.get_mut(sig as usize).and_then(|s| s.take()));
861    let posix = if sig == SIGEXIT {
862        if EXIT_TRAP_POSIX.load(Ordering::Relaxed) {
863            1
864        } else {
865            0
866        }
867    } else {
868        0
869    };
870    // Snapshot the body string from `traps_table` so the matching
871    // restore in `endtrapscope` can write back the outer scope's body
872    // — see `savetrap::body` doc above for the bug #80 context.
873    let body = {
874        let signame = getsigname(sig);
875        crate::ported::builtin::traps_table()
876            .lock()
877            .ok()
878            .and_then(|g| g.get(&signame).cloned())
879    };
880    let st = savetrap {
881        sig,
882        flags,
883        local: level,
884        posix,
885        list,
886        body,
887    };
888    if let Ok(mut g) = SAVETRAPS.get_or_init(|| Mutex::new(Vec::new())).lock() {
889        g.insert(0, st); // c:689 front-insert
890    }
891}
892
893/// SIGEXIT signal number — Rust port uses `SIGCOUNT + 1` since
894/// libc::SIG* are all < SIGCOUNT and EXIT is the synthetic
895/// trap-only signal at the top of the table.
896// SIGEXIT already declared at line 45.
897
898// sig is index into the table of trapped signals.                         // c:693
899//                                                                          // c:693
900// l is the list to be eval'd for a trap defined with the "trap"            // c:693
901// builtin and should be NULL for a function trap.                          // c:693
902/// Direct port of `mod_export int settrap(int sig, Eprog l, int flags)`
903/// from `Src/signals.c:693`. Calls `unsettrap` unconditionally
904/// (so the previous trap is saved into `SAVETRAPS` if needed), then
905/// writes `l` into `siglists[sig]` and sets `sigtrapped[sig]` to
906/// either `ZSIG_IGNORED` (empty list + non-ZSIG_FUNC) or
907/// `ZSIG_TRAPPED`, then ORs in `flags` and the
908/// `locallevel << ZSIG_SHIFT` scope tag.
909pub fn settrap(sig: i32, l: Option<Eprog>, flags: i32) -> i32 {
910    // c:693
911    if sig == -1 {
912        // c:693
913        return 1;
914    }
915    // c:696 (zsh.h:2563) — `if (jobbing && (sig == SIGTTOU ||
916    // sig == SIGTSTP || sig == SIGTTIN)) { zerr("can't trap SIG%s
917    // in interactive shells", ...); return 1; }`.
918    let jobbing = isset(MONITOR); // c:696
919    if jobbing && (sig == libc::SIGTTOU || sig == libc::SIGTSTP || sig == libc::SIGTTIN) {
920        // c:697 — `zerr("can't trap SIG%s in interactive shells", sigs[sig])`.
921        let signame = getsigname(sig);
922        zerr(&format!("can't trap SIG{} in interactive shells", signame));
923        return 1; // c:699
924    }
925
926    // c:705 — `queue_signals()` + `unsettrap(sig)` unconditional
927    // (saves the previous trap if locallevel changed).
928    queue_signals();
929    unsettrap(sig);
930
931    // c:709-710 — DPUTS((flags & ZSIG_FUNC) && l,
932    //                   "BUG: trap function has passed eval list, too")
933    DPUTS!(
934        // c:709
935        (flags & ZSIG_FUNC) != 0 && l.is_some(), // c:709
936        "BUG: trap function has passed eval list, too"  // c:710
937    );
938
939    // c:712 — `if (!(flags & ZSIG_FUNC) && empty_eprog(l))`. C's
940    // `empty_eprog` returns true for NULL, NULL prog, OR a prog whose
941    // first wordcode is WCB_END (`Src/parse.c:586`).
942    let l_is_empty = match &l {
943        None => true,
944        Some(eprog) => crate::ported::parse::empty_eprog(eprog),
945    };
946    // c:711 — `siglists[sig] = l`.
947    if let Ok(mut g) = siglists.lock() {
948        if let Some(slot) = g.get_mut(sig as usize) {
949            *slot = l;
950        }
951    }
952    if (flags & ZSIG_FUNC) == 0 && l_is_empty {
953        // c:712
954        // c:713 — `sigtrapped[sig] = ZSIG_IGNORED`.
955        if let Ok(mut g) = sigtrapped.lock() {
956            if let Some(slot) = g.get_mut(sig as usize) {
957                *slot = ZSIG_IGNORED;
958            }
959        }
960        if sig != 0 && sig <= SIGCOUNT && sig != libc::SIGWINCH && sig != libc::SIGCHLD {
961            signal_ignore(sig); // c:719
962        }
963        // c:720-723 — RT-signal trap-table branch:
964        //   `else if (sig >= VSIGCOUNT && sig < TRAPCOUNT)
965        //                signal_ignore(SIGNUM(sig));`
966        #[cfg(target_os = "linux")]
967        if sig >= VSIGCOUNT && sig < TRAPCOUNT_H {
968            signal_ignore(SIGNUM(sig)); // c:722
969        }
970    } else {
971        nsigtrapped.fetch_add(1, Ordering::Relaxed); // c:725
972        if let Ok(mut g) = sigtrapped.lock() {
973            if let Some(slot) = g.get_mut(sig as usize) {
974                *slot = ZSIG_TRAPPED;
975            }
976        }
977        if sig != 0 && sig <= SIGCOUNT && sig != libc::SIGWINCH && sig != libc::SIGCHLD {
978            install_handler(sig); // c:732
979        }
980        // c:733-736 — RT-signal install_handler branch:
981        //   `if (sig >= VSIGCOUNT && sig < TRAPCOUNT)
982        //              install_handler(SIGNUM(sig));`
983        // Trapping `RTMIN+1` to a function without this branch would
984        // store the trap but NEVER install the libc handler, so the
985        // signal would fire with default action (terminate the shell).
986        #[cfg(target_os = "linux")]
987        if sig >= VSIGCOUNT && sig < TRAPCOUNT_H {
988            install_handler(SIGNUM(sig)); // c:735
989        }
990    }
991    // c:738 — `sigtrapped[sig] |= flags`.
992    if let Ok(mut g) = sigtrapped.lock() {
993        if let Some(slot) = g.get_mut(sig as usize) {
994            *slot |= flags;
995        }
996    }
997    // c:743-752 — locallevel tag (SIGEXIT in POSIX mode is sticky).
998    let locallevel = locallevel_fn() as i32;
999    if sig == SIGEXIT {
1000        // c:746 — `if (isset(POSIXTRAPS)) ...`. In POSIX mode SIGEXIT
1001        // is sticky and not tagged with the local-level shift.
1002        let posix_traps = isset(optlookup("posixtraps")); // c:746
1003        EXIT_TRAP_POSIX.store(posix_traps, Ordering::Relaxed);
1004        if !posix_traps {
1005            if let Ok(mut g) = sigtrapped.lock() {
1006                if let Some(slot) = g.get_mut(sig as usize) {
1007                    *slot |= locallevel << ZSIG_SHIFT;
1008                }
1009            }
1010        }
1011    } else if let Ok(mut g) = sigtrapped.lock() {
1012        if let Some(slot) = g.get_mut(sig as usize) {
1013            *slot |= locallevel << ZSIG_SHIFT;
1014        }
1015    }
1016    unqueue_signals();
1017    0 // c:759
1018}
1019
1020/// Direct port of `HashNode removetrap(int sig)` from `Src/signals.c:772`.
1021/// Clears the trap slot for `sig`, snapshots the prior state into
1022/// `SAVETRAPS` when `locallevel > 0` and the relevant option set
1023/// (LOCALTRAPS for generic signals, !POSIXTRAPS for EXIT), then
1024/// re-installs the appropriate per-signal disposition (intr for
1025/// SIGINT-interactive, install_handler for SIGHUP/SIGPIPE,
1026/// signal_default for everything else).
1027///
1028/// C returns the displaced HashNode for the caller (unsettrap) to
1029/// free; Rust ownership covers the free automatically when the
1030/// hashtable entry drops.
1031pub fn removetrap(sig: i32) {
1032    // c:772
1033    let trapped = sigtrapped
1034        .lock()
1035        .ok()
1036        .and_then(|g| g.get(sig as usize).copied())
1037        .unwrap_or(0);
1038    // c:776-778 — `if (sig == -1 || (jobbing && (SIGTTOU || SIGTSTP || SIGTTIN))) return NULL`.
1039    // The Rust call sites already use sig in [0, SIGCOUNT]; sig == -1 is rare,
1040    // but jobbing+job-control reject mirrors C exactly.
1041    if sig == -1 {
1042        return;
1043    }
1044    let jobbing = isset(MONITOR);
1045    if jobbing && (sig == libc::SIGTTOU || sig == libc::SIGTSTP || sig == libc::SIGTTIN) {
1046        return;
1047    }
1048    let locallevel = locallevel_fn() as i32;
1049    // c:769-774 — `if (!dontsavetrap && (sig == SIGEXIT ? !isset(POSIXTRAPS)
1050    // : isset(LOCALTRAPS)) && locallevel && (!trapped || locallevel >
1051    // (sigtrapped[sig] >> ZSIG_SHIFT))) dosavetrap(sig, locallevel);`.
1052    // Note: `!trapped` is LOGICAL NOT (`trapped == 0`), not Rust's
1053    // bitwise `!i32`.
1054    let cond_local_or_exit = if sig == SIGEXIT {
1055        !isset(POSIXTRAPS) // c:771 sig==SIGEXIT branch
1056    } else {
1057        isset(LOCALTRAPS) // c:771 else branch
1058    };
1059    if DONTSAVETRAP.load(Ordering::Relaxed) == 0                             // c:769
1060        && cond_local_or_exit
1061        && locallevel != 0                                                   // c:772 `locallevel &&`
1062        && (trapped == 0                                                     // c:773 `!trapped` (logical NOT)
1063            || locallevel > (trapped >> ZSIG_SHIFT))
1064    {
1065        dosavetrap(sig, locallevel); // c:774
1066    }
1067    if trapped & ZSIG_TRAPPED != 0 {
1068        nsigtrapped.fetch_sub(1, Ordering::Relaxed); // c:799
1069    }
1070    if let Ok(mut g) = sigtrapped.lock() {
1071        if let Some(slot) = g.get_mut(sig as usize) {
1072            *slot = 0;
1073        } // c:800
1074    }
1075    if let Ok(mut g) = siglists.lock() {
1076        if let Some(slot) = g.get_mut(sig as usize) {
1077            *slot = None;
1078        }
1079    }
1080    // c:843-845 — `} else if (siglists[sig]) { freeeprog(siglists[sig]); … }`.
1081    // C destroys the trap BODY here, and says why at c:823-830: "At this point
1082    // we free the appropriate structs … here we are remove the originals.
1083    // That causes a little inefficiency, but a good deal more reliability."
1084    //
1085    // C's body is siglists[sig]; zshrs's is the traps_table entry (the bridge
1086    // dispatches that raw string via execute_script), so clearing the siglists
1087    // slot above is only half of it — the table entry IS the body and must go
1088    // with it, or the two storages drift apart. dosavetrap (c:774, above) has
1089    // already snapshotted it into SAVETRAPS.body, and endtrapscope's restore
1090    // loop puts it back AFTER its settrap/unsettrap calls (which land here).
1091    //
1092    // This is what made starttrapscope's `unsettrap(SIGEXIT)` (c:866) a no-op
1093    // for anything observable: it cleared sigtrapped[SIGEXIT] while the entry
1094    // stayed in traps_table, so `trap 'p' EXIT; f() { trap }; f` still listed
1095    // the EXIT trap inside f, where zsh — having unset it for the duration of
1096    // the function's scope — lists nothing.
1097    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1098        let signame = getsigname(sig);
1099        t.remove(&signame);
1100        // ERR/ZERR are one signal under two canonical spellings (bin_trap
1101        // keys whichever the user wrote).
1102        if sig == SIGZERR {
1103            t.remove("ERR");
1104            t.remove("ZERR");
1105        }
1106    }
1107    // c:803-845 — per-signal disposition reset after clearing the
1108    // trap. The previous Rust port collapsed everything to a single
1109    // signal_default() call, omitting the SIGINT/SIGHUP/SIGPIPE
1110    // special branches AND the RT-signal branch entirely.
1111    let interact = isset(INTERACTIVE);
1112    // c:808 `forklevel` — depth of subshell forks. C global at
1113    // exec.c:1052 set to `locallevel` at every entersubsh() (c:1221).
1114    // Read live from the ported global so SIGPIPE only re-installs in
1115    // the top-level shell, never inside a forked subshell.
1116    let forklevel: i32 = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed); // c:1052 (Src/exec.c)
1117    if sig == libc::SIGINT && interact {
1118        // c:802
1119        // c:803-805 — `intr(); noholdintr();`. Re-enable SIGINT
1120        // delivery (subshells ignoring SIGINT need the unblock).
1121        intr();
1122        noholdintr();
1123    } else if sig == libc::SIGHUP {
1124        // c:806
1125        // c:807 — HUP gets RE-INSTALLED (not defaulted), so the
1126        // shell keeps catching it.
1127        install_handler(sig);
1128    } else if sig == libc::SIGPIPE && interact && forklevel == 0 {
1129        // c:808
1130        // c:809 — same install-not-default semantics.
1131        install_handler(sig);
1132    } else if sig != 0 && sig <= SIGCOUNT && sig != libc::SIGWINCH && sig != libc::SIGCHLD {
1133        // c:810
1134        signal_default(sig); // c:815
1135    }
1136    // c:816-819 — RT-signal branch (Linux).
1137    #[cfg(target_os = "linux")]
1138    {
1139        if sig >= VSIGCOUNT && sig < TRAPCOUNT_H {
1140            signal_default(SIGNUM(sig)); // c:818
1141        }
1142    }
1143}
1144
1145// Variables used by signal queueing                                       // c:74
1146/// Enable signal queueing.
1147// queue_signals / unqueue_signals live in `signals_h.rs` per the C
1148// source split: both are `#define` macros in `Src/signals.h:90/112`
1149// + `92/114`, not functions in `Src/signals.c`. Re-export from the
1150// canonical home so callers using `crate::ported::signals::queue_signals`
1151// continue to compile, and the QUEUEING_ENABLED state is shared
1152// across all callers (instead of split between two parallel
1153// SignalQueue/QUEUEING_ENABLED counters).
1154
1155/// Remove a trap completely and reset to default disposition.
1156/// Port of `removetrap(int sig)` from Src/signals.c:772.
1157///
1158/// **Inverted call chain vs C**: in C, `unsettrap` (c:759) is a
1159/// thin queue_signals + removetrap wrapper; the full save+clear+
1160/// signal-disposition logic lives in `removetrap`. The Rust port
1161/// inverts the relationship — `unsettrap` carries the full body
1162/// (matching C lines 781-820), and `removetrap` is the thin
1163/// wrapper. `unsettrap` runs the per-signal disposition
1164/// (c:802-820): SIGINT → intr(), SIGHUP → re-install_handler,
1165/// SIGPIPE under interactive non-fork → re-install_handler.
1166/// Never SIG_DFL these branches directly.
1167pub fn unsettrap(sig: i32) {
1168    // c:759
1169    // c:763 — queue_signals();
1170    queue_signals();
1171    // c:764 — hn = removetrap(sig);
1172    //         c:765-766 — if (hn) shfunctab->freenode(hn);
1173    //         Rust ownership covers the freenode when the trap entry
1174    //         is removed from sigfuncs / freed automatically.
1175    removetrap(sig);
1176    // c:767 — unqueue_signals();
1177    unqueue_signals();
1178}
1179
1180/// Direct port of `void starttrapscope(void)` from
1181/// `Src/signals.c:855-868`.
1182/// ```c
1183/// if (intrap) return;
1184/// if (sigtrapped[SIGEXIT] && !exit_trap_posix) {
1185///     locallevel++;
1186///     unsettrap(SIGEXIT);
1187///     locallevel--;
1188/// }
1189/// ```
1190///
1191/// Saves the SIGEXIT trap aside for restoration at the parent
1192/// scope's `endtrapscope` (the locallevel++/-- bump tags the
1193/// save entry with the higher scope so it's restored
1194/// when THIS scope ends, not the outer one's).
1195/// Port of `starttrapscope` from `Src/signals.c:855`.
1196pub fn starttrapscope() {
1197    // c:855
1198    // c:855 — `if (intrap) return`.
1199    if intrap.load(Ordering::Relaxed) != 0 {
1200        return;
1201    }
1202    // c:863 — `if (sigtrapped[SIGEXIT] && !exit_trap_posix)`.
1203    let exit_flags = sigtrapped
1204        .lock()
1205        .ok()
1206        .and_then(|g| g.get(SIGEXIT as usize).copied())
1207        .unwrap_or(0);
1208    if exit_flags != 0 && !EXIT_TRAP_POSIX.load(Ordering::Relaxed) {
1209        // c:865-867 — bump locallevel so the dosavetrap inside
1210        // unsettrap tags the save entry with the outer scope's
1211        // level. Rust's locallevel is a global counter in utils.rs.
1212        inc_locallevel();
1213        unsettrap(SIGEXIT); // c:866
1214        crate::ported::utils::dec_locallevel();
1215    }
1216}
1217
1218/// End the current trap scope — restore any traps that were
1219/// Direct port of `void endtrapscope(void)` from
1220/// `Src/signals.c:880`. Pops the pending entries from
1221/// `SAVETRAPS` whose `local > locallevel` (i.e. captured at a
1222/// deeper scope) and restores each via `settrap`. The pending
1223/// SIGEXIT trap (if any) is split out so it runs AFTER the
1224/// other restores complete.
1225pub fn endtrapscope() {
1226    // c:880
1227    let locallevel = locallevel_fn();
1228
1229    // c:891-908 — pull the SIGEXIT trap aside so we can run it last.
1230    let exit_flags = sigtrapped
1231        .lock()
1232        .ok()
1233        .and_then(|g| g.get(SIGEXIT as usize).copied())
1234        .unwrap_or(0);
1235    let mut exittr: i32 = 0;
1236    // Bug #80 — capture the body BEFORE the SAVETRAPS pop loop
1237    // potentially writes back an outer scope's body into traps_table.
1238    // Without this snapshot, a nested fn EXIT trap could fire the
1239    // wrong (outer) body when the deepest level exits.
1240    let mut exit_body: Option<String> = None;
1241    if intrap.load(Ordering::Relaxed) == 0                                   // c:891 !intrap
1242        && !EXIT_TRAP_POSIX.load(Ordering::Relaxed)                          // c:892 !exit_trap_posix
1243        && exit_flags != 0
1244    {
1245        exittr = exit_flags;
1246        // Snapshot the body so the dispatch at the end of this fn
1247        // fires THIS scope's trap, not whatever traps_table got
1248        // restored to.
1249        exit_body = {
1250            let signame = getsigname(SIGEXIT);
1251            crate::ported::builtin::traps_table()
1252                .lock()
1253                .ok()
1254                .and_then(|g| g.get(&signame).cloned().or_else(|| g.get("EXIT").cloned()))
1255        };
1256        // c:902-906 — clear SIGEXIT slot.
1257        if let Ok(mut g) = sigtrapped.lock() {
1258            if let Some(slot) = g.get_mut(SIGEXIT as usize) {
1259                *slot = 0;
1260            }
1261        }
1262        if let Ok(mut g) = siglists.lock() {
1263            if let Some(slot) = g.get_mut(SIGEXIT as usize) {
1264                *slot = None;
1265            }
1266        }
1267        // Clear the inner-scope's body from traps_table; if a saved
1268        // outer body needs restoring, the SAVETRAPS pop loop below
1269        // writes it back.
1270        if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1271            let signame = getsigname(SIGEXIT);
1272            t.remove(&signame);
1273            t.remove("EXIT"); // alias-safe
1274        }
1275        if exit_flags & ZSIG_TRAPPED != 0 {
1276            nsigtrapped.fetch_sub(1, Ordering::Relaxed); // c:904
1277        }
1278    }
1279
1280    // c:911-959 — pop savetraps entries whose local > locallevel.
1281    if let Ok(mut traps) = SAVETRAPS.get_or_init(|| Mutex::new(Vec::new())).lock() {
1282        while let Some(st) = traps.first() {
1283            // c:912 firstnode
1284            if st.local <= locallevel as i32 {
1285                break;
1286            } // c:914
1287            let st = traps.remove(0); // c:915
1288
1289            // c:919 — `if (st->flags && (st->list != NULL))`. BOTH must
1290            // be truthy. The previous Rust port used `||` (either),
1291            // wrongly firing the restore branch on a flags-only or
1292            // list-only savetrap entry.
1293            if st.flags != 0 && st.list.is_some() {
1294                // c:919
1295                // c:921-922 — prevent settrap from saving this.
1296                DONTSAVETRAP.fetch_add(1, Ordering::Relaxed);
1297                // c:923-926 — ZSIG_FUNC takes (NULL, ZSIG_FUNC); list
1298                // traps take (st.list, 0). The current Rust port
1299                // collapses both into a single settrap(list, flags)
1300                // call — works because settrap stores the flags and
1301                // list independently. Pin the ZSIG_FUNC branch as a
1302                // comment for future refactors.
1303                let _ = settrap(st.sig, st.list, st.flags); // c:925/927
1304                if st.sig == SIGEXIT {
1305                    EXIT_TRAP_POSIX.store(st.posix != 0, Ordering::Relaxed); // c:929
1306                }
1307                DONTSAVETRAP.fetch_sub(1, Ordering::Relaxed); // c:930
1308            } else if st.flags != 0 && st.body.is_some() {
1309                // Saved entry was a list-trap installed via bin_trap
1310                // (body in traps_table, no Eprog). Re-arm sigtrapped
1311                // with the saved flags so the dispatch path finds it.
1312                DONTSAVETRAP.fetch_add(1, Ordering::Relaxed);
1313                let _ = settrap(st.sig, None, st.flags);
1314                if st.sig == SIGEXIT {
1315                    EXIT_TRAP_POSIX.store(st.posix != 0, Ordering::Relaxed);
1316                }
1317                DONTSAVETRAP.fetch_sub(1, Ordering::Relaxed);
1318            } else {
1319                // c:933 — `else if (sigtrapped[sig])`. Only fires when
1320                // the current slot has a trap set. The previous Rust
1321                // port unconditionally entered this branch, calling
1322                // unsettrap on slots that were already cleared.
1323                let cur_trapped = sigtrapped
1324                    .lock()
1325                    .ok()
1326                    .and_then(|g| g.get(st.sig as usize).copied())
1327                    .unwrap_or(0);
1328                if cur_trapped != 0 {
1329                    // c:933
1330                    // c:938 — `if (sig != SIGEXIT || !exit_trap_posix)`.
1331                    if st.sig != SIGEXIT || !EXIT_TRAP_POSIX.load(Ordering::Relaxed) {
1332                        unsettrap(st.sig); // c:939
1333                    }
1334                }
1335            }
1336            // Bug #80 — put the saved body string back into traps_table so
1337            // the outer scope's dispatch finds the right body.
1338            //
1339            // MUST run AFTER the settrap/unsettrap branches above, not
1340            // before. C restores the body THROUGH settrap
1341            // (`settrap(st->sig, st->list, st->flags)`, c:925) because its
1342            // body lives in the Eprog it hands over. zshrs's body lives in
1343            // traps_table and is restored separately, so an insert placed
1344            // before those calls is undone by them: settrap and unsettrap
1345            // both reach removetrap, which frees the body (c:843-845).
1346            // Ordering it last is what makes the two storages agree.
1347            {
1348                let signame = getsigname(st.sig);
1349                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1350                    match &st.body {
1351                        Some(b) => {
1352                            t.insert(signame, b.clone());
1353                        }
1354                        None => {
1355                            t.remove(&signame);
1356                        }
1357                    }
1358                }
1359            }
1360        }
1361    }
1362
1363    // c:961-969 — run the SIGEXIT trap, last (AFTER the savetraps
1364    // pop loop above so a restored deeper SIGEXIT is replaced by
1365    // the current scope's saved-aside trap before dispatch).
1366    //
1367    // C `dotrapargs(SIGEXIT, &exittr, exitfn)` invokes either:
1368    //   - ZSIG_FUNC: the `TRAPEXIT` shell function from shfunctab.
1369    //   - else: the eprog from siglists[SIGEXIT].
1370    if exittr != 0 && (exittr & ZSIG_FUNC) != 0 {
1371        // c:961, c:1132 FUNC branch — dispatch the TRAPEXIT shfunc.
1372        let signame = getsigname(SIGEXIT);
1373        let trap_fn = format!("TRAP{}", signame);
1374        if crate::ported::utils::getshfunc(&trap_fn).is_some() {
1375            let args = vec![SIGEXIT.to_string()];
1376            let _ = crate::ported::exec::dispatch_function_call(&trap_fn, &args);
1377        }
1378    } else if exittr != 0 {
1379        // c:961 else branch — non-FUNC eprog. The Rust port stores
1380        // the trap body as a string in `traps_table` (populated by
1381        // `bin_trap` / settrap). Dispatch through the execute_script
1382        // hook installed by fusevm_bridge — same path used by
1383        // signals.rs dotrap for non-FUNC traps.
1384        //
1385        // Bug #80 — use the body snapshot taken BEFORE the SAVETRAPS
1386        // pop loop, so the dispatch fires THIS scope's body (not the
1387        // outer body restored by the loop).
1388        let body = exit_body.unwrap_or_default();
1389        if !body.is_empty() {
1390            // Bracket the dispatch so the recursive
1391            // `execute_script_zsh_pipeline`'s own end-of-pipeline EXIT
1392            // check doesn't see the OUTER scope's body (which the
1393            // SAVETRAPS pop loop just restored into traps_table) and
1394            // fire it prematurely. Pull "EXIT" aside for the duration
1395            // of the dispatch, restore after.
1396            let stash = crate::ported::builtin::traps_table()
1397                .lock()
1398                .ok()
1399                .and_then(|mut t| t.remove("EXIT"));
1400            let _ = crate::ported::exec::execute_script(&body); // c:961 eprog body
1401            if let Some(b) = stash {
1402                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1403                    t.insert("EXIT".to_string(), b);
1404                }
1405            }
1406        }
1407    }
1408}
1409
1410/// Direct port of `mod_export int handletrap(int sig)` from
1411/// `Src/signals.c:972`. Trap-queue gate called from the async
1412/// signal handlers. Returns 0 if the signal isn't trapped; if
1413/// trapped + queueing enabled it pushes onto `trap_queue` and
1414/// returns 1; otherwise it calls `dotrap(SIGIDX(sig))` (with the
1415/// SIGALRM TMOUT reset at the end) and returns 1.
1416pub fn handletrap(sig: i32) -> i32 {
1417    // c:972
1418    let idx = crate::ported::signals_h::SIGIDX(sig);
1419    let trapped = sigtrapped
1420        .lock()
1421        .ok()
1422        .and_then(|g| g.get(idx as usize).copied())
1423        .unwrap_or(0);
1424    if trapped == 0 {
1425        return 0;
1426    } // c:974
1427
1428    if trap_queueing_enabled.load(Ordering::SeqCst) != 0 {
1429        // c:977
1430        // c:980-986 — push onto `trap_queue` ring buffer.
1431        let r = trap_queue_rear.load(Ordering::SeqCst);
1432        let new_rear = (r + 1) % MAX_QUEUE_SIZE;
1433        if new_rear != trap_queue_front.load(Ordering::SeqCst) {
1434            trap_queue[new_rear].store(sig, Ordering::SeqCst);
1435            trap_queue_rear.store(new_rear, Ordering::SeqCst);
1436        }
1437        return 1;
1438    }
1439
1440    dotrap(idx); // c:990
1441
1442    if sig == libc::SIGALRM {
1443        // c:992
1444        // c:996 — `if ((tmout = getiparam("TMOUT"))) alarm(tmout);`
1445        // Re-arm the TMOUT timer after the trap dispatched.
1446        #[cfg(unix)]
1447        unsafe {
1448            let tmout = getiparam("TMOUT");
1449            if tmout > 0 {
1450                libc::alarm(tmout as u32); // c:996
1451            }
1452        }
1453    }
1454    1
1455}
1456
1457/// Direct port of `void queue_traps(int wait_cmd)` from
1458/// `Src/signals.c:1024-1033`.
1459///
1460/// C body:
1461///     if (!isset(TRAPSASYNC) && !wait_cmd)
1462///         trap_queueing_enabled = 1;
1463///
1464/// C ONLY enables queueing when NEITHER `TRAPSASYNC` is set NOR the
1465/// caller is the `wait` builtin (which wants traps to fire immediately
1466/// so the wait can be interrupted). The flag is a boolean (`= 1` /
1467/// `= 0`), symmetric with the reset in `unqueue_traps` at c:1042.
1468pub fn queue_traps(wait_cmd: i32) {
1469    // c:1024
1470    // c:1026 — both gates must be off for queueing to be enabled.
1471    if !isset(TRAPSASYNC) && wait_cmd == 0 {
1472        trap_queueing_enabled.store(1, Ordering::SeqCst); // c:1031
1473    }
1474}
1475
1476// Disable trap queuing and run the traps.                                 // c:1041
1477/// Direct port of `void unqueue_traps(void)` from
1478/// `Src/signals.c:1041`. Disables `trap_queueing_enabled` and
1479/// flushes the pending queue by dispatching each sig through
1480/// `handletrap()`.
1481pub fn unqueue_traps() {
1482    // c:1041
1483    // c:1041 — `trap_queueing_enabled = 0;`
1484    trap_queueing_enabled.store(0, Ordering::SeqCst);
1485    // c:1046 — `while (trap_queue_front != trap_queue_rear) (void) handletrap(...);`
1486    loop {
1487        let f = trap_queue_front.load(Ordering::SeqCst);
1488        let r = trap_queue_rear.load(Ordering::SeqCst);
1489        if f == r {
1490            break;
1491        }
1492        let nf = (f + 1) % MAX_QUEUE_SIZE;
1493        let sig = trap_queue[nf].load(Ordering::SeqCst);
1494        trap_queue_front.store(nf, Ordering::SeqCst);
1495        let _ = handletrap(sig);
1496    }
1497}
1498
1499// Standard call to execute a trap for a given signal.                     // c:1245
1500/// Direct port of `void dotrap(int sig)` from `Src/signals.c:1245`.
1501/// Dispatches the trap registered for `sig`:
1502///   - ZSIG_FUNC: invoke the `TRAPxxx` shell function from shfunctab
1503///     via `doshfunc` with the signal number as the single arg.
1504///   - else: execute the eprog in `siglists[sig]` via fusevm
1505///     dispatch when wired (currently no-op pending VM bridge for
1506///     eprog).
1507/// Maintains `intrap` / `in_exit_trap` flags around the call so
1508/// observers (the `exit` builtin, the `zexit` driver) can branch on
1509/// whether we're inside an EXIT-trap callback.
1510pub fn dotrap(sig: i32) -> i32 {
1511    // c:1245
1512    // c:1248 — `int q = queue_signal_level();` capture at entry.
1513    // Required for the c:1280 `restore_queue_signals(q)` tail. The
1514    // previous Rust port omitted the capture and tail-restored to 0
1515    // unconditionally — that zeroed the queue level even when the
1516    // caller had set it to a non-zero value (e.g. nested dotrap
1517    // from inside a queue_signals/unqueue_signals block).
1518    let q = crate::ported::signals_h::queue_signal_level(); // c:1248
1519
1520    let trapped = sigtrapped
1521        .lock()
1522        .ok()
1523        .and_then(|g| g.get(sig as usize).copied())
1524        .unwrap_or(0);
1525    // c:1259 — `if ((sigtrapped[sig] & ZSIG_IGNORED) || !funcprog || errflag) return;`
1526    if trapped & ZSIG_IGNORED != 0 {
1527        return 0;
1528    }
1529    // Look up a fallback raw-text body installed by `bin_trap`
1530    // (`trap '...' SIG` form). bin_trap stores the body in the
1531    // canonical `traps_table` HashMap<String, String> but never
1532    // calls settrap, so `sigtrapped[sig]` may be 0 here even when
1533    // there IS a live trap. Treat presence in traps_table as
1534    // equivalent to ZSIG_TRAPPED for the dispatch decision and
1535    // dispatch via the crate::ported::exec::execute_script fn-ptr installed
1536    // by fusevm_bridge.
1537    let signame_for_lookup = getsigname(sig);
1538    let table_body: Option<String> = {
1539        let aliases: &[&str] = match sig {
1540            x if x == SIGZERR => &["ZERR", "ERR"],
1541            x if x == SIGDEBUG => &["DEBUG"],
1542            x if x == SIGEXIT => &["EXIT"],
1543            _ => &[],
1544        };
1545        let mut found = None;
1546        if let Ok(t) = crate::ported::builtin::traps_table().lock() {
1547            if let Some(b) = t.get(&signame_for_lookup) {
1548                found = Some(b.clone());
1549            } else {
1550                for alias in aliases {
1551                    if let Some(b) = t.get(*alias) {
1552                        found = Some(b.clone());
1553                        break;
1554                    }
1555                }
1556            }
1557        }
1558        found
1559    };
1560    if trapped & (ZSIG_TRAPPED | ZSIG_FUNC) == 0 && table_body.is_none() {
1561        return 0;
1562    }
1563    if errflag.load(Ordering::Relaxed) != 0 {
1564        return 0;
1565    }
1566    // c:Src/signals.c:1112-1119 — while ALREADY inside a trap, exactly
1567    // three traps are suppressed and every other signal still dispatches:
1568    //     if (intrap) {
1569    //         switch (sig) {
1570    //         case SIGEXIT:
1571    //         case SIGDEBUG:
1572    //         case SIGZERR:
1573    //             return;
1574    //         }
1575    //     }
1576    // C keeps this in dotrapargs; C's dotrap (c:1244-1281) has no guard at
1577    // all and just delegates to it (c:1254). zshrs's dotrap dispatches
1578    // inline instead, so the guard has to sit here — but it must be C's
1579    // SELECTIVE guard, not a blanket one.
1580    //
1581    // The blanket `if (intrap) return` this replaces was a band-aid against
1582    // `trap "false" ERR; false` recursing (the body's own failure re-enters
1583    // on ZERR). C prevents that with this same guard — ZERR is one of the
1584    // three — so the selective form still stops the recursion while no
1585    // longer swallowing signals zsh delivers: `TRAPUSR2() { kill -USR1 $$ }`
1586    // runs the USR1 trap in zsh and was dropped here.
1587    if intrap.load(Ordering::Relaxed) != 0
1588        && (sig == SIGEXIT || sig == SIGDEBUG || sig == SIGZERR)
1589    {
1590        return 0;
1591    }
1592
1593    // c:1123 — `intrap++`. A COUNTER, not a flag (c:Src/signals.c:1057
1594    // `volatile int intrap`): now that non-suppressed signals can dispatch
1595    // from inside a trap body, a nested dotrap must not reset the outer
1596    // one's state on the way out.
1597    intrap.fetch_add(1, Ordering::SeqCst);
1598    // c:1270 — `dont_queue_signals()`. C disables signal queueing for
1599    // the duration of the trap dispatch so signals delivered while
1600    // the trap is running run inline (not queued for later).
1601    crate::ported::signals_h::dont_queue_signals(); // c:1270
1602
1603    // c:1272-1273 — `if (sig == SIGEXIT) ++in_exit_trap;` (counter,
1604    // not boolean — depth tracking lets observers detect re-entry).
1605    if sig == SIGEXIT {
1606        in_exit_trap.fetch_add(1, Ordering::SeqCst); // c:1273
1607    }
1608
1609    // c:1251 — `if (sigtrapped[sig] & ZSIG_FUNC)` → run TRAPxxx shfunc.
1610    // c:Src/signals.c:1251-1259 — the C source dispatches ONE of
1611    // the two arms based on sigtrapped flags: ZSIG_FUNC → call
1612    // shfunc, else → run siglists eprog. The C settrap → unsettrap
1613    // chain ensures only one form is active at a time. zshrs's
1614    // port stores string-form bodies separately in `traps_table`
1615    // (not touched by removetrap), so both can coexist. Track
1616    // whether the function-form fired so the string-form fallback
1617    // below doesn't double-dispatch. Bug #541 in docs/BUGS.md.
1618    let mut fn_dispatched = false;
1619    if trapped & ZSIG_FUNC != 0 {
1620        let signame = getsigname(sig);
1621        let trap_fn = format!("TRAP{}", signame);
1622        if getshfunc(&trap_fn).is_some() {
1623            // c:1252-1255 — `dotrapargs(sig, sigtrapped+sig, funcprog)`.
1624            //              Drives the shfunc with `$1 = sig`. With the
1625            //              executor not directly callable from this
1626            //              signal-handler context, route through the
1627            //              canonical `crate::exec::doshfunc` entry which
1628            //              handles the arg+env+local-scope wrap.
1629            let args = vec![sig.to_string()];
1630            let _ = crate::ported::exec::dispatch_function_call(&trap_fn, &args);
1631            fn_dispatched = true;
1632        }
1633    }
1634    // Additional function-form check: even when ZSIG_FUNC isn't
1635    // set on sigtrapped (the function was defined after the
1636    // string-form trap, and zshrs's exec.rs:6292 didn't update
1637    // sigtrapped via the doshfunc path), look in shfunctab
1638    // directly. zsh's last-defined-wins semantics treat the
1639    // function as the active handler — the older string-form
1640    // entry in traps_table must not also fire.
1641    if !fn_dispatched {
1642        let signame = getsigname(sig);
1643        let trap_fn = format!("TRAP{}", signame);
1644        if getshfunc(&trap_fn).is_some() {
1645            let args = vec![sig.to_string()];
1646            let _ = crate::ported::exec::dispatch_function_call(&trap_fn, &args);
1647            fn_dispatched = true;
1648        }
1649    }
1650    // c:1268 — non-FUNC `siglists[sig]` eprog branch. The canonical
1651    // settrap→siglists path isn't fully wired (bin_trap stores raw
1652    // body text into `traps_table` rather than parsing to Eprog and
1653    // calling settrap). Dispatch via the crate::ported::exec::execute_script
1654    // fn-ptr installed by fusevm_bridge — no direct ShellExecutor
1655    // reach-in from src/ported/ (see memory
1656    // feedback_no_exec_script_from_ported).
1657    // Skip the string-form fallback when a function-form already
1658    // fired — zsh semantics are last-defined-replaces, not
1659    // both-fire. Bug #541 in docs/BUGS.md.
1660    if let Some(body) = table_body.filter(|_| !fn_dispatched) {
1661        // c:Bug #56 — when a trap fires DURING `$(...)` capture
1662        // (cmdsub redirected fd 1 to a pipe), the trap body's
1663        // stdout would land in the captured value. zsh forks each
1664        // cmdsub so traps run in the parent process whose fd 1 is
1665        // the terminal. zshrs's in-process cmdsub publishes the
1666        // saved outer stdout via CMDSUBST_OUTER_FDS so the trap
1667        // dispatcher can route body output to the parent's real
1668        // stdout instead. Temporarily restore fd 1 to that saved
1669        // outer stdout around the body, then revert to the
1670        // cmdsub-bound fd. Same idea as bash's command-subst trap
1671        // routing (Functions/Misc/runtraps).
1672        let outer = crate::ported::exec::cmdsubst_outer_stdout();
1673        let saved_inner = if outer.is_some() {
1674            unsafe { libc::dup(libc::STDOUT_FILENO) }
1675        } else {
1676            -1
1677        };
1678        if let Some(out_fd) = outer {
1679            unsafe {
1680                libc::dup2(out_fd, libc::STDOUT_FILENO);
1681            }
1682        }
1683        // c:1268/1170 — `execode((Eprog)sigfn, 1, 0, "trap")`. The THIRD
1684        // argument is `exiting`, and C passes 0: a trap body is not an
1685        // exiting list, so `execlist`'s
1686        // `if (exiting && sigtrapped[SIGEXIT]) dotrap(SIGEXIT);`
1687        // (c:Src/exec.c:1645) never fires inside a trap body.
1688        //
1689        // zshrs has no `exiting` parameter here: `execute_script` drives a
1690        // full script pipeline, and that driver fires the EXIT trap at the
1691        // end of EVERY pipeline it runs (vm_helper.rs:2111-2126). So
1692        // dispatching a USR1/USR2/DEBUG string trap through it ALSO ran any
1693        // installed EXIT trap — early, and then again at real script exit:
1694        // `trap 'print b' EXIT; trap 'print a' USR1; kill -USR1 $$` printed
1695        // `a b … b` where zsh prints `a … b`.
1696        //
1697        // Pull the EXIT body aside for the duration of the nested pipeline so
1698        // its end-of-script check finds nothing to fire, then put it back.
1699        // Same bracket `endtrapscope` uses around its own EXIT dispatch
1700        // (c:961 arm). Skipped when sig IS SIGEXIT — that body is the EXIT
1701        // trap, and C consumes it (`sigtrapped[SIGEXIT] = 0`, c:Src/exec.c:1648)
1702        // rather than restoring it.
1703        let exit_stash = if sig != SIGEXIT {
1704            crate::ported::builtin::traps_table()
1705                .lock()
1706                .ok()
1707                .and_then(|mut t| t.remove("EXIT"))
1708        } else {
1709            None
1710        };
1711        let _ = crate::ported::exec::execute_script(&body); // c:1268
1712        if let Some(b) = exit_stash {
1713            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1714                t.insert("EXIT".to_string(), b);
1715            }
1716        }
1717        if let Some(_) = outer {
1718            if saved_inner >= 0 {
1719                unsafe {
1720                    libc::dup2(saved_inner, libc::STDOUT_FILENO);
1721                    libc::close(saved_inner);
1722                }
1723            }
1724        }
1725    }
1726
1727    // c:1277 — `if (sig == SIGEXIT) --in_exit_trap;` (decrement, not
1728    // store-0). The previous Rust port used `store(0)` which would
1729    // mask a re-entered trap — a TRAP_EXIT inside another TRAP_EXIT
1730    // would clear the flag prematurely.
1731    if sig == SIGEXIT {
1732        in_exit_trap.fetch_sub(1, Ordering::SeqCst); // c:1277
1733    }
1734    // c:1280 — `restore_queue_signals(q)` — restore to the level
1735    // captured at entry (c:1248). Now properly captured above; the
1736    // previous tail was a hardcoded `intrap.store(0)` only.
1737    crate::ported::signals_h::restore_queue_signals(q); // c:1280
1738    intrap.fetch_sub(1, Ordering::SeqCst); // c:1236 — `intrap--`, paired with c:1123
1739    0
1740}
1741
1742/// Direct port of `void dotrapargs(int sig, int *sigtr, void *sigfn)` from
1743/// `Src/signals.c:1081`. Drives a single trap callback for `sig`:
1744/// suspends `breaks`/`retflag`/`lastval` so the body runs in a fresh
1745/// control-flow scope, dispatches the function (ZSIG_FUNC) or eprog
1746/// (non-FUNC) body, then restores the caller's flags applying the
1747/// trap_state / trap_return / try_tryflag rules.
1748///
1749/// ```c
1750/// void
1751/// dotrapargs(int sig, int *sigtr, void *sigfn)
1752/// {
1753///     LinkList args;
1754///     char *name, num[4];
1755///     int obreaks = breaks;
1756///     int oretflag = retflag;
1757///     int olastval = lastval;
1758///     int isfunc;
1759///     int traperr, new_trap_state, new_trap_return;
1760///     if ((*sigtr & ZSIG_IGNORED) || !sigfn || errflag) return;
1761///     if (intrap) {
1762///         switch (sig) { case SIGEXIT: case SIGDEBUG: case SIGZERR: return; }
1763///     }
1764///     queue_signals();
1765///     intrap++;
1766///     *sigtr |= ZSIG_IGNORED;
1767///     zcontext_save();
1768///     execsave();
1769///     breaks = retflag = 0;
1770///     traplocallevel = locallevel;
1771///     runhookdef(BEFORETRAPHOOK, NULL);
1772///     if (*sigtr & ZSIG_FUNC) {
1773///         /* ... build args, doshfunc(...) ... */
1774///     } else {
1775///         trap_return = -2;
1776///         trap_state = TRAP_STATE_PRIMED;
1777///         trapisfunc = isfunc = 0;
1778///         execode((Eprog)sigfn, 1, 0, "trap");
1779///     }
1780///     runhookdef(AFTERTRAPHOOK, NULL);
1781///     traperr = errflag;
1782///     new_trap_state = trap_state;
1783///     new_trap_return = trap_return;
1784///     execrestore();
1785///     zcontext_restore();
1786///     /* ... restore breaks/retflag/lastval per FORCE_RETURN / traperr ... */
1787///     if (zleactive && resetneeded) zleentry(ZLE_CMD_REFRESH);
1788///     if (*sigtr != ZSIG_IGNORED) *sigtr &= ~ZSIG_IGNORED;
1789///     intrap--;
1790///     unqueue_signals();
1791/// }
1792/// ```
1793#[allow(clippy::too_many_arguments)]
1794pub fn dotrapargs(sig: i32, sigtr: &mut i32, sigfn: Option<&str>) {
1795    // c:1081
1796
1797    let obreaks: i32 = BREAKS.load(Ordering::SeqCst); // c:1085
1798    let oretflag: i32 = RETFLAG.load(Ordering::SeqCst); // c:1086
1799    let olastval: i32 = LASTVAL.load(Ordering::SeqCst); // c:1087
1800    let isfunc: i32; // c:1088
1801    let traperr: i32; // c:1089
1802    let new_trap_state: i32; // c:1089
1803    let new_trap_return: i32; // c:1089
1804
1805    // c:1101 — `if ((*sigtr & ZSIG_IGNORED) || !sigfn || errflag) return;`
1806    if (*sigtr & ZSIG_IGNORED) != 0                                          // c:1101
1807        || sigfn.is_none()
1808        || errflag.load(Ordering::SeqCst) != 0
1809    {
1810        return; // c:1102
1811    }
1812
1813    // c:1112-1119 — disallow synchronous traps from nesting.
1814    if intrap.load(Ordering::SeqCst) != 0 {
1815        // c:1112
1816        if sig == SIGEXIT || sig == SIGDEBUG || sig == SIGZERR {
1817            // c:1113-1117
1818            return; // c:1117
1819        }
1820    }
1821
1822    queue_signals(); // c:1121
1823
1824    intrap.fetch_add(1, Ordering::SeqCst); // c:1123
1825    *sigtr |= ZSIG_IGNORED; // c:1124
1826                            // Mirror into the sigtrapped slab so observers (handletrap, dotrap
1827                            // re-entry) see the same ZSIG_IGNORED bit.
1828    if let Ok(mut g) = sigtrapped.lock() {
1829        if let Some(slot) = g.get_mut(sig as usize) {
1830            *slot |= ZSIG_IGNORED;
1831        }
1832    }
1833
1834    zcontext_save(); // c:1126
1835                     // c:1128 — `execsave()` saves trap_return/trap_state. Without a
1836                     // canonical `execsave` port yet, snapshot the two atomics inline.
1837    let saved_trap_state = TRAP_STATE.load(Ordering::SeqCst); // c:1128 execsave
1838    let saved_trap_return = TRAP_RETURN.load(Ordering::SeqCst); // c:1128 execsave
1839    BREAKS.store(0, Ordering::SeqCst); // c:1129 breaks = 0
1840    RETFLAG.store(0, Ordering::SeqCst); // c:1129 retflag = 0
1841    traplocallevel.store(
1842        crate::ported::params::locallevel.load(Ordering::SeqCst),
1843        Ordering::SeqCst,
1844    ); // c:1130
1845
1846    // c:1131 — `runhookdef(BEFORETRAPHOOK, NULL);` — fire any
1847    // registered "before-trap" module hooks. Looked up by name
1848    // through gethookdef so the module dispatcher picks up
1849    // installed handlers (zsh/zle's zlebeforetrap etc.).
1850    let hd = crate::ported::module::gethookdef("BEFORETRAPHOOK");
1851    if !hd.is_null() {
1852        let _ = crate::ported::module::runhookdef(hd, std::ptr::null_mut());
1853    }
1854    let _ = BEFORETRAPHOOK; // c:1131 — const retained for source-cite parity
1855
1856    if (*sigtr & ZSIG_FUNC) != 0 {
1857        // c:1132
1858        let osc = SFCONTEXT.load(Ordering::SeqCst); // c:1133 osc
1859                                                    // c:1133 — `int old_incompfunc = incompfunc;` — snapshot the
1860                                                    // completion-function-active flag so the trap dispatch can
1861                                                    // run code outside the comp-fn scope and restore on return.
1862        let old_incompfunc: i32 = crate::ported::zle::complete::INCOMPFUNC.load(Ordering::Relaxed);
1863        let hn = gettrapnode(sig, false); // c:1134
1864
1865        let mut args: Vec<String> = Vec::new(); // c:1136 znewlinklist
1866                                                // c:1144-1149 — pick the right TRAPxxx name from the function table
1867                                                // (multi-named aliases) or build the canonical TRAP<SIGNAME>.
1868        let name = match hn {
1869            Some(n) => ztrdup(&n), // c:1145 ztrdup(hn->nam)
1870            None => {
1871                // c:1146
1872                format!("TRAP{}", getsigname(sig)) // c:1147-1148
1873            }
1874        };
1875        args.push(name.clone()); // c:1150 zaddlinknode(args, name)
1876        let num = format!("{}", sig); // c:1151 sprintf(num, "%d", sig)
1877        args.push(num); // c:1152
1878
1879        TRAP_RETURN.store(-1, Ordering::SeqCst); // c:1154 trap_return = -1; /* incremented by doshfunc */
1880        TRAP_STATE.store(TRAP_STATE_PRIMED, Ordering::SeqCst); // c:1155
1881        trapisfunc.store(1, Ordering::SeqCst); // c:1156
1882        isfunc = 1;
1883
1884        SFCONTEXT.store(SFC_SIGNAL, Ordering::SeqCst); // c:1158
1885                                                       // c:1159 — `incompfunc = 0;` — clear the active-compfn flag
1886                                                       // so user-level trap handlers can run normal `complete` /
1887                                                       // `compadd` etc. without being mis-detected as inside a
1888                                                       // completion widget.
1889        crate::ported::zle::complete::INCOMPFUNC.store(0, Ordering::Relaxed);
1890        // c:1160 — `doshfunc((Shfunc)sigfn, args, 1);`. Direct
1891        // doshfunc call mirrors C — argv[0] = TRAP name, argv[1..]
1892        // = signum etc. body_runner routes through the host's
1893        // body-only entry so we don't double-wrap the scope.
1894        let fn_name = sigfn.unwrap_or("").to_string();
1895        let shf_clone: Option<crate::ported::zsh_h::shfunc> = {
1896            let tab = crate::ported::hashtable::shfunctab_lock().read();
1897            tab.ok().and_then(|t| t.get(&fn_name).cloned())
1898        };
1899        if let Some(mut shf) = shf_clone {
1900            let body_args = args.clone();
1901            let name_for_body = fn_name.clone();
1902            let body_runner = move || -> i32 {
1903                crate::ported::exec::run_function_body(&name_for_body, &body_args[1..]).unwrap_or(0)
1904            };
1905            let _ = crate::ported::exec::doshfunc(&mut shf, args.clone(), true, body_runner);
1906        }
1907        SFCONTEXT.store(osc, Ordering::SeqCst); // c:1161
1908                                                // c:1162 — `incompfunc = old_incompfunc;` — restore the
1909                                                // completion-function-active flag we snapshotted at c:1133.
1910        crate::ported::zle::complete::INCOMPFUNC.store(old_incompfunc, Ordering::Relaxed);
1911        let _ = args; // c:1163 freelinklist(args)
1912        zsfree(name); // c:1164 zsfree(name)
1913    } else {
1914        // c:1165
1915        TRAP_RETURN.store(-2, Ordering::SeqCst); // c:1166 trap_return = -2
1916        TRAP_STATE.store(TRAP_STATE_PRIMED, Ordering::SeqCst); // c:1167
1917        trapisfunc.store(0, Ordering::SeqCst); // c:1168
1918        isfunc = 0;
1919        // c:1170 — `execode((Eprog)sigfn, 1, 0, "trap");` — execute
1920        // the trap's compiled Eprog body. zshrs models the non-
1921        // ZSIG_FUNC trap action as source TEXT (sigfn: Option<&str>)
1922        // rather than a pre-compiled Eprog; re-parse + execute via
1923        // the fusevm pipeline so the trap action actually fires.
1924        // When sigfns[] is re-shaped to carry compiled Eprogs (matching
1925        // C's `void *sigfns[sig]` cast at c:1170), swap in
1926        // `crate::ported::exec::execode(eprog, 1, 0, "trap")` directly.
1927        if let Some(src) = sigfn {
1928            let _ = crate::ported::exec::execute_script_zsh_pipeline(src);
1929        }
1930    }
1931
1932    // c:1172 — `runhookdef(AFTERTRAPHOOK, NULL);` — fire any registered
1933    // "after-trap" module hooks. Same shape as BEFORETRAPHOOK above.
1934    let hd = crate::ported::module::gethookdef("AFTERTRAPHOOK");
1935    if !hd.is_null() {
1936        let _ = crate::ported::module::runhookdef(hd, std::ptr::null_mut());
1937    }
1938    let _ = AFTERTRAPHOOK; // c:1172 — const retained for source-cite parity
1939
1940    traperr = errflag.load(Ordering::SeqCst); // c:1174
1941
1942    new_trap_state = TRAP_STATE.load(Ordering::SeqCst); // c:1177
1943    new_trap_return = TRAP_RETURN.load(Ordering::SeqCst); // c:1178
1944
1945    // c:1180 — `execrestore()` restores trap_return/trap_state.
1946    TRAP_STATE.store(saved_trap_state, Ordering::SeqCst); // c:1180
1947    TRAP_RETURN.store(saved_trap_return, Ordering::SeqCst); // c:1180
1948    zcontext_restore(); // c:1181
1949
1950    if new_trap_state == TRAP_STATE_FORCE_RETURN                             // c:1183
1951        && !(isfunc != 0 && new_trap_return == 0)
1952    // c:1184
1953    {
1954        if isfunc != 0 {
1955            // c:1186
1956            BREAKS.store(LOOPS.load(Ordering::SeqCst), Ordering::SeqCst); // c:1187 breaks = loops
1957            if sig == libc::SIGINT || sig == libc::SIGQUIT {
1958                // c:1196
1959                errflag.fetch_or(
1960                    // c:1197 errflag |= ERRFLAG_INT
1961                    ERRFLAG_INT,
1962                    Ordering::SeqCst,
1963                );
1964            } else {
1965                // c:1198
1966                errflag.fetch_or(
1967                    // c:1199 errflag |= ERRFLAG_ERROR
1968                    ERRFLAG_ERROR,
1969                    Ordering::SeqCst,
1970                );
1971            }
1972        }
1973        LASTVAL.store(new_trap_return, Ordering::SeqCst); // c:1202
1974        RETFLAG.store(1, Ordering::SeqCst); // c:1204 retflag = 1
1975    } else {
1976        // c:1205
1977        if traperr != 0 && !EMULATION(EMULATE_SH) {
1978            // c:1206
1979            LASTVAL.store(1, Ordering::SeqCst); // c:1207
1980        } else {
1981            // c:1208
1982            // c:1210 — keep pre-trap lastval.
1983            LASTVAL.store(olastval, Ordering::SeqCst); // c:1213
1984        }
1985        if try_tryflag.load(Ordering::SeqCst) != 0 {
1986            // c:1215 try_tryflag
1987            if traperr != 0 {
1988                // c:1216
1989                errflag.fetch_or(
1990                    // c:1217
1991                    ERRFLAG_ERROR,
1992                    Ordering::SeqCst,
1993                );
1994            } else {
1995                // c:1218
1996                errflag.fetch_and(
1997                    // c:1219 errflag &= ~ERRFLAG_ERROR
1998                    !ERRFLAG_ERROR,
1999                    Ordering::SeqCst,
2000                );
2001            }
2002        }
2003        BREAKS.fetch_add(obreaks, Ordering::SeqCst); // c:1220 breaks += obreaks
2004        RETFLAG.store(oretflag, Ordering::SeqCst); // c:1222 retflag = oretflag
2005        let cur_breaks = BREAKS.load(Ordering::SeqCst);
2006        let cur_loops = LOOPS.load(Ordering::SeqCst);
2007        if cur_breaks > cur_loops {
2008            // c:1223
2009            BREAKS.store(cur_loops, Ordering::SeqCst); // c:1224
2010        }
2011    }
2012
2013    // c:1231 — `if (zleactive && resetneeded) zleentry(ZLE_CMD_REFRESH);`
2014    if zleactive.load(Ordering::SeqCst) != 0 && RESETNEEDED.load(Ordering::SeqCst) != 0 {
2015        let _ = zleentry(ZLE_CMD_REFRESH);
2016    }
2017
2018    if *sigtr != ZSIG_IGNORED {
2019        // c:1234
2020        *sigtr &= !ZSIG_IGNORED; // c:1235
2021        if let Ok(mut g) = sigtrapped.lock() {
2022            if let Some(slot) = g.get_mut(sig as usize) {
2023                *slot &= !ZSIG_IGNORED;
2024            }
2025        }
2026    }
2027    intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
2028
2029    unqueue_signals(); // c:1238
2030}
2031
2032// `try_tryflag` lives at `Src/loop.c:731` (the always/try block depth
2033// counter); ported to `crate::ported::r#loop::try_tryflag`. dotrapargs
2034// above reads it from its canonical home per PORT.md Rule C (header /
2035// file placement).
2036
2037/// Resolve a real-time signal name to its number.
2038/// Port of `int rtsigno(const char* signame)` from `Src/signals.c:1291-1313`.
2039///
2040/// **C signature**: `int rtsigno(const char* signame)` — takes a
2041/// NAME STRING ("RTMIN", "RTMIN+3", "RTMAX-1", etc.) and returns
2042/// the signal number, or 0 on parse failure.
2043///
2044/// Rust signature: `(signame: &str) -> Option<i32>` — `None`
2045/// matches C's `0` sentinel. Uses `libc::SIGRTMIN()` /
2046/// `libc::SIGRTMAX()` for canonical bounds.
2047pub fn rtsigno(signame: &str) -> Option<i32> {
2048    // c:1291
2049    #[cfg(target_os = "linux")]
2050    {
2051        let sigrtmin = libc::SIGRTMIN();
2052        let sigrtmax = libc::SIGRTMAX();
2053        let maxofs = sigrtmax - sigrtmin; // c:1296
2054
2055        // c:1298-1306 — `if (!strncmp(signame, "RTMIN", 5)) ...
2056        // else if (!strncmp(signame, "RTMAX", 5)) ... else return 0;`
2057        let (sig, dir, op): (i32, i32, char) = if let Some(rest) = signame.strip_prefix("RTMIN") {
2058            (sigrtmin, 1, '+') // c:1300
2059        } else if let Some(rest) = signame.strip_prefix("RTMAX") {
2060            (sigrtmax, -1, '-') // c:1302
2061        } else {
2062            return None; // c:1304 return 0
2063        };
2064
2065        // c:1307-1311 — `if (signame[5] == x.op) { offset = strtol(...);
2066        //                                          if (offset > maxofs) return 0;
2067        //                                          x.sig += offset * x.dir; }`
2068        let rest = if signame.starts_with("RTMIN") {
2069            &signame[5..]
2070        } else {
2071            &signame[5..]
2072        };
2073        let mut final_sig = sig;
2074        if !rest.is_empty() {
2075            if rest.starts_with(op) {
2076                let num_str = &rest[1..];
2077                let offset: i32 = match num_str.parse() {
2078                    Ok(n) => n,
2079                    Err(_) => return None, // c:1312
2080                };
2081                if offset > maxofs {
2082                    return None; // c:1310 return 0
2083                }
2084                final_sig += offset * dir;
2085            } else {
2086                // c:1313 — `if (*end) return 0;` — any non-op trailing → fail.
2087                return None;
2088            }
2089        }
2090        Some(final_sig)
2091    }
2092    #[cfg(not(target_os = "linux"))]
2093    {
2094        let _ = signame;
2095        None
2096    }
2097}
2098
2099/// Resolve a real-time signal number to its `RTMIN+N` / `RTMAX-N` name.
2100/// Port of `char *rtsigname(int signo, int alt)` from `Src/signals.c:1317`.
2101///
2102/// C body picks the SHORTER form between `RTMIN+N` and `RTMAX-N`,
2103/// preferring the smaller offset unless `alt` is set (which flips
2104/// the choice via XOR). `signo` outside `[SIGRTMIN..=SIGRTMAX]`
2105/// returns NULL — Rust returns empty string for the equivalent.
2106///
2107/// The previous Rust port:
2108///   1. Dropped the `alt` argument entirely (callers got the
2109///      `alt=0` default, but no way to flip).
2110///   2. ALWAYS produced the `RTMIN+N` form, ignoring the C "shorter
2111///      form wins" contract — for high-numbered RT signals where
2112///      RTMAX-N is the shorter form, C would emit `RTMAX-N` but
2113///      Rust emitted the longer `RTMIN+N`.
2114///   3. Used a hardcoded `sigrtmin = 34` constant instead of
2115///      `libc::SIGRTMIN()` (real-time signal numbers can vary by
2116///      libc version/build).
2117///   4. Out-of-range input produced `SIG{n}` — C returns NULL.
2118///
2119/// **Signature divergence from C**: C takes `(signo, alt)`; Rust port
2120/// takes `(sig)` with `alt=0` implicit because the only in-tree caller
2121/// (`params.rs:1640`) doesn't need the alt flip. A future caller that
2122/// needs alt-form can be added then.
2123/// WARNING: param names don't match C — Rust=(sig) vs C=(signo, alt)
2124pub fn rtsigname(sig: i32) -> String {
2125    // c:1317
2126    #[cfg(target_os = "linux")]
2127    {
2128        let sigrtmin = libc::SIGRTMIN();
2129        let sigrtmax = libc::SIGRTMAX();
2130        // c:1325-1326 — `if (signo < SIGRTMIN || signo > SIGRTMAX) return NULL;`
2131        if sig < sigrtmin || sig > sigrtmax {
2132            return String::new();
2133        }
2134        // c:1319-1323 — `int minofs = signo - SIGRTMIN; int maxofs =
2135        // SIGRTMAX - signo; int form = alt ^ (maxofs < minofs);`
2136        // With alt=0 always, form simplifies to `maxofs < minofs`.
2137        let minofs = sig - sigrtmin;
2138        let maxofs = sigrtmax - sig;
2139        let form = maxofs < minofs;
2140        // c:1328-1334 — pick `RTMIN+` or `RTMAX-` per `form`.
2141        let prefix = if form { "RTMAX-" } else { "RTMIN+" };
2142        let offset = if form { maxofs } else { minofs };
2143        if offset == 0 {
2144            // c:1334 — buf[5] = '\0' → drop the trailing sign char.
2145            prefix[..5].to_string()
2146        } else {
2147            format!("{}{}", prefix, offset)
2148        }
2149    }
2150    #[cfg(not(target_os = "linux"))]
2151    {
2152        let _ = sig;
2153        String::new()
2154    }
2155}
2156
2157// ---------------------------------------------------------------------------
2158// Signal-queue state. Direct ports of `Src/signals.c:77-92`:
2159//
2160//   mod_export volatile int queueing_enabled, queue_front, queue_rear;   // c:77
2161//   mod_export int signal_queue[MAX_QUEUE_SIZE];                         // c:79
2162//   mod_export sigset_t signal_mask_queue[MAX_QUEUE_SIZE];               // c:81
2163//   static volatile int trap_queueing_enabled,
2164//                       trap_queue_front, trap_queue_rear;               // c:90
2165//   static int trap_queue[MAX_QUEUE_SIZE];                               // c:92
2166//
2167// C uses flat module-level variables; Rust mirrors with file-scope
2168// `AtomicI32` + `LazyLock<Mutex<Vec<...>>>` slabs so concurrent
2169// pushes from the async signal handler synchronize without UB.
2170// ---------------------------------------------------------------------------
2171
2172/// Signal-queue depth counter. Port of `mod_export volatile int
2173/// queueing_enabled` from `Src/signals.c:77`.
2174pub static queueing_enabled: AtomicI32 = AtomicI32::new(0); // c:77
2175
2176/// Ring-buffer head. Port of `mod_export volatile int queue_front`
2177/// from `Src/signals.c:77`.
2178pub static queue_front: AtomicUsize = AtomicUsize::new(0); // c:77
2179
2180/// Ring-buffer tail. Port of `mod_export volatile int queue_rear`
2181/// from `Src/signals.c:77`.
2182pub static queue_rear: AtomicUsize = AtomicUsize::new(0); // c:77
2183
2184/// Port of `mod_export volatile int queue_in` from `Src/signals.c:84`.
2185/// Companion counter bumped by `queue_signals()` (signals.h:90) and
2186/// decremented by `unqueue_signals()` (signals.h:94); used by
2187/// `dont_queue_signals()` to snapshot the depth (signals.h:99) and
2188/// by debug assertions (DPUTS2 at signals.h:105).
2189pub static queue_in: AtomicI32 = AtomicI32::new(0); // c:84
2190
2191#[allow(clippy::declare_interior_mutable_const)]
2192const ATOM_I32_ZERO: AtomicI32 = AtomicI32::new(0);
2193
2194/// Per-slot signal numbers. Port of `mod_export int
2195/// signal_queue[MAX_QUEUE_SIZE]` from `Src/signals.c:79`.
2196pub static signal_queue: [AtomicI32; MAX_QUEUE_SIZE] = // c:79
2197    [ATOM_I32_ZERO; MAX_QUEUE_SIZE];
2198
2199/// Per-slot blocked-mask snapshots. Port of `mod_export sigset_t
2200/// signal_mask_queue[MAX_QUEUE_SIZE]` from `Src/signals.c:81`.
2201/// `sigset_t` isn't Copy on every platform — wrapped in a Mutex
2202/// so the slabs initialize without const-eval gymnastics.
2203pub static signal_mask_queue: std::sync::LazyLock<Mutex<Vec<libc::sigset_t>>> = // c:81
2204    std::sync::LazyLock::new(|| {
2205            let zero: libc::sigset_t = unsafe { std::mem::zeroed() };
2206            Mutex::new(vec![zero; MAX_QUEUE_SIZE])
2207        });
2208
2209/// Trap-queue depth counter. Port of `static volatile int
2210/// trap_queueing_enabled` from `Src/signals.c:90`.
2211pub static trap_queueing_enabled: AtomicI32 = AtomicI32::new(0); // c:90
2212
2213/// Trap-queue head. Port of `static volatile int trap_queue_front`
2214/// from `Src/signals.c:90`.
2215pub static trap_queue_front: AtomicUsize = AtomicUsize::new(0); // c:90
2216
2217/// Trap-queue tail. Port of `static volatile int trap_queue_rear`
2218/// from `Src/signals.c:90`.
2219pub static trap_queue_rear: AtomicUsize = AtomicUsize::new(0); // c:90
2220
2221/// Port of `int last_signal` from `Src/signals.c:238`. Holds the
2222/// signal number of the most recent delivery; used by `wait_cmd`
2223/// in jobs.c to set `$?` to `128 + last_signal` when a trapped
2224/// signal interrupts wait.
2225pub static last_signal: AtomicI32 = AtomicI32::new(0); // c:238
2226
2227// ---------------------------------------------------------------------------
2228// Per-signal trap state. Direct ports of the C globals declared in
2229// `Src/signals.c:39/53/58`:
2230//
2231//   mod_export int      *sigtrapped;       // c:39 — flag word per sig
2232//   mod_export Eprog    *siglists;         // c:53 — Eprog per sig (trap body)
2233//   mod_export volatile int nsigtrapped;   // c:58 — trapped-signal count
2234//
2235// C allocates parallel arrays of length TRAPCOUNT at init time
2236// (`Src/init.c:1398`). Rust mirrors with `Mutex<Vec<...>>` slabs
2237// sized to TRAPCOUNT plus an atomic counter. TRAPxxx-function
2238// trap bodies are NOT stored here in C either — `dotrap` looks
2239// them up via `gettrapnode()` from shfunctab on signal delivery
2240// (`Src/jobs.c:gettrapnode`).
2241// ---------------------------------------------------------------------------
2242
2243/// Per-signal flag word. Port of `mod_export int *sigtrapped`
2244/// from `Src/signals.c:39`. Bit values are `ZSIG_TRAPPED`,
2245/// `ZSIG_IGNORED`, `ZSIG_FUNC`, plus `(locallevel << ZSIG_SHIFT)`
2246/// in the high bits.
2247pub static sigtrapped: std::sync::LazyLock<Mutex<Vec<i32>>> = // c:39
2248    std::sync::LazyLock::new(|| Mutex::new(vec![0; TRAPCOUNT as usize]));
2249
2250/// Per-signal Eprog body. Port of `mod_export Eprog *siglists`
2251/// from `Src/signals.c:53`. NULL for ZSIG_FUNC entries (function
2252/// body resolves through `gettrapnode` at dispatch time).
2253pub static siglists: std::sync::LazyLock<Mutex<Vec<Option<Eprog>>>> =
2254    // c:53
2255    std::sync::LazyLock::new(|| Mutex::new((0..TRAPCOUNT as usize).map(|_| None).collect()));
2256
2257/// Count of `ZSIG_TRAPPED`-flagged signals. Port of
2258/// `mod_export volatile int nsigtrapped` from `Src/signals.c:58`.
2259pub static nsigtrapped: AtomicI32 = AtomicI32::new(0); // c:58
2260
2261/// File-scope `int intrap` from `Src/signals.c`. Set while a
2262/// trap body is running so nested `dotrap` calls short-circuit
2263/// (matches the c:1245 dispatcher's `if (intrap) return`).
2264pub static intrap: AtomicI32 = AtomicI32::new(0); // c:intrap
2265
2266/// File-scope `int in_exit_trap` from `Src/signals.c:60`. Set
2267/// while the EXIT trap body is running so `exit` and friends can
2268/// distinguish "real" exit from exit-trap-driven exit.
2269pub static in_exit_trap: AtomicI32 = AtomicI32::new(0); // c:60
2270
2271/// Port of `volatile int trapisfunc` from `Src/signals.c:1062`.
2272/// Set by `dotrapargs()` (signals.c:1156) when the trap body is a
2273/// shell function (vs. inline command) — the `IN_EVAL_TRAP()` macro
2274/// at zsh.h:2962 tests this against `intrap` + `locallevel`.
2275pub static trapisfunc: AtomicI32 = AtomicI32::new(0); // c:1062
2276
2277/// Port of `volatile int traplocallevel` from `Src/signals.c:1069`.
2278/// Captures `locallevel` at trap-entry so the trap body can detect
2279/// whether it's running inside the same scope it was registered in
2280/// (the third leg of `IN_EVAL_TRAP()` at zsh.h:2962).
2281pub static traplocallevel: AtomicI32 = AtomicI32::new(0); // c:1069
2282
2283/// File-scope `LinkList savetraps` from `Src/signals.c`. Stack of
2284/// saved trap entries — pushed by `dosavetrap`, popped by
2285/// `endtrapscope`. Inserts at front so it works as a LIFO stack.
2286pub static SAVETRAPS: OnceLock<Mutex<Vec<savetrap>>> = OnceLock::new();
2287
2288/// File-scope `int exit_trap_posix` from `Src/signals.c`. POSIX-mode
2289/// EXIT trap flag — when set, exit traps survive function-scope
2290/// teardown instead of being unset.
2291pub static EXIT_TRAP_POSIX: AtomicBool = AtomicBool::new(false);
2292
2293/// File-scope `int dontsavetrap` from `Src/signals.c`. Counter
2294/// suppressing `dosavetrap` calls during `settrap` invoked from
2295/// `endtrapscope`'s restore loop (so the restore itself doesn't
2296/// push fresh save entries).
2297pub static DONTSAVETRAP: AtomicI32 = AtomicI32::new(0);
2298
2299/// Port of `killpg()` libc passthrough — used by jobs.c / signals.c
2300/// callers; not in zsh source itself but referenced via libc.
2301pub fn killpg(pgrp: i32, sig: i32) -> i32 {
2302    unsafe { libc::killpg(pgrp, sig) }
2303}
2304
2305/// Port of `kill()` libc passthrough.
2306pub fn kill(pid: i32, sig: i32) -> i32 {
2307    unsafe { libc::kill(pid, sig) }
2308}
2309
2310// ---------------------------------------------------------------------------
2311// `interact` flag — mirrors C's global `interact` int (Src/init.c).
2312// Used by intr / holdintr / noholdintr / install_handler to gate
2313// SIGINT-related setup on interactive shell mode.
2314// ---------------------------------------------------------------------------
2315
2316fn interact_lock() -> &'static AtomicBool {
2317    static INTERACT: AtomicBool = AtomicBool::new(false);
2318    &INTERACT
2319}
2320
2321/// Setter for the `interact` flag. Called by init.rs once the
2322/// shell-mode dispatch determines whether stdin is a tty / `-i`
2323/// was passed.
2324///
2325/// Writes to the canonical INTERACTIVE option flag so the read
2326/// side (`is_interact` → `isset(INTERACTIVE)`) sees it.
2327pub fn set_interact(v: bool) {
2328    crate::ported::options::opt_state_set("interactive", v);
2329}
2330
2331/// Read the `interact` flag.
2332///
2333/// C: `#define interact (isset(INTERACTIVE))` (Src/zsh.h:2562) —
2334/// the canonical interact predicate reads the INTERACTIVE option
2335/// flag from the options table.
2336///
2337/// The previous Rust port read `interact_lock()` (a private
2338/// AtomicBool) whose only writer was `set_interact()` — and
2339/// `set_interact` was called ONLY from this file's tests. In
2340/// production, `interact_lock` stayed at the default `false`, so
2341/// every `if is_interact()` gate in `intr`/`nointr`/`holdintr`/
2342/// `noholdintr` short-circuited regardless of whether the shell
2343/// was actually interactive. `setopt INTERACTIVE` had no effect
2344/// on signal handling.
2345///
2346/// Route through canonical isset() so the option drives the predicate.
2347pub fn is_interact() -> bool {
2348    isset(INTERACTIVE)
2349}
2350
2351// ===========================================================
2352// Methods moved verbatim from src/ported/vm_helper because their
2353// C counterpart's source file maps 1:1 to this Rust module.
2354// Phase: drift
2355// ===========================================================
2356
2357// BEGIN moved-from-exec-rs
2358// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
2359
2360// END moved-from-exec-rs
2361
2362#[cfg(test)]
2363mod tests {
2364    use super::*;
2365    use crate::options::dosetopt;
2366    use crate::zsh_h::{HUP, MONITOR, TRAPSASYNC};
2367
2368    #[test]
2369    fn test_sig_by_name() {
2370        let _g = crate::test_util::global_state_lock();
2371        assert_eq!(getsigidx("INT"), Some(libc::SIGINT));
2372        assert_eq!(getsigidx("SIGINT"), Some(libc::SIGINT));
2373        assert_eq!(getsigidx("int"), Some(libc::SIGINT));
2374        assert_eq!(getsigidx("HUP"), Some(libc::SIGHUP));
2375        assert_eq!(getsigidx("TERM"), Some(libc::SIGTERM));
2376        assert_eq!(getsigidx("EXIT"), Some(SIGEXIT));
2377        assert_eq!(getsigidx("9"), Some(9));
2378    }
2379
2380    #[test]
2381    fn test_getsigname() {
2382        let _g = crate::test_util::global_state_lock();
2383        assert_eq!(getsigname(libc::SIGINT), "INT");
2384        assert_eq!(getsigname(libc::SIGHUP), "HUP");
2385        assert_eq!(getsigname(SIGEXIT), "EXIT");
2386    }
2387
2388    #[test]
2389    fn test_signal_queue() {
2390        let _g = crate::test_util::global_state_lock();
2391        let before = queueing_enabled.load(Ordering::SeqCst);
2392        queue_signals();
2393        assert_eq!(queueing_enabled.load(Ordering::SeqCst), before + 1);
2394        unqueue_signals();
2395        assert_eq!(queueing_enabled.load(Ordering::SeqCst), before);
2396    }
2397
2398    #[test]
2399    fn test_signal_mask_zero_returns_empty() {
2400        let _g = crate::test_util::global_state_lock();
2401        // C: `if (sig) sigaddset(&set, sig);` — sig==0 yields empty set.
2402        let s = signal_mask(0);
2403        let r = unsafe { libc::sigismember(&s, libc::SIGINT) };
2404        assert_eq!(r, 0);
2405    }
2406
2407    #[test]
2408    fn test_signal_mask_includes_only_specified() {
2409        let _g = crate::test_util::global_state_lock();
2410        let s = signal_mask(libc::SIGUSR1);
2411        assert_eq!(unsafe { libc::sigismember(&s, libc::SIGUSR1) }, 1);
2412        assert_eq!(unsafe { libc::sigismember(&s, libc::SIGUSR2) }, 0);
2413    }
2414
2415    #[test]
2416    fn test_interact_flag_round_trip() {
2417        let _g = crate::test_util::global_state_lock();
2418        let prev = is_interact();
2419        set_interact(true);
2420        assert!(is_interact());
2421        set_interact(false);
2422        assert!(!is_interact());
2423        set_interact(prev);
2424    }
2425
2426    #[test]
2427    fn test_signal_block_returns_old_mask() {
2428        let _g = crate::test_util::global_state_lock();
2429        let prev = is_interact();
2430        set_interact(false); // ensure no test side-effects from interactive paths
2431        let mask = signal_mask(libc::SIGUSR2);
2432        let old = signal_block(&mask);
2433        // Restore to old state.
2434        let _ = signal_setmask(&old);
2435        // Verify the post-block mask had SIGUSR2 set by re-blocking
2436        // and unblocking. The test just checks the returned old set
2437        // is valid (no crash, syscall returned).
2438        let _ = old;
2439        set_interact(prev);
2440    }
2441
2442    /// `Src/signals.c:158-168` — `signal_mask(sig)` builds a fresh
2443    /// sigset containing only `sig`. The `sig == 0` arm at c:163
2444    /// returns an empty set (no `sigaddset` call). Pin both arms.
2445    #[cfg(unix)]
2446    #[test]
2447    fn signal_mask_includes_only_requested_signal() {
2448        let _g = crate::test_util::global_state_lock();
2449        let m = signal_mask(libc::SIGUSR1);
2450        // c:166 — `sigaddset(&set, sig)` for the requested signal.
2451        assert_eq!(
2452            unsafe { libc::sigismember(&m, libc::SIGUSR1) },
2453            1,
2454            "c:166 — requested signal must be set"
2455        );
2456        // Other signals not in the set.
2457        assert_eq!(unsafe { libc::sigismember(&m, libc::SIGUSR2) }, 0);
2458        assert_eq!(unsafe { libc::sigismember(&m, libc::SIGTERM) }, 0);
2459    }
2460
2461    /// `Src/signals.c:163` — `if (sig) sigaddset(&set, sig)`. With
2462    /// `sig == 0` no `sigaddset` runs, so the returned set is empty.
2463    /// Regression dropping the `if (sig)` guard would `sigaddset(&set, 0)`
2464    /// which is implementation-defined (Linux: EINVAL; macOS: may
2465    /// "succeed" with a bogus member).
2466    #[cfg(unix)]
2467    #[test]
2468    fn signal_mask_with_zero_returns_empty_set() {
2469        let _g = crate::test_util::global_state_lock();
2470        let m = signal_mask(0);
2471        // Every signal must be NOT a member of an empty set.
2472        for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGUSR1, libc::SIGUSR2] {
2473            assert_eq!(
2474                unsafe { libc::sigismember(&m, sig) },
2475                0,
2476                "c:163 — sig=0 produces empty set, but {} found",
2477                sig
2478            );
2479        }
2480    }
2481
2482    /// `Src/signals.c:1291-1313` — `rtsigno(signame)` parses a NAME
2483    /// STRING ("RTMIN", "RTMIN+N", "RTMAX-N") and returns the signum,
2484    /// or 0 (Rust None) on parse failure.
2485    ///
2486    /// The previous Rust port had a completely wrong signature
2487    /// (`rtsigno(i32)` taking an offset int). Now matches C exactly.
2488    #[cfg(target_os = "linux")]
2489    #[test]
2490    fn rtsigno_parses_rt_signal_names() {
2491        let _g = crate::test_util::global_state_lock();
2492        let sigrtmin = libc::SIGRTMIN();
2493        let sigrtmax = libc::SIGRTMAX();
2494        // Bare RTMIN / RTMAX (no offset).
2495        assert_eq!(rtsigno("RTMIN"), Some(sigrtmin), "c:1300 — bare RTMIN");
2496        assert_eq!(rtsigno("RTMAX"), Some(sigrtmax), "c:1302 — bare RTMAX");
2497        // With offset.
2498        assert_eq!(
2499            rtsigno("RTMIN+1"),
2500            Some(sigrtmin + 1),
2501            "c:1307-1311 — RTMIN+N"
2502        );
2503        assert_eq!(
2504            rtsigno("RTMAX-1"),
2505            Some(sigrtmax - 1),
2506            "c:1307-1311 — RTMAX-N"
2507        );
2508        // Invalid input.
2509        assert_eq!(rtsigno("SIGINT"), None, "c:1304 — non-RT name returns None");
2510        assert_eq!(rtsigno(""), None, "empty string returns None");
2511        // Out-of-range offset.
2512        let maxofs = sigrtmax - sigrtmin;
2513        assert_eq!(
2514            rtsigno(&format!("RTMIN+{}", maxofs + 1)),
2515            None,
2516            "c:1310 — offset > maxofs returns None"
2517        );
2518        // Malformed (non-op trailing char).
2519        assert_eq!(
2520            rtsigno("RTMINx"),
2521            None,
2522            "c:1313 — trailing non-op char returns None"
2523        );
2524    }
2525
2526    /// `Src/signals.c:1317-1338` — `rtsigname(signo, alt)` picks the
2527    /// shorter form between `RTMIN+N` and `RTMAX-N`. Pin the contract
2528    /// on Linux where SIGRTMIN/SIGRTMAX are real.
2529    #[cfg(target_os = "linux")]
2530    #[test]
2531    fn rtsigname_picks_shorter_form_between_rtmin_rtmax() {
2532        let _g = crate::test_util::global_state_lock();
2533        let sigrtmin = libc::SIGRTMIN();
2534        let sigrtmax = libc::SIGRTMAX();
2535        // SIGRTMIN itself → "RTMIN" (offset 0; trailing '+' dropped).
2536        assert_eq!(
2537            rtsigname(sigrtmin),
2538            "RTMIN",
2539            "c:1334 — offset 0 → bare 'RTMIN' (no '+0')"
2540        );
2541        // SIGRTMAX itself → "RTMAX" (offset 0; trailing '-' dropped).
2542        assert_eq!(
2543            rtsigname(sigrtmax),
2544            "RTMAX",
2545            "c:1334 — offset 0 → bare 'RTMAX' (no '-0')"
2546        );
2547        // SIGRTMIN+1 — minofs=1, maxofs=(sigrtmax-sigrtmin-1) > 1 →
2548        // form=false → "RTMIN+1".
2549        assert_eq!(
2550            rtsigname(sigrtmin + 1),
2551            "RTMIN+1",
2552            "c:1322 — minofs < maxofs → form=0 → RTMIN+1"
2553        );
2554        // SIGRTMAX-1 — maxofs=1, minofs=(sigrtmax-sigrtmin-1) > 1 →
2555        // form=true → "RTMAX-1".
2556        assert_eq!(
2557            rtsigname(sigrtmax - 1),
2558            "RTMAX-1",
2559            "c:1322 — maxofs < minofs → form=1 → RTMAX-1"
2560        );
2561        // Out of range → empty string (C: NULL).
2562        assert_eq!(
2563            rtsigname(sigrtmin - 1),
2564            "",
2565            "c:1326 — signo < SIGRTMIN → NULL (empty)"
2566        );
2567        assert_eq!(
2568            rtsigname(sigrtmax + 1),
2569            "",
2570            "c:1326 — signo > SIGRTMAX → NULL (empty)"
2571        );
2572    }
2573
2574    /// `Src/signals.c:269-274` — `wait_for_processes` waitpid flags
2575    /// must include WCONTINUED so children resumed via SIGCONT
2576    /// surface a status update through waitpid. Pin the flag union
2577    /// composition: WNOHANG | WUNTRACED | WCONTINUED.
2578    #[cfg(unix)]
2579    #[test]
2580    fn wait_for_processes_uses_canonical_waitpid_flags() {
2581        let _g = crate::test_util::global_state_lock();
2582        // We can't easily intercept libc::waitpid from a test, so pin
2583        // the canonical flags directly via libc constants — if a
2584        // future regression drops WCONTINUED, the const assertion
2585        // fails (catching the drift even when no child is reaped).
2586        let canonical = libc::WNOHANG | libc::WUNTRACED | libc::WCONTINUED;
2587        // Each component must be a distinct, non-zero bit.
2588        assert_ne!(libc::WNOHANG, 0);
2589        assert_ne!(libc::WUNTRACED, 0);
2590        assert_ne!(libc::WCONTINUED, 0);
2591        assert_eq!(
2592            libc::WNOHANG & libc::WUNTRACED,
2593            0,
2594            "WNOHANG and WUNTRACED must be disjoint bits"
2595        );
2596        assert_eq!(
2597            libc::WNOHANG & libc::WCONTINUED,
2598            0,
2599            "WNOHANG and WCONTINUED must be disjoint bits"
2600        );
2601        assert_eq!(
2602            libc::WUNTRACED & libc::WCONTINUED,
2603            0,
2604            "WUNTRACED and WCONTINUED must be disjoint bits"
2605        );
2606        // The combined mask is the canonical WAITFLAGS per c:271.
2607        assert!(
2608            canonical >= libc::WNOHANG + libc::WUNTRACED + libc::WCONTINUED,
2609            "canonical mask must include all three bits"
2610        );
2611        // `wait_for_processes` is a void-returning poll-loop on the
2612        // current process; call it to verify it doesn't hang. Whether
2613        // any child gets reaped depends on the full-suite's prior
2614        // tests — other `std::process::Command::spawn` based tests can
2615        // leave reapable children alive. Pin the no-hang property; the
2616        // is-empty check is best-effort.
2617        let _result = wait_for_processes();
2618    }
2619
2620    /// `Src/signals.c:1024-1033` — `queue_traps(wait_cmd)` enables
2621    /// queueing ONLY when BOTH `!isset(TRAPSASYNC)` AND `!wait_cmd`.
2622    /// Pin:
2623    ///   * TRAPSASYNC=on  → queue_traps(0) is a no-op.
2624    ///   * wait_cmd=1     → queue_traps(1) is a no-op.
2625    ///   * both off       → queue_traps(0) sets trap_queueing_enabled=1.
2626    #[cfg(unix)]
2627    #[test]
2628    fn queue_traps_respects_trapsasync_and_wait_cmd() {
2629        let _g = crate::test_util::global_state_lock();
2630        let saved = isset(TRAPSASYNC);
2631
2632        // Setup: TRAPSASYNC off, trap_queueing_enabled cleared.
2633        dosetopt(TRAPSASYNC, 0, 0);
2634        trap_queueing_enabled.store(0, Ordering::SeqCst);
2635
2636        // wait_cmd=1 → queueing stays disabled.
2637        queue_traps(1);
2638        assert_eq!(
2639            trap_queueing_enabled.load(Ordering::SeqCst),
2640            0,
2641            "c:1026 — wait_cmd=1 gate must block queueing"
2642        );
2643
2644        // wait_cmd=0 + TRAPSASYNC=off → queueing enabled.
2645        queue_traps(0);
2646        assert_eq!(
2647            trap_queueing_enabled.load(Ordering::SeqCst),
2648            1,
2649            "c:1031 — both gates off → queueing enabled = 1"
2650        );
2651
2652        // Reset; turn TRAPSASYNC on.
2653        trap_queueing_enabled.store(0, Ordering::SeqCst);
2654        dosetopt(TRAPSASYNC, 1, 0);
2655        queue_traps(0);
2656        assert_eq!(
2657            trap_queueing_enabled.load(Ordering::SeqCst),
2658            0,
2659            "c:1026 — TRAPSASYNC=on must block queueing even with wait_cmd=0"
2660        );
2661
2662        // Restore.
2663        dosetopt(TRAPSASYNC, if saved { 1 } else { 0 }, 0);
2664        trap_queueing_enabled.store(0, Ordering::SeqCst);
2665    }
2666
2667    /// `Src/signals.c:696-699` — `settrap` rejects trapping
2668    /// SIGTTOU/SIGTSTP/SIGTTIN when `jobbing` (= `isset(MONITOR)`).
2669    /// c:Src/signals.c:1112-1119 — while already inside a trap, EXACTLY
2670    /// SIGEXIT, SIGDEBUG and SIGZERR are suppressed; every other signal
2671    /// still dispatches:
2672    ///
2673    /// ```c
2674    ///     if (intrap) {
2675    ///         switch (sig) {
2676    ///         case SIGEXIT:
2677    ///         case SIGDEBUG:
2678    ///         case SIGZERR:
2679    ///             return;
2680    ///         }
2681    ///     }
2682    /// ```
2683    ///
2684    /// The guard used to be a blanket `if (intrap) return` for every
2685    /// signal, which had two consequences: the ERR trap could not re-enter
2686    /// (correct, but by accident), and a signal zsh DOES deliver from
2687    /// inside a trap body was swallowed — `TRAPUSR2() { kill -USR1 $$ }`
2688    /// runs the USR1 trap in zsh.
2689    ///
2690    /// Pin:
2691    ///   * MONITOR unset → settrap on SIGTSTP succeeds (returns 0).
2692    ///   * MONITOR set   → settrap on SIGTSTP rejected (returns 1).
2693    #[cfg(unix)]
2694    #[test]
2695    fn settrap_rejects_job_control_signals_when_monitor_set() {
2696        let _g = crate::test_util::global_state_lock();
2697        // Save current MONITOR state; restore at end.
2698        let saved = isset(MONITOR);
2699        // MONITOR off → trapping SIGTSTP is allowed.
2700        dosetopt(MONITOR, 0, 0);
2701        assert_eq!(
2702            settrap(libc::SIGTSTP, None, 0),
2703            0,
2704            "c:696 — MONITOR off → settrap on SIGTSTP succeeds"
2705        );
2706        // Cleanup our successful set.
2707        unsettrap(libc::SIGTSTP);
2708
2709        // MONITOR on → trapping SIGTSTP is rejected.
2710        // Use force=1 to bypass the c:854 SHTTY check (tests have
2711        // no real tty); we only care that the option flag flips,
2712        // not the pgrp acquisition side effect.
2713        dosetopt(MONITOR, 1, 1);
2714        assert_eq!(
2715            settrap(libc::SIGTSTP, None, 0),
2716            1,
2717            "c:696-699 — MONITOR on → settrap on SIGTSTP rejected"
2718        );
2719        assert_eq!(
2720            settrap(libc::SIGTTOU, None, 0),
2721            1,
2722            "c:696-699 — SIGTTOU also rejected under MONITOR"
2723        );
2724        assert_eq!(
2725            settrap(libc::SIGTTIN, None, 0),
2726            1,
2727            "c:696-699 — SIGTTIN also rejected under MONITOR"
2728        );
2729
2730        // Restore prior MONITOR state (also force=1 to bypass tty check).
2731        dosetopt(MONITOR, if saved { 1 } else { 0 }, 1);
2732    }
2733
2734    /// Pin: `killrunjobs` short-circuits when `HUP` option is
2735    /// unset per `Src/signals.c:512` (`if (unset(HUP)) return;`).
2736    /// Pin the side-effect-free path; testing the killpg side is
2737    /// inherently hostile (would kill real process groups) and
2738    /// requires fork+exec scaffolding outside unit-test scope.
2739    #[cfg(unix)]
2740    #[test]
2741    fn killrunjobs_short_circuits_when_hup_unset() {
2742        let _g = crate::test_util::global_state_lock();
2743        let saved = isset(HUP);
2744        // Force HUP off; killrunjobs must return immediately.
2745        dosetopt(HUP, 0, 0);
2746        // If the body iterated, it would try to read JOBTAB. With
2747        // no jobs added it would return without doing anything. The
2748        // contract pinned here: this returns without panicking
2749        // regardless of jobtab state.
2750        killrunjobs(0);
2751        killrunjobs(1);
2752        // Restore.
2753        dosetopt(HUP, if saved { 1 } else { 0 }, 0);
2754    }
2755
2756    // ═══════════════════════════════════════════════════════════════════
2757    // C-parity tests pinning Src/signals.c.
2758    // ═══════════════════════════════════════════════════════════════════
2759
2760    /// `intr()` is a no-op when not interactive. C:
2761    ///   `if (interact) install_handler(SIGINT);`
2762    #[test]
2763    fn intr_returns_without_panic() {
2764        let _g = crate::test_util::global_state_lock();
2765        intr();
2766        intr();
2767        intr();
2768    }
2769
2770    /// `nointr()` symmetric no-op when not interactive.
2771    #[test]
2772    fn nointr_returns_without_panic() {
2773        let _g = crate::test_util::global_state_lock();
2774        nointr();
2775        nointr();
2776    }
2777
2778    /// `holdintr()`/`noholdintr()` block-then-unblock SIGINT.
2779    #[test]
2780    fn holdintr_noholdintr_round_trip_no_panic() {
2781        let _g = crate::test_util::global_state_lock();
2782        holdintr();
2783        holdintr();
2784        noholdintr();
2785        noholdintr();
2786    }
2787
2788    /// `signal_mask(SIGTERM)` returns sigset with ONLY SIGTERM set.
2789    /// C `Src/signals.c:194` — sigemptyset + sigaddset(SIGTERM).
2790    #[cfg(unix)]
2791    #[test]
2792    fn signal_mask_sigterm_isolates_signal() {
2793        let _g = crate::test_util::global_state_lock();
2794        let mask = signal_mask(libc::SIGTERM);
2795        let has_term = unsafe { libc::sigismember(&mask, libc::SIGTERM) };
2796        let has_int = unsafe { libc::sigismember(&mask, libc::SIGINT) };
2797        assert_eq!(has_term, 1, "SIGTERM bit must be set");
2798        assert_eq!(has_int, 0, "SIGINT bit must NOT be set");
2799    }
2800
2801    /// `signal_block` + `signal_unblock` round-trip safely.
2802    #[cfg(unix)]
2803    #[test]
2804    fn signal_block_unblock_round_trip_no_panic() {
2805        let _g = crate::test_util::global_state_lock();
2806        let mask = signal_mask(libc::SIGUSR1);
2807        let prev = signal_block(&mask);
2808        let _ = signal_unblock(&mask);
2809        let _ = signal_setmask(&prev);
2810    }
2811
2812    // ═══════════════════════════════════════════════════════════════════
2813    // C-parity tests pinning Src/signals.c contracts.
2814    // ═══════════════════════════════════════════════════════════════════
2815
2816    /// c:1304 — `rtsigno` returns None for non-RT names. Pin so any
2817    /// accidental fall-through that returns Some(0) is caught (would
2818    /// alias as a valid signal number → null-signal kill).
2819    #[cfg(target_os = "linux")]
2820    #[test]
2821    fn rtsigno_returns_none_for_non_rt_names() {
2822        let _g = crate::test_util::global_state_lock();
2823        assert_eq!(rtsigno("TERM"), None);
2824        assert_eq!(rtsigno("KILL"), None);
2825        assert_eq!(rtsigno(""), None);
2826        assert_eq!(rtsigno("RT"), None); // RTMIN/RTMAX prefix incomplete
2827    }
2828
2829    /// c:1310 — offset exceeding maxofs returns None.
2830    #[cfg(target_os = "linux")]
2831    #[test]
2832    fn rtsigno_returns_none_for_offset_overflow() {
2833        let _g = crate::test_util::global_state_lock();
2834        assert_eq!(rtsigno("RTMIN+999"), None);
2835        assert_eq!(rtsigno("RTMAX-999"), None);
2836    }
2837
2838    /// c:1313 — trailing non-op chars after RTMIN/RTMAX → None.
2839    #[cfg(target_os = "linux")]
2840    #[test]
2841    fn rtsigno_returns_none_for_bad_trailing_chars() {
2842        let _g = crate::test_util::global_state_lock();
2843        assert_eq!(rtsigno("RTMINx"), None);
2844        assert_eq!(rtsigno("RTMAX-abc"), None);
2845    }
2846
2847    /// c:1300 — `RTMIN` with no offset returns SIGRTMIN.
2848    #[cfg(target_os = "linux")]
2849    #[test]
2850    fn rtsigno_bare_rtmin_returns_sigrtmin() {
2851        let _g = crate::test_util::global_state_lock();
2852        assert_eq!(rtsigno("RTMIN"), Some(libc::SIGRTMIN()));
2853    }
2854
2855    /// c:1302 — `RTMAX` with no offset returns SIGRTMAX.
2856    #[cfg(target_os = "linux")]
2857    #[test]
2858    fn rtsigno_bare_rtmax_returns_sigrtmax() {
2859        let _g = crate::test_util::global_state_lock();
2860        assert_eq!(rtsigno("RTMAX"), Some(libc::SIGRTMAX()));
2861    }
2862
2863    /// c:1307 — RTMIN+0 == RTMIN; RTMAX-0 == RTMAX (offset of 0 valid).
2864    #[cfg(target_os = "linux")]
2865    #[test]
2866    fn rtsigno_zero_offset_equals_bare() {
2867        let _g = crate::test_util::global_state_lock();
2868        assert_eq!(rtsigno("RTMIN+0"), Some(libc::SIGRTMIN()));
2869        assert_eq!(rtsigno("RTMAX-0"), Some(libc::SIGRTMAX()));
2870    }
2871
2872    /// c:1307 — wrong-direction offset rejected: RTMIN-N and RTMAX+N
2873    /// fall through (RTMIN only accepts `+`, RTMAX only accepts `-`).
2874    #[cfg(target_os = "linux")]
2875    #[test]
2876    fn rtsigno_wrong_direction_offset_returns_none() {
2877        let _g = crate::test_util::global_state_lock();
2878        assert_eq!(rtsigno("RTMIN-1"), None); // RTMIN doesn't take minus
2879        assert_eq!(rtsigno("RTMAX+1"), None); // RTMAX doesn't take plus
2880    }
2881
2882    /// `signal_mask` with negative signal is harmless (just empty set).
2883    #[cfg(unix)]
2884    #[test]
2885    fn signal_mask_with_negative_sig_does_not_panic() {
2886        let _g = crate::test_util::global_state_lock();
2887        // sigaddset rejects invalid signals but doesn't crash.
2888        let _mask = signal_mask(-1);
2889    }
2890
2891    /// `signal_setmask` round-trip: saving current mask, replacing,
2892    /// then restoring should leave process state unchanged.
2893    #[cfg(unix)]
2894    #[test]
2895    fn signal_setmask_round_trip_preserves_mask() {
2896        let _g = crate::test_util::global_state_lock();
2897        unsafe {
2898            let mut current: libc::sigset_t = std::mem::zeroed();
2899            libc::sigprocmask(libc::SIG_BLOCK, std::ptr::null(), &mut current);
2900            let new_mask = signal_mask(libc::SIGUSR2);
2901            let old = signal_setmask(&new_mask);
2902            let _ = signal_setmask(&old);
2903            // No assertion on final state — pin: no panic.
2904        }
2905    }
2906
2907    /// `kill(0, 0)` is a no-op null-check — should not error.
2908    #[cfg(unix)]
2909    #[test]
2910    fn kill_with_sig_zero_is_a_null_check() {
2911        let _g = crate::test_util::global_state_lock();
2912        // sig=0 → no signal sent; rc encodes whether the process exists.
2913        let _ = kill(std::process::id() as i32, 0);
2914    }
2915
2916    /// `killpg` accepts negative pgrp values (libc passthrough).
2917    /// Pin no-panic — the syscall handles validation.
2918    #[cfg(unix)]
2919    #[test]
2920    fn killpg_with_invalid_pgrp_does_not_panic() {
2921        let _g = crate::test_util::global_state_lock();
2922        let _ = killpg(-1, 0);
2923    }
2924
2925    /// `intr` / `nointr` are idempotent — calling either twice in a
2926    /// row should not leave residual state changes.
2927    #[test]
2928    fn intr_nointr_pair_idempotent() {
2929        let _g = crate::test_util::global_state_lock();
2930        intr();
2931        intr();
2932        nointr();
2933        nointr();
2934    }
2935
2936    /// `set_interact` / `is_interact` round-trip via the option flag.
2937    /// Pin that the setter actually flips the predicate (this caught
2938    /// the historical bug where set_interact wrote to a dead AtomicBool).
2939    #[test]
2940    fn set_interact_round_trip_flips_predicate() {
2941        let _g = crate::test_util::global_state_lock();
2942        let saved = is_interact();
2943        set_interact(true);
2944        assert!(is_interact(), "set_interact(true) must set predicate");
2945        set_interact(false);
2946        assert!(!is_interact(), "set_interact(false) must clear predicate");
2947        set_interact(saved);
2948    }
2949
2950    /// `signal_mask(0)` matches `signal_mask(SIGTERM)` minus SIGTERM:
2951    /// signal 0 alone produces an empty set.
2952    #[cfg(unix)]
2953    #[test]
2954    fn signal_mask_zero_has_no_sigterm() {
2955        let _g = crate::test_util::global_state_lock();
2956        let m = signal_mask(0);
2957        let has_term = unsafe { libc::sigismember(&m, libc::SIGTERM) };
2958        assert_eq!(has_term, 0, "signal 0 must not add SIGTERM");
2959    }
2960
2961    // ═══════════════════════════════════════════════════════════════════
2962    // Additional C-parity tests for Src/signals.c
2963    // c:160 signal_mask / c:1291 rtsigno / c:1317 rtsigname
2964    // ═══════════════════════════════════════════════════════════════════
2965
2966    /// c:160 — `signal_mask(SIG)` only contains SIG, no other signal.
2967    #[cfg(unix)]
2968    #[test]
2969    fn signal_mask_isolates_only_requested_signal() {
2970        let _g = crate::test_util::global_state_lock();
2971        let m = signal_mask(libc::SIGUSR1);
2972        for s in [libc::SIGTERM, libc::SIGINT, libc::SIGUSR2, libc::SIGHUP] {
2973            let has = unsafe { libc::sigismember(&m, s) };
2974            assert_eq!(has, 0, "signal_mask(SIGUSR1) must not include {}", s);
2975        }
2976        let has_usr1 = unsafe { libc::sigismember(&m, libc::SIGUSR1) };
2977        assert_eq!(has_usr1, 1, "signal_mask(SIGUSR1) MUST include SIGUSR1");
2978    }
2979
2980    /// c:160 — `signal_mask` is a pure function (same input → same output,
2981    /// no side effects).
2982    #[cfg(unix)]
2983    #[test]
2984    fn signal_mask_is_pure() {
2985        let _g = crate::test_util::global_state_lock();
2986        for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGUSR1] {
2987            let m1 = signal_mask(sig);
2988            let m2 = signal_mask(sig);
2989            for s in [libc::SIGINT, libc::SIGTERM, libc::SIGUSR1, libc::SIGUSR2] {
2990                let h1 = unsafe { libc::sigismember(&m1, s) };
2991                let h2 = unsafe { libc::sigismember(&m2, s) };
2992                assert_eq!(h1, h2, "signal_mask must be pure for sig {}", sig);
2993            }
2994        }
2995    }
2996
2997    /// c:1291 — `rtsigno("RTMIN")` returns Some(SIGRTMIN) on Linux,
2998    /// None elsewhere.
2999    #[test]
3000    fn rtsigno_bare_rtmin_smoke() {
3001        #[cfg(target_os = "linux")]
3002        {
3003            assert_eq!(rtsigno("RTMIN"), Some(libc::SIGRTMIN()));
3004        }
3005        #[cfg(not(target_os = "linux"))]
3006        {
3007            assert_eq!(rtsigno("RTMIN"), None, "non-linux: no rt signals");
3008        }
3009    }
3010
3011    /// c:1291 — empty string is not a valid rt signal name.
3012    #[test]
3013    fn rtsigno_empty_string_returns_none() {
3014        assert_eq!(rtsigno(""), None);
3015    }
3016
3017    /// c:1291 — random non-RT names return None.
3018    #[test]
3019    fn rtsigno_arbitrary_names_return_none() {
3020        for s in ["INT", "TERM", "rtmin", "RTMINX", "RT", "MIN", "SIGRTMIN"] {
3021            assert_eq!(rtsigno(s), None, "{:?} must not parse as RT name", s);
3022        }
3023    }
3024
3025    /// c:1291 — `rtsigno("RTMIN+0")` equals `rtsigno("RTMIN")` (zero offset
3026    /// is a no-op).
3027    #[test]
3028    #[cfg(target_os = "linux")]
3029    fn rtsigno_rtmin_plus_zero_equals_bare_rtmin() {
3030        assert_eq!(rtsigno("RTMIN+0"), rtsigno("RTMIN"));
3031    }
3032
3033    /// c:1317 — `rtsigname(SIG)` returns empty string for out-of-range.
3034    #[test]
3035    #[cfg(target_os = "linux")]
3036    fn rtsigname_out_of_range_returns_empty() {
3037        let too_low = libc::SIGRTMIN() - 1;
3038        let too_high = libc::SIGRTMAX() + 1;
3039        assert_eq!(rtsigname(too_low), "");
3040        assert_eq!(rtsigname(too_high), "");
3041        assert_eq!(rtsigname(0), "");
3042        assert_eq!(rtsigname(-1), "");
3043    }
3044
3045    /// c:1317 — `rtsigname(SIGRTMIN)` returns "RTMIN" (zero offset).
3046    #[test]
3047    #[cfg(target_os = "linux")]
3048    fn rtsigname_sigrtmin_returns_rtmin() {
3049        assert_eq!(rtsigname(libc::SIGRTMIN()), "RTMIN");
3050    }
3051
3052    /// c:1317 — `rtsigname(SIGRTMAX)` returns "RTMAX" (zero offset).
3053    #[test]
3054    #[cfg(target_os = "linux")]
3055    fn rtsigname_sigrtmax_returns_rtmax() {
3056        assert_eq!(rtsigname(libc::SIGRTMAX()), "RTMAX");
3057    }
3058
3059    /// c:1317 — `rtsigname` and `rtsigno` round-trip for SIGRTMIN.
3060    #[test]
3061    #[cfg(target_os = "linux")]
3062    fn rtsigname_rtsigno_round_trip_sigrtmin() {
3063        let s = rtsigname(libc::SIGRTMIN());
3064        let n = rtsigno(&s);
3065        assert_eq!(n, Some(libc::SIGRTMIN()));
3066    }
3067
3068    /// c:175 — `signal_block(empty)` is a no-op (returns current mask,
3069    /// blocks nothing new).
3070    #[cfg(unix)]
3071    #[test]
3072    fn signal_block_empty_set_no_panic() {
3073        let _g = crate::test_util::global_state_lock();
3074        let empty: libc::sigset_t = unsafe {
3075            let mut s: libc::sigset_t = std::mem::zeroed();
3076            libc::sigemptyset(&mut s);
3077            s
3078        };
3079        let _prev = signal_block(&empty);
3080        // Restore (block-empty + unblock-empty is round-trip-safe).
3081        let _ = signal_unblock(&empty);
3082    }
3083
3084    // ═══════════════════════════════════════════════════════════════════
3085    // Additional C-parity tests for Src/signals.c
3086    // c:107 intr / c:131 nointr / c:152 holdintr / c:171 noholdintr /
3087    // c:2050 kill / c:2045 killpg / c:2071 set_interact / c:2091 is_interact
3088    // ═══════════════════════════════════════════════════════════════════
3089
3090    /// c:107 — `intr` is idempotent (callable repeatedly).
3091    #[test]
3092    fn intr_idempotent_full_sweep() {
3093        let _g = crate::test_util::global_state_lock();
3094        for _ in 0..10 {
3095            intr();
3096        }
3097    }
3098
3099    /// c:131 — `nointr` is idempotent.
3100    #[test]
3101    fn nointr_idempotent_full_sweep() {
3102        let _g = crate::test_util::global_state_lock();
3103        for _ in 0..10 {
3104            nointr();
3105        }
3106    }
3107
3108    /// c:152 — `holdintr` is idempotent.
3109    #[test]
3110    fn holdintr_idempotent() {
3111        let _g = crate::test_util::global_state_lock();
3112        for _ in 0..10 {
3113            holdintr();
3114        }
3115        // Restore via noholdintr to match push/pop semantic.
3116        for _ in 0..10 {
3117            noholdintr();
3118        }
3119    }
3120
3121    /// c:2050 — `kill(0, 0)` self-check returns i32 (type pin).
3122    /// kill(pid=0, sig=0) is a permission check: returns 0 if alive +
3123    /// permitted, -1 otherwise. Safe in test context.
3124    #[test]
3125    fn kill_self_check_returns_i32_type() {
3126        let _g = crate::test_util::global_state_lock();
3127        let _: i32 = kill(0, 0);
3128    }
3129
3130    /// c:2045 — `killpg(0, 0)` returns i32 (compile-time type pin).
3131    #[test]
3132    fn killpg_returns_i32_type() {
3133        let _g = crate::test_util::global_state_lock();
3134        let _: i32 = killpg(0, 0);
3135    }
3136
3137    /// c:2071 + c:2091 — `set_interact(false)` then `is_interact()` round-trips.
3138    #[test]
3139    fn set_interact_round_trip_preserves_value() {
3140        let _g = crate::test_util::global_state_lock();
3141        let saved = is_interact();
3142        set_interact(true);
3143        assert!(is_interact(), "set(true) → true");
3144        set_interact(false);
3145        assert!(!is_interact(), "set(false) → false");
3146        set_interact(saved);
3147    }
3148
3149    /// c:2091 — `is_interact()` returns bool (compile-time type pin).
3150    #[test]
3151    fn is_interact_returns_bool_type() {
3152        let _g = crate::test_util::global_state_lock();
3153        let _: bool = is_interact();
3154    }
3155
3156    /// c:194 — `signal_mask(SIGINT|SIGTERM)` masks differ from
3157    /// `signal_mask(SIGINT)` alone.
3158    #[cfg(unix)]
3159    #[test]
3160    fn signal_mask_two_signals_differ_from_one() {
3161        let _g = crate::test_util::global_state_lock();
3162        let one = signal_mask(libc::SIGINT);
3163        let single_has_term = unsafe { libc::sigismember(&one, libc::SIGTERM) };
3164        assert_eq!(
3165            single_has_term, 0,
3166            "single SIGINT mask must NOT contain SIGTERM"
3167        );
3168    }
3169
3170    /// c:217 — `signal_block` returns sigset_t (compile-time type pin).
3171    #[cfg(unix)]
3172    #[test]
3173    fn signal_block_returns_sigset_t_type() {
3174        let _g = crate::test_util::global_state_lock();
3175        let empty: libc::sigset_t = unsafe {
3176            let mut s: libc::sigset_t = std::mem::zeroed();
3177            libc::sigemptyset(&mut s);
3178            s
3179        };
3180        let _: libc::sigset_t = signal_block(&empty);
3181        // Cleanup
3182        let _ = signal_unblock(&empty);
3183    }
3184
3185    /// c:246 — `signal_setmask` returns sigset_t (compile-time type pin).
3186    #[cfg(unix)]
3187    #[test]
3188    fn signal_setmask_returns_sigset_t_type() {
3189        let _g = crate::test_util::global_state_lock();
3190        let cur = signal_block(&unsafe {
3191            let mut s: libc::sigset_t = std::mem::zeroed();
3192            libc::sigemptyset(&mut s);
3193            s
3194        });
3195        let _: libc::sigset_t = signal_setmask(&cur);
3196    }
3197
3198    // ═══════════════════════════════════════════════════════════════════
3199    // Additional C-parity tests for Src/signals.c
3200    // c:107 intr/nointr / c:152 holdintr/noholdintr / c:194 signal_mask /
3201    // c:1791 rtsigno / c:1868 rtsigname / c:2050 kill / c:2071 set_interact
3202    // ═══════════════════════════════════════════════════════════════════
3203
3204    /// c:107 — `intr` is idempotent.
3205    #[test]
3206    fn intr_idempotent() {
3207        let _g = crate::test_util::global_state_lock();
3208        for _ in 0..10 {
3209            intr();
3210        }
3211    }
3212
3213    /// c:131 — `nointr` is idempotent.
3214    #[test]
3215    fn nointr_idempotent() {
3216        let _g = crate::test_util::global_state_lock();
3217        for _ in 0..10 {
3218            nointr();
3219        }
3220    }
3221
3222    /// c:152 — `holdintr` is idempotent (alt with 10-call loop).
3223    #[test]
3224    fn holdintr_idempotent_alt() {
3225        let _g = crate::test_util::global_state_lock();
3226        for _ in 0..10 {
3227            holdintr();
3228        }
3229    }
3230
3231    /// c:171 — `noholdintr` is idempotent.
3232    #[test]
3233    fn noholdintr_idempotent() {
3234        let _g = crate::test_util::global_state_lock();
3235        for _ in 0..10 {
3236            noholdintr();
3237        }
3238    }
3239
3240    /// c:194 — `signal_mask(SIGINT)` returns sigset containing SIGINT.
3241    #[cfg(unix)]
3242    #[test]
3243    fn signal_mask_sigint_contains_sigint() {
3244        let _g = crate::test_util::global_state_lock();
3245        let m = signal_mask(libc::SIGINT);
3246        let contains = unsafe { libc::sigismember(&m, libc::SIGINT) };
3247        assert_eq!(contains, 1, "signal_mask(SIGINT) must contain SIGINT");
3248    }
3249
3250    /// c:1791 — `rtsigno` returns Option<i32> (compile-time pin).
3251    #[test]
3252    fn rtsigno_returns_option_i32_type() {
3253        let _g = crate::test_util::global_state_lock();
3254        let _: Option<i32> = rtsigno("RTMIN");
3255    }
3256
3257    /// c:1791 — `rtsigno("")` empty name returns None.
3258    #[test]
3259    fn rtsigno_empty_returns_none() {
3260        let _g = crate::test_util::global_state_lock();
3261        assert!(rtsigno("").is_none(), "empty name → None");
3262    }
3263
3264    /// c:1791 — `rtsigno` for nonsense name returns None.
3265    #[test]
3266    fn rtsigno_nonsense_returns_none() {
3267        let _g = crate::test_util::global_state_lock();
3268        assert!(rtsigno("__bogus_rtsig_xyz__").is_none());
3269    }
3270
3271    /// c:1868 — `rtsigname` returns String (compile-time pin).
3272    #[test]
3273    fn rtsigname_returns_string_type() {
3274        let _g = crate::test_util::global_state_lock();
3275        let _: String = rtsigname(0);
3276    }
3277
3278    /// c:2050 — `kill(self, 0)` (existence check) returns i32.
3279    #[cfg(unix)]
3280    #[test]
3281    fn kill_self_pid_zero_sig_returns_i32_alt() {
3282        let _g = crate::test_util::global_state_lock();
3283        let pid = unsafe { libc::getpid() };
3284        let _: i32 = kill(pid, 0);
3285    }
3286
3287    /// c:2071/2091 — `set_interact(true)` twice still leaves true.
3288    #[test]
3289    fn set_interact_twice_true_still_true() {
3290        let _g = crate::test_util::global_state_lock();
3291        let saved = is_interact();
3292        set_interact(true);
3293        assert!(is_interact());
3294        set_interact(true);
3295        assert!(is_interact(), "set(true) twice still true");
3296        set_interact(saved);
3297    }
3298
3299    // ═══════════════════════════════════════════════════════════════════
3300    // Additional C-parity pins for Src/signals.c
3301    // c:194 signal_mask / c:1791 rtsigno / c:1868 rtsigname /
3302    // c:2045 killpg / c:2050 kill / c:2071/2091 set/is_interact /
3303    // c:107-194 intr/holdintr family / c:1326 queue_traps / c:1339 unqueue_traps
3304    // ═══════════════════════════════════════════════════════════════════
3305
3306    /// c:1791 — `rtsigno` is for REAL-TIME signal names only (e.g.
3307    /// `RTMIN+1`); regular signal names like `TERM`/`INT` are NOT
3308    /// matched here. Pin the documented contract.
3309    #[test]
3310    fn rtsigno_rejects_regular_signal_names() {
3311        assert_eq!(
3312            rtsigno("TERM"),
3313            None,
3314            "rtsigno is rt-only; TERM (regular) must return None"
3315        );
3316        assert_eq!(
3317            rtsigno("INT"),
3318            None,
3319            "rtsigno is rt-only; INT (regular) must return None"
3320        );
3321    }
3322
3323    /// c:1791 — `rtsigno("0")` empty signal name path doesn't panic.
3324    #[test]
3325    fn rtsigno_numeric_zero_no_panic() {
3326        let _ = rtsigno("0");
3327    }
3328
3329    /// c:1868 — `rtsigname(invalid)` (negative or huge) doesn't panic.
3330    #[test]
3331    fn rtsigname_extreme_values_no_panic() {
3332        let _ = rtsigname(-1);
3333        let _ = rtsigname(99999);
3334        let _ = rtsigname(0);
3335    }
3336
3337    /// c:1868 — `rtsigname` is deterministic for same input.
3338    #[test]
3339    fn rtsigname_deterministic() {
3340        for sig in [1, 2, 9, 15, 0] {
3341            let a = rtsigname(sig);
3342            let b = rtsigname(sig);
3343            assert_eq!(a, b, "rtsigname({}) must be deterministic", sig);
3344        }
3345    }
3346
3347    /// c:194 — `signal_mask(0)` doesn't panic.
3348    #[test]
3349    fn signal_mask_signal_zero_no_panic() {
3350        let _g = crate::test_util::global_state_lock();
3351        let _: libc::sigset_t = signal_mask(0);
3352    }
3353
3354    /// c:194 — `signal_mask` returns sigset_t (type pin).
3355    #[test]
3356    fn signal_mask_returns_sigset_t_type() {
3357        let _g = crate::test_util::global_state_lock();
3358        let _: libc::sigset_t = signal_mask(libc::SIGUSR1);
3359    }
3360
3361    /// c:2071/2091 — set_interact(false) then is_interact() returns false.
3362    #[test]
3363    fn set_interact_false_round_trip() {
3364        let _g = crate::test_util::global_state_lock();
3365        let saved = is_interact();
3366        set_interact(false);
3367        assert!(
3368            !is_interact(),
3369            "after set(false), is_interact must be false"
3370        );
3371        set_interact(saved);
3372    }
3373
3374    /// c:2071/2091 — set_interact() alternation is monotonic w.r.t. observed value.
3375    #[test]
3376    fn set_interact_alternation_round_trip() {
3377        let _g = crate::test_util::global_state_lock();
3378        let saved = is_interact();
3379        set_interact(true);
3380        assert!(is_interact());
3381        set_interact(false);
3382        assert!(!is_interact());
3383        set_interact(true);
3384        assert!(is_interact());
3385        set_interact(false);
3386        assert!(!is_interact());
3387        set_interact(saved);
3388    }
3389
3390    /// c:1326/1339 — queue_traps + unqueue_traps round-trip is safe.
3391    #[test]
3392    fn queue_unqueue_traps_round_trip_safe() {
3393        let _g = crate::test_util::global_state_lock();
3394        queue_traps(0);
3395        unqueue_traps();
3396        queue_traps(1);
3397        unqueue_traps();
3398    }
3399
3400    /// c:1339 — `unqueue_traps` on empty queue safe.
3401    #[test]
3402    fn unqueue_traps_on_empty_queue_no_panic() {
3403        let _g = crate::test_util::global_state_lock();
3404        for _ in 0..10 {
3405            unqueue_traps();
3406        }
3407    }
3408
3409    /// c:107/131 — intr/nointr alternation safe.
3410    #[test]
3411    fn intr_nointr_alternation_safe() {
3412        let _g = crate::test_util::global_state_lock();
3413        for _ in 0..5 {
3414            intr();
3415            nointr();
3416        }
3417    }
3418
3419    /// c:152/171 — holdintr/noholdintr alternation safe.
3420    #[test]
3421    fn holdintr_noholdintr_alternation_safe() {
3422        let _g = crate::test_util::global_state_lock();
3423        for _ in 0..5 {
3424            holdintr();
3425            noholdintr();
3426        }
3427    }
3428}