diff --git a/crates/bashkit/src/builtins/jobctl.rs b/crates/bashkit/src/builtins/jobctl.rs index 90a259637..33c8443fa 100644 --- a/crates/bashkit/src/builtins/jobctl.rs +++ b/crates/bashkit/src/builtins/jobctl.rs @@ -13,7 +13,7 @@ use async_trait::async_trait; -use super::{Builtin, Context}; +use super::{Builtin, BuiltinSideEffect, Context}; use crate::error::Result; use crate::interpreter::{ExecResult, JobInfo, JobState}; @@ -49,7 +49,7 @@ fn parse_signal(spec: &str) -> Option { SIGNALS.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) } -fn signal_name(n: i32) -> Option<&'static str> { +pub(crate) fn signal_name(n: i32) -> Option<&'static str> { SIGNALS.iter().find(|(_, v)| *v == n).map(|(name, _)| *name) } @@ -135,8 +135,14 @@ impl Builtin for Kill { let mut stderr = String::new(); let mut exit_code = 0; + let mut side_effects = Vec::new(); for target in targets { if target.parse::() == Ok(SHELL_PID) { + // `kill -SIG $$`: the interpreter runs the trap, or ends the + // script with 128 + signal. `-0` only tests for the process. + if signal != 0 { + side_effects.push(BuiltinSideEffect::SignalSelf(signal)); + } continue; } let resolved = ctx.shell.as_ref().and_then(|shell| { @@ -174,6 +180,7 @@ impl Builtin for Kill { Ok(ExecResult { stderr: stderr.into(), exit_code, + side_effects, ..Default::default() }) } diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 379913773..5aef6baea 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -210,6 +210,7 @@ pub use iconv::Iconv; pub use inspect::{File, Less, Stat}; pub use install::Install; pub use introspect::{Hash, Type, Which}; +pub(crate) use jobctl::signal_name; pub use join::Join; #[cfg(feature = "jq")] pub use jq::Jq; diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 5d868630e..c7809e405 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -9947,6 +9947,7 @@ impl Interpreter { self.refresh_shopt_flags(); let effects = std::mem::take(&mut result.side_effects); let mut shift_failed = false; + let mut pending_self_signal = None; for effect in &effects { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { @@ -9966,6 +9967,9 @@ impl Interpreter { self.insert_array_checked(name.clone(), arr); } } + builtins::BuiltinSideEffect::SignalSelf(signal) => { + pending_self_signal = Some(*signal); + } builtins::BuiltinSideEffect::RemoveArray(name) => { self.arrays_mut().remove(name); } @@ -10022,6 +10026,41 @@ impl Interpreter { if shift_failed { result.exit_code = 1; } + if let Some(signal) = pending_self_signal { + self.deliver_self_signal(signal, result).await; + } + } + + /// `kill -SIG $$`: bash runs that signal's trap if one is set, and + /// otherwise lets the default action end the shell with status + /// 128 + signal. Signals whose default is to be ignored do neither. + async fn deliver_self_signal(&mut self, signal: i32, result: &mut ExecResult) { + if let Some(trap_cmd) = self.signal_trap(signal) { + let mut stdout = std::mem::take(&mut result.stdout); + let mut stderr = std::mem::take(&mut result.stderr); + let flow = self + .run_trap_command(&trap_cmd, &mut stdout, &mut stderr) + .await; + result.stdout = stdout; + result.stderr = stderr; + // An `exit` inside the handler ends the script, as it does in bash. + if let Some((ControlFlow::Exit(code), _)) = flow { + self.last_exit_code = code; + result.exit_code = code; + result.control_flow = ControlFlow::Exit(code); + } + return; + } + if matches!( + builtins::signal_name(signal), + Some("CHLD" | "URG" | "WINCH" | "CONT") + ) { + return; + } + let status = 128 + signal; + self.last_exit_code = status; + result.exit_code = status; + result.control_flow = ControlFlow::Exit(status); } /// Resolve a path relative to cwd, normalizing `.` and `..` components. @@ -11341,6 +11380,53 @@ impl Interpreter { (crate::StreamData::new(), crate::StreamData::new()) } + /// Run a trap handler's command, merging its output into the result the + /// caller is building. + /// Returns the handler's own control flow (an `exit` inside it), if any. + async fn run_trap_command( + &mut self, + trap_cmd: &str, + stdout: &mut crate::StreamData, + stderr: &mut crate::StreamData, + ) -> Option<(ControlFlow, i32)> { + // THREAT[TM-DOS-030]: Propagate interpreter parser limits. + let Ok(trap_script) = Parser::with_limits( + trap_cmd, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ) + .with_execution_budget(self.execution_budget.clone()) + .parse() else { + return None; + }; + let was_in_trap = self.in_trap; + self.in_trap = true; + let emit_before = self.output_emit_count; + let result = self.execute_command_sequence(&trap_script.commands).await; + self.in_trap = was_in_trap; + let Ok(trap_result) = result else { + return None; + }; + self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before); + stdout.append(&trap_result.stdout); + stderr.append(&trap_result.stderr); + Some((trap_result.control_flow, trap_result.exit_code)) + } + + /// The trap set for `signal`, under any of the spellings `trap` accepts + /// (`TERM`, `SIGTERM`, `15`). + fn signal_trap(&self, signal: i32) -> Option { + let name = builtins::signal_name(signal); + let keys = [ + name.map(str::to_string), + name.map(|n| format!("SIG{n}")), + Some(signal.to_string()), + ]; + keys.into_iter() + .flatten() + .find_map(|key| self.scoped.traps.get(&key).cloned()) + } + async fn run_err_trap( &mut self, stdout: &mut crate::StreamData, @@ -11352,25 +11438,7 @@ impl Interpreter { return; } if let Some(trap_cmd) = self.scoped.traps.get("ERR").cloned() { - // THREAT[TM-DOS-030]: Propagate interpreter parser limits - if let Ok(trap_script) = Parser::with_limits( - &trap_cmd, - self.limits.max_ast_depth, - self.limits.max_parser_operations, - ) - .with_execution_budget(self.execution_budget.clone()) - .parse() - { - self.in_trap = true; - let emit_before = self.output_emit_count; - let result = self.execute_command_sequence(&trap_script.commands).await; - self.in_trap = false; - if let Ok(trap_result) = result { - self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before); - stdout.append(&trap_result.stdout); - stderr.append(&trap_result.stderr); - } - } + let _ = self.run_trap_command(&trap_cmd, stdout, stderr).await; } } } diff --git a/crates/bashkit/src/interpreter/state.rs b/crates/bashkit/src/interpreter/state.rs index a9a9e5992..01d549f5a 100644 --- a/crates/bashkit/src/interpreter/state.rs +++ b/crates/bashkit/src/interpreter/state.rs @@ -52,6 +52,9 @@ pub enum BuiltinSideEffect { SetLastExitCode(i32), /// Set a shell variable (respects local scoping via `set_variable`). SetVariable { name: String, value: String }, + /// The shell signalled itself (`kill -SIG $$`): run that signal's trap if + /// one is set, otherwise end the script with status 128 + signal. + SignalSelf(i32), } /// Result of executing a bash script. diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index a7d676086..019a92c82 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -97,17 +97,28 @@ async fn l_net_002_default_deny_no_resolution() { #[tokio::test] async fn l_sig_001_signal_traps_not_delivered() { let mut bash = Bash::new(); + // `kill -SIG $$` is the shell signalling itself, which the shell delivers: + // the trap fires. No host signal can arrive from outside, so nothing else + // ever triggers a handler. let result = bash .exec(r#"trap 'echo TRAPPED' INT; kill -INT $$; echo after"#) .await .unwrap(); assert!( - !result.stdout.contains("TRAPPED"), - "INT trap must not fire: {}", + result.stdout.contains("TRAPPED"), + "a self-sent INT must fire the trap: {}", result.stdout ); assert!(result.stdout.contains("after")); + // Without a handler the default action ends the script with 128 + signal. + let result = bash + .exec(r#"echo body; kill -TERM $$; echo never"#) + .await + .unwrap(); + assert_eq!(result.exit_code, 143); + assert!(!result.stdout.contains("never"), "{}", result.stdout); + let result = bash .exec(r#"trap 'echo EXITED' EXIT; echo body"#) .await diff --git a/crates/bashkit/tests/spec_cases/bash/kill-self.test.sh b/crates/bashkit/tests/spec_cases/bash/kill-self.test.sh new file mode 100644 index 000000000..f54d81952 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/kill-self.test.sh @@ -0,0 +1,40 @@ +### kill_self_status_per_signal +bash -c 'kill -9 $$' 2>/dev/null; echo "kill9=$?" +bash -c 'kill -TERM $$' 2>/dev/null; echo "term=$?" +bash -c 'kill -INT $$' 2>/dev/null; echo "int=$?" +bash -c 'kill -0 $$; echo zero=$?' 2>/dev/null +### expect +kill9=137 +term=143 +int=130 +zero=0 +### end + +### kill_self_runs_the_trap +trap 'echo caught-USR1' USR1 +kill -USR1 $$ +echo still here +trap 'echo caught-TERM' TERM +kill -TERM $$ +echo "after=$?" +### expect +caught-USR1 +still here +caught-TERM +after=0 +### end + +### kill_self_trap_can_exit +bash -c 'trap "echo bye; exit 143" TERM; kill -TERM $$; echo never' +echo "rc=$?" +### expect +bye +rc=143 +### end + +### kill_self_ignored_signal +kill -CHLD $$ +echo alive +### expect +alive +### end diff --git a/knowledge/log.md b/knowledge/log.md index dc56341d8..86ad11dd4 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -34,6 +34,7 @@ * **Fix**: `[[ a < b ]]` and `[[ a > b ]]` compare strings instead of being swallowed as redirections (the conditional grammar has no redirects), so `[[ a < B ]]` is false in byte order like bash. `shopt` listings pad the name to 15 columns and separate the state with a tab, matching bash. * **Fix**: arithmetic evaluation reports errors instead of yielding 0. `$((1/0))` and `$((08))` now print bash's `line N: EXPR: division by 0 (error token is "0")` / `value too great for base` and abort the command with status 1; `(( ))` and `let` print the same with their `((: ` / `let: ` prefix and fail without aborting the script. A new `arith_error` channel on the interpreter carries the failure out of the `&self` evaluator, reported where `set -u` errors are. Error lines say `bash: line N:` because the interpreter does not track the running script's path (see architecture). * **Fix**: `{name[i]}` is accepted as an fd-variable redirect, so `exec {COPROC[1]}>&-` closes that descriptor instead of running `{COPROC[1]}` as a command. Fd-variable values resolve through array elements, which is how a coproc hands its descriptors out. +* **Feature**: the shell delivers signals it sends itself. `kill -SIG $$` runs that signal's `trap` handler (under any spelling: `TERM`, `SIGTERM`, `15`), and an `exit` inside the handler ends the script; with no handler the default action ends the script with status 128 + signal, so `kill -9 $$` exits 137 and `kill -TERM $$` 143. Signals whose default is to be ignored (CHLD, URG, WINCH, CONT) do nothing, and `kill -0 $$` still only tests. Carried by a new `SignalSelf` builtin side effect. L-SIG-001 narrowed: no signal can arrive from outside the sandbox, but a self-sent one is delivered. * **Decision**: CPython's workload priority is agents running `python3` for scripted logic (processing a file, transforming data). Low-use stdlib modules are no longer shipped: `unittest`, `doctest`, profilers, `compileall`/`zipapp`, `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest anyway). Stdlib zip 3.58 to 3.16 MB. L-CPY-009 updated. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#workload-priority). * **Feature**: `yes` and `seq` stream into pipelines. As a producer stage they write chunks into the pipe as they go (`Context::stdout_stream`), so `yes | head -1` and `seq 1000000 | head -1` stop after the first chunk with `PIPESTATUS` `141 0` instead of running to the output cap and printing a truncation notice. Step 2 of the roadmap (streaming builtin I/O), starting with the generators. * **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <