feat(pi-shell): add execute_shell_streams with per-stream raw byte sinks

This commit is contained in:
can1357
2026-05-12 14:11:08 +02:00
parent 900a42e8d9
commit f81af06a2b
5 changed files with 379 additions and 1 deletions
Generated
+1
View File
@@ -2505,6 +2505,7 @@ dependencies = [
"brush-builtins",
"brush-core",
"brush-parser 0.3.0",
"bytes",
"clap",
"libc",
"os_pipe",
+1
View File
@@ -189,6 +189,7 @@ toml = "1.1"
# Error Handling
# ──────────────────────────────────────────────────────────────────────────────
anyhow = "1.0"
bytes = "1"
# ──────────────────────────────────────────────────────────────────────────────
# CLI & Configuration
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
bytes.workspace = true
brush-builtins.workspace = true
brush-core.workspace = true
brush-parser.workspace = true
+1 -1
View File
@@ -8,5 +8,5 @@ pub mod windows;
pub use brush_core::commands::{ChildSessionAction, child_session_action};
pub use shell::{
MinimizerResult, Shell, ShellExecuteOptions, ShellExecuteResult, ShellOptions, ShellRunOptions,
ShellRunResult, execute_shell,
ShellRunResult, StreamSinks, execute_shell, execute_shell_streams,
};
+375
View File
@@ -12,6 +12,7 @@ use std::{
};
use anyhow::{Error, Result};
use bytes::Bytes;
use brush_builtins::{BuiltinSet, default_builtins};
use brush_core::{
ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ProcessGroupPolicy,
@@ -196,6 +197,40 @@ pub async fn execute_shell(
run_shell_oneshot(config, run_config, on_chunk, cancel_token).await
}
/// Optional per-stream raw byte sinks for [`execute_shell_streams`].
///
/// When a sink is `Some`, that stream's pipe is drained directly into the
/// channel with no UTF-8 decoding and no merging. When `None`, the
/// corresponding pipe is still drained (to avoid blocking the child) but
/// its bytes are dropped.
#[derive(Default)]
pub struct StreamSinks {
pub stdout: Option<mpsc::UnboundedSender<Bytes>>,
pub stderr: Option<mpsc::UnboundedSender<Bytes>>,
}
/// One-shot execution that delivers stdout and stderr as raw byte chunks on
/// separate channels. The minimizer is intentionally disabled — its
/// `MinimizerResult.text` contract presumes a single merged transcript.
pub async fn execute_shell_streams(
options: ShellExecuteOptions,
streams: StreamSinks,
cancel_token: CancelToken,
) -> Result<ShellExecuteResult> {
let config = ShellConfig {
session_env: options.session_env,
snapshot_path: options.snapshot_path,
minimizer: None,
};
let run_config = ShellRunConfig {
command: options.command,
cwd: options.cwd,
env: options.env,
minimizer: None,
};
run_shell_oneshot_streams(config, run_config, streams, cancel_token).await
}
async fn run_shell_session(
session: Arc<TokioMutex<Option<ShellSessionCore>>>,
abort_state: ShellAbortState,
@@ -309,6 +344,51 @@ async fn run_shell_oneshot(
})
}
async fn run_shell_oneshot_streams(
config: ShellConfig,
run_config: ShellRunConfig,
streams: StreamSinks,
ct: CancelToken,
) -> Result<ShellExecuteResult> {
let tokio_cancel = CancellationToken::new();
let mut task = tokio::spawn({
let tokio_cancel = tokio_cancel.clone();
async move {
let mut session = create_session(&config).await?;
run_shell_command_streams(&mut session, &run_config, streams, tokio_cancel).await
}
});
let run_result = tokio::select! {
result = &mut task => result,
reason = ct.wait() => {
tokio_cancel.cancel();
let graceful = time::timeout(Duration::from_secs(2), &mut task).await;
if graceful.is_err() {
task.abort();
let _ = task.await;
}
return Ok(ShellExecuteResult {
exit_code: None,
cancelled: matches!(reason, AbortReason::Signal),
timed_out: matches!(reason, AbortReason::Timeout),
minimized: None,
});
},
};
let res = run_result
.unwrap_or_else(|err| Err(Error::msg(format!("Shell execution task failed: {err}"))));
let exec = res?;
Ok(ShellExecuteResult {
exit_code: Some(exit_code(&exec)),
cancelled: false,
timed_out: false,
minimized: None,
})
}
fn null_file() -> Result<OpenFile> {
openfiles::null().map_err(|err| Error::msg(format!("Failed to create null file: {err}")))
}
@@ -729,6 +809,259 @@ async fn run_shell_command(
Ok((result, minimized_out))
}
async fn run_shell_command_streams(
session: &mut ShellSessionCore,
options: &ShellRunConfig,
streams: StreamSinks,
cancel_token: CancellationToken,
) -> Result<ExecutionResult> {
if let Some(cwd) = options.cwd.as_deref() {
session
.shell
.set_working_dir(cwd)
.map_err(|err| Error::msg(format!("Failed to set cwd: {err}")))?;
}
let mut env_scope_pushed = false;
if let Some(env) = options.env.as_ref() {
session
.shell
.env_mut()
.push_scope(EnvironmentScope::Command);
env_scope_pushed = true;
for (key, value) in env {
let normalized_key = normalize_env_key(key);
if should_skip_env_var(normalized_key) {
continue;
}
let mut var = ShellVariable::new(ShellValue::String(value.clone()));
var.export();
if let Err(err) =
session
.shell
.env_mut()
.add(normalized_key, var, EnvironmentScope::Command)
{
let _ = session.shell.env_mut().pop_scope(EnvironmentScope::Command);
return Err(Error::msg(format!("Failed to set env: {err}")));
}
}
}
let (stdout_reader, stdout_writer) = pipe_to_files("stdout")?;
let (stderr_reader, stderr_writer) = pipe_to_files("stderr")?;
let stdout_file = OpenFile::from(stdout_writer);
let stderr_file = OpenFile::from(stderr_writer);
let mut params = session.shell.default_exec_params();
params.set_fd(OpenFiles::STDIN_FD, null_file()?);
params.set_fd(OpenFiles::STDOUT_FD, stdout_file);
params.set_fd(OpenFiles::STDERR_FD, stderr_file);
params.process_group_policy = ProcessGroupPolicy::NewProcessGroup;
params.set_cancel_token(cancel_token.clone());
let baseline_descendants = process::current_descendant_pids();
let reader_cancel = CancellationToken::new();
let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1);
let StreamSinks { stdout: stdout_sink, stderr: stderr_sink } = streams;
let mut stdout_handle = tokio::spawn(Box::pin(read_output_bytes(
stdout_reader,
stdout_sink,
reader_cancel.clone(),
activity_tx.clone(),
)));
let mut stderr_handle = tokio::spawn(Box::pin(read_output_bytes(
stderr_reader,
stderr_sink,
reader_cancel.clone(),
activity_tx,
)));
let cancel_bridge = tokio::spawn({
let cancel_token = cancel_token.clone();
let reader_cancel = reader_cancel.clone();
async move {
cancel_token.cancelled().await;
reader_cancel.cancel();
}
});
let process_cancel_bridge = tokio::spawn({
let cancel_token = cancel_token.clone();
let baseline_descendants = baseline_descendants.clone();
async move {
cancel_token.cancelled().await;
const WAVES: u32 = 3;
for wave in 0..WAVES {
let mut targets = process::TerminationTargets::new();
process::add_new_descendants(&mut targets, &baseline_descendants);
if targets.is_empty() {
return;
}
let signal = if wave == 0 {
process::TERM_SIGNAL
} else {
process::KILL_SIGNAL
};
targets.signal(signal);
if wave + 1 < WAVES {
let pause = if wave == 0 {
Duration::from_millis(75)
} else {
Duration::from_millis(150)
};
time::sleep(pause).await;
}
}
}
});
let source_info = SourceInfo::from("pi-shell:streams");
let result = session
.shell
.run_string(options.command.clone(), &source_info, &params)
.await;
if cancel_token.is_cancelled() {
terminate_background_jobs(&session.shell);
}
if env_scope_pushed {
session
.shell
.env_mut()
.pop_scope(EnvironmentScope::Command)
.map_err(|err| Error::msg(format!("Failed to pop env scope: {err}")))?;
}
drop(params);
const POST_EXIT_IDLE: Duration = Duration::from_millis(250);
const POST_EXIT_MAX: Duration = Duration::from_secs(2);
const READER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);
let mut stdout_finished = false;
let mut stderr_finished = false;
let mut idle_timer = Box::pin(time::sleep(POST_EXIT_IDLE));
let mut max_timer = Box::pin(time::sleep(POST_EXIT_MAX));
loop {
if stdout_finished && stderr_finished {
break;
}
tokio::select! {
res = &mut stdout_handle, if !stdout_finished => {
let _ = res;
stdout_finished = true;
}
res = &mut stderr_handle, if !stderr_finished => {
let _ = res;
stderr_finished = true;
}
msg = activity_rx.recv() => {
if msg.is_none() {
break;
}
idle_timer.as_mut().reset(time::Instant::now() + POST_EXIT_IDLE);
}
() = &mut idle_timer => break,
() = &mut max_timer => break,
}
}
if !stdout_finished || !stderr_finished {
reader_cancel.cancel();
}
if !stdout_finished {
if time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stdout_handle)
.await
.is_err()
{
stdout_handle.abort();
let _ = stdout_handle.await;
}
}
if !stderr_finished {
if time::timeout(READER_SHUTDOWN_TIMEOUT, &mut stderr_handle)
.await
.is_err()
{
stderr_handle.abort();
let _ = stderr_handle.await;
}
}
cancel_bridge.abort();
let _ = cancel_bridge.await;
if cancel_token.is_cancelled() {
// Let the kill-wave bridge finish all three signal passes so stragglers
// have a chance to receive SIGKILL.
let _ = process_cancel_bridge.await;
} else {
process_cancel_bridge.abort();
let _ = process_cancel_bridge.await;
}
let result = result.map_err(|err| Error::msg(format!("Shell execution failed: {err}")))?;
Ok(result)
}
async fn read_output_bytes(
reader: fs::File,
sink: Option<mpsc::UnboundedSender<Bytes>>,
cancel_token: CancellationToken,
activity: mpsc::Sender<()>,
) {
const BUF: usize = 65536;
#[cfg(unix)]
let Ok(reader) = register_nonblocking_pipe(reader) else {
return;
};
#[cfg(not(unix))]
let mut reader = tokio::fs::File::from_std(reader);
loop {
let mut buf = vec![0u8; BUF];
#[cfg(unix)]
let n = {
let Ok(mut readiness) = (tokio::select! {
ready = reader.readable() => ready,
() = cancel_token.cancelled() => break,
}) else {
break;
};
match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf)) {
Ok(Ok(0)) => break,
Ok(Ok(n)) => n,
Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue,
Ok(Err(_)) => break,
Err(_would_block) => continue,
}
};
#[cfg(not(unix))]
let n = {
let read_future = reader.read(&mut buf);
tokio::pin!(read_future);
match tokio::select! {
res = &mut read_future => res,
() = cancel_token.cancelled() => break,
} {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
};
let _ = activity.try_send(());
buf.truncate(n);
if let Some(sink) = sink.as_ref()
&& sink.send(Bytes::from(buf)).is_err()
{
// Receiver dropped — stop forwarding and let the pipe close.
break;
}
}
}
fn terminate_background_jobs(shell: &BrushShell) {
let mut targets = process::TerminationTargets::new();
for job in &shell.jobs().jobs {
@@ -1505,4 +1838,46 @@ mod tests {
.expect("reader task should stop after cancellation")
.expect("reader task should not panic");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn execute_shell_streams_separates_stdout_and_stderr() {
let (stdout_tx, mut stdout_rx) = mpsc::unbounded_channel::<Bytes>();
let (stderr_tx, mut stderr_rx) = mpsc::unbounded_channel::<Bytes>();
let options = ShellExecuteOptions {
command: "echo out; echo err 1>&2".to_string(),
..Default::default()
};
let streams = StreamSinks { stdout: Some(stdout_tx), stderr: Some(stderr_tx) };
let result = execute_shell_streams(options, streams, CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(0));
assert!(!result.cancelled);
let mut stdout = Vec::new();
while let Some(chunk) = stdout_rx.recv().await {
stdout.extend_from_slice(&chunk);
}
let mut stderr = Vec::new();
while let Some(chunk) = stderr_rx.recv().await {
stderr.extend_from_slice(&chunk);
}
assert_eq!(stdout, b"out\n");
assert_eq!(stderr, b"err\n");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn execute_shell_streams_works_when_sinks_are_none() {
// Both sinks `None` — pipes must still drain so the child can exit.
let options = ShellExecuteOptions {
command: "yes done | head -n 100 1>&2; echo final".to_string(),
..Default::default()
};
let result = execute_shell_streams(options, StreamSinks::default(), CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(0));
}
}