From dd40cfce575e848c4d05b2e3182061544ef4f58a Mon Sep 17 00:00:00 2001 From: cataggar <87583576+cataggar@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:15:37 +0000 Subject: [PATCH] fix: report signals ignored at shell entry Capture inherited signal dispositions before Rust and shell internals can alter them, preserve the snapshot across clones, and include ignored signals in trap -p output. Assisted-by: GitHub Copilot CLI:gpt-5.6-sol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- brush-builtins/src/trap.rs | 13 ++++ brush-core/src/shell.rs | 9 ++- brush-core/src/sys/stubs/signal.rs | 4 ++ brush-core/src/sys/unix/signal.rs | 70 +++++++++++++++++++ brush-core/src/traps.rs | 52 ++++++++++++++ .../tests/cases/compat/builtins/trap.yaml | 4 ++ 6 files changed, 151 insertions(+), 1 deletion(-) diff --git a/brush-builtins/src/trap.rs b/brush-builtins/src/trap.rs index 1b9acf0e0..fed1299b2 100644 --- a/brush-builtins/src/trap.rs +++ b/brush-builtins/src/trap.rs @@ -71,6 +71,13 @@ impl TrapCommand { for (signal, _) in context.shell.traps().iter_handlers() { Self::display_handlers_for(context, signal)?; } + for (number, name) in context.shell.traps().iter_ignored_signals_at_entry() { + let has_handler = TrapSignal::try_from(number) + .is_ok_and(|signal| context.shell.traps().handles(signal)); + if !has_handler { + writeln!(context.stdout(), "trap -- '' {name}")?; + } + } Ok(()) } @@ -84,6 +91,12 @@ impl TrapCommand { "trap -- '{}' {signal_type}", handler.command )?; + } else if let Some(name) = context + .shell + .traps() + .ignored_signal_name_at_entry(signal_type) + { + writeln!(context.stdout(), "trap -- '' {name}")?; } Ok(()) } diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs index 4969b97d8..feb470e78 100644 --- a/brush-core/src/shell.rs +++ b/brush-core/src/shell.rs @@ -9,7 +9,7 @@ use tokio::sync::Mutex; use crate::{ ExecutionControlFlow, ExecutionResult, builtins, env::ShellEnvironment, error, extensions, - functions, interfaces, jobs, keywords, openfiles, options::RuntimeOptions, pathcache, + functions, interfaces, jobs, keywords, openfiles, options::RuntimeOptions, pathcache, sys, wellknownvars, }; @@ -210,6 +210,7 @@ impl Shell { pub(crate) fn new(options: CreateOptions) -> Result { // Compute runtime options before moving fields out of `options`. let runtime_options = RuntimeOptions::defaults_from(&options); + let ignored_signals = sys::signal::ignored_signals(); // Instantiate the shell with some defaults. let mut shell = Self { @@ -227,6 +228,12 @@ impl Shell { ..Self::default() }; + for (number, name) in ignored_signals { + shell + .traps + .record_ignored_signal_at_entry(*number, name.clone()); + } + // Add in any open files provided. shell.open_files.update_from(options.fds.into_iter()); diff --git a/brush-core/src/sys/stubs/signal.rs b/brush-core/src/sys/stubs/signal.rs index 24cab4e81..5fd58bff4 100644 --- a/brush-core/src/sys/stubs/signal.rs +++ b/brush-core/src/sys/stubs/signal.rs @@ -32,6 +32,10 @@ impl TryFrom for Signal { } } +pub(crate) fn ignored_signals() -> &'static [(i32, String)] { + &[] +} + pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into()) } diff --git a/brush-core/src/sys/unix/signal.rs b/brush-core/src/sys/unix/signal.rs index 36830d6a4..2bd1c9791 100644 --- a/brush-core/src/sys/unix/signal.rs +++ b/brush-core/src/sys/unix/signal.rs @@ -4,6 +4,76 @@ use crate::{error, sys, traps}; pub(crate) use nix::sys::signal::Signal; +static PROCESS_ENTRY_IGNORED_SIGNALS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +#[cfg_attr( + target_vendor = "apple", + unsafe(link_section = "__DATA,__mod_init_func") +)] +#[cfg_attr(not(target_vendor = "apple"), unsafe(link_section = ".init_array"))] +#[used] +static CAPTURE_IGNORED_SIGNALS_AT_PROCESS_ENTRY: extern "C" fn() = + capture_ignored_signals_at_process_entry; + +extern "C" fn capture_ignored_signals_at_process_entry() { + let _ignored_signals = PROCESS_ENTRY_IGNORED_SIGNALS.get_or_init(query_ignored_signals); +} + +pub(crate) fn ignored_signals() -> &'static [(i32, String)] { + PROCESS_ENTRY_IGNORED_SIGNALS + .get_or_init(query_ignored_signals) + .as_slice() +} + +fn query_ignored_signals() -> Vec<(i32, String)> { + let signals = Signal::iterator().map(|signal| (signal as i32, signal.as_str().to_owned())); + + #[cfg(target_os = "linux")] + let signals = signals.chain(realtime_signals()); + + signals + .filter_map(|(number, name)| match signal_is_ignored(number) { + Ok(true) => Some((number, name)), + Ok(false) | Err(_) => None, + }) + .collect() +} + +fn signal_is_ignored(signal: i32) -> Result { + let mut action = std::mem::MaybeUninit::::uninit(); + + // SAFETY: A null second argument queries the current disposition without changing it. + // On success, `sigaction` fully initializes the output structure. + nix::errno::Errno::result(unsafe { + nix::libc::sigaction(signal, std::ptr::null(), action.as_mut_ptr()) + })?; + + // SAFETY: The successful `sigaction` call above initialized `action`. + let action = unsafe { action.assume_init() }; + Ok(action.sa_sigaction == nix::libc::SIG_IGN) +} + +#[cfg(target_os = "linux")] +fn realtime_signals() -> impl Iterator { + let min = nix::libc::SIGRTMIN(); + let max = nix::libc::SIGRTMAX(); + let midpoint = min + (max - min) / 2; + + (min..=max).map(move |signal| { + let name = if signal == min { + "SIGRTMIN".to_owned() + } else if signal <= midpoint { + format!("SIGRTMIN+{}", signal - min) + } else if signal == max { + "SIGRTMAX".to_owned() + } else { + format!("SIGRTMAX-{}", max - signal) + }; + (signal, name) + }) +} + pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> { nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT) .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; diff --git a/brush-core/src/traps.rs b/brush-core/src/traps.rs index e43248890..bab309b73 100644 --- a/brush-core/src/traps.rs +++ b/brush-core/src/traps.rs @@ -178,6 +178,9 @@ pub struct TrapHandler { pub struct TrapHandlerConfig { /// Registered handlers for traps; maps signal type to command. handlers: HashMap, + /// Signals whose disposition was ignored when the shell was created. + #[cfg_attr(feature = "serde", serde(default))] + ignored_signals_at_entry: HashMap, } impl TrapHandlerConfig { @@ -202,6 +205,25 @@ impl TrapHandlerConfig { self.handlers.contains_key(&signal_type) } + /// Iterates over signals that were ignored when the shell was created. + pub fn iter_ignored_signals_at_entry(&self) -> impl Iterator { + self.ignored_signals_at_entry + .iter() + .map(|(number, name)| (*number, name.as_str())) + } + + /// Returns the name of a signal if it was ignored when the shell was created. + pub fn ignored_signal_name_at_entry(&self, signal_type: TrapSignal) -> Option<&str> { + i32::try_from(signal_type) + .ok() + .and_then(|number| self.ignored_signals_at_entry.get(&number)) + .map(String::as_str) + } + + pub(crate) fn record_ignored_signal_at_entry(&mut self, number: i32, name: String) { + self.ignored_signals_at_entry.insert(number, name); + } + /// Registers a handler for a trap signal. /// /// # Arguments @@ -233,3 +255,33 @@ impl TrapHandlerConfig { self.handlers.remove(&signal_type); } } + +#[cfg(test)] +mod tests { + use super::{TrapHandlerConfig, TrapSignal}; + + #[test] + fn ignored_signal_can_be_looked_up_by_trap_signal() { + let Some(signal) = crate::sys::signal::Signal::iterator().next() else { + return; + }; + let mut traps = TrapHandlerConfig::default(); + traps.record_ignored_signal_at_entry(signal as i32, signal.as_str().to_owned()); + + assert_eq!( + traps.ignored_signal_name_at_entry(TrapSignal::Signal(signal)), + Some(signal.as_str()) + ); + } + + #[test] + fn ignored_signal_snapshot_is_preserved_by_clone() { + let mut traps = TrapHandlerConfig::default(); + traps.record_ignored_signal_at_entry(10, "SIGUSR1".to_owned()); + + let cloned = traps.clone(); + let ignored = cloned.iter_ignored_signals_at_entry().collect::>(); + + assert_eq!(ignored, vec![(10, "SIGUSR1")]); + } +} diff --git a/brush-shell/tests/cases/compat/builtins/trap.yaml b/brush-shell/tests/cases/compat/builtins/trap.yaml index ac13faee5..4e0b11e0f 100644 --- a/brush-shell/tests/cases/compat/builtins/trap.yaml +++ b/brush-shell/tests/cases/compat/builtins/trap.yaml @@ -558,6 +558,10 @@ cases: trap 'echo c' EXIT trap -p | sort + - name: "trap -p reports signals ignored at shell entry in subshells" + stdin: | + bash -c 'trap "" USR1; exec "$1" --norc --noprofile -c "trap -p USR1; (trap -p USR1)"' _ "$0" + - name: "trap -p - with specific signal" stdin: | trap 'echo "[int]"' INT