diff --git a/crates/pi-builtins/src/host.rs b/crates/pi-builtins/src/host.rs index e76f9e52f..2a5fac497 100644 --- a/crates/pi-builtins/src/host.rs +++ b/crates/pi-builtins/src/host.rs @@ -101,6 +101,14 @@ pub(crate) struct Host { stdin_is_search_input: bool, } +struct CancelOnDrop(Arc); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + impl Host { /// The name the utility was invoked as. Differs from [`Utility::NAME`] when /// one implementation backs several builtins (`grep` and `rg`). @@ -578,6 +586,7 @@ async fn run_utility( let mut host = build_host(&context, U::NAME)?; let cancel = context.cancel_token(); let cancel_flag = host.cancel_flag(); + let _cancel_on_drop = CancelOnDrop(Arc::clone(&cancel_flag)); drop(context); let mut handle = tokio::task::spawn_blocking(move || { diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 079652433..90be597da 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -38,6 +38,12 @@ struct ShellSessionCore { shell: BrushShell, } +impl Drop for ShellSessionCore { + fn drop(&mut self) { + terminate_internal_background_jobs(&mut self.shell); + } +} + #[derive(Clone, Default)] struct ShellAbortState(Arc>>); @@ -1408,10 +1414,16 @@ async fn terminate_run(registry: &process::SpawnRegistry) { } } } -fn terminate_background_jobs(shell: &mut BrushShell) { - let mut targets = process::TerminationTargets::new(); +fn terminate_internal_background_jobs(shell: &mut BrushShell) { for job in &mut shell.jobs_mut().jobs { job.abort_internal_tasks(); + } +} + +fn terminate_background_jobs(shell: &mut BrushShell) { + let mut targets = process::TerminationTargets::new(); + terminate_internal_background_jobs(shell); + for job in &shell.jobs().jobs { if let Some(pgid) = job.process_group_id() { targets.add_pgid(pgid); } @@ -4432,6 +4444,28 @@ replace = [{ pattern = "hello", replacement = "HI" }] } } + #[tokio::test(flavor = "multi_thread")] + async fn one_shot_completion_aborts_internal_background_jobs() { + let marker = tempfile::NamedTempFile::new().expect("marker file"); + let marker_path = marker.path().to_string_lossy(); + std::fs::remove_file(marker.path()).expect("remove initial marker"); + let command = format!("{{ sleep 1; echo leaked > {}; }} &", quote_arg(&marker_path)); + + execute_shell( + ShellExecuteOptions { command, ..Default::default() }, + None, + CancelToken::default(), + ) + .await + .expect("one-shot shell execution"); + time::sleep(Duration::from_millis(100)).await; + + assert!( + !marker.path().exists(), + "an internal background job outlived its one-shot shell session" + ); + } + /// `live_background_job_count` reports 0 when the session has no live /// external background jobs and 1 while one is running. The host relies on /// this to retain a per-call shell whose `&`/`nohup` child is still alive diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index d9cf4c609..a9bd5676e 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed shell-internal background jobs such as `yes >/dev/null &` surviving a one-shot shell session and consuming CPU indefinitely after the command returned ([#8341](https://github.com/can1357/oh-my-pi/issues/8341)). + ## [17.2.12] - 2026-08-08 ### Changed