feat(brush-core-vendored): added process cancellation support with CancellationToken and platform-specific termination
- Added process cancellation support via optional CancellationToken parameter to wait() methods across process execution layers. - Implemented platform-specific process termination using SIGKILL on Unix and TerminateProcess on Windows when cancellation is triggered. - Added ProcessWaitResult::Cancelled variant to represent cancelled process execution with exit code 130. - Added windows-sys dependency with Win32_Foundation and Win32_System_Threading features for Windows process termination support. - Removed process_id_tracker and process_group_id_tracker fields from ExecutionParameters struct to simplify process tracking.
This commit is contained in:
@@ -41,14 +41,6 @@ name = "brush_core"
|
||||
path = "src/lib.rs"
|
||||
bench = false
|
||||
|
||||
[[example]]
|
||||
name = "call-func"
|
||||
path = "examples/call-func.rs"
|
||||
|
||||
[[example]]
|
||||
name = "custom-builtin"
|
||||
path = "examples/custom-builtin.rs"
|
||||
|
||||
[dependencies.async-recursion]
|
||||
version = "1.1.1"
|
||||
|
||||
@@ -166,6 +158,10 @@ version = "=0.3.4"
|
||||
[target."cfg(windows)".dependencies.whoami]
|
||||
version = "1.6.1"
|
||||
|
||||
[target."cfg(windows)".dependencies.windows-sys]
|
||||
version = "0.59"
|
||||
features = ["Win32_Foundation", "Win32_System_Threading"]
|
||||
|
||||
[target.wasm32-unknown-unknown.dependencies.getrandom]
|
||||
version = "0.3.4"
|
||||
features = ["wasm_js"]
|
||||
|
||||
@@ -24,6 +24,7 @@ impl From<processes::ProcessWaitResult> for results::ExecutionResult {
|
||||
match wait_result {
|
||||
processes::ProcessWaitResult::Completed(output) => output.into(),
|
||||
processes::ProcessWaitResult::Stopped => Self::stopped(),
|
||||
processes::ProcessWaitResult::Cancelled => Self::new(130),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,15 +67,9 @@ pub struct ExecutionParameters {
|
||||
pub process_group_policy: ProcessGroupPolicy,
|
||||
/// Optional cancellation token shared with callers.
|
||||
cancel_token: Option<CancellationToken>,
|
||||
/// Optional tracker for the first spawned process ID.
|
||||
process_id_tracker: Option<std::sync::Arc<std::sync::atomic::AtomicI32>>,
|
||||
/// Optional tracker for the process group ID used by execution.
|
||||
process_group_id_tracker: Option<std::sync::Arc<std::sync::atomic::AtomicI32>>,
|
||||
}
|
||||
|
||||
impl ExecutionParameters {
|
||||
const UNSET_TRACKER_VALUE: i32 = 0;
|
||||
|
||||
/// Assigns a cancellation token for this execution.
|
||||
pub fn set_cancel_token(&mut self, token: CancellationToken) {
|
||||
self.cancel_token = Some(token);
|
||||
@@ -509,7 +504,10 @@ async fn wait_for_pipeline_processes_and_update_status(
|
||||
|
||||
while let Some(child) = process_spawn_results.pop_front() {
|
||||
ensure_not_cancelled(params)?;
|
||||
match child.wait(!stopped_children.is_empty()).await? {
|
||||
match child
|
||||
.wait(!stopped_children.is_empty(), params.cancel_token())
|
||||
.await?
|
||||
{
|
||||
ExecutionWaitResult::Completed(current_result) => {
|
||||
result = current_result;
|
||||
*shell.last_exit_status_mut() = result.exit_code.into();
|
||||
|
||||
@@ -43,12 +43,17 @@ impl JobTask {
|
||||
pub async fn wait(&mut self) -> Result<JobTaskWaitResult, error::Error> {
|
||||
match self {
|
||||
Self::External(process) => {
|
||||
let wait_result = process.wait().await?;
|
||||
// Background jobs don't receive cancellation tokens
|
||||
let wait_result = process.wait(None).await?;
|
||||
match wait_result {
|
||||
processes::ProcessWaitResult::Completed(output) => {
|
||||
Ok(JobTaskWaitResult::Completed(output.into()))
|
||||
}
|
||||
processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped),
|
||||
processes::ProcessWaitResult::Cancelled => {
|
||||
// Should never happen since we pass None, but handle gracefully
|
||||
Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Internal(handle) => Ok(JobTaskWaitResult::Completed(handle.await??)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Process management
|
||||
|
||||
use futures::FutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{error, sys};
|
||||
|
||||
@@ -32,18 +33,35 @@ impl ChildProcess {
|
||||
}
|
||||
|
||||
/// Waits for the process to exit.
|
||||
pub async fn wait(&mut self) -> Result<ProcessWaitResult, error::Error> {
|
||||
///
|
||||
/// If a cancellation token is provided and triggered, the process will be killed.
|
||||
pub async fn wait(
|
||||
&mut self,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<ProcessWaitResult, error::Error> {
|
||||
#[allow(unused_mut, reason = "only mutated on some platforms")]
|
||||
let mut sigtstp = sys::signal::tstp_signal_listener()?;
|
||||
#[allow(unused_mut, reason = "only mutated on some platforms")]
|
||||
let mut sigchld = sys::signal::chld_signal_listener()?;
|
||||
|
||||
let cancelled = async {
|
||||
match &cancel_token {
|
||||
Some(token) => token.cancelled().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
tokio::pin!(cancelled);
|
||||
|
||||
#[allow(clippy::ignored_unit_patterns)]
|
||||
loop {
|
||||
tokio::select! {
|
||||
output = &mut self.exec_future => {
|
||||
break Ok(ProcessWaitResult::Completed(output?))
|
||||
},
|
||||
_ = &mut cancelled => {
|
||||
self.kill();
|
||||
break Ok(ProcessWaitResult::Cancelled)
|
||||
},
|
||||
_ = sigtstp.recv() => {
|
||||
break Ok(ProcessWaitResult::Stopped)
|
||||
},
|
||||
@@ -61,6 +79,32 @@ impl ChildProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminates the process if we have a PID.
|
||||
fn kill(&self) {
|
||||
let Some(pid) = self.pid else { return };
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::Signal::SIGKILL);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
|
||||
|
||||
// SAFETY: Windows API calls with proper handle management
|
||||
unsafe {
|
||||
#[expect(clippy::cast_sign_loss)]
|
||||
let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32);
|
||||
if handle != 0 {
|
||||
let _ = TerminateProcess(handle, 1);
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<Result<std::process::Output, error::Error>> {
|
||||
let checkable_future = &mut self.exec_future;
|
||||
checkable_future
|
||||
@@ -75,4 +119,6 @@ pub enum ProcessWaitResult {
|
||||
Completed(std::process::Output),
|
||||
/// The process stopped and has not yet completed.
|
||||
Stopped,
|
||||
/// The process was killed due to cancellation.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -220,19 +220,26 @@ impl From<ExecutionResult> for ExecutionSpawnResult {
|
||||
}
|
||||
}
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
impl ExecutionSpawnResult {
|
||||
/// Waits for the command to complete.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `no_wait` - If true, do not wait for the command to complete; return immediately.
|
||||
pub async fn wait(self, no_wait: bool) -> Result<ExecutionWaitResult, error::Error> {
|
||||
/// * `cancel_token` - Optional cancellation token; if triggered, kills the process.
|
||||
pub async fn wait(
|
||||
self,
|
||||
no_wait: bool,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<ExecutionWaitResult, error::Error> {
|
||||
match self {
|
||||
Self::StartedProcess(mut child) => {
|
||||
let process_wait_result = if !no_wait {
|
||||
// Wait for the process to exit or for a relevant signal, whichever happens
|
||||
// first.
|
||||
child.wait().await?
|
||||
child.wait(cancel_token).await?
|
||||
} else {
|
||||
processes::ProcessWaitResult::Stopped
|
||||
};
|
||||
@@ -242,6 +249,10 @@ impl ExecutionSpawnResult {
|
||||
ExecutionWaitResult::Completed(ExecutionResult::from(output))
|
||||
}
|
||||
processes::ProcessWaitResult::Stopped => ExecutionWaitResult::Stopped(child),
|
||||
processes::ProcessWaitResult::Cancelled => {
|
||||
// 130 = 128 + SIGINT (2), standard shell interrupted exit code
|
||||
ExecutionWaitResult::Completed(ExecutionResult::new(130))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(wait_result)
|
||||
|
||||
@@ -26,9 +26,6 @@ use std::{
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::configure_windows_path;
|
||||
|
||||
use brush_builtins::{BuiltinSet, default_builtins};
|
||||
use brush_core::{
|
||||
CreateOptions, ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult,
|
||||
@@ -45,6 +42,8 @@ use napi::{
|
||||
use napi_derive::napi;
|
||||
use parking_lot::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
#[cfg(windows)]
|
||||
use windows::configure_windows_path;
|
||||
|
||||
use crate::work::launch_task;
|
||||
|
||||
@@ -56,8 +55,6 @@ struct ExecutionControl {
|
||||
session_key: String,
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct ExecutionGuard {
|
||||
execution_id: String,
|
||||
}
|
||||
@@ -263,8 +260,7 @@ async fn execute_shell_with_options(
|
||||
|
||||
let run_result = {
|
||||
let mut session = session.lock().await;
|
||||
let run_future =
|
||||
run_shell_command(&mut session, &options, on_chunk, cancel_token.clone());
|
||||
let run_future = run_shell_command(&mut session, &options, on_chunk, cancel_token.clone());
|
||||
tokio::pin!(run_future);
|
||||
|
||||
let run_result = if let Some(ms) = timeout_ms {
|
||||
@@ -588,7 +584,6 @@ fn remove_session(session_key: &str) {
|
||||
sessions.remove(session_key);
|
||||
}
|
||||
|
||||
|
||||
fn read_output(mut reader: std::fs::File, on_chunk: Option<ThreadsafeFunction<String>>) {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending = Vec::new();
|
||||
@@ -691,9 +686,9 @@ impl builtins::Command for SleepCommand {
|
||||
) -> 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());
|
||||
}
|
||||
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 {
|
||||
@@ -701,20 +696,20 @@ impl builtins::Command for SleepCommand {
|
||||
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())
|
||||
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)]
|
||||
@@ -736,9 +731,9 @@ impl builtins::Command for TimeoutCommand {
|
||||
let duration = self.duration.clone();
|
||||
let command = self.command.clone();
|
||||
async move {
|
||||
if context.is_cancelled() {
|
||||
return Ok(ExecutionExitCode::Interrupted.into());
|
||||
}
|
||||
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));
|
||||
@@ -755,29 +750,29 @@ impl builtins::Command for TimeoutCommand {
|
||||
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 cancel_token = context.cancel_token();
|
||||
let run_future = context.shell.run_string(command_line, ¶ms);
|
||||
tokio::pin!(run_future);
|
||||
let result = if let Some(cancel_token) = cancel_token {
|
||||
tokio::select! {
|
||||
result = &mut run_future => result,
|
||||
() = time::sleep(timeout) => Ok(ExecutionResult::new(124)),
|
||||
_ = cancel_token.cancelled() => Ok(ExecutionExitCode::Interrupted.into()),
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
result = &mut run_future => result,
|
||||
() = time::sleep(timeout) => Ok(ExecutionResult::new(124)),
|
||||
}
|
||||
};
|
||||
Ok(result?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cancel_token) = cancel_token {
|
||||
tokio::select! {
|
||||
result = &mut run_future => result,
|
||||
() = time::sleep(timeout) => Ok(ExecutionResult::new(124)),
|
||||
() = cancel_token.cancelled() => Ok(ExecutionExitCode::Interrupted.into()),
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
result = &mut run_future => result,
|
||||
() = time::sleep(timeout) => Ok(ExecutionResult::new(124)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse_duration(input: &str) -> Option<Duration> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
process::Command,
|
||||
};
|
||||
use std::{path::Path, process::Command};
|
||||
|
||||
use brush_core::{Shell as BrushShell, ShellValue, ShellVariable};
|
||||
use napi::{Error, Result};
|
||||
use winreg::{
|
||||
enums::HKEY_LOCAL_MACHINE,
|
||||
RegKey,
|
||||
};
|
||||
use winreg::{RegKey, enums::HKEY_LOCAL_MACHINE};
|
||||
|
||||
pub fn configure_windows_path(shell: &mut BrushShell) -> Result<()> {
|
||||
let Some(git_usr_bin) = find_git_usr_bin() else {
|
||||
@@ -55,12 +49,9 @@ fn path_contains_entry(path_value: &str, entry: &str) -> bool {
|
||||
}
|
||||
|
||||
fn find_git_usr_bin() -> Option<String> {
|
||||
for install_path in [
|
||||
query_git_install_path_from_registry(),
|
||||
query_git_install_path_from_where(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
for install_path in [query_git_install_path_from_registry(), query_git_install_path_from_where()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(path) = git_usr_bin_with_ls(&install_path) {
|
||||
return Some(path);
|
||||
@@ -72,10 +63,7 @@ fn find_git_usr_bin() -> Option<String> {
|
||||
|
||||
fn query_git_install_path_from_registry() -> Option<String> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
let key_paths = [
|
||||
"SOFTWARE\\GitForWindows",
|
||||
"SOFTWARE\\WOW6432Node\\GitForWindows",
|
||||
];
|
||||
let key_paths = ["SOFTWARE\\GitForWindows", "SOFTWARE\\WOW6432Node\\GitForWindows"];
|
||||
|
||||
for key_path in key_paths {
|
||||
if let Ok(key) = hklm.open_subkey(key_path) {
|
||||
|
||||
@@ -71,3 +71,4 @@ blank_lines_upper_bound = 1
|
||||
force_explicit_abi = true
|
||||
hex_literal_case = "Lower"
|
||||
newline_style = "Unix"
|
||||
ignore = ["crates/brush-core-vendored/**"]
|
||||
|
||||
Reference in New Issue
Block a user