diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index d88abdeb..d269791c 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1367,7 +1367,29 @@ impl Interpreter { } else { None }; + + // Suspend output callback while output redirects are active + // so that maybe_emit_output inside the compound body does not + // leak output that will be redirected (e.g. `{ cmd; } 2>/dev/null`). + let has_output_redirect = redirects.iter().any(|r| { + !matches!( + r.kind, + RedirectKind::Input | RedirectKind::HereDoc | RedirectKind::HereString + ) + }); + let saved_callback = if has_output_redirect { + self.output_callback.take() + } else { + None + }; + let result = self.execute_compound(compound).await?; + + // Restore callback before applying redirections + if let Some(cb) = saved_callback { + self.output_callback = Some(cb); + } + if let Some(prev) = prev_pipeline_stdin { self.pipeline_stdin = prev; } @@ -3593,7 +3615,8 @@ impl Interpreter { let err_msg = first.trim_start_matches("\x00ERR\x00").to_string(); self.last_exit_code = 1; self.restore_variables(var_saves); - return Ok(ExecResult::err(err_msg, 1)); + let result = ExecResult::err(err_msg, 1); + return self.apply_redirections(result, &command.redirects).await; } let xtrace_line = self.build_xtrace_line(&name, &args); @@ -10853,4 +10876,26 @@ echo "count=$COUNT" let result = run_script(r#"cat <( x=5; (( x > 3 )) && echo YES )"#).await; assert_eq!(result.stdout.trim(), "YES"); } + + #[tokio::test] + async fn test_stderr_redirect_devnull_simple_and_compound() { + // Issue #1116: 2>/dev/null must suppress stderr from builtins + let result = run_script("ls /nonexistent 2>/dev/null; echo exit:$?").await; + assert_eq!(result.stderr, "", "simple: stderr should be suppressed"); + assert_eq!(result.stdout.trim(), "exit:2"); + + // Compound command + let result = run_script("{ ls /nonexistent; } 2>/dev/null; echo exit:$?").await; + assert_eq!(result.stderr, "", "compound: stderr should be suppressed"); + assert_eq!(result.stdout.trim(), "exit:2"); + + // &>/dev/null + let result = run_script("ls /nonexistent &>/dev/null; echo exit:$?").await; + assert_eq!(result.stderr, "", "&>: stderr should be suppressed"); + assert_eq!(result.stdout.trim(), "exit:2"); + + // failglob + redirect + let result = run_script("shopt -s failglob; ls ./*.html 2>/dev/null; echo exit:$?").await; + assert_eq!(result.stderr, "", "failglob: stderr should be suppressed"); + } } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 27899aa2..b34b3f88 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -5410,4 +5410,33 @@ echo missing fi"#, // 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024 assert!(len <= 1024, "string length {len} must be ≤ 1024"); } + + /// Issue #1116: 2>/dev/null must suppress stderr in streaming mode + #[tokio::test] + async fn test_stderr_redirect_devnull_streaming() { + let stderr_chunks = Arc::new(Mutex::new(Vec::new())); + let stderr_cb = stderr_chunks.clone(); + let mut bash = Bash::new(); + + // Compound command — the main bug: callback fired before redirect applied + let result = bash + .exec_streaming( + "{ ls /nonexistent; } 2>/dev/null; echo exit:$?", + Box::new(move |_stdout, stderr| { + if !stderr.is_empty() { + stderr_cb.lock().unwrap().push(stderr.to_string()); + } + }), + ) + .await + .unwrap(); + + assert_eq!(result.stderr, "", "final stderr should be empty"); + let stderr_chunks = stderr_chunks.lock().unwrap(); + assert!( + stderr_chunks.is_empty(), + "no stderr should be streamed when 2>/dev/null is used, got: {:?}", + *stderr_chunks + ); + } } diff --git a/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh b/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh index 58438ec3..6260ac90 100644 --- a/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh @@ -123,6 +123,30 @@ echo exit: $? exit: 1 ### end +### redirect_stderr_suppress_ls +# Suppress stderr from ls with 2>/dev/null (issue #1116) +ls /nonexistent 2>/dev/null +echo exit: $? +### expect +exit: 2 +### end + +### redirect_stderr_suppress_compound +# Suppress stderr from compound command with 2>/dev/null (issue #1116) +{ ls /nonexistent; } 2>/dev/null +echo exit: $? +### expect +exit: 2 +### end + +### redirect_combined_suppress_ls +# Suppress both stdout and stderr with &>/dev/null (issue #1116) +ls /nonexistent &>/dev/null +echo exit: $? +### expect +exit: 2 +### end + ### redirect_stderr_to_file_content # Redirect stderr content to file and verify it sleep abc 2>/tmp/sleep_err.txt