From 3943b2903ac2a57266ad2c899121d507dd5e39b0 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 23:52:20 +0000 Subject: [PATCH 1/4] fix(parser): reserved word before ( and & before a closing keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `then ( cmd )` parsed as a function named `then`, and the last `cmd &` in `{ }`, `do … done`, `then … fi` dropped the `&` and ran in the foreground. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/parser/mod.rs | 19 +++++++++++++++++++ .../tests/spec_cases/bash/subshell.test.sh | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 676f7d6f6..42cfe82d6 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -454,6 +454,20 @@ impl<'a> Parser<'a> { if let Some(cmd) = self.parse_pipeline()? { rest.push((op, cmd)); } else { + // `{ cmd & }`, `do cmd & done`: the `&` before a closing + // keyword still backgrounds `cmd`. + if matches!(op, ListOperator::Background) { + rest.push(( + ListOperator::Background, + Command::Simple(SimpleCommand { + name: Word::literal(""), + args: vec![], + redirects: vec![], + assignments: vec![], + span: self.current_span, + }), + )); + } break; } } @@ -770,7 +784,12 @@ impl<'a> Parser<'a> { _ => { // Check for POSIX-style function: name() { body } // Don't match if word contains '=' (that's an assignment like arr=(a b c)) + // Reserved words (`then (cmd)`) never name a function. if !word.contains('=') + && !matches!( + word.as_str(), + "then" | "else" | "elif" | "fi" | "do" | "done" | "esac" | "in" | "!" + ) && matches!(self.peek_next(), Some(tokens::Token::LeftParen)) { return self.parse_function_posix().map(Some); diff --git a/crates/bashkit/tests/spec_cases/bash/subshell.test.sh b/crates/bashkit/tests/spec_cases/bash/subshell.test.sh index bad4be47b..96967f33f 100644 --- a/crates/bashkit/tests/spec_cases/bash/subshell.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/subshell.test.sh @@ -138,3 +138,17 @@ echo "survived: $?" ### expect survived: 1 ### end + +### subshell_after_reserved_words +# A reserved word followed by `(` starts a subshell, not a function named +# `then`/`else`/`do`. +if true; then ( echo then-sub ); fi +if false; then :; else (echo else-sub); fi +for i in 1; do ( echo do-sub ); done +while false; do :; done; if false; then :; elif true; then (echo elif-sub); fi +### expect +then-sub +else-sub +do-sub +elif-sub +### end From bd95585ec9a1b0d206f6886c2e83812b9053aa2d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 23:52:21 +0000 Subject: [PATCH 2/4] feat(interpreter): run background jobs concurrently `cmd &` now runs concurrently with the shell (on by default, `concurrent_jobs(false)` opts out). Jobs are forked interpreters polled on the caller's task, so wasm and Terminal keep working; exec() waits for all of them. Adds jobs, disown, fg, bg, ps, pgrep, pkill; wait -n/-p/jobspecs; kill terminates jobs with 128+signal. Live jobs are capped session-wide by max_background_jobs (TM-DOS-122). Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/docs/compatibility.md | 10 +- crates/bashkit/docs/threat-model.md | 3 +- crates/bashkit/src/builtins/fileops.rs | 48 -- crates/bashkit/src/builtins/jobctl.rs | 698 ++++++++++++++++++ crates/bashkit/src/builtins/mod.rs | 4 +- crates/bashkit/src/builtins/wait.rs | 156 +++- crates/bashkit/src/interpreter/jobs.rs | 549 +++++++++++--- crates/bashkit/src/interpreter/mod.rs | 278 +++++-- crates/bashkit/src/lib.rs | 35 + crates/bashkit/src/limits.rs | 13 + crates/bashkit/src/profile.rs | 1 + .../integration/background_exec_tests.rs | 57 +- .../integration/limitations_evidence_tests.rs | 21 +- .../tests/spec_cases/bash/jobs.test.sh | 146 ++++ .../tests/spec_cases/bash/wait.test.sh | 4 - knowledge/foundations/parallel-execution.md | 26 + knowledge/log.md | 1 + knowledge/operations/limitations.md | 2 +- knowledge/security/threat-model.md | 1 + knowledge/status/builtins.json | 30 +- 20 files changed, 1846 insertions(+), 237 deletions(-) create mode 100644 crates/bashkit/src/builtins/jobctl.rs create mode 100644 crates/bashkit/tests/spec_cases/bash/jobs.test.sh diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index dd7437986..fa01c2225 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -92,7 +92,11 @@ for sandbox security reasons. See the compliance spec for details. | `cut` | `-d DELIM`, `-f FIELDS` | Extract fields | | `tr` | `-d`, character ranges | Translate/delete chars | | `date` | `+FORMAT`, `-u`, `-R`, `-I`, `-r`, `-d`/`--date` (relative, compound, epoch) | Display/format date with sandboxed IANA timezone support | -| `wait` | `[JOB_ID...]` | Wait for background jobs | +| `wait` | `[-n] [-p VAR] [-f] [ID...]` (PID or `%jobspec`) | Wait for background jobs | +| `jobs` | `-l`, `-p` | List running background jobs | +| `ps` | `-f`, `aux`, `-o`, `-p` | Virtual process list (shell + jobs) | +| `pgrep`/`pkill` | `-f`, `-l`, `-a`, `-c`, `-x`, `-SIGNAL` | Match jobs by command | +| `disown`, `fg`, `bg` | `[JOBSPEC]` | `fg` waits; `bg` is a no-op (jobs already run) | | `curl` | `-s`, `-o`, `-X`, `-d`/`--data`, `--data-raw`, `--data-binary`, `--data-urlencode`, `-G`/`--get`, `-H`, `-I`, `-f`, `-L`, `-w`, `--compressed`, `-u`, `-A`, `-e`, `-v`, `-m` | HTTP client (requires http_client feature) | | `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) | | `time` | `[-p] [-f FORMAT] [-o FILE] [-a] [-v] [--] PIPELINE` | Reserved-word timing; elapsed/status/Bashkit counters are truthful, host CPU/RSS fields say `unavailable` | @@ -164,7 +168,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and |---------|-------------------|-------| | `ln` | `-s`, `-f` | Create links | | `chown` | `OWNER[:GROUP] FILE` | Change ownership (virtual) | -| `kill` | `-SIGNAL PID` | Send signals (virtual) | +| `kill` | `-l`, `-s SIG`, `-n NUM`, `-SIG`, `PID`/`%job` | Terminate background jobs (exit 128+signal); stop/continue signals are ignored | | `trap` | `COMMAND SIGNAL...`, `-p`, `-l` | Signal/event handlers | | `type` | `NAME...` | Describe command type | | `which` | `NAME...` | Locate a command | @@ -465,7 +469,7 @@ cargo test --test spec_tests -- bash_comparison_tests --ignored - [x] Byte inspection (`strings`, `od`, `xxd`, `hexdump`) - [x] `basename`/`dirname` builtins - [x] `date` builtin -- [x] Background execution (`&`, `wait`) - parsed, runs synchronously +- [x] Background execution (`&`, `wait`) - jobs run concurrently on the session's task (no stop/continue) - [x] Network (`curl`, `wget`) - full HTTP implementation with security mitigations - [x] `timeout` builtin - stub, requires interpreter-level integration - [x] Process substitution (`<(cmd)`, `>(cmd)`) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 201549797..0ae5bf27e 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -87,7 +87,8 @@ through configurable limits. | Builtin parser depth (TM-DOS-027) | Deeply nested awk/jq | `MAX_AWK_PARSER_DEPTH` (100) + `MAX_JQ_JSON_DEPTH` (100) | MITIGATED | | Collect dirs recursion (TM-DOS-049) | Deep VFS tree | Mitigated by `max_path_depth` | MITIGATED | | Python read-only self-mount recursion (TM-DOS-118) | `bash.mount("/", bash.fs(), read_only=True)` hides the live filesystem identity behind a wrapper | Python validates the resolved filesystem identity before wrapping it | MITIGATED | -| find traversal amplification (TM-DOS-121) | `find -L` over a symlink cycle, unbounded `-exec ... {} +` batches, or `*`-heavy `-name` patterns | Canonical-path loop detection with a 40-hop symlink cap, budget-charged directory listings, capped output, batches flushed every 4096 paths, linear-time glob matching | +| find traversal amplification (TM-DOS-121) | `find -L` over a symlink cycle, unbounded `-exec ... {} +` batches, or `*`-heavy `-name` patterns | Canonical-path loop detection with a 40-hop symlink cap, budget-charged directory listings, capped output, batches flushed every 4096 paths, linear-time glob matching | MITIGATED | +| Background job flooding (TM-DOS-122) | `while :; do sleep 99 & done` holds unbounded concurrent interpreters | `max_background_jobs` (64 default, 16 hardened) caps live jobs; extra `&` fails like bash's fork EAGAIN; jobs share the session budget, timeout and cancellation; every job is reaped when `exec()` returns | MITIGATED | **Parser and Arithmetic:** diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index bfd3b0008..634503499 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -992,54 +992,6 @@ impl Builtin for Chown { } } -/// The kill builtin - send signal to process (no-op in VFS). -/// -/// Usage: kill [-s SIGNAL] [-SIGNAL] PID... -/// -/// Since there are no real processes in the virtual environment, kill is a no-op -/// that accepts the command syntax for compatibility. -pub struct Kill; - -#[async_trait] -impl Builtin for Kill { - async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( - ctx.args, - "Usage: kill [-s SIGNAL | -SIGNAL] PID...\nSend a signal to a process.\n\n -s SIGNAL\tspecify the signal to send\n -l, -L\tlist signal names\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", - Some("kill (bashkit) 0.1"), - ) { - return Ok(r); - } - - let mut pids: Vec<&str> = Vec::new(); - - for arg in ctx.args { - if arg == "-l" || arg == "-L" { - // List signal names - return Ok(ExecResult::ok( - "HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM\n" - .to_string(), - )); - } - if arg.starts_with('-') { - continue; // skip signal spec - } - pids.push(arg); - } - - if pids.is_empty() { - return Ok(ExecResult::err( - "kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ...\n" - .to_string(), - 2, - )); - } - - // In VFS, no real processes exist — just succeed silently - Ok(ExecResult::ok(String::new())) - } -} - /// The mktemp builtin - create temporary files or directories. /// /// Argument surface is generated from uutils/coreutils' `uu_app()` via diff --git a/crates/bashkit/src/builtins/jobctl.rs b/crates/bashkit/src/builtins/jobctl.rs new file mode 100644 index 000000000..90a259637 --- /dev/null +++ b/crates/bashkit/src/builtins/jobctl.rs @@ -0,0 +1,698 @@ +//! Job control and process builtins over the virtual job table: +//! `kill`, `jobs`, `disown`, `bg`, `fg`, `ps`, `pgrep`, `pkill`. +//! +//! Decisions: +//! - The process table is the shell (`$$` = 1, command `bash`) plus its +//! background jobs (virtual PIDs from `interpreter/jobs.rs`). Nothing is +//! read from the host (TM-INF-014). +//! - `kill` delivers a signal by dropping the job's future; the job's status +//! becomes 128 + signal. Signals to the shell itself (`kill $$`) and +//! `-0` probes succeed without effect. +//! - Jobs never stop, so `bg` is a no-op on a running job and `fg` waits for +//! it (printing its command first, as bash does). + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::{ExecResult, JobInfo, JobState}; + +const SIGNALS: &[(&str, i32)] = &[ + ("HUP", 1), + ("INT", 2), + ("QUIT", 3), + ("ILL", 4), + ("TRAP", 5), + ("ABRT", 6), + ("BUS", 7), + ("FPE", 8), + ("KILL", 9), + ("USR1", 10), + ("SEGV", 11), + ("USR2", 12), + ("PIPE", 13), + ("ALRM", 14), + ("TERM", 15), + ("CHLD", 17), + ("CONT", 18), + ("STOP", 19), + ("TSTP", 20), +]; + +/// Signal number for `9`, `KILL`, `SIGKILL`, `kill` (case-insensitive). +fn parse_signal(spec: &str) -> Option { + if let Ok(n) = spec.parse::() { + return (0..=64).contains(&n).then_some(n); + } + let upper = spec.to_ascii_uppercase(); + let name = upper.strip_prefix("SIG").unwrap_or(&upper); + SIGNALS.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) +} + +fn signal_name(n: i32) -> Option<&'static str> { + SIGNALS.iter().find(|(_, v)| *v == n).map(|(name, _)| *name) +} + +const SHELL_PID: u32 = 1; + +/// The `kill` builtin. +pub struct Kill; + +#[async_trait] +impl Builtin for Kill { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version( + ctx.args, + "Usage: kill [-s SIGNAL | -SIGNAL] PID|%JOB...\n kill -l [SIGNAL]\nSend a signal to a job.\n\n -s SIGNAL\tspecify the signal to send\n -n NUM\tspecify the signal number\n -l, -L\tlist signal names\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + Some("kill (bashkit) 0.1"), + ) { + return Ok(r); + } + + let mut signal = 15; + let mut targets: Vec<&str> = Vec::new(); + let mut args = ctx.args.iter().peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-l" | "-L" => { + // `kill -l 143` / `kill -l 15` → name; bare → list. + if let Some(v) = args.next() { + let n = v.parse::().unwrap_or(-1); + let n = if n > 128 { n - 128 } else { n }; + return Ok(match signal_name(n) { + Some(name) => ExecResult::ok(format!("{name}\n")), + None => ExecResult::err( + format!("bash: kill: {v}: invalid signal specification\n"), + 1, + ), + }); + } + let list: Vec = SIGNALS + .iter() + .map(|(n, v)| format!("{v:2}) SIG{n}")) + .collect(); + return Ok(ExecResult::ok(format!("{}\n", list.join("\n")))); + } + "-s" | "-n" => { + let Some(spec) = args.next() else { + return Ok(ExecResult::err( + format!("bash: kill: {arg}: option requires an argument\n"), + 2, + )); + }; + match parse_signal(spec) { + Some(n) => signal = n, + None => { + return Ok(ExecResult::err( + format!("bash: kill: {spec}: invalid signal specification\n"), + 1, + )); + } + } + } + "--" => targets.extend(args.by_ref().map(String::as_str)), + a if a.starts_with('-') && targets.is_empty() && a.len() > 1 => { + match parse_signal(&a[1..]) { + Some(n) => signal = n, + None => { + return Ok(ExecResult::err( + format!("bash: kill: {}: invalid signal specification\n", &a[1..]), + 1, + )); + } + } + } + a => targets.push(a), + } + } + + if targets.is_empty() { + return Ok(ExecResult::err( + "kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]\n", + 2, + )); + } + + let mut stderr = String::new(); + let mut exit_code = 0; + for target in targets { + if target.parse::() == Ok(SHELL_PID) { + continue; + } + let resolved = ctx.shell.as_ref().and_then(|shell| { + let jobs = shell.jobs(); + let mut table = jobs.lock(); + let id = table.resolve(target)?; + let running = table + .list() + .iter() + .any(|j| j.pid as usize == id && j.state == JobState::Running); + if signal != 0 && running { + table.kill(id, signal); + } + Some(running) + }); + match resolved { + Some(true) => {} + // Finished but not reaped: bash reports no such process. + Some(false) | None => { + exit_code = 1; + if target.starts_with('%') && resolved.is_none() { + stderr.push_str(&format!("bash: kill: {target}: no such job\n")); + } else if target.parse::().is_ok() { + stderr.push_str(&format!("bash: kill: ({target}) - No such process\n")); + } else if resolved.is_none() { + stderr.push_str(&format!( + "bash: kill: {target}: arguments must be process or job IDs\n" + )); + } else { + stderr.push_str(&format!("bash: kill: {target}: no such job\n")); + } + } + } + } + Ok(ExecResult { + stderr: stderr.into(), + exit_code, + ..Default::default() + }) + } +} + +fn job_marker(idx: usize, len: usize) -> char { + if idx + 1 == len { + '+' + } else if idx + 2 == len { + '-' + } else { + ' ' + } +} + +fn state_text(state: &JobState) -> String { + match state { + JobState::Running => "Running".to_string(), + JobState::Done(0) => "Done".to_string(), + JobState::Done(c) if *c > 128 => match signal_name(c - 128) { + Some("TERM") => "Terminated".to_string(), + Some("KILL") => "Killed".to_string(), + Some("INT") => "Interrupt".to_string(), + Some(name) => name.to_string(), + None => format!("Exit {c}"), + }, + JobState::Done(c) => format!("Exit {c}"), + } +} + +/// The `jobs` builtin: `jobs [-l|-p|-r|-s] [JOBSPEC...]`. +pub struct Jobs; + +#[async_trait] +impl Builtin for Jobs { + async fn execute(&self, ctx: Context<'_>) -> Result { + let Some(shell) = ctx.shell.as_ref() else { + return Ok(ExecResult::ok(String::new())); + }; + let (mut long, mut pids_only, mut running_only, mut stopped_only) = + (false, false, false, false); + let mut specs = Vec::new(); + for arg in ctx.args { + match arg.as_str() { + "-l" => long = true, + "-p" => pids_only = true, + "-r" => running_only = true, + "-s" => stopped_only = true, + "-n" => {} + a if a.starts_with('-') => { + return Ok(ExecResult::err( + format!( + "bash: jobs: {a}: invalid option\njobs: usage: jobs [-lnprs] [jobspec ...]\n" + ), + 2, + )); + } + a => specs.push(a.to_string()), + } + } + let jobs = shell.jobs(); + let table = jobs.lock(); + // Non-interactive bash forgets finished jobs, so `jobs` lists only + // running ones (by job number); `+` is the newest, `-` the one before. + let mut running: Vec = table + .list() + .into_iter() + .filter(|j| j.state == JobState::Running) + .collect(); + running.sort_by_key(|j| j.number); + let selected: Vec = if specs.is_empty() { + (0..running.len()).collect() + } else { + let mut out = Vec::new(); + for spec in &specs { + let pos = table + .resolve(spec) + .and_then(|id| running.iter().position(|j| j.pid as usize == id)); + match pos { + Some(pos) => out.push(pos), + None => { + return Ok(ExecResult::err( + format!("bash: jobs: {spec}: no such job\n"), + 1, + )); + } + } + } + out + }; + let mut output = String::new(); + for pos in selected { + let job = &running[pos]; + if stopped_only { + continue; + } + let _ = running_only; + if pids_only { + output.push_str(&format!("{}\n", job.pid)); + continue; + } + let marker = job_marker(pos, running.len()); + let state = format!("{:<24}", state_text(&job.state)); + if long { + output.push_str(&format!( + "[{}]{} {} {}{} &\n", + job.number, marker, job.pid, state, job.command + )); + } else { + output.push_str(&format!( + "[{}]{} {}{} &\n", + job.number, marker, state, job.command + )); + } + } + Ok(ExecResult::ok(output)) + } +} + +/// The `disown` builtin: forget jobs (`-a` all, default current). +pub struct Disown; + +#[async_trait] +impl Builtin for Disown { + async fn execute(&self, ctx: Context<'_>) -> Result { + let Some(shell) = ctx.shell.as_ref() else { + return Ok(ExecResult::ok(String::new())); + }; + let jobs = shell.jobs(); + let mut table = jobs.lock(); + let mut specs: Vec<&str> = Vec::new(); + let mut all = false; + for arg in ctx.args { + match arg.as_str() { + "-a" => all = true, + "-h" | "-r" => {} + a => specs.push(a), + } + } + if all { + for id in table.ids() { + table.disown(id); + } + return Ok(ExecResult::ok(String::new())); + } + let current = specs.is_empty(); + if current { + specs.push("%+"); + } + for spec in specs { + match table.resolve(spec) { + Some(id) => { + table.disown(id); + } + None => { + let name = if current { "current" } else { spec }; + return Ok(ExecResult::err( + format!("bash: disown: {name}: no such job\n"), + 1, + )); + } + } + } + Ok(ExecResult::ok(String::new())) + } +} + +/// The `bg` builtin. Jobs never stop, so a running job is left as is. +pub struct Bg; + +#[async_trait] +impl Builtin for Bg { + async fn execute(&self, ctx: Context<'_>) -> Result { + let Some(shell) = ctx.shell.as_ref() else { + return Ok(ExecResult::err("bash: bg: no job control\n", 1)); + }; + let jobs = shell.jobs(); + let table = jobs.lock(); + let spec = ctx.args.first().map(String::as_str).unwrap_or("%+"); + let Some(id) = table.resolve(spec) else { + let name = if ctx.args.is_empty() { "current" } else { spec }; + return Ok(ExecResult::err( + format!("bash: bg: {name}: no such job\n"), + 1, + )); + }; + let info = table.list().into_iter().find(|j| j.pid as usize == id); + match info { + Some(j) if j.state == JobState::Running => Ok(ExecResult::err( + format!("bash: bg: job {} already in background\n", j.number), + 0, + )), + _ => Ok(ExecResult::err( + format!("bash: bg: job has terminated\n[{id}]+ Done\n"), + 1, + )), + } + } +} + +/// The `fg` builtin: wait for a job, printing its command first. +pub struct Fg; + +#[async_trait] +impl Builtin for Fg { + async fn execute(&self, ctx: Context<'_>) -> Result { + let Some(shell) = ctx.shell.as_ref() else { + return Ok(ExecResult::err("bash: fg: no job control\n", 1)); + }; + let jobs = shell.jobs(); + let spec = ctx.args.first().map(String::as_str).unwrap_or("%+"); + let found = { + let table = jobs.lock(); + table.resolve(spec).and_then(|id| { + table + .list() + .into_iter() + .find(|j| j.pid as usize == id) + .map(|j| (id, j.command)) + }) + }; + let Some((id, command)) = found else { + let name = if ctx.args.is_empty() { "current" } else { spec }; + return Ok(ExecResult::err( + format!("bash: fg: {name}: no such job\n"), + 1, + )); + }; + let result = jobs + .wait_until(|t| { + t.reap(id) + .map(Some) + .or_else(|| (!t.ids().contains(&id)).then_some(None)) + }) + .await; + let mut out = crate::StreamData::from(format!("{command}\n")); + let (stderr, code) = match result { + Some(r) => { + out.append(&r.stdout); + (r.stderr, r.exit_code) + } + None => (crate::StreamData::new(), 0), + }; + Ok(ExecResult { + stdout: out, + stderr, + exit_code: code, + ..Default::default() + }) + } +} + +/// Process table rows: the shell itself, then each job. +fn process_rows(ctx: &Context<'_>) -> Vec<(u32, String, String)> { + let mut rows = vec![(SHELL_PID, "bash".to_string(), "bash".to_string())]; + if let Some(shell) = ctx.shell.as_ref() { + for job in shell.jobs().lock().list() { + if job.state == JobState::Running { + let name = job + .command + .split_whitespace() + .next() + .unwrap_or("bash") + .to_string(); + rows.push((job.pid, name, job.command)); + } + } + } + rows +} + +fn user_name(ctx: &Context<'_>) -> String { + ctx.variables + .get("USER") + .or_else(|| ctx.env.get("USER")) + .cloned() + .unwrap_or_else(|| super::DEFAULT_USERNAME.to_string()) +} + +/// The `ps` builtin over the virtual process table. +/// +/// Formats: default (`PID TTY TIME CMD`), `-f`/`-ef` (full), `aux` (BSD +/// user format), `-o pid=` / `-o pid,comm` (subset of keywords). +pub struct Ps; + +#[async_trait] +impl Builtin for Ps { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut full = false; + let mut bsd_user = false; + let mut columns: Option> = None; + let mut pid_filter: Option> = None; + let mut args = ctx.args.iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-o" | "-O" => { + if let Some(spec) = args.next() { + columns = Some(spec.split(',').map(str::to_string).collect()); + } + } + "-p" | "--pid" => { + if let Some(list) = args.next() { + pid_filter = Some( + list.split(',') + .filter_map(|p| p.trim().parse().ok()) + .collect(), + ); + } + } + a if !a.starts_with('-') && a.contains('u') => bsd_user = true, + a if a.starts_with('-') && a.contains('f') => full = true, + _ => {} + } + } + let rows: Vec<_> = process_rows(&ctx) + .into_iter() + .filter(|(pid, ..)| pid_filter.as_ref().is_none_or(|f| f.contains(pid))) + .collect(); + if pid_filter.is_some() && rows.is_empty() { + return Ok(ExecResult::with_code( + if columns.is_some() { + String::new() + } else { + " PID TTY TIME CMD\n".to_string() + }, + 1, + )); + } + let user = user_name(&ctx); + let mut out = String::new(); + if let Some(cols) = columns { + let headers: Vec<(String, Option)> = cols + .iter() + .map(|c| match c.split_once('=') { + Some((k, h)) => (k.to_string(), Some(h.to_string())), + None => (c.clone(), None), + }) + .collect(); + let header: Vec = headers + .iter() + .map(|(k, h)| h.clone().unwrap_or_else(|| k.to_ascii_uppercase())) + .collect(); + if header.iter().any(|h| !h.is_empty()) { + out.push_str(&format!("{}\n", header.join(" "))); + } + for (pid, name, cmd) in &rows { + let fields: Vec = headers + .iter() + .map(|(k, _)| match k.as_str() { + "pid" => pid.to_string(), + "ppid" => if *pid == SHELL_PID { "0" } else { "1" }.to_string(), + "comm" | "ucomm" => name.clone(), + "args" | "cmd" | "command" => cmd.clone(), + "user" | "uname" => user.clone(), + "uid" => "1000".to_string(), + "stat" | "s" => "S".to_string(), + "tty" | "tt" => "pts/0".to_string(), + "time" => "00:00:00".to_string(), + _ => "-".to_string(), + }) + .collect(); + out.push_str(&format!("{}\n", fields.join(" "))); + } + } else if bsd_user { + out.push_str( + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n", + ); + for (pid, _, cmd) in &rows { + out.push_str(&format!( + "{:<8} {:>7} 0.0 0.0 0 0 pts/0 S 00:00 0:00 {}\n", + user, pid, cmd + )); + } + } else if full { + out.push_str("UID PID PPID C STIME TTY TIME CMD\n"); + for (pid, _, cmd) in &rows { + let ppid = if *pid == SHELL_PID { 0 } else { SHELL_PID }; + out.push_str(&format!( + "{:<8} {:>7} {:>7} 0 00:00 pts/0 00:00:00 {}\n", + user, pid, ppid, cmd + )); + } + } else { + out.push_str(" PID TTY TIME CMD\n"); + for (pid, name, _) in &rows { + out.push_str(&format!("{:>7} pts/0 00:00:00 {}\n", pid, name)); + } + } + Ok(ExecResult::ok(out)) + } +} + +/// `pgrep` and `pkill` (selected by `kill_signal`): match jobs by command +/// name (or full command line with `-f`) against a regex. +#[derive(Default)] +pub struct Pgrep { + kill: bool, +} + +impl Pgrep { + /// `pgrep`. + pub fn new() -> Self { + Self { kill: false } + } + + /// `pkill`. + pub fn pkill() -> Self { + Self { kill: true } + } +} + +#[async_trait] +impl Builtin for Pgrep { + async fn execute(&self, ctx: Context<'_>) -> Result { + let tool = if self.kill { "pkill" } else { "pgrep" }; + let mut full = false; + let mut list_name = false; + let mut list_full = false; + let mut count = false; + let mut exact = false; + let mut signal = 15; + let mut pattern: Option<&str> = None; + for arg in ctx.args { + match arg.as_str() { + "-f" | "--full" => full = true, + "-l" | "--list-name" => list_name = true, + "-a" | "--list-full" => list_full = true, + "-c" | "--count" => count = true, + "-x" | "--exact" => exact = true, + a if self.kill && a.starts_with('-') && parse_signal(&a[1..]).is_some() => { + signal = parse_signal(&a[1..]).unwrap_or(15); + } + a if a.starts_with('-') => { + return Ok(ExecResult::err( + format!( + "{tool}: invalid option -- '{}'\n", + a.trim_start_matches('-') + ), + 2, + )); + } + a => pattern = Some(a), + } + } + let Some(pattern) = pattern else { + return Ok(ExecResult::err( + format!("{tool}: no matching criteria specified\n"), + 2, + )); + }; + let re = match regex::Regex::new(&if exact { + format!("^(?:{pattern})$") + } else { + pattern.to_string() + }) { + Ok(re) => re, + Err(_) => { + return Ok(ExecResult::err( + format!("{tool}: invalid regular expression\n"), + 2, + )); + } + }; + let matches: Vec<(u32, String, String)> = process_rows(&ctx) + .into_iter() + .filter(|(_, name, cmd)| re.is_match(if full { cmd } else { name })) + .collect(); + if self.kill + && let Some(shell) = ctx.shell.as_ref() + { + let jobs = shell.jobs(); + let mut table = jobs.lock(); + for (pid, ..) in &matches { + if *pid != SHELL_PID + && let Some(id) = table.resolve(&pid.to_string()) + { + table.kill(id, signal); + } + } + } + let code = if matches.is_empty() { 1 } else { 0 }; + if count { + return Ok(ExecResult::with_code(format!("{}\n", matches.len()), code)); + } + if self.kill { + return Ok(ExecResult::with_code(String::new(), code)); + } + let mut out = String::new(); + for (pid, name, cmd) in &matches { + if list_full { + out.push_str(&format!("{pid} {cmd}\n")); + } else if list_name { + out.push_str(&format!("{pid} {name}\n")); + } else { + out.push_str(&format!("{pid}\n")); + } + } + Ok(ExecResult::with_code(out, code)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signals_parse() { + assert_eq!(parse_signal("9"), Some(9)); + assert_eq!(parse_signal("KILL"), Some(9)); + assert_eq!(parse_signal("sigterm"), Some(15)); + assert_eq!(parse_signal("BOGUS"), None); + assert_eq!(signal_name(15), Some("TERM")); + } + + #[test] + fn job_states_render_like_bash() { + assert_eq!(state_text(&JobState::Done(0)), "Done"); + assert_eq!(state_text(&JobState::Done(3)), "Exit 3"); + assert_eq!(state_text(&JobState::Done(143)), "Terminated"); + assert_eq!(state_text(&JobState::Running), "Running"); + } +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 31d24a0fd..9bea4735a 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -73,6 +73,7 @@ mod iconv; mod inspect; mod install; mod introspect; +mod jobctl; mod join; #[cfg(feature = "jq")] mod jq; @@ -184,7 +185,7 @@ pub use export::Export; pub use expr::Expr; pub use factor::Factor; pub use fc::Fc; -pub use fileops::{Chmod, Chown, Cp, Kill, Ln, Mkdir, Mktemp, Mv, Rm, Touch}; +pub use fileops::{Chmod, Chown, Cp, Ln, Mkdir, Mktemp, Mv, Rm, Touch}; pub use find::Find; pub use flow::{Break, Colon, Continue, Exit, False, Return, True}; pub use fold::Fold; @@ -226,6 +227,7 @@ pub use shellenv::{Enable, Locale, Ulimit, Umask}; pub use shuf::Shuf; pub use tsort::Tsort; +pub use jobctl::{Bg, Disown, Fg, Jobs, Kill, Pgrep, Ps}; #[cfg(feature = "terminal")] pub use pager::More; pub use sleep::Sleep; diff --git a/crates/bashkit/src/builtins/wait.rs b/crates/bashkit/src/builtins/wait.rs index 189b051d1..4041a48ff 100644 --- a/crates/bashkit/src/builtins/wait.rs +++ b/crates/bashkit/src/builtins/wait.rs @@ -1,6 +1,8 @@ //! Wait builtin — wait for background jobs to complete. //! -//! Accesses the shared job table via [`ShellRef`](super::ShellRef). +//! Accesses the shared job table via [`ShellRef`](super::ShellRef). Jobs are +//! driven by the interpreter alongside the foreground script, so awaiting +//! here lets them run (see `interpreter/jobs.rs`). use async_trait::async_trait; @@ -10,11 +12,16 @@ use crate::interpreter::{BuiltinSideEffect, ExecResult}; /// `wait` builtin — wait for background jobs to complete. /// -/// Usage: wait [JOB_ID...] +/// Usage: `wait [-n] [-p VAR] [ID...]`; an ID is a PID (`$!`) or a jobspec +/// (`%N`, `%%`, `%-`). /// -/// If no JOB_ID is specified, wait for all background jobs. -/// Returns the exit status of the last job waited for. -/// Merges background job stdout/stderr into the result. +/// - No ID: wait for every job; status 0. +/// - IDs: wait for each; status of the last; 127 for an unknown ID. +/// - `-n`: wait for the next job (of the IDs, if given) to finish; its +/// status, or 127 when there is none. `-p VAR` stores its PID. +/// +/// Output of the jobs waited for that was not reported yet is part of this +/// command's output. pub struct Wait; #[async_trait] @@ -24,45 +31,134 @@ impl Builtin for Wait { // No shell state — no-op (no jobs to wait for) return Ok(ExecResult::ok(String::new())); }; - let jobs = shell.jobs(); - let mut last_exit_code = 0i32; - let mut stdout = String::new(); - let mut stderr = String::new(); - if ctx.args.is_empty() { - // Wait for all background jobs, collecting their output - let results = jobs.lock().await.wait_all_results().await; - for r in results { - stdout.push_str(&r.stdout); - stderr.push_str(&r.stderr); - last_exit_code = r.exit_code; + let mut next = false; + let mut pid_var: Option = None; + let mut ids: Vec<&str> = Vec::new(); + let mut args = ctx.args.iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-n" => next = true, + "-f" => {} + "-p" => match args.next() { + Some(v) => pid_var = Some(v.clone()), + None => { + return Ok(ExecResult::err( + "bash: wait: -p: option requires an argument\n", + 2, + )); + } + }, + "--" => ids.extend(args.by_ref().map(String::as_str)), + _ => ids.push(arg), + } + } + + let mut stdout = crate::StreamData::new(); + let mut stderr = crate::StreamData::new(); + let mut exit_code = 0i32; + let mut reaped_pid: Option = None; + + if next { + // Wait for any (listed) job to finish; 127 when none is left. + let wanted: Option> = (!ids.is_empty()).then(|| { + let table = jobs.lock(); + ids.iter().filter_map(|s| table.resolve(s)).collect() + }); + let picked = jobs + .wait_until(|t| { + let candidates: Vec = match &wanted { + Some(w) => w + .iter() + .copied() + .filter(|id| t.ids().contains(id)) + .collect(), + None => t.ids(), + }; + if candidates.is_empty() { + return Some(None); + } + let done = t.finished_ids(); + candidates + .iter() + .find(|id| done.contains(id)) + .map(|id| Some(*id)) + }) + .await; + match picked { + Some(id) => { + let mut table = jobs.lock(); + reaped_pid = u32::try_from(id).ok(); + if let Some(r) = table.reap(id) { + stdout.append(&r.stdout); + stderr.append(&r.stderr); + exit_code = r.exit_code; + } + } + None => exit_code = 127, + } + } else if ids.is_empty() { + jobs.wait_until(|t| (t.running_count() == 0).then_some(())) + .await; + let mut table = jobs.lock(); + for id in table.ids() { + if let Some(r) = table.reap(id) { + stdout.append(&r.stdout); + stderr.append(&r.stderr); + } } } else { - // Wait for specific job IDs - for arg in ctx.args { - if let Ok(job_id) = arg.parse::() - && let Some(r) = jobs.lock().await.wait_for(job_id).await - { - stdout.push_str(&r.stdout); - stderr.push_str(&r.stderr); - last_exit_code = r.exit_code; + for spec in ids { + let Some(id) = jobs.lock().resolve(spec) else { + let msg = if spec.starts_with('%') { + format!("bash: wait: {spec}: no such job\n") + } else if spec.parse::().is_ok() { + format!("bash: wait: pid {spec} is not a child of this shell\n") + } else { + format!("bash: wait: `{spec}': not a pid or valid job spec\n") + }; + stderr.append(&crate::StreamData::from(msg)); + exit_code = 127; + continue; + }; + let r = jobs + .wait_until(|t| { + t.reap(id).map(Some).or_else(|| { + // Reaped by someone else meanwhile. + (!t.ids().contains(&id)).then_some(None) + }) + }) + .await; + match r { + Some(r) => { + stdout.append(&r.stdout); + stderr.append(&r.stderr); + exit_code = r.exit_code; + } + None => exit_code = 127, } } } let mut result = ExecResult { - stdout: stdout.into(), - stderr: stderr.into(), - exit_code: last_exit_code, + stdout, + stderr, + exit_code, ..Default::default() }; + if let Some(var) = pid_var { + result.side_effects.push(BuiltinSideEffect::SetVariable { + name: var, + value: reaped_pid.map(|p| p.to_string()).unwrap_or_default(), + }); + } result .side_effects - .push(BuiltinSideEffect::SetLastExitCode(last_exit_code)); + .push(BuiltinSideEffect::SetLastExitCode(exit_code)); Ok(result) } } -// Integration tests for wait are in tests/interpreter_tests.rs -// (wait needs the full interpreter to have meaningful background jobs) +// Integration tests for wait live in tests/spec_cases/bash/{wait,jobs}.test.sh +// (wait needs the full interpreter to have background jobs). diff --git a/crates/bashkit/src/interpreter/jobs.rs b/crates/bashkit/src/interpreter/jobs.rs index 3666c9ca5..9c77f9190 100644 --- a/crates/bashkit/src/interpreter/jobs.rs +++ b/crates/bashkit/src/interpreter/jobs.rs @@ -1,31 +1,89 @@ -//! Job table for background execution +//! Job control: background jobs (`cmd &`) as concurrently polled futures. //! -//! Tracks background jobs spawned with `&` and their exit status. -//! Background commands execute synchronously for deterministic output -//! ordering, but their results are stored here so `wait` and `$!` work -//! correctly. +//! Decisions: +//! - A job runs on a forked interpreter (the same isolation as a subshell) +//! as a boxed `'static` future. Jobs are not `tokio::spawn`ed: the +//! interpreter's `execute()` polls them together with the foreground script +//! on the same task ([`JobDriver`]), so no threads or reactor are needed, +//! wasm keeps working, and a `Terminal`'s pull-driven session future drives +//! its jobs too. Jobs make progress whenever the foreground awaits +//! (`sleep`, `wait`, I/O), which is what `a & b & wait` needs. +//! - Spawning polls the job once right away. A job that never blocks (`echo +//! hi &`) finishes inside that poll, so its output lands in order, as it +//! did when jobs ran synchronously. Only jobs that block run concurrently. +//! - Output of a job that finishes later is delivered at the next reap +//! point: `wait`, a top-level command boundary, or the end of `exec()` +//! (which waits for every job, like a pipe reader waiting for EOF). +//! - `kill` drops the job's future (immediate, even mid-`sleep`); the job +//! reports 128 + signal (143 for TERM). +//! - THREAT[TM-DOS-122]: live jobs are capped by +//! `ExecutionLimits::max_background_jobs`; past the cap `&` fails. Jobs share +//! the request's execution budget, cancellation token and timeout. +//! - Virtual PIDs start at [`FIRST_PID`] and never reuse; `$$` stays 1. use std::collections::BTreeMap; -use std::sync::Arc; -use tokio::sync::Mutex; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard}; +use std::task::{Context, Poll, Waker}; + +use futures_util::future::{AbortHandle, Abortable}; +use futures_util::stream::{FuturesUnordered, StreamExt}; use crate::interpreter::ExecResult; -/// Job table for tracking background jobs. -/// -/// Background commands run synchronously (for deterministic output ordering), -/// so by the time a job is registered its result is already fully computed. We -/// therefore store the finished [`ExecResult`] directly rather than a -/// `tokio::task::JoinHandle` — there is nothing left to await. This also keeps -/// the interpreter usable on `wasm32-unknown-unknown`, where `tokio::spawn` -/// panics (no reactor running). +/// First virtual PID handed out (`$!` of the first job). +pub const FIRST_PID: u32 = 1001; + +/// Boxed job future: the job's final result. +pub(crate) type JobFuture = Pin + Send + 'static>>; + +type Running = Pin)> + Send + 'static>>; + +/// State of one job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobState { + /// Still running. + Running, + /// Finished with this exit code. + Done(i32), +} + +struct Job { + /// Job number (`%N`): one more than the highest running job's, like + /// bash, so numbers are reused once jobs finish. + number: usize, + pid: u32, + command: String, + abort: Option, + /// Final result, once finished and until reaped. + result: Option, + /// Signal number the job was killed with, if any. + killed: Option, + /// Whether finished output has been handed to the parent. + reported: bool, +} + +/// Snapshot of a job for `jobs`/`ps`. +#[derive(Debug, Clone)] +pub struct JobInfo { + /// Job number (`%N`). + pub number: usize, + /// Virtual PID. + pub pid: u32, + /// Command text. + pub command: String, + /// Current state. + pub state: JobState, +} + +/// Job table plus the futures of running jobs. Jobs are keyed by PID. pub struct JobTable { - /// Active jobs indexed by ID - jobs: BTreeMap, - /// Next job ID to assign - next_id: usize, - /// Last spawned job ID (for $!) - last_job_id: Option, + jobs: BTreeMap, + next_pid: u32, + running: FuturesUnordered, + driver: Option, } impl Default for JobTable { @@ -35,114 +93,419 @@ impl Default for JobTable { } impl JobTable { - /// Create a new empty job table + /// Create an empty table. pub fn new() -> Self { Self { jobs: BTreeMap::new(), - next_id: 1, - last_job_id: None, + next_pid: FIRST_PID, + running: FuturesUnordered::new(), + driver: None, + } + } + + /// Allocate a virtual PID (jobs and coprocesses share the sequence). + pub fn alloc_pid(&mut self) -> u32 { + let pid = self.next_pid; + self.next_pid = self.next_pid.saturating_add(1); + pid + } + + fn insert(&mut self, command: String) -> (usize, u32) { + let number = self + .jobs + .values() + .filter(|j| j.result.is_none()) + .map(|j| j.number) + .max() + .unwrap_or(0) + + 1; + let pid = self.alloc_pid(); + let id = pid as usize; + self.jobs.insert( + id, + Job { + number, + pid, + command, + abort: None, + result: None, + killed: None, + reported: false, + }, + ); + (id, pid) + } + + /// Register a job that already finished (sequential mode, or a job that + /// completed during its first poll). Its output counts as reported. + pub fn spawn_finished(&mut self, command: String, result: ExecResult) -> (usize, u32) { + let (id, pid) = self.insert(command); + let exit = result.exit_code; + if let Some(job) = self.jobs.get_mut(&id) { + job.result = Some(ExecResult::with_code(String::new(), exit)); + job.reported = true; } + (id, pid) } - /// Register a finished background job. - /// - /// Returns the job ID that can be used with wait. - pub fn spawn(&mut self, result: ExecResult) -> usize { - let id = self.next_id; - self.next_id += 1; - self.jobs.insert(id, result); - self.last_job_id = Some(id); - id + /// Register a running job. + pub(crate) fn spawn_running(&mut self, command: String, fut: JobFuture) -> (usize, u32) { + let (id, pid) = self.insert(command); + let (handle, reg) = AbortHandle::new_pair(); + if let Some(job) = self.jobs.get_mut(&id) { + job.abort = Some(handle); + } + let fut = Abortable::new(fut, reg); + self.running + .push(Box::pin(async move { (id, fut.await.ok()) })); + if let Some(w) = self.driver.take() { + w.wake(); + } + (id, pid) } - /// Get the last spawned job ID (for $!) - pub fn last_job_id(&self) -> Option { - self.last_job_id + /// Number of jobs still running. + pub fn running_count(&self) -> usize { + self.jobs.values().filter(|j| j.result.is_none()).count() } - /// Wait for a specific job to complete - pub async fn wait_for(&mut self, job_id: usize) -> Option { - self.jobs.remove(&job_id) + /// Running jobs as `(number, key)`, oldest first. + fn running_by_number(&self) -> Vec<(usize, usize)> { + let mut v: Vec<(usize, usize)> = self + .jobs + .iter() + .filter(|(_, j)| j.result.is_none()) + .map(|(id, j)| (j.number, *id)) + .collect(); + v.sort_unstable(); + v } - /// Wait for all jobs to complete - /// - /// Returns the exit code of the last job - #[allow(dead_code)] - pub async fn wait_all(&mut self) -> i32 { - self.wait_all_results() - .await - .last() - .map(|r| r.exit_code) - .unwrap_or(0) + /// Key for a `wait`/`kill` operand: `%N`, `%%`/`%+`, `%-` (running + /// jobs), or a PID (any job not yet reaped). + pub fn resolve(&self, spec: &str) -> Option { + if let Some(rest) = spec.strip_prefix('%') { + let running = self.running_by_number(); + return match rest { + "" | "%" | "+" => running.last().map(|(_, id)| *id), + "-" => running.iter().rev().nth(1).map(|(_, id)| *id), + n => { + let n: usize = n.parse().ok()?; + running.iter().find(|(num, _)| *num == n).map(|(_, id)| *id) + } + }; + } + let pid: u32 = spec.parse().ok()?; + self.jobs + .iter() + .find(|(_, j)| j.pid == pid) + .map(|(id, _)| *id) } - /// Wait for all jobs and return their results (preserving output). - pub async fn wait_all_results(&mut self) -> Vec { - std::mem::take(&mut self.jobs).into_values().collect() + /// Kill a running job with `signal`. Returns false if it already ended. + pub fn kill(&mut self, id: usize, signal: i32) -> bool { + let Some(job) = self.jobs.get_mut(&id) else { + return false; + }; + if job.result.is_some() { + return false; + } + // Default-ignored signals (CHLD, URG, WINCH) and stop/continue + // (STOP, TSTP, TTIN, TTOU, CONT) leave the job running: jobs can't + // be suspended (L-PROC-002). + if matches!(signal, 0 | 17..=23 | 28) { + return true; + } + if let Some(handle) = job.abort.take() { + handle.abort(); + } + job.killed = Some(signal); + true + } + + /// Take a finished job's result, removing it from the table. + pub fn reap(&mut self, id: usize) -> Option { + let job = self.jobs.get(&id)?; + job.result.as_ref()?; + let job = self.jobs.remove(&id)?; + let mut result = job.result?; + if job.reported { + result.stdout = crate::StreamData::new(); + result.stderr = crate::StreamData::new(); + } + Some(result) + } + + /// Output of finished, not yet reported jobs (in job order). The jobs + /// stay in the table so `wait $pid` still returns their status. + pub fn take_finished_output(&mut self) -> (crate::StreamData, crate::StreamData) { + let mut out = crate::StreamData::new(); + let mut err = crate::StreamData::new(); + for job in self.jobs.values_mut() { + if job.reported { + continue; + } + if let Some(result) = &mut job.result { + out.append(&std::mem::take(&mut result.stdout)); + err.append(&std::mem::take(&mut result.stderr)); + job.reported = true; + } + } + (out, err) + } + + /// Finished jobs, in job order. + pub fn finished_ids(&self) -> Vec { + self.jobs + .iter() + .filter(|(_, j)| j.result.is_some()) + .map(|(id, _)| *id) + .collect() } - /// Check if there are any active jobs - #[allow(dead_code)] - pub fn has_jobs(&self) -> bool { - !self.jobs.is_empty() + /// All job numbers, in order. + pub fn ids(&self) -> Vec { + self.jobs.keys().copied().collect() } - /// Get the number of active jobs - #[allow(dead_code)] - pub fn job_count(&self) -> usize { - self.jobs.len() + /// Snapshot of every job. + pub fn list(&self) -> Vec { + self.jobs + .values() + .map(|j| JobInfo { + number: j.number, + pid: j.pid, + command: j.command.clone(), + state: match &j.result { + Some(r) => JobState::Done(r.exit_code), + None => JobState::Running, + }, + }) + .collect() + } + + /// Forget a job (`disown`). + pub fn disown(&mut self, id: usize) -> bool { + self.jobs.remove(&id).is_some() + } + + /// Drop every job (running ones are aborted). + pub fn clear(&mut self) { + for job in self.jobs.values_mut() { + if let Some(handle) = job.abort.take() { + handle.abort(); + } + } + self.jobs.clear(); + self.running = FuturesUnordered::new(); + } + + fn finish(&mut self, id: usize, result: Option) { + if let Some(job) = self.jobs.get_mut(&id) { + job.abort = None; + job.result = Some(match (result, job.killed) { + (Some(r), None) => r, + (_, Some(sig)) => ExecResult::with_code(String::new(), 128 + sig), + (None, None) => ExecResult::with_code(String::new(), 143), + }); + } + } + + /// Poll running jobs; returns true when at least one finished. + fn poll_running(&mut self, cx: &mut Context<'_>) -> bool { + let mut progressed = false; + while let Poll::Ready(Some((id, result))) = self.running.poll_next_unpin(cx) { + self.finish(id, result); + progressed = true; + } + self.driver = Some(cx.waker().clone()); + progressed } } -/// Thread-safe wrapper around JobTable -pub type SharedJobTable = Arc>; +/// Shared job table plus a wakeup for waiters. +pub struct JobControl { + table: StdMutex, + changed: tokio::sync::Notify, + /// Live jobs across the whole session: this shell and every job forked + /// from it (nested `&` inside a job counts too). + live: Arc, +} -/// Create a new shared job table -pub fn new_shared_job_table() -> SharedJobTable { - Arc::new(Mutex::new(JobTable::new())) +/// One live job's claim on the session-wide job cap; released on drop +/// (finish or abort). +pub struct JobSlot(Arc); + +impl Drop for JobSlot { + fn drop(&mut self) { + self.0.fetch_sub(1, AtomicOrdering::AcqRel); + } } -#[cfg(test)] -mod tests { - use super::*; +impl JobControl { + fn new() -> Self { + Self::with_live(Arc::new(AtomicUsize::new(0))) + } - #[tokio::test] - async fn test_spawn_and_wait() { - let mut table = JobTable::new(); + fn with_live(live: Arc) -> Self { + Self { + table: StdMutex::new(JobTable::new()), + changed: tokio::sync::Notify::new(), + live, + } + } - // Register a finished job - let job_id = table.spawn(ExecResult::ok("hello".to_string())); - assert_eq!(job_id, 1); - assert_eq!(table.last_job_id(), Some(1)); + /// Empty table for a forked job that shares this session's job cap. + pub fn fork(&self) -> SharedJobTable { + Arc::new(Self::with_live(Arc::clone(&self.live))) + } - // Wait for it - let result = table.wait_for(job_id).await; - assert!(result.is_some()); - assert_eq!(result.unwrap().exit_code, 0); + /// Claim a slot under `max` live jobs session-wide (TM-DOS-122). + pub fn try_claim(&self, max: usize) -> Option { + self.live + .fetch_update(AtomicOrdering::AcqRel, AtomicOrdering::Acquire, |n| { + (n < max).then_some(n + 1) + }) + .ok() + .map(|_| JobSlot(Arc::clone(&self.live))) } - #[tokio::test] - async fn test_wait_all() { - let mut table = JobTable::new(); + /// Lock the table. Never held across an `.await`. + pub fn lock(&self) -> MutexGuard<'_, JobTable> { + self.table.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Wait until `ready` returns `Some`, re-checking after every job + /// completion. Jobs are driven by the [`JobDriver`] polled alongside. + pub async fn wait_until(&self, mut ready: impl FnMut(&mut JobTable) -> Option) -> T { + loop { + let notified = self.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(v) = ready(&mut self.lock()) { + return v; + } + notified.await; + } + } + + /// Future that drives running jobs; never completes on its own. + pub fn driver(self: &Arc) -> JobDriver { + JobDriver { + control: Arc::clone(self), + } + } + + /// Drive every running job to completion (end of `exec()`). + pub async fn finish_all(self: &Arc) { + let driver = self.driver(); + let all_done = self.wait_until(|t| (t.running_count() == 0).then_some(())); + tokio::pin!(all_done); + tokio::pin!(driver); + std::future::poll_fn(|cx| { + let _ = driver.as_mut().poll(cx); + all_done.as_mut().poll(cx) + }) + .await; + } +} + +/// Polls the running jobs of one [`JobControl`]. +pub struct JobDriver { + control: Arc, +} + +impl Future for JobDriver { + type Output = (); - // Register multiple finished jobs - for i in 0..3 { - table.spawn(ExecResult::ok(format!("job {}", i))); + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let progressed = self.control.lock().poll_running(cx); + if progressed { + self.control.changed.notify_waiters(); } + Poll::Pending + } +} + +/// Run `fut` while driving the jobs of `control` on the same task. +pub(crate) async fn with_jobs(control: &Arc, fut: F) -> F::Output { + let driver = control.driver(); + tokio::pin!(fut); + tokio::pin!(driver); + std::future::poll_fn(|cx| { + if let Poll::Ready(v) = fut.as_mut().poll(cx) { + return Poll::Ready(v); + } + let _ = driver.as_mut().poll(cx); + // A job may have finished and woken a waiter inside `fut`. + fut.as_mut().poll(cx) + }) + .await +} + +/// Thread-safe job control handle. +pub type SharedJobTable = Arc; - assert_eq!(table.job_count(), 3); +/// Create a new shared job table. +pub fn new_shared_job_table() -> SharedJobTable { + Arc::new(JobControl::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; - let exit_code = table.wait_all().await; - assert_eq!(exit_code, 0); - assert!(!table.has_jobs()); + fn sleeper(ms: u64, out: &'static str) -> JobFuture { + Box::pin(async move { + tokio::time::sleep(Duration::from_millis(ms)).await; + ExecResult::ok(out.to_string()) + }) } #[tokio::test] - async fn test_wait_for_nonexistent() { - let mut table = JobTable::new(); + async fn jobs_run_concurrently_with_the_foreground() { + let jobs = new_shared_job_table(); + let start = tokio::time::Instant::now(); + let (a, pa) = jobs.lock().spawn_running("a".into(), sleeper(100, "a\n")); + let (b, _) = jobs.lock().spawn_running("b".into(), sleeper(100, "b\n")); + assert_eq!(pa, FIRST_PID); + with_jobs(&jobs, async { + jobs.wait_until(|t| (t.running_count() == 0).then_some(())) + .await; + }) + .await; + assert!(start.elapsed() < Duration::from_millis(190)); + assert_eq!(jobs.lock().reap(a).unwrap().stdout.to_string(), "a\n"); + assert_eq!(jobs.lock().reap(b).unwrap().exit_code, 0); + } - let result = table.wait_for(999).await; - assert!(result.is_none()); + #[tokio::test] + async fn kill_aborts_and_reports_signal() { + let jobs = new_shared_job_table(); + let (id, _) = jobs.lock().spawn_running("s".into(), sleeper(10_000, "")); + assert!(jobs.lock().kill(id, 15)); + jobs.finish_all().await; + assert_eq!(jobs.lock().reap(id).unwrap().exit_code, 143); + } + + #[tokio::test] + async fn resolve_specs() { + let jobs = new_shared_job_table(); + let mut t = jobs.lock(); + let (a, pa) = t.spawn_finished("a".into(), ExecResult::ok(String::new())); + let (b, _) = t.spawn_finished("b".into(), ExecResult::ok(String::new())); + // Finished jobs keep their PID for `wait` but leave the job list. + assert_eq!(t.resolve("%1"), None); + assert_eq!(t.resolve(&pa.to_string()), Some(a)); + assert_eq!(t.resolve("999"), None); + drop(t); + let (c, _) = jobs.lock().spawn_running("c".into(), sleeper(10_000, "")); + let (d, _) = jobs.lock().spawn_running("d".into(), sleeper(10_000, "")); + let t = jobs.lock(); + assert_eq!(t.resolve("%1"), Some(c)); + assert_eq!(t.resolve("%%"), Some(d)); + assert_eq!(t.resolve("%-"), Some(c)); + assert_ne!(b, c); } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 914bdeb82..1521cecc7 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -20,7 +20,7 @@ mod state; mod time_command; #[allow(unused_imports)] -pub use jobs::{JobTable, SharedJobTable}; +pub use jobs::{JobInfo, JobState, JobTable, SharedJobTable}; pub use state::{BuiltinSideEffect, ControlFlow, ExecResult}; use time_command::{ TimeUsage, render_time_format, sanitize_time_path, validate_time_format, verbose_time_report, @@ -180,6 +180,27 @@ impl ShellFeatures { /// Predicate selecting which default builtins a shell registers. pub(crate) type BuiltinFilter = Arc bool + Send + Sync>; +/// Best-effort source text of a command, for `jobs` and `ps`. +fn describe_command(cmd: &Command) -> String { + match cmd { + Command::Simple(c) => std::iter::once(&c.name) + .chain(c.args.iter()) + .map(|w| w.to_string()) + .filter(|w| !w.is_empty()) + .collect::>() + .join(" "), + Command::Pipeline(p) => p + .commands + .iter() + .map(describe_command) + .collect::>() + .join(" | "), + Command::List(l) => describe_command(&l.first), + Command::Compound(..) => "(...)".to_string(), + Command::Function(f) => format!("{} ()", f.name), + } +} + fn word_literal_text(word: &Word) -> Option<&str> { if word.parts.len() == 1 && let WordPart::Literal(s) = &word.parts[0] @@ -366,6 +387,7 @@ const ENV_SHELL_ONLY_BUILTINS: &[&str] = &[ "continue", "declare", "dirs", + "disown", "enable", "eval", "exec", @@ -1233,13 +1255,13 @@ pub struct Interpreter { current_line: usize, /// HTTP client for network builtins (curl, wget) #[cfg(feature = "http_client")] - http_client: Option, + http_client: Option>, /// Git client for git builtins #[cfg(feature = "git")] git_client: Option, /// SSH client for ssh/scp/sftp builtins #[cfg(feature = "ssh")] - ssh_client: Option, + ssh_client: Option>, /// Stdin inherited from pipeline for compound commands (while read, etc.) /// Each read operation consumes one line, advancing through the data. pipeline_stdin: Option, @@ -1315,7 +1337,7 @@ pub struct Interpreter { /// command boundary with `Error::Cancelled`. cancelled: Arc, /// Interceptor hooks registry (shared with Bash callers). - hooks: crate::hooks::Hooks, + hooks: Arc, /// True while executing a trap handler. Suppresses recursive DEBUG trap /// invocation to prevent amplification attacks (TM-DOS-035). in_trap: bool, @@ -1340,6 +1362,11 @@ pub struct Interpreter { shell_features: ShellFeatures, /// Hardened profiles intentionally reduce elapsed-time precision. hardened_timing: bool, + /// `&` jobs run concurrently (default) or to completion at spawn. + concurrent_jobs: bool, + /// Nesting depth of `execute_script_body`; finished background job output + /// is delivered only between top-level (depth 1) commands. + script_depth: usize, } struct ArithmeticExpansionState { @@ -1546,6 +1573,11 @@ impl Interpreter { "sleep" => Sleep, "kill" => Kill, "wait" => Wait, + "jobs" => Jobs, + "disown" => Disown, + "bg" => Bg, + "fg" => Fg, + "ps" => Ps, "timeout" => Timeout, // Navigation "pushd" => Pushd, @@ -1661,6 +1693,8 @@ impl Interpreter { "find".to_string(), Arc::new(builtins::Find::new(clock, &username_val)), ); + builtins.insert("pgrep".to_string(), Arc::new(builtins::Pgrep::new())); + builtins.insert("pkill".to_string(), Arc::new(builtins::Pgrep::pkill())); builtins.insert( "id".to_string(), Arc::new(builtins::Id::with_username(&username_val)), @@ -1779,7 +1813,7 @@ impl Interpreter { pending_fd_targets: Vec::new(), pending_fd_capture_depth: 0, cancelled: Arc::new(AtomicBool::new(false)), - hooks: crate::hooks::Hooks::default(), + hooks: Arc::new(crate::hooks::Hooks::default()), in_trap: false, condition_sequence_depth: 0, deferred_proc_subs: Vec::new(), @@ -1787,6 +1821,8 @@ impl Interpreter { random_state: AtomicU32::new(random_seed), shell_features, hardened_timing, + concurrent_jobs: true, + script_depth: 0, } } @@ -1850,9 +1886,91 @@ impl Interpreter { } } + /// Run `&` jobs concurrently (default) or to completion when spawned. + pub(crate) fn set_concurrent_jobs(&mut self, enabled: bool) { + self.concurrent_jobs = enabled; + } + + /// Fork the shell for a background job: a subshell's view of the state + /// (variables, functions, cwd, fds, options) with shared filesystem, + /// builtins, hooks, budget and cancellation. The job gets its own job + /// table, output accumulators and history. + fn fork_for_job(&self) -> Interpreter { + let random_seed = self + .random_state + .load(Ordering::Relaxed) + .wrapping_mul(1_103_515_245) + .wrapping_add(12_345); + Interpreter { + fs: Arc::clone(&self.fs), + env: self.env.clone(), + scoped: self.scoped.clone(), + flags: self.flags, + cwd: self.cwd.clone(), + last_exit_code: self.last_exit_code, + builtins: self.builtins.clone(), + host_builtins: self.host_builtins.clone(), + command_resolver: self.command_resolver.clone(), + call_stack: self.call_stack.clone(), + bash_source_stack: self.bash_source_stack.clone(), + limits: self.limits.clone(), + session_limits: self.session_limits.clone(), + memory_limits: self.memory_limits.clone(), + memory_budget: self.memory_budget.clone(), + memory_limit_error: None, + trace: crate::trace::TraceCollector::new(self.trace.mode()), + counters: self.counters.clone(), + execution_budget: self.execution_budget.clone(), + jobs: self.jobs.fork(), + current_line: self.current_line, + #[cfg(feature = "http_client")] + http_client: self.http_client.clone(), + #[cfg(feature = "git")] + git_client: self.git_client.clone(), + #[cfg(feature = "ssh")] + ssh_client: self.ssh_client.clone(), + pipeline_stdin: None, + getopts_char_idx: self.getopts_char_idx, + last_bg_pid: self.last_bg_pid.clone(), + output_callback: None, + execution_extensions: Arc::clone(&self.execution_extensions), + output_emit_count: 0, + output_stream_stdout_bytes: 0, + output_stream_stderr_bytes: 0, + nounset_error: None, + pipestatus: Vec::new(), + expanding_aliases: HashSet::new(), + regex_cache: RuntimeRegexCache::default(), + history: Vec::new(), + history_bytes: 0, + history_saved_entries: 0, + history_needs_rewrite: false, + history_file: None, + history_loaded: true, + subst_generation: self.subst_generation, + coproc_buffers: HashMap::new(), + coproc_next_fd: self.coproc_next_fd, + exec_fd_table: self.exec_fd_table.clone(), + pending_fd_output: HashMap::new(), + pending_fd_targets: Vec::new(), + pending_fd_capture_depth: 0, + cancelled: Arc::clone(&self.cancelled), + hooks: Arc::clone(&self.hooks), + in_trap: false, + condition_sequence_depth: 0, + deferred_proc_subs: Vec::new(), + proc_sub_paths: HashSet::new(), + random_state: AtomicU32::new(random_seed), + shell_features: self.shell_features, + hardened_timing: self.hardened_timing, + concurrent_jobs: self.concurrent_jobs, + script_depth: 1, + } + } + /// Replace the hooks registry (called from BashBuilder::build). pub(crate) fn set_hooks(&mut self, hooks: crate::hooks::Hooks) { - self.hooks = hooks; + self.hooks = Arc::new(hooks); } // === CoW accessors === @@ -2717,13 +2835,13 @@ impl Interpreter { /// This is only available when the `http_client` feature is enabled. #[cfg(feature = "http_client")] pub fn set_http_client(&mut self, client: crate::network::HttpClient) { - self.http_client = Some(client); + self.http_client = Some(Arc::new(client)); } /// Get a mutable reference to the HTTP client (for setting hooks after build). #[cfg(feature = "http_client")] pub(crate) fn http_client_mut(&mut self) -> Option<&mut crate::network::HttpClient> { - self.http_client.as_mut() + self.http_client.as_mut().and_then(Arc::get_mut) } /// Set the git client for git builtins. @@ -2739,7 +2857,7 @@ impl Interpreter { /// This is only available when the `ssh` feature is enabled. #[cfg(feature = "ssh")] pub fn set_ssh_client(&mut self, client: crate::builtins::ssh::SshClient) { - self.ssh_client = Some(client); + self.ssh_client = Some(Arc::new(client)); } /// Execute a script. @@ -2750,10 +2868,19 @@ impl Interpreter { // not represent host-level exec() invocations. let result = { - let result = self.execute_script_body(script, true, true).await; - // Script boundary cleanup: background jobs are scoped to a single exec() - // call, so they cannot accumulate across long-lived sessions. - let _ = self.jobs.lock().await.wait_all_results().await; + let jobs = Arc::clone(&self.jobs); + let mut result = + jobs::with_jobs(&jobs, self.execute_script_body(script, true, true)).await; + // Script boundary: background jobs are scoped to a single exec() + // call. Like a pipe reader waiting for EOF, the call waits for + // every job and delivers output not yet reported. + jobs.finish_all().await; + let (out, err) = jobs.lock().take_finished_output(); + if let Ok(r) = &mut result { + r.stdout.append(&out); + r.stderr.append(&err); + } + jobs.lock().clear(); if let Some(error) = &self.memory_limit_error { Err(crate::error::Error::ResourceLimit(error.clone())) } else { @@ -2790,6 +2917,20 @@ impl Interpreter { script: &Script, run_exit_trap: bool, fire_exit_hook: bool, + ) -> Result { + self.script_depth += 1; + let result = self + .execute_script_body_inner(script, run_exit_trap, fire_exit_hook) + .await; + self.script_depth -= 1; + result + } + + async fn execute_script_body_inner( + &mut self, + script: &Script, + run_exit_trap: bool, + fire_exit_hook: bool, ) -> Result { let mut stdout = crate::StreamData::new(); let mut stderr = crate::StreamData::new(); @@ -2800,10 +2941,18 @@ impl Interpreter { let max_stderr = self.limits.max_stderr_bytes; let mut stopped = false; + let top_level = self.script_depth == 1; for command in &script.commands { self.check_cancelled()?; let emit_before = self.output_emit_count; - let result = self.execute_command(command).await?; + let mut result = self.execute_command(command).await?; + if top_level { + // Background jobs that finished meanwhile report here, as + // bash prints their output while the script continues. + let (out, err) = self.take_finished_job_output(); + result.stdout.append(&out); + result.stderr.append(&err); + } self.check_cancelled()?; self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); @@ -4407,10 +4556,7 @@ impl Interpreter { self.arrays_mut().insert(name.clone(), arr); // Set NAME_PID to a virtual PID (use job table counter) - let virtual_pid = { - let table = self.jobs.lock().await; - table.last_job_id().unwrap_or(0) + 1000 - }; + let virtual_pid = self.jobs.lock().alloc_pid(); self.vars_mut() .insert(format!("{}_PID", name), virtual_pid.to_string()); @@ -5270,34 +5416,77 @@ impl Interpreter { } } - /// Run a command as a "background" job. + /// Run a command as a background job (`cmd &`). /// - /// Executes the command synchronously (deterministic in virtual env) but - /// stores the result in the job table so `wait` and `$!` work correctly. - /// The command's stdout is emitted immediately (like real bash terminal output). + /// With concurrent jobs (the default) the command runs on a forked shell + /// as a job future driven alongside the foreground (see `jobs.rs`). It is + /// polled once right away, so a job that never blocks finishes here and + /// its output is emitted in order, as before. Sequential mode runs the + /// command to completion here. async fn spawn_in_background( &mut self, cmd: &Command, parent_stdout: &mut crate::StreamData, parent_stderr: &mut crate::StreamData, ) -> Result<()> { - // Execute the command synchronously + let text = describe_command(cmd); let emit_before = self.output_emit_count; - let result = self.execute_command(cmd).await?; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); - - // Emit output immediately (background output goes to terminal in real bash) - parent_stdout.append(&result.stdout); - parent_stderr.append(&result.stderr); - - // Store only the exit code in the job table (output already emitted). - // The command already ran to completion synchronously, so the result is - // final — register it directly rather than round-tripping through - // tokio::spawn (which also panics on wasm, where no reactor runs). - let exit_code = result.exit_code; - let job_result = ExecResult::with_code(String::new(), exit_code); - let job_id = self.jobs.lock().await.spawn(job_result); - self.last_bg_pid = Some(job_id.to_string()); + let finished = if self.concurrent_jobs { + // THREAT[TM-DOS-122]: cap live jobs. + // The cap is session-wide (nested jobs share it) and a job is a + // subshell for nesting depth, so `f(){ f & f & }; f` stays bounded. + let slot = self.jobs.try_claim(self.limits.max_background_jobs); + let mut child = self.fork_for_job(); + let depth_ok = child.counters.push_subshell(&child.limits).is_ok(); + let Some(slot) = slot.filter(|_| depth_ok) else { + let msg = format!( + "bash: fork: retry: Resource temporarily unavailable (max {} background jobs, {} nested)\n", + self.limits.max_background_jobs, self.limits.max_subshell_depth + ); + parent_stderr.append(&crate::StreamData::from(msg)); + self.last_exit_code = 1; + return Ok(()); + }; + let cmd = cmd.clone(); + let mut fut: jobs::JobFuture = Box::pin(async move { + let _slot = slot; + let jobs = Arc::clone(&child.jobs); + let result = jobs::with_jobs(&jobs, child.execute_command(&cmd)).await; + jobs.finish_all().await; + let (out, err) = jobs.lock().take_finished_output(); + match result { + Ok(mut r) => { + r.stdout.append(&out); + r.stderr.append(&err); + if let ControlFlow::Exit(code) = r.control_flow { + r.exit_code = code; + } + r.control_flow = ControlFlow::None; + r + } + Err(e) => ExecResult::err(format!("bash: {e}\n"), 1), + } + }); + match std::future::poll_fn(|cx| std::task::Poll::Ready(fut.as_mut().poll(cx))).await { + std::task::Poll::Ready(result) => Some(result), + std::task::Poll::Pending => { + let (_, pid) = self.jobs.lock().spawn_running(text.clone(), fut); + self.last_bg_pid = Some(pid.to_string()); + None + } + } + } else { + Some(self.execute_command(cmd).await?) + }; + + if let Some(result) = finished { + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + // Emit output immediately (background output goes to terminal in real bash) + parent_stdout.append(&result.stdout); + parent_stderr.append(&result.stderr); + let (_, pid) = self.jobs.lock().spawn_finished(text, result); + self.last_bg_pid = Some(pid.to_string()); + } // Background commands always return exit code 0 to the parent. // The real exit code lives in the job table for `wait` to read. @@ -5305,6 +5494,11 @@ impl Interpreter { Ok(()) } + /// Output of background jobs that finished since the last report. + fn take_finished_job_output(&self) -> (crate::StreamData, crate::StreamData) { + self.jobs.lock().take_finished_output() + } + /// Execute a command list (cmd1 && cmd2 || cmd3) #[allow(unused_assignments)] // control_flow may be set but overwritten async fn execute_list(&mut self, list: &CommandList) -> Result { @@ -6497,11 +6691,11 @@ impl Interpreter { fs, stdin, #[cfg(feature = "http_client")] - http_client: self.http_client.as_ref(), + http_client: self.http_client.as_deref(), #[cfg(feature = "git")] git_client: self.git_client.as_ref(), #[cfg(feature = "ssh")] - ssh_client: self.ssh_client.as_ref(), + ssh_client: self.ssh_client.as_deref(), shell: Some(shell_ref), }; @@ -6562,11 +6756,11 @@ impl Interpreter { fs, stdin, #[cfg(feature = "http_client")] - http_client: self.http_client.as_ref(), + http_client: self.http_client.as_deref(), #[cfg(feature = "git")] git_client: self.git_client.as_ref(), #[cfg(feature = "ssh")] - ssh_client: self.ssh_client.as_ref(), + ssh_client: self.ssh_client.as_deref(), shell: Some(shell_ref), }; diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index ee4e87ec3..b856d3994 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1931,6 +1931,8 @@ pub struct BashBuilder { no_rootfs: bool, /// When true, symlinks are stored but not followed (pre-0.19 behavior). no_follow_symlinks: bool, + /// When true, `&` jobs run to completion when spawned. + sequential_jobs: bool, /// Interceptor hooks hooks_on_exit: Vec>, hooks_before_exec: Vec>, @@ -3327,6 +3329,35 @@ impl BashBuilder { self } + /// Run background jobs (`cmd &`) concurrently (on by default). + /// + /// Jobs run on a forked shell and are polled together with the + /// foreground script on the same task (no threads), so `sleep 1 & sleep 1 + /// & wait` takes one second and `kill $!` stops a running job. `exec()` + /// returns once every job finished. With `false`, each job runs to + /// completion when it is started, which keeps output order fully + /// deterministic (eval replay, snapshot tests). See + /// `knowledge/foundations/parallel-execution.md` ("Background jobs"). + /// + /// ```rust + /// # use bashkit::Bash; + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = Bash::builder().build(); + /// let r = bash.exec("sleep 5 & kill $!; wait $!; echo $?").await?; + /// assert_eq!(r.stdout, "143\n"); + /// + /// let mut seq = Bash::builder().concurrent_jobs(false).build(); + /// let r = seq.exec("(sleep 0.01; echo a) & echo b; wait").await?; + /// assert_eq!(r.stdout, "a\nb\n"); + /// # Ok(()) + /// # } + /// ``` + pub fn concurrent_jobs(mut self, enabled: bool) -> Self { + self.sequential_jobs = !enabled; + self + } + /// Build the Bash instance. /// /// If mounted files are specified, they are added via an [`OverlayFs`] layer @@ -3488,6 +3519,10 @@ impl BashBuilder { root.set_commands(result.interpreter.rootfs_command_names()); } + if self.sequential_jobs { + result.interpreter.set_concurrent_jobs(false); + } + // Set hooks after build — avoids adding another arg to build_with_fs. let hooks = hooks::Hooks { on_exit: self.hooks_on_exit, diff --git a/crates/bashkit/src/limits.rs b/crates/bashkit/src/limits.rs index a7d8aed3e..0bdfcb75c 100644 --- a/crates/bashkit/src/limits.rs +++ b/crates/bashkit/src/limits.rs @@ -151,6 +151,12 @@ pub struct ExecutionLimits { /// Default: 10MB (10,000,000 bytes) pub max_word_split_bytes: usize, + // THREAT[TM-DOS-122]: each `&` job holds a forked shell state and a + // pending future until it finishes or is killed. + /// Maximum background jobs running at once; past it `&` fails. + /// Default: 64 + pub max_background_jobs: usize, + /// Whether to capture the final environment state in ExecResult. /// Default: false (opt-in to avoid cloning cost when not needed) pub capture_final_env: bool, @@ -181,6 +187,7 @@ impl Default for ExecutionLimits { max_history_output_bytes: 1_048_576, // 1MB max_word_split_fields: 100_000, max_word_split_bytes: 10_000_000, + max_background_jobs: 64, capture_final_env: false, } } @@ -231,6 +238,12 @@ impl ExecutionLimits { } } + /// Set the maximum number of background jobs running at once. + pub fn max_background_jobs(mut self, count: usize) -> Self { + self.max_background_jobs = count; + self + } + /// Set maximum command count. /// A value of 0 is a valid strict limit that disables command execution. pub fn max_commands(mut self, count: usize) -> Self { diff --git a/crates/bashkit/src/profile.rs b/crates/bashkit/src/profile.rs index ee0489f81..08696fac9 100644 --- a/crates/bashkit/src/profile.rs +++ b/crates/bashkit/src/profile.rs @@ -157,6 +157,7 @@ impl ExecutionProfile { max_history_output_bytes: 262_144, max_word_split_fields: 10_000, max_word_split_bytes: 1_000_000, + max_background_jobs: 16, capture_final_env: false, }; let session = SessionLimits::new() diff --git a/crates/bashkit/tests/integration/background_exec_tests.rs b/crates/bashkit/tests/integration/background_exec_tests.rs index 01f55deb4..ba9c59436 100644 --- a/crates/bashkit/tests/integration/background_exec_tests.rs +++ b/crates/bashkit/tests/integration/background_exec_tests.rs @@ -87,9 +87,9 @@ cat /tmp/wait_pid.txt #[tokio::test] async fn background_exit_code_via_wait() { let mut bash = Bash::new(); - let result = bash.exec("false &\nwait\necho $?").await.unwrap(); - // wait should return the exit code of the background job - assert!(result.stdout.contains("1")); + let result = bash.exec("false &\nwait $!\necho $?").await.unwrap(); + // `wait $pid` returns the job's status; bare `wait` returns 0 (bash). + assert_eq!(result.stdout.trim(), "1"); } /// cmd1 & cmd2 — cmd2 runs in foreground while cmd1 is backgrounded @@ -113,3 +113,54 @@ async fn background_jobs_do_not_leak_across_exec_calls() { assert_eq!(result.exit_code, 0); assert_eq!(result.stdout.trim(), "0"); } + +/// TM-DOS-122: live background jobs are capped by `max_background_jobs`. +#[tokio::test] +async fn background_job_limit() { + let limits = bashkit::ExecutionLimits::new().max_background_jobs(2); + let mut bash = Bash::builder().limits(limits).build(); + let result = bash + .exec("sleep 5 & sleep 5 & sleep 5 & echo rc=$?; kill %1 %2; wait") + .await + .unwrap(); + assert!(result.stdout.contains("rc=1"), "stdout: {}", result.stdout); + assert!( + result.stderr.contains("Resource temporarily unavailable"), + "stderr: {}", + result.stderr + ); +} + +/// TM-DOS-122: a fork bomb terminates. Nested `&` shares the session-wide +/// job cap and counts as subshell nesting (TM-DOS-092). Runs on a big-stack +/// thread: unoptimized builds use far more stack per nesting level. +#[test] +fn background_fork_bomb_is_bounded() { + let handle = std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let limits = bashkit::ExecutionLimits::new() + .max_background_jobs(8) + .max_subshell_depth(4); + let mut bash = Bash::builder().limits(limits).build(); + let run = bash.exec("f() { f & f & }; f; wait; echo survived"); + tokio::time::timeout(std::time::Duration::from_secs(30), run) + .await + .expect("fork bomb must terminate") + .unwrap() + }) + }) + .unwrap(); + let result = handle.join().unwrap(); + assert!( + result.stdout.contains("survived"), + "stdout: {}", + result.stdout + ); + assert!(result.stderr.contains("Resource temporarily unavailable")); +} diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index f4118f63f..60af06b12 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -8,19 +8,20 @@ use bashkit::Bash; -/// L-PROC-002: no job control — `jobs`/`fg`/`bg` are not commands. +/// L-PROC-002: no stop/continue job control — background jobs run +/// concurrently, but `kill -STOP` cannot suspend one and `suspend` does +/// not exist. #[tokio::test] async fn l_proc_002_no_job_control() { let mut bash = Bash::new(); - for cmd in ["jobs", "fg", "bg"] { - let result = bash.exec(cmd).await.unwrap(); - assert_eq!(result.exit_code, 127, "{cmd} must be unknown"); - assert!( - result.stderr.contains("command not found"), - "{cmd}: {}", - result.stderr - ); - } + let result = bash + .exec("sleep 0.2 & kill -STOP %1; wait %1; echo rc=$?; suspend") + .await + .unwrap(); + // The job was not stopped: it ran to completion. + assert!(result.stdout.contains("rc=0"), "stdout: {}", result.stdout); + assert_eq!(result.exit_code, 127); + assert!(result.stderr.contains("suspend: command not found")); } /// L-PROC-003: no process spawning — names outside the builtin registry, diff --git a/crates/bashkit/tests/spec_cases/bash/jobs.test.sh b/crates/bashkit/tests/spec_cases/bash/jobs.test.sh new file mode 100644 index 000000000..9d7fa0b4c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/jobs.test.sh @@ -0,0 +1,146 @@ +### jobs_run_concurrently +# two one-second sleeps in the background finish in about one second +start=$(date +%s%N) +sleep 1 & sleep 1 & wait +end=$(date +%s%N) +(( (end - start) / 1000000 < 1900 )) && echo concurrent +### expect +concurrent +### end + +### jobs_bg_output_after_foreground +# a blocking job reports after the foreground output that ran first +(sleep 0.2; echo bg) & +echo fg +wait +### expect +fg +bg +### end + +### jobs_wait_returns_job_status +(sleep 0.1; exit 3) & +pid=$! +wait $pid +echo "code=$?" +### expect +code=3 +### end + +### jobs_wait_unknown_pid +wait 99999 +echo "code=$?" +### expect +code=127 +### end + +### jobs_wait_n_picks_first_finished +sleep 0.4 & +(sleep 0.1; exit 7) & +wait -n +echo "first=$?" +wait -n +echo "second=$?" +wait -n +echo "none=$?" +### expect +first=7 +second=0 +none=127 +### end + +### jobs_kill_running_job +sleep 10 & +kill $! +wait $! +echo "code=$?" +### expect +code=143 +### end + +### jobs_kill_signal_forms +sleep 10 & +kill -9 %1 +wait %1 2>/dev/null +echo "k9=$?" +sleep 10 & +kill -s INT $! +wait $! +echo "int=$?" +kill -l 143 +### expect +k9=137 +int=130 +TERM +### end + +### jobs_kill_unknown_pid +kill 99999 +echo "code=$?" +### expect +code=1 +### end + +### jobs_list_running +sleep 2 & sleep 3 & +true & +sleep 0.1 +jobs +jobs -p | wc -l +kill %1 %2 +wait +### expect +[1]- Running sleep 2 & +[2]+ Running sleep 3 & +2 +### end + +### jobs_numbers_reused_after_finish +sleep 0.1 & +wait +sleep 2 & +jobs +kill %1 +### expect +[1]+ Running sleep 2 & +### end + +### jobs_variables_are_forked +x=1 +(x=2; echo "in=$x") & +wait +echo "out=$x" +### expect +in=2 +out=1 +### end + +### jobs_pgrep_pkill +### bash_diff: real pgrep also sees host processes +sleep 10 & +sleep 10 & +pgrep -c sleep +pkill sleep +wait +pgrep sleep || echo none +### expect +2 +none +### end + +### trailing_ampersand_in_compound +# The last `cmd &` before `}`, `done`, `fi` or `)` still runs in background. +f() { sleep 0.3 & } +start=$(date +%s%N) +f +{ sleep 0.3 & } +for i in 1; do sleep 0.3 & done +if true; then sleep 0.3 & fi +end=$(date +%s%N) +(( (end - start) / 1000000 < 250 )) && echo async +jobs | wc -l +wait +### expect +async +4 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/wait.test.sh b/crates/bashkit/tests/spec_cases/bash/wait.test.sh index 011ba160f..7b31fbb72 100644 --- a/crates/bashkit/tests/spec_cases/bash/wait.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/wait.test.sh @@ -1,5 +1,4 @@ ### wait_basic -### bash_diff: VFS runs background jobs synchronously # wait returns success wait echo $? @@ -8,7 +7,6 @@ echo $? ### end ### wait_with_pid -### bash_diff: VFS runs background jobs synchronously # wait with a PID argument echo "hello" & wait $! @@ -27,7 +25,6 @@ set ### end ### bang_var_isolated_across_subshell -### bash_diff: VFS runs background jobs synchronously # A $(...) subshell starting its own background job must not change the # parent's $! (last background PID is per-shell state, like real bash). true & @@ -39,7 +36,6 @@ isolated ### end ### bg_internal_names_are_ordinary_vars -### bash_diff: VFS runs background jobs synchronously # _LAST_BG_PID / _BG_EXIT_CODE are no longer interpreter channels in the # variable namespace; backgrounding tracks $! in typed state and leaves these # user variables untouched. diff --git a/knowledge/foundations/parallel-execution.md b/knowledge/foundations/parallel-execution.md index f37d6e8c8..ac5f7abd3 100644 --- a/knowledge/foundations/parallel-execution.md +++ b/knowledge/foundations/parallel-execution.md @@ -20,6 +20,32 @@ Implemented - Filesystem: thread-safe via `Arc` + `RwLock` - `Arc::clone(&fs)` shares one filesystem across instances; instances run in parallel sharing it +## Background Jobs + +`cmd &` runs concurrently inside one `exec()`, on by default +(`BashBuilder::concurrent_jobs(false)` restores sequential execution). + +- A job is a forked interpreter (`Interpreter::fork_for_job`): copied + variables, functions, cwd and options; shared filesystem, execution budget, + cancel token, hooks and extensions. Changes inside the job never reach the + parent, like a forked subshell. +- Jobs are futures in a `FuturesUnordered` owned by `JobControl` + (`interpreter/jobs.rs`), polled by `with_jobs` alongside the foreground + future on the same task. No `tokio::spawn`: works on wasm and inside the + in-process `Terminal`, and needs no `Send + 'static` runtime handle. +- At spawn the job is polled once. A job that never blocks finishes right + there, so `echo a & echo b` keeps bash's usual order. +- Output of a job that finishes later is captured and delivered at `wait`, at + each top-level command boundary, and at the end of `exec()`. Streams are + not interleaved byte by byte. +- `exec()` waits for every job (`finish_all`) before returning; nothing + outlives the call. Within an interactive session this means a job started + at one prompt finishes before the next prompt. +- Jobs get virtual PIDs from 1001. `$$` is 1. `ps`, `pgrep`, `pkill`, `kill` + and `jobs` read the same table. `kill` aborts the job's future; its status + becomes 128+signal. +- Limit: `max_background_jobs` (TM-DOS-122). + ## Benchmark Run `cargo bench --bench parallel_execution` when changes touch: diff --git a/knowledge/log.md b/knowledge/log.md index f1e416270..47f69e732 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -7,6 +7,7 @@ ## 2026-10-06 +* **Feature**: [Background jobs](foundations/parallel-execution.md#background-jobs) run concurrently, on by default (`concurrent_jobs(false)` opts out). Jobs are forked interpreters polled on the caller's task, not `tokio::spawn`, so wasm and `Terminal` keep working; `exec()` waits for all of them. New `jobs`, `disown`, `fg`, `bg`, `ps`, `pgrep`, `pkill`; `wait -n`/`-p`/jobspecs; `kill` terminates jobs with 128+signal and ignores stop/continue. L-PROC-002 narrowed to "no stop/continue". Job flooding capped by `max_background_jobs` (TM-DOS-122). * **Security**: Following symlinks in the VFS exposed a gap in the CPython WASI host: `path_filestat_get` ignored its lookup flags and called `stat`, so once `stat` followed links `os.path.islink` returned False and `os.unlink` on a link to a directory failed `EISDIR`. It now honors `SYMLINK_FOLLOW` (lstat when unset) and unlink/rmdir check the link itself. Resolution stays inside the VFS, so a link aimed at a host path finds nothing (TM-PY-CPY-001, `symlinks_resolve_inside_the_vfs_only`). See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). * **Decision**: [VFS](foundations/vfs.md) follows symlinks like Linux, on by default (`FollowFs`, outermost layer; `follow_symlinks(false)` opts out). Targets are VFS paths only and lookups stop after 40 links, so TM-ESC-002 and TM-DOS-011 now rest on namespace confinement and the hop cap instead of never following. New `FileSystem::lstat`; builtins that describe a link use it. The JS bindings follow Node: `stat` follows links and the new `lstat` (on `Bash`, `BashTool` and `fs()`) reports the link itself. * **Feature**: Default [root filesystem](foundations/vfs.md) (`RootFs`): synthetic read-only `/etc`, static `/proc`, a `/bin` + `/usr/bin` stub per builtin, `/root`, `/dev/zero`, under the session fs (session files win). `/etc/passwd` has no root line so `root:x:0:0` always means a host leak. `rootfs(false)` opts out; the logic-only `ScriptedTool` shell does. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index f7d9c2450..7cfd061ea 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -43,7 +43,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | ID | Limitation | Why | Evidence | |----|------------|-----|----------| | L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution (fd redirects work) | True process replace would break sandbox containment | TM-ESC-005 | -| L-PROC-002 | No job control (`bg`, `fg`, `jobs`) | Requires process state; interactive-only feature | `l_proc_002_no_job_control` | +| L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` | | L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | | L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` | | L-ROOTFS-001 | Default rootfs is static and read-only: `/proc` has no `self`, pid dirs, `uptime` or live counters; `/etc/passwd` has no root entry; `/dev/zero` yields 1 MiB per read; `/bin`, `/usr/bin` are stubs that dispatch builtins | Host state must not leak (TM-INF-003, TM-ISO-018); fixed values keep runs deterministic | `rootfs_layout`, `threat_etc_passwd_blocked` | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 177174bb2..f37fe0c3d 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -289,6 +289,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | | TM-DOS-120 | Interactive input wait vs. execution timeout | Excluding keyboard waits from `ExecutionLimits::timeout` (so a `vi` session is not killed while a person or agent thinks) must not let busy work escape the deadline | Only time blocked in the terminal device read is excluded (`InputWaitClock`); the timer re-arms with the remaining *active* budget, so CPU work, `sleep`, and pending callbacks still time out. Only a `Terminal` installs the clock; plain `exec()` is unchanged. Regressions: `terminal::tests::waiting_for_input_does_not_count_toward_timeout`, `terminal::tests::busy_work_still_times_out` | **MITIGATED** | | TM-DOS-121 | `find` traversal amplification | `find -L` over a symlink cycle (`ln -s .. d/up`) re-enters the same directories forever; `-exec ... \;` as a predicate or `{} +` batches could also queue unbounded command lines; the old `-name` glob matcher recursed with string clones per `*` (exponential on `*a*a*...b`) | `-L`/`-H` resolve each directory to its canonical path (realpath with a 40-hop ELOOP cap) and refuse to enter one already on the current path (`File system loop detected`, exit 1); every directory listing charges `consume_work`; find's own output stays capped by `FIND_MAX_OUTPUT_BYTES`; `{} +` batches flush every 4096 paths; `fnmatch` uses a single star restore point (O(value x pattern)). Regression tests: `find_follow_detects_loops` spec case, `ls::glob::tests::many_stars_are_linear` | **MITIGATED** | +| TM-DOS-122 | Background job flooding | `while :; do sleep 99 & done`, or a fork bomb `f(){ f & f & }; f`, keeps unbounded concurrent forked interpreters alive | `ExecutionLimits::max_background_jobs` (default 64, `hardened()` 16) caps live jobs; the next `&` prints bash's `fork: retry: Resource temporarily unavailable` and returns 1 without forking. Jobs are polled on the caller's task (no `tokio::spawn`), share the session's command/loop budget, timeout and cancel token, and `exec()` waits for or drops every job before returning, so nothing outlives the call. Regressions: `jobs::tests::*`, `jobs.test.sh` spec cases, `background_job_limit` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** | **TM-DOS-051** is historical. The custom parser was deleted with the `yaml` diff --git a/knowledge/status/builtins.json b/knowledge/status/builtins.json index 971d20ee0..0ddd5c5e7 100644 --- a/knowledge/status/builtins.json +++ b/knowledge/status/builtins.json @@ -53,6 +53,10 @@ "feature": null, "name": "bc" }, + { + "feature": null, + "name": "bg" + }, { "feature": null, "name": "break" @@ -177,6 +181,10 @@ "feature": null, "name": "dirs" }, + { + "feature": null, + "name": "disown" + }, { "feature": null, "name": "dotenv" @@ -237,6 +245,10 @@ "feature": null, "name": "fc" }, + { + "feature": null, + "name": "fg" + }, { "feature": null, "name": "file" @@ -313,6 +325,10 @@ "feature": null, "name": "install" }, + { + "feature": null, + "name": "jobs" + }, { "feature": null, "name": "join" @@ -417,6 +433,14 @@ "feature": null, "name": "patch" }, + { + "feature": null, + "name": "pgrep" + }, + { + "feature": null, + "name": "pkill" + }, { "feature": null, "name": "popd" @@ -429,6 +453,10 @@ "feature": null, "name": "printf" }, + { + "feature": null, + "name": "ps" + }, { "feature": null, "name": "pushd" @@ -750,5 +778,5 @@ "name": "zip" } ], - "count": 187 + "count": 194 } From cea9f3abe6b57d7672ed9dc693edb638c187ab4a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 02:57:34 +0000 Subject: [PATCH 3/4] test(js): fork bomb may exit 0 once backgrounded; require a limit hit Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit-js/__test__/security.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/bashkit-js/__test__/security.spec.ts b/crates/bashkit-js/__test__/security.spec.ts index 45fb841d0..ea37722da 100644 --- a/crates/bashkit-js/__test__/security.spec.ts +++ b/crates/bashkit-js/__test__/security.spec.ts @@ -106,9 +106,13 @@ test("WB: recursive function depth limited (TM-DOS-020)", (t) => { test("WB: fork bomb pattern blocked (TM-DOS-021)", (t) => { const bash = new Bash({ maxCommands: 100 }); const r = bash.executeSync(":(){ :|:& };:"); - // Should fail — no real process forking and command limit + // Background jobs run concurrently, so like real bash the top-level `:` + // returns 0 once it has backgrounded the bomb. The bomb itself must hit a + // limit (job slots, function depth or command budget) and terminate. t.true( - r.exitCode !== 0 || r.error !== undefined, + r.exitCode !== 0 || + r.error !== undefined || + /limit|fork: retry/.test(r.stderr), "fork bomb pattern must not run indefinitely", ); }); From 0502405c14f7ecb74127f666e78ff869343cee50 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 03:27:58 +0000 Subject: [PATCH 4/4] perf(interpreter): keep nested $(...) stack use at main's level The job-aware script wrapper and the parser's empty background command added ~60 KB of debug stack to 50-deep command substitution, enough to overflow under coverage instrumentation. Box the inner script future and the background spawn, and build the empty command out of line. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/interpreter/mod.rs | 14 +++++++------- crates/bashkit/src/parser/mod.rs | 28 +++++++++++++++++---------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 1521cecc7..77a284ed0 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -2919,9 +2919,9 @@ impl Interpreter { fire_exit_hook: bool, ) -> Result { self.script_depth += 1; - let result = self - .execute_script_body_inner(script, run_exit_trap, fire_exit_hook) - .await; + // Boxed so this wrapper adds no stack per nested `$(...)` level. + let result = + Box::pin(self.execute_script_body_inner(script, run_exit_trap, fire_exit_hook)).await; self.script_depth -= 1; result } @@ -5513,8 +5513,9 @@ impl Interpreter { let first_is_bg = matches!(list.rest.first(), Some((ListOperator::Background, _))); if first_is_bg { - self.spawn_in_background(&list.first, &mut stdout, &mut stderr) - .await?; + // Boxed: the job path holds a forked Interpreter across an await, + // which would otherwise bloat every recursive execute_list frame. + Box::pin(self.spawn_in_background(&list.first, &mut stdout, &mut stderr)).await?; exit_code = 0; control_flow = ControlFlow::None; exit_code_from_conditional_context = false; @@ -5602,8 +5603,7 @@ impl Interpreter { if should_execute { if should_background { - self.spawn_in_background(cmd, &mut stdout, &mut stderr) - .await?; + Box::pin(self.spawn_in_background(cmd, &mut stdout, &mut stderr)).await?; exit_code = 0; exit_code_from_conditional_context = false; } else { diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 42cfe82d6..2a1f2e092 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -457,16 +457,7 @@ impl<'a> Parser<'a> { // `{ cmd & }`, `do cmd & done`: the `&` before a closing // keyword still backgrounds `cmd`. if matches!(op, ListOperator::Background) { - rest.push(( - ListOperator::Background, - Command::Simple(SimpleCommand { - name: Word::literal(""), - args: vec![], - redirects: vec![], - assignments: vec![], - span: self.current_span, - }), - )); + rest.push(empty_background(self.current_span)); } break; } @@ -3871,6 +3862,23 @@ impl<'a> Parser<'a> { } } +/// `cmd &` right before a closing keyword: an empty command carrying the `&`. +/// Out of line so the `Command` temporary does not enlarge the recursive +/// `parse_command_list` frame (nested `$(...)` parse depth, TM-DOS-044). +#[inline(never)] +fn empty_background(span: Span) -> (ListOperator, Command) { + ( + ListOperator::Background, + Command::Simple(SimpleCommand { + name: Word::literal(""), + args: vec![], + redirects: vec![], + assignments: vec![], + span, + }), + ) +} + #[cfg(test)] mod tests { use super::*;