0a3c192d7d
Fixes #230
1039 lines
28 KiB
Rust
1039 lines
28 KiB
Rust
//! Brush-based shell execution exported via N-API.
|
|
//!
|
|
//! # Overview
|
|
//! Executes shell commands in a non-interactive brush-core shell, streaming
|
|
//! output back to JavaScript via a threadsafe callback.
|
|
//!
|
|
//! # Example
|
|
//! ```ignore
|
|
//! const shell = new natives.Shell();
|
|
//! const result = await shell.run({ command: "ls" }, (chunk) => {
|
|
//! console.log(chunk);
|
|
//! });
|
|
//! ```
|
|
|
|
#[cfg(windows)]
|
|
use std::collections::HashSet;
|
|
use std::{
|
|
collections::HashMap,
|
|
fs,
|
|
io::{self, Write},
|
|
str,
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
mod windows;
|
|
|
|
use brush_builtins::{BuiltinSet, default_builtins};
|
|
use brush_core::{
|
|
CreateOptions, ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult,
|
|
ProcessGroupPolicy, Shell as BrushShell, ShellValue, ShellVariable, builtins,
|
|
env::EnvironmentScope,
|
|
openfiles::{self, OpenFile, OpenFiles},
|
|
sys, traps,
|
|
};
|
|
use clap::Parser;
|
|
use napi::{
|
|
bindgen_prelude::*,
|
|
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
|
tokio::{
|
|
self,
|
|
sync::{Mutex as TokioMutex, mpsc},
|
|
time,
|
|
},
|
|
};
|
|
use napi_derive::napi;
|
|
use tokio::io::AsyncReadExt as _;
|
|
use tokio_util::sync::CancellationToken;
|
|
#[cfg(windows)]
|
|
use windows::configure_windows_path;
|
|
|
|
use crate::task;
|
|
|
|
struct ShellSessionCore {
|
|
shell: BrushShell,
|
|
current_abort: Option<task::AbortToken>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ShellConfig {
|
|
session_env: Option<HashMap<String, String>>,
|
|
snapshot_path: Option<String>,
|
|
}
|
|
|
|
/// Options for configuring a persistent shell session.
|
|
#[napi(object)]
|
|
pub struct ShellOptions {
|
|
/// Environment variables to apply once per session.
|
|
pub session_env: Option<HashMap<String, String>>,
|
|
/// Optional snapshot file to source on session creation.
|
|
pub snapshot_path: Option<String>,
|
|
}
|
|
|
|
/// Options for running a shell command (internal, lifetime-free).
|
|
struct ShellRunConfig {
|
|
/// Command string to execute in the shell.
|
|
command: String,
|
|
/// Working directory for the command.
|
|
cwd: Option<String>,
|
|
/// Environment variables to apply for this command only.
|
|
env: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
/// Options for running a shell command.
|
|
#[napi(object)]
|
|
pub struct ShellRunOptions<'env> {
|
|
/// Command string to execute in the shell.
|
|
pub command: String,
|
|
/// Working directory for the command.
|
|
pub cwd: Option<String>,
|
|
/// Environment variables to apply for this command only.
|
|
pub env: Option<HashMap<String, String>>,
|
|
/// Timeout in milliseconds before cancelling the command.
|
|
#[napi(js_name = "timeoutMs")]
|
|
pub timeout_ms: Option<u32>,
|
|
/// Abort signal for cancelling the operation.
|
|
pub signal: Option<Unknown<'env>>,
|
|
}
|
|
|
|
/// Result of running a shell command.
|
|
#[napi(object)]
|
|
pub struct ShellRunResult {
|
|
/// Exit code when the command completes normally.
|
|
pub exit_code: Option<i32>,
|
|
/// Whether the command was cancelled via abort.
|
|
pub cancelled: bool,
|
|
/// Whether the command timed out before completion.
|
|
pub timed_out: bool,
|
|
}
|
|
|
|
/// Persistent brush-core shell session.
|
|
#[napi]
|
|
pub struct Shell {
|
|
session: Arc<TokioMutex<Option<ShellSessionCore>>>,
|
|
config: ShellConfig,
|
|
}
|
|
|
|
#[napi]
|
|
impl Shell {
|
|
#[napi(constructor)]
|
|
/// Create a new shell session from optional configuration.
|
|
///
|
|
/// The options set session-scoped environment variables and a snapshot path.
|
|
pub fn new(options: Option<ShellOptions>) -> Self {
|
|
let config = options.map_or_else(
|
|
|| ShellConfig { session_env: None, snapshot_path: None },
|
|
|opt| ShellConfig { session_env: opt.session_env, snapshot_path: opt.snapshot_path },
|
|
);
|
|
Self { session: Arc::new(TokioMutex::new(None)), config }
|
|
}
|
|
|
|
/// Run a shell command using the provided options.
|
|
///
|
|
/// The `on_chunk` callback receives streamed stdout/stderr output. Returns
|
|
/// the exit code when the command completes, or flags when cancelled or
|
|
/// timed out.
|
|
#[napi]
|
|
pub fn run<'e>(
|
|
&self,
|
|
env: &'e Env,
|
|
options: ShellRunOptions<'e>,
|
|
#[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option<
|
|
ThreadsafeFunction<String>,
|
|
>,
|
|
) -> Result<PromiseRaw<'e, ShellRunResult>> {
|
|
let ct = task::CancelToken::new(options.timeout_ms, options.signal);
|
|
let session = self.session.clone();
|
|
let config = self.config.clone();
|
|
|
|
let run_config =
|
|
ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env };
|
|
|
|
task::future(env, "shell.run", async move {
|
|
run_shell_session(session, config, run_config, on_chunk, ct).await
|
|
})
|
|
}
|
|
|
|
/// Abort all running commands for this shell session.
|
|
///
|
|
/// Returns `Ok(())` even when no commands are running.
|
|
#[napi]
|
|
pub async fn abort(&self) -> Result<()> {
|
|
if let Some(session) = self.session.lock().await.as_ref()
|
|
&& let Some(at) = &session.current_abort
|
|
{
|
|
at.abort(task::AbortReason::Signal);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Run a shell command within a persistent session.
|
|
async fn run_shell_session(
|
|
session: Arc<TokioMutex<Option<ShellSessionCore>>>,
|
|
config: ShellConfig,
|
|
run_config: ShellRunConfig,
|
|
on_chunk: Option<ThreadsafeFunction<String>>,
|
|
mut ct: task::CancelToken,
|
|
) -> Result<ShellRunResult> {
|
|
let tokio_cancel = CancellationToken::new();
|
|
|
|
let mut run_task = tokio::spawn({
|
|
let session = session.clone();
|
|
let tokio_cancel = tokio_cancel.clone();
|
|
let at = ct.emplace_abort_token();
|
|
async move {
|
|
let mut session_guard = session.lock().await;
|
|
|
|
let session = match &mut *session_guard {
|
|
Some(session) => session,
|
|
None => session_guard.insert(create_session(&config).await?),
|
|
};
|
|
session.current_abort = Some(at);
|
|
run_shell_command(session, &run_config, on_chunk, tokio_cancel).await
|
|
}
|
|
});
|
|
|
|
let res = tokio::select! {
|
|
res = &mut run_task => res,
|
|
reason = ct.wait() => {
|
|
tokio_cancel.cancel();
|
|
let graceful = time::timeout(Duration::from_secs(2), &mut run_task).await;
|
|
if graceful.is_err() {
|
|
run_task.abort();
|
|
let _ = run_task.await;
|
|
}
|
|
// Use try_lock to avoid deadlocking if another task holds the session.
|
|
// If we can't acquire the lock, the session will be cleaned up when the
|
|
// holding task finishes.
|
|
if let Ok(mut guard) = session.try_lock() {
|
|
*guard = None;
|
|
}
|
|
return Ok(ShellRunResult {
|
|
exit_code: None,
|
|
cancelled: matches!(reason, task::AbortReason::Signal),
|
|
timed_out: matches!(reason, task::AbortReason::Timeout),
|
|
});
|
|
}
|
|
};
|
|
let res =
|
|
res.unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}"))));
|
|
|
|
let keepalive = res.as_ref().is_ok_and(session_keepalive);
|
|
if keepalive {
|
|
// Clear abort token when command completes
|
|
if let Some(session_core) = session.lock().await.as_mut() {
|
|
session_core.current_abort = None;
|
|
}
|
|
} else {
|
|
*session.lock().await = None;
|
|
}
|
|
Ok(ShellRunResult { exit_code: Some(exit_code(&res?)), cancelled: false, timed_out: false })
|
|
}
|
|
|
|
/// Options for executing a shell command via brush-core.
|
|
#[napi(object)]
|
|
pub struct ShellExecuteOptions<'env> {
|
|
/// Command string to execute in the shell.
|
|
pub command: String,
|
|
/// Working directory for the command.
|
|
pub cwd: Option<String>,
|
|
/// Environment variables to apply for this command only.
|
|
pub env: Option<HashMap<String, String>>,
|
|
/// Environment variables to apply once per session.
|
|
pub session_env: Option<HashMap<String, String>>,
|
|
/// Timeout in milliseconds before cancelling the command.
|
|
#[napi(js_name = "timeoutMs")]
|
|
pub timeout_ms: Option<u32>,
|
|
/// Optional snapshot file to source on session creation.
|
|
#[napi(js_name = "snapshotPath")]
|
|
pub snapshot_path: Option<String>,
|
|
/// Abort signal for cancelling the operation.
|
|
pub signal: Option<Unknown<'env>>,
|
|
}
|
|
|
|
/// Result of executing a shell command via brush-core.
|
|
#[napi(object)]
|
|
pub struct ShellExecuteResult {
|
|
/// Exit code when the command completes normally.
|
|
pub exit_code: Option<i32>,
|
|
/// Whether the command was cancelled via abort.
|
|
pub cancelled: bool,
|
|
/// Whether the command timed out before completion.
|
|
pub timed_out: bool,
|
|
}
|
|
|
|
/// Execute a brush shell command.
|
|
///
|
|
/// Creates a fresh session for each call. The `on_chunk` callback receives
|
|
/// streamed stdout/stderr output. Returns the exit code when the command
|
|
/// completes, or flags when cancelled or timed out.
|
|
#[napi(js_name = "executeShell")]
|
|
pub fn execute_shell<'env>(
|
|
env: &'env Env,
|
|
options: ShellExecuteOptions<'env>,
|
|
#[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option<
|
|
ThreadsafeFunction<String>,
|
|
>,
|
|
) -> Result<PromiseRaw<'env, ShellExecuteResult>> {
|
|
let config =
|
|
ShellConfig { session_env: options.session_env, snapshot_path: options.snapshot_path };
|
|
let run_config =
|
|
ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env };
|
|
|
|
let ct = task::CancelToken::new(options.timeout_ms, options.signal);
|
|
task::future(env, "shell.execute", async move {
|
|
run_shell_oneshot(config, run_config, on_chunk, ct).await
|
|
})
|
|
}
|
|
|
|
/// Run a shell command in a fresh session (one-shot execution).
|
|
async fn run_shell_oneshot(
|
|
config: ShellConfig,
|
|
run_config: ShellRunConfig,
|
|
on_chunk: Option<ThreadsafeFunction<String>>,
|
|
ct: task::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(&mut session, &run_config, on_chunk, 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, task::AbortReason::Signal),
|
|
timed_out: matches!(reason, task::AbortReason::Timeout),
|
|
})
|
|
},
|
|
};
|
|
|
|
let res = run_result
|
|
.unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}"))));
|
|
|
|
Ok(ShellExecuteResult { exit_code: Some(exit_code(&res?)), cancelled: false, timed_out: false })
|
|
}
|
|
|
|
fn null_file() -> Result<OpenFile> {
|
|
openfiles::null().map_err(|err| Error::from_reason(format!("Failed to create null file: {err}")))
|
|
}
|
|
|
|
const fn exit_code(result: &ExecutionResult) -> i32 {
|
|
match result.exit_code {
|
|
ExecutionExitCode::Success => 0,
|
|
ExecutionExitCode::GeneralError => 1,
|
|
ExecutionExitCode::InvalidUsage => 2,
|
|
ExecutionExitCode::Unimplemented => 99,
|
|
ExecutionExitCode::CannotExecute => 126,
|
|
ExecutionExitCode::NotFound => 127,
|
|
ExecutionExitCode::Interrupted => 130,
|
|
ExecutionExitCode::Custom(code) => code as i32,
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn normalize_env_key(key: &str) -> &str {
|
|
if key.eq_ignore_ascii_case("PATH") {
|
|
"PATH"
|
|
} else {
|
|
key
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
const fn normalize_env_key(key: &str) -> &str {
|
|
key
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn merge_path_values(existing: &str, incoming: &str) -> String {
|
|
let mut merged = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
push_unique_paths(&mut merged, &mut seen, existing);
|
|
push_unique_paths(&mut merged, &mut seen, incoming);
|
|
|
|
std::env::join_paths(merged.iter())
|
|
.map(|paths| paths.to_string_lossy().to_string())
|
|
.unwrap_or_else(|_| merged.join(";"))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn push_unique_paths(merged: &mut Vec<String>, seen: &mut HashSet<String>, value: &str) {
|
|
for segment in std::env::split_paths(value) {
|
|
let segment_str = segment.to_string_lossy().to_string();
|
|
let normalized = normalize_path_segment(&segment_str);
|
|
if normalized.is_empty() {
|
|
continue;
|
|
}
|
|
if seen.insert(normalized) {
|
|
merged.push(segment_str);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn normalize_path_segment(segment: &str) -> String {
|
|
let trimmed = segment.trim().trim_matches('"');
|
|
if trimmed.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let mut normalized = std::path::PathBuf::new();
|
|
for component in std::path::Path::new(trimmed).components() {
|
|
normalized.push(component.as_os_str());
|
|
}
|
|
|
|
normalized.to_string_lossy().to_ascii_lowercase()
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn merge_path_values(_existing: &str, incoming: &str) -> String {
|
|
incoming.to_string()
|
|
}
|
|
|
|
async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
|
|
let create_options = CreateOptions {
|
|
interactive: false,
|
|
login: false,
|
|
no_profile: true,
|
|
no_rc: true,
|
|
do_not_inherit_env: true,
|
|
builtins: default_builtins(BuiltinSet::BashMode),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut shell = BrushShell::new(create_options)
|
|
.await
|
|
.map_err(|err| Error::from_reason(format!("Failed to initialize shell: {err}")))?;
|
|
|
|
if let Some(exec_builtin) = shell.builtin_mut("exec") {
|
|
exec_builtin.disabled = true;
|
|
}
|
|
if let Some(suspend_builtin) = shell.builtin_mut("suspend") {
|
|
suspend_builtin.disabled = true;
|
|
}
|
|
shell.register_builtin("sleep", builtins::builtin::<SleepCommand>());
|
|
shell.register_builtin("timeout", builtins::builtin::<TimeoutCommand>());
|
|
|
|
let mut merged_path: Option<String> = None;
|
|
for (key, value) in std::env::vars() {
|
|
let normalized_key = normalize_env_key(&key);
|
|
if should_skip_env_var(normalized_key) {
|
|
continue;
|
|
}
|
|
if normalized_key == "PATH" {
|
|
merged_path = Some(match merged_path {
|
|
Some(existing) => merge_path_values(&existing, &value),
|
|
None => value,
|
|
});
|
|
continue;
|
|
}
|
|
let mut var = ShellVariable::new(ShellValue::String(value));
|
|
var.export();
|
|
shell
|
|
.env
|
|
.set_global(normalized_key, var)
|
|
.map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?;
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
if merged_path.is_none() {
|
|
if let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) {
|
|
merged_path = Some(value.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
|
|
if let Some(path_value) = merged_path {
|
|
let mut var = ShellVariable::new(ShellValue::String(path_value));
|
|
var.export();
|
|
shell
|
|
.env
|
|
.set_global("PATH", var)
|
|
.map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?;
|
|
}
|
|
|
|
if let Some(env) = config.session_env.as_ref() {
|
|
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();
|
|
shell
|
|
.env
|
|
.set_global(normalized_key, var)
|
|
.map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?;
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
configure_windows_path(&mut shell)?;
|
|
|
|
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
|
|
source_snapshot(&mut shell, snapshot_path).await?;
|
|
}
|
|
|
|
Ok(ShellSessionCore { shell, current_abort: None })
|
|
}
|
|
|
|
async fn source_snapshot(shell: &mut BrushShell, snapshot_path: &str) -> Result<()> {
|
|
let mut params = shell.default_exec_params();
|
|
params.set_fd(OpenFiles::STDIN_FD, null_file()?);
|
|
params.set_fd(OpenFiles::STDOUT_FD, null_file()?);
|
|
params.set_fd(OpenFiles::STDERR_FD, null_file()?);
|
|
|
|
let escaped = snapshot_path.replace('\'', "'\\''");
|
|
let command = format!("source '{escaped}'");
|
|
shell
|
|
.run_string(command, ¶ms)
|
|
.await
|
|
.map_err(|err| Error::from_reason(format!("Failed to source snapshot: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_shell_command(
|
|
session: &mut ShellSessionCore,
|
|
options: &ShellRunConfig,
|
|
on_chunk: Option<ThreadsafeFunction<String>>,
|
|
cancel_token: CancellationToken,
|
|
) -> Result<ExecutionResult> {
|
|
if let Some(cwd) = options.cwd.as_deref() {
|
|
session
|
|
.shell
|
|
.set_working_dir(cwd)
|
|
.map_err(|err| Error::from_reason(format!("Failed to set cwd: {err}")))?;
|
|
}
|
|
|
|
let (reader_file, writer_file) = pipe_to_files("output")?;
|
|
|
|
let stdout_file = OpenFile::from(
|
|
writer_file
|
|
.try_clone()
|
|
.map_err(|err| Error::from_reason(format!("Failed to clone pipe: {err}")))?,
|
|
);
|
|
let stderr_file = OpenFile::from(writer_file);
|
|
|
|
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 mut env_scope_pushed = false;
|
|
if let Some(env) = options.env.as_ref() {
|
|
session.shell.env.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
|
|
.add(normalized_key, var, EnvironmentScope::Command)
|
|
{
|
|
let _ = session.shell.env.pop_scope(EnvironmentScope::Command);
|
|
return Err(Error::from_reason(format!("Failed to set env: {err}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
let reader_cancel = CancellationToken::new();
|
|
let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1);
|
|
let mut reader_handle = tokio::spawn({
|
|
let reader_cancel = reader_cancel.clone();
|
|
async move {
|
|
read_output(reader_file, on_chunk, reader_cancel, activity_tx).await;
|
|
Result::<()>::Ok(())
|
|
}
|
|
});
|
|
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 result = session
|
|
.shell
|
|
.run_string(options.command.clone(), ¶ms)
|
|
.await;
|
|
|
|
if cancel_token.is_cancelled() {
|
|
terminate_background_jobs(&session.shell);
|
|
}
|
|
|
|
if env_scope_pushed {
|
|
session
|
|
.shell
|
|
.env
|
|
.pop_scope(EnvironmentScope::Command)
|
|
.map_err(|err| Error::from_reason(format!("Failed to pop env scope: {err}")))?;
|
|
}
|
|
|
|
drop(params);
|
|
|
|
// The foreground command can complete while background jobs keep the
|
|
// stdout/stderr pipe open. Don't hang forever waiting for EOF; drain output
|
|
// for a short period, then cancel.
|
|
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 reader_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 {
|
|
tokio::select! {
|
|
res = &mut reader_handle => {
|
|
let _ = res;
|
|
reader_finished = true;
|
|
break;
|
|
}
|
|
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 !reader_finished {
|
|
reader_cancel.cancel();
|
|
match time::timeout(READER_SHUTDOWN_TIMEOUT, &mut reader_handle).await {
|
|
Ok(res) => {
|
|
let _ = res;
|
|
}
|
|
Err(_) => {
|
|
reader_handle.abort();
|
|
let _ = reader_handle.await;
|
|
}
|
|
}
|
|
}
|
|
cancel_bridge.abort();
|
|
let _ = cancel_bridge.await;
|
|
|
|
result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}")))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn terminate_background_jobs(shell: &BrushShell) {
|
|
if shell.jobs.jobs.is_empty() {
|
|
return;
|
|
}
|
|
let Ok(signal) = "TERM".parse::<traps::TrapSignal>() else {
|
|
return;
|
|
};
|
|
let mut pgids = Vec::new();
|
|
for job in &shell.jobs.jobs {
|
|
if let Some(pid) = job.process_group_id().or_else(|| job.representative_pid()) {
|
|
let _ = sys::signal::kill_process(pid, signal);
|
|
pgids.push(pid);
|
|
}
|
|
}
|
|
if pgids.is_empty() {
|
|
return;
|
|
}
|
|
tokio::spawn(async move {
|
|
time::sleep(Duration::from_millis(500)).await;
|
|
let Ok(signal) = "KILL".parse::<traps::TrapSignal>() else {
|
|
return;
|
|
};
|
|
for pid in pgids {
|
|
let _ = sys::signal::kill_process(pid, signal);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn terminate_background_jobs(shell: &BrushShell) {
|
|
if shell.jobs.jobs.is_empty() {
|
|
return;
|
|
}
|
|
let Ok(signal) = "TERM".parse::<traps::TrapSignal>() else {
|
|
return;
|
|
};
|
|
let mut pids = Vec::new();
|
|
for job in &shell.jobs.jobs {
|
|
if let Some(pid) = job.process_group_id().or_else(|| job.representative_pid()) {
|
|
let _ = sys::signal::kill_process(pid, signal);
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
if pids.is_empty() {
|
|
return;
|
|
}
|
|
tokio::spawn(async move {
|
|
time::sleep(Duration::from_millis(500)).await;
|
|
let Ok(signal) = "KILL".parse::<traps::TrapSignal>() else {
|
|
return;
|
|
};
|
|
for pid in pids {
|
|
let _ = sys::signal::kill_process(pid, signal);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn should_skip_env_var(key: &str) -> bool {
|
|
if key.starts_with("BASH_FUNC_") && key.ends_with("%%") {
|
|
return true;
|
|
}
|
|
|
|
matches!(
|
|
key,
|
|
"BASH_ENV"
|
|
| "ENV"
|
|
| "HISTFILE"
|
|
| "HISTTIMEFORMAT"
|
|
| "HISTCMD"
|
|
| "PS0"
|
|
| "PS1"
|
|
| "PS2"
|
|
| "PS4"
|
|
| "BRUSH_PS_ALT"
|
|
| "READLINE_LINE"
|
|
| "READLINE_POINT"
|
|
| "BRUSH_VERSION"
|
|
| "BASH"
|
|
| "BASHOPTS"
|
|
| "BASH_ALIASES"
|
|
| "BASH_ARGV0"
|
|
| "BASH_CMDS"
|
|
| "BASH_SOURCE"
|
|
| "BASH_SUBSHELL"
|
|
| "BASH_VERSINFO"
|
|
| "BASH_VERSION"
|
|
| "SHELLOPTS"
|
|
| "SHLVL"
|
|
| "SHELL"
|
|
| "COMP_WORDBREAKS"
|
|
| "DIRSTACK"
|
|
| "EPOCHREALTIME"
|
|
| "EPOCHSECONDS"
|
|
| "FUNCNAME"
|
|
| "GROUPS"
|
|
| "IFS"
|
|
| "LINENO"
|
|
| "MACHTYPE"
|
|
| "OSTYPE"
|
|
| "OPTERR"
|
|
| "OPTIND"
|
|
| "PIPESTATUS"
|
|
| "PPID"
|
|
| "PWD"
|
|
| "OLDPWD"
|
|
| "RANDOM"
|
|
| "SRANDOM"
|
|
| "SECONDS"
|
|
| "UID"
|
|
| "EUID"
|
|
| "HOSTNAME"
|
|
| "HOSTTYPE"
|
|
)
|
|
}
|
|
|
|
const fn session_keepalive(result: &ExecutionResult) -> bool {
|
|
match result.next_control_flow {
|
|
ExecutionControlFlow::Normal => true,
|
|
ExecutionControlFlow::BreakLoop { .. } => false,
|
|
ExecutionControlFlow::ContinueLoop { .. } => false,
|
|
ExecutionControlFlow::ReturnFromFunctionOrScript => false,
|
|
ExecutionControlFlow::ExitShell => false,
|
|
}
|
|
}
|
|
|
|
async fn read_output(
|
|
reader: fs::File,
|
|
on_chunk: Option<ThreadsafeFunction<String>>,
|
|
cancel_token: CancellationToken,
|
|
activity: mpsc::Sender<()>,
|
|
) {
|
|
const REPLACEMENT: &str = "\u{FFFD}";
|
|
const BUF: usize = 4096;
|
|
let mut buf = [0u8; BUF + 4]; // +4 for max UTF-8 char
|
|
let mut it = 0;
|
|
|
|
let reader = tokio::fs::File::from_std(reader);
|
|
tokio::pin!(reader);
|
|
|
|
loop {
|
|
let read_future = reader.read(&mut buf[it..BUF]);
|
|
tokio::pin!(read_future);
|
|
let n = match tokio::select! {
|
|
res = &mut read_future => res,
|
|
() = cancel_token.cancelled() => break,
|
|
} {
|
|
Ok(0) => break, // EOF
|
|
Ok(n) => n,
|
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
|
Err(_) => break,
|
|
};
|
|
if n > 0 {
|
|
let _ = activity.try_send(());
|
|
}
|
|
it += n;
|
|
|
|
// Consume as much of `pending` as is decodable *right now*.
|
|
while it > 0 {
|
|
let pending = &buf[..it];
|
|
match str::from_utf8(pending) {
|
|
Ok(text) => {
|
|
emit_chunk(text, on_chunk.as_ref());
|
|
it = 0;
|
|
break;
|
|
},
|
|
Err(err) => {
|
|
let p = err.valid_up_to();
|
|
if p > 0 {
|
|
// SAFETY: [..p] is guaranteed valid UTF-8 by valid_up_to().
|
|
let text = unsafe { str::from_utf8_unchecked(&pending[..p]) };
|
|
emit_chunk(text, on_chunk.as_ref());
|
|
// copy p..it to the beginning of the buffer
|
|
buf.copy_within(p..it, 0);
|
|
it -= p;
|
|
}
|
|
|
|
match err.error_len() {
|
|
Some(p) => {
|
|
// Invalid byte sequence: emit replacement and drop those bytes.
|
|
emit_chunk(REPLACEMENT, on_chunk.as_ref());
|
|
// copy p..it to the beginning of the buffer
|
|
buf.copy_within(p..it, 0);
|
|
it -= p;
|
|
// continue loop in case more bytes remain after the
|
|
// invalid sequence
|
|
},
|
|
None => {
|
|
// Incomplete UTF-8 sequence at end: keep bytes for next read.
|
|
break;
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush whatever is left at EOF (including an incomplete final sequence).
|
|
for chunk in buf[..it].utf8_chunks() {
|
|
let valid = chunk.valid();
|
|
if !valid.is_empty() {
|
|
emit_chunk(valid, on_chunk.as_ref());
|
|
}
|
|
if !chunk.invalid().is_empty() {
|
|
emit_chunk(REPLACEMENT, on_chunk.as_ref());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_chunk(text: &str, callback: Option<&ThreadsafeFunction<String>>) {
|
|
if let Some(callback) = callback {
|
|
callback.call(Ok(text.to_string()), ThreadsafeFunctionCallMode::NonBlocking);
|
|
}
|
|
}
|
|
|
|
fn pipe_to_files(label: &str) -> Result<(fs::File, fs::File)> {
|
|
let (r, w) = os_pipe::pipe()
|
|
.map_err(|err| Error::from_reason(format!("Failed to create {label} pipe: {err}")))?;
|
|
|
|
#[cfg(unix)]
|
|
let (r, w): (fs::File, fs::File) = {
|
|
use std::os::unix::io::{FromRawFd, IntoRawFd};
|
|
let r = r.into_raw_fd();
|
|
let w = w.into_raw_fd();
|
|
// SAFETY: We just obtained these fds from os_pipe and own them exclusively.
|
|
unsafe { (FromRawFd::from_raw_fd(r), FromRawFd::from_raw_fd(w)) }
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
let (r, w): (fs::File, fs::File) = {
|
|
use std::os::windows::io::{FromRawHandle, IntoRawHandle};
|
|
let r = r.into_raw_handle();
|
|
let w = w.into_raw_handle();
|
|
// SAFETY: We just obtained these handles from os_pipe and own them exclusively.
|
|
unsafe { (FromRawHandle::from_raw_handle(r), FromRawHandle::from_raw_handle(w)) }
|
|
};
|
|
|
|
Ok((r, w))
|
|
}
|
|
|
|
#[derive(Parser)]
|
|
#[command(disable_help_flag = true)]
|
|
struct SleepCommand {
|
|
#[arg(required = true)]
|
|
durations: Vec<String>,
|
|
}
|
|
|
|
impl builtins::Command for SleepCommand {
|
|
type Error = brush_core::Error;
|
|
|
|
fn execute(
|
|
&self,
|
|
context: ExecutionContext<'_>,
|
|
) -> impl Future<Output = std::result::Result<ExecutionResult, brush_core::Error>> + Send {
|
|
let durations = self.durations.clone();
|
|
async move {
|
|
if context.is_cancelled() {
|
|
return Ok(ExecutionExitCode::Interrupted.into());
|
|
}
|
|
let mut total = Duration::from_millis(0);
|
|
for duration in &durations {
|
|
let Some(parsed) = parse_duration(duration) else {
|
|
let _ = writeln!(context.stderr(), "sleep: invalid time interval '{duration}'");
|
|
return Ok(ExecutionResult::new(1));
|
|
};
|
|
total += parsed;
|
|
}
|
|
let sleep = time::sleep(total);
|
|
tokio::pin!(sleep);
|
|
if let Some(cancel_token) = context.cancel_token() {
|
|
tokio::select! {
|
|
() = &mut sleep => Ok(ExecutionResult::success()),
|
|
() = cancel_token.cancelled() => Ok(ExecutionExitCode::Interrupted.into()),
|
|
}
|
|
} else {
|
|
sleep.await;
|
|
Ok(ExecutionResult::success())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Parser)]
|
|
#[command(disable_help_flag = true)]
|
|
struct TimeoutCommand {
|
|
#[arg(required = true)]
|
|
duration: String,
|
|
#[arg(required = true, num_args = 1.., trailing_var_arg = true)]
|
|
command: Vec<String>,
|
|
}
|
|
|
|
impl builtins::Command for TimeoutCommand {
|
|
type Error = brush_core::Error;
|
|
|
|
fn execute(
|
|
&self,
|
|
context: ExecutionContext<'_>,
|
|
) -> impl Future<Output = std::result::Result<ExecutionResult, brush_core::Error>> + Send {
|
|
let duration = self.duration.clone();
|
|
let command = self.command.clone();
|
|
async move {
|
|
if context.is_cancelled() {
|
|
return Ok(ExecutionExitCode::Interrupted.into());
|
|
}
|
|
let Some(timeout) = parse_duration(&duration) else {
|
|
let _ = writeln!(context.stderr(), "timeout: invalid time interval '{duration}'");
|
|
return Ok(ExecutionResult::new(125));
|
|
};
|
|
if command.is_empty() {
|
|
let _ = writeln!(context.stderr(), "timeout: missing command");
|
|
return Ok(ExecutionResult::new(125));
|
|
}
|
|
|
|
let child_cancel = CancellationToken::new();
|
|
let mut params = context.params.clone();
|
|
params.process_group_policy = ProcessGroupPolicy::NewProcessGroup;
|
|
params.set_cancel_token(child_cancel.clone());
|
|
|
|
let mut command_line = String::new();
|
|
for (idx, arg) in command.iter().enumerate() {
|
|
if idx > 0 {
|
|
command_line.push(' ');
|
|
}
|
|
command_line.push_str("e_arg(arg));
|
|
}
|
|
|
|
let cancel_token = context.cancel_token();
|
|
let run_future = context.shell.run_string(command_line, ¶ms);
|
|
tokio::pin!(run_future);
|
|
|
|
if let Some(cancel_token) = cancel_token {
|
|
tokio::select! {
|
|
result = &mut run_future => result,
|
|
() = time::sleep(timeout) => {
|
|
child_cancel.cancel();
|
|
// Wait briefly for the child to exit after cancellation.
|
|
let _ = time::timeout(Duration::from_secs(2), &mut run_future).await;
|
|
Ok(ExecutionResult::new(124))
|
|
},
|
|
() = cancel_token.cancelled() => {
|
|
child_cancel.cancel();
|
|
Ok(ExecutionExitCode::Interrupted.into())
|
|
},
|
|
}
|
|
} else {
|
|
tokio::select! {
|
|
result = &mut run_future => result,
|
|
() = time::sleep(timeout) => {
|
|
child_cancel.cancel();
|
|
// Wait briefly for the child to exit after cancellation.
|
|
let _ = time::timeout(Duration::from_secs(2), &mut run_future).await;
|
|
Ok(ExecutionResult::new(124))
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn parse_duration(input: &str) -> Option<Duration> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let (number, multiplier) = match trimmed.chars().last()? {
|
|
's' => (&trimmed[..trimmed.len() - 1], 1.0),
|
|
'm' => (&trimmed[..trimmed.len() - 1], 60.0),
|
|
'h' => (&trimmed[..trimmed.len() - 1], 3600.0),
|
|
'd' => (&trimmed[..trimmed.len() - 1], 86400.0),
|
|
ch if ch.is_ascii_alphabetic() => return None,
|
|
_ => (trimmed, 1.0),
|
|
};
|
|
let value = number.parse::<f64>().ok()?;
|
|
if value.is_sign_negative() {
|
|
return None;
|
|
}
|
|
let millis = value * multiplier * 1000.0;
|
|
if !millis.is_finite() || millis < 0.0 {
|
|
return None;
|
|
}
|
|
Some(Duration::from_millis(millis.round() as u64))
|
|
}
|
|
|
|
fn quote_arg(arg: &str) -> String {
|
|
if arg.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
let safe = arg
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '+'));
|
|
if safe {
|
|
return arg.to_string();
|
|
}
|
|
let escaped = arg.replace('\'', "'\"'\"'");
|
|
format!("'{escaped}'")
|
|
}
|