feat(command): implement Drop for CommandChild with kill_on_drop option (#3149)

* feat(command): implement Drop for CommandChild with kill_on_drop option

* fmt

* log

* merge fixes, docs

* clippy

* Apply suggestion from @lucasfernog [skip ci]

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
TungliesandLucas Nogueira authored and GitHub committed 2026-10-16 09:00:00 -03:00
1 parent 0bd82c829b
commit 738c323cfc
2 files changed
+104 -2

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"shell": minor:feat
"shell-js": minor:feat
---
Add `Command::kill_on_drop` to kill the spawned process (or its process group / job object when `Command::set_process_group` is enabled) when its `CommandChild` is dropped.
+98 -2
View File
@@ -63,6 +63,7 @@ pub struct Command {
cmd: StdCommand,
raw_out: bool,
process_group: bool,
kill_on_drop: bool,
}
/// Spawned child process.
@@ -74,6 +75,7 @@ pub struct CommandChild {
process_group: bool,
#[cfg(windows)]
job: Option<job_object::JobObject>,
kill_on_drop: bool,
}
impl CommandChild {
@@ -85,7 +87,16 @@ impl CommandChild {
/// Sends a kill signal to the child and waits for it to exit.
/// With `process_group` enabled this kills the whole process group (POSIX) or job object (Windows).
pub fn kill(self) -> crate::Result<()> {
pub fn kill(mut self) -> crate::Result<()> {
self.send_kill()?;
// Already killed, so `Drop` must not do it again.
self.kill_on_drop = false;
self.inner.wait()?;
Ok(())
}
/// Sends the kill signal without waiting for the child to exit.
fn send_kill(&self) -> crate::Result<()> {
// No early return when the child already exited: the rest of its
// process group or job object may still be running.
#[cfg(unix)]
@@ -110,7 +121,6 @@ impl CommandChild {
None => self.inner.kill()?,
}
self.inner.wait()?;
Ok(())
}
@@ -120,6 +130,17 @@ impl CommandChild {
}
}
impl Drop for CommandChild {
/// Kills the child process (or its process group / job object) if `kill_on_drop` is enabled.
fn drop(&mut self) {
if self.kill_on_drop
&& let Err(e) = self.send_kill()
{
log::error!("Failed to kill child process {}: {}", self.inner.id(), e);
}
}
}
/// Describes the result of a process after it has terminated.
#[derive(Debug)]
pub struct ExitStatus {
@@ -210,6 +231,7 @@ impl Command {
cmd: command,
raw_out: false,
process_group: false,
kill_on_drop: false,
}
}
@@ -288,6 +310,21 @@ impl Command {
self
}
/// Configures whether the child process is killed when its [`CommandChild`] is dropped.
/// Defaults to `false`, so a dropped [`CommandChild`] leaves the process running.
///
/// The kill works like [`CommandChild::kill`] but does not wait for the process to exit.
/// Without [`Command::set_process_group`] only the child itself is killed, and any
/// processes it spawned keep running; enable it to kill the whole process group (POSIX)
/// or job object (Windows).
///
/// This has no effect on processes spawned from the JavaScript API; the plugin kills those when the app exits.
#[must_use]
pub fn kill_on_drop(mut self, kill_on_drop: bool) -> Self {
self.kill_on_drop = kill_on_drop;
self
}
/// Spawns the command.
///
/// # Examples
@@ -350,6 +387,7 @@ impl Command {
pub fn spawn(self) -> crate::Result<(Receiver<CommandEvent>, CommandChild)> {
let raw = self.raw_out;
let process_group = self.process_group;
let kill_on_drop = self.kill_on_drop;
let mut command: StdCommand = self.into();
let (stdout_reader, stdout_writer) = pipe()?;
let (stderr_reader, stderr_writer) = pipe()?;
@@ -425,6 +463,7 @@ impl Command {
process_group,
#[cfg(windows)]
job,
kill_on_drop,
},
))
}
@@ -774,6 +813,63 @@ mod tests {
assert_ne!(ret, 0, "process group should no longer exist");
}
#[cfg(not(windows))]
fn wait_terminated(mut rx: Receiver<CommandEvent>) -> TerminatedPayload {
tauri::async_runtime::block_on(async move {
while let Some(event) = rx.recv().await {
if let CommandEvent::Terminated(payload) = event {
return payload;
}
}
panic!("channel closed without a Terminated event");
})
}
#[cfg(not(windows))]
#[test]
fn test_cmd_kill_on_drop() {
let (rx, child) = Command::new("sleep")
.args(["60"])
.kill_on_drop(true)
.spawn()
.unwrap();
drop(child);
let payload = wait_terminated(rx);
assert_eq!(payload.signal, Some(libc::SIGKILL));
}
#[cfg(not(windows))]
#[test]
fn test_cmd_no_kill_on_drop_by_default() {
let (_rx, child) = Command::new("sleep").args(["60"]).spawn().unwrap();
let pid = child.pid();
drop(child);
std::thread::sleep(std::time::Duration::from_millis(200));
let ret = unsafe { libc::kill(pid as i32, 0) };
assert_eq!(ret, 0, "process should still be running");
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
}
#[cfg(not(windows))]
#[test]
fn test_cmd_kill_on_drop_process_group() {
// The shell's own child (sleep) must die too, not just the shell.
let (rx, child) = Command::new("sh")
.args(["-c", "sleep 60; true"])
.set_process_group(true)
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.pid();
drop(child);
wait_terminated(rx);
let ret = unsafe { libc::killpg(pid as i32, 0) };
assert_ne!(ret, 0, "process group should no longer exist");
}
#[test]
fn test_cmd_process_group_output() {
// Hangs on Windows if the suspended child is never resumed.