aft/bash_background/
process.rs1#[cfg(unix)]
2use std::path::Path;
3use std::process::Child;
10#[cfg(windows)]
11use std::process::{Command, Stdio};
12#[cfg(unix)]
13use std::thread;
14use std::time::Duration;
15#[cfg(unix)]
16use std::time::Instant;
17
18pub const TERMINATE_GRACE: Duration = Duration::from_secs(2);
19
20#[cfg(unix)]
28pub(crate) const PAYLOAD_WRAPPER: &[u8] = br#"#!/bin/sh
29shell=$1
30command=$2
31exit_fd=$3
32pipeline_status_fd=$4
33pipeline_shell=$5
34case "$pipeline_status_fd:$pipeline_shell" in
35 5:bash)
36 "$shell" -c "$command
37__aft_code=\$? __aft_ps=(\"\${PIPESTATUS[@]}\")
38printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
39exit \"\$__aft_code\""
40 ;;
41 5:zsh)
42 "$shell" -c "$command
43__aft_code=\$? __aft_ps=(\"\${pipestatus[@]}\")
44printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
45exit \"\$__aft_code\""
46 ;;
47 *)
48 # POSIX sh has no per-segment pipeline status array; preserve the original
49 # invocation instead of changing its semantics with a best-effort guess.
50 "$shell" -c "$command"
51 ;;
52esac
53code=$?
54printf "%s" "$code" >&"$exit_fd"
55exit "$code"
56"#;
57
58#[cfg(unix)]
59pub(crate) fn pipeline_shell_kind(shell: &Path) -> Option<&'static str> {
60 match shell.file_name().and_then(|name| name.to_str()) {
61 Some("bash") => Some("bash"),
62 Some("zsh") => Some("zsh"),
63 _ => None,
64 }
65}
66
67#[cfg(unix)]
68pub fn terminate_process(child: &mut Child) {
69 let pgid = child.id() as i32;
70 terminate_pgid(pgid, Some(child));
71}
72
73#[cfg(unix)]
74pub fn terminate_pgid(pgid: i32, mut child: Option<&mut Child>) {
75 unsafe {
76 libc::killpg(pgid, libc::SIGTERM);
77 }
78 let grace_started = Instant::now();
79 while grace_started.elapsed() < TERMINATE_GRACE {
80 if let Some(child) = child.as_deref_mut() {
81 if matches!(child.try_wait(), Ok(Some(_))) {
82 break;
88 }
89 }
90 thread::sleep(Duration::from_millis(50));
91 }
92 unsafe {
93 libc::killpg(pgid, libc::SIGKILL);
94 }
95}
96
97#[cfg(windows)]
98pub fn terminate_process(child: &mut Child) {
99 terminate_pid(child.id());
100}
101
102#[cfg(windows)]
103pub fn terminate_pid(pid: u32) {
104 let pid = pid.to_string();
105 let _ = Command::new("taskkill")
106 .args(["/PID", &pid, "/T", "/F"])
107 .stdout(Stdio::null())
108 .stderr(Stdio::null())
109 .status();
110}
111
112#[cfg(unix)]
113pub fn is_process_alive(pid: u32) -> bool {
114 let Ok(pid) = i32::try_from(pid) else {
115 return false;
116 };
117 if pid <= 0 {
118 return false;
119 }
120 (unsafe { libc::kill(pid, 0) == 0 })
121 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
122}
123
124#[cfg(windows)]
125pub fn is_process_alive(pid: u32) -> bool {
126 use std::ffi::c_void;
127
128 type Handle = *mut c_void;
129
130 extern "system" {
131 fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
132 fn GetExitCodeProcess(hProcess: Handle, lpExitCode: *mut u32) -> i32;
133 fn CloseHandle(hObject: Handle) -> i32;
134 }
135
136 const FALSE: i32 = 0;
137 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
138 const STILL_ACTIVE: u32 = 0x103;
139
140 if pid == 0 {
141 return false;
142 }
143
144 unsafe {
145 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
146 if handle.is_null() {
147 return false;
148 }
149 let mut exit_code = 0;
150 let ok = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
151 let _ = CloseHandle(handle);
152 ok
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn is_process_alive_returns_true_for_self() {
162 assert!(is_process_alive(std::process::id()));
163 }
164
165 #[cfg(unix)]
166 #[test]
167 fn payload_wrapper_captures_bash_and_zsh_pipeline_statuses() {
168 use std::os::fd::AsRawFd;
169 use std::os::unix::process::CommandExt;
170 use std::process::Command;
171
172 let mut shells = vec![("/bin/bash", "bash")];
173 if Path::new("/bin/zsh").is_file() {
174 shells.push(("/bin/zsh", "zsh"));
175 }
176
177 for (shell, kind) in shells {
178 let temp = tempfile::tempdir().expect("create wrapper test directory");
179 let exit_path = temp.path().join("exit");
180 let status_path = temp.path().join("pipeline-status");
181 let exit_file = std::fs::File::create(&exit_path).expect("create exit marker");
182 let status_file = std::fs::File::create(&status_path).expect("create status file");
183 let exit_fd = exit_file.as_raw_fd();
184 let status_fd = status_file.as_raw_fd();
185 let mut command = Command::new("/bin/sh");
186 command.args([
187 "-c",
188 std::str::from_utf8(PAYLOAD_WRAPPER).expect("wrapper is UTF-8"),
189 "aft-payload-wrapper",
190 shell,
191 "false | true",
192 "3",
193 "5",
194 kind,
195 ]);
196 unsafe {
197 command.pre_exec(move || {
198 let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 6);
204 let status_copy = libc::fcntl(status_fd, libc::F_DUPFD_CLOEXEC, 6);
205 if exit_copy < 0
206 || status_copy < 0
207 || libc::dup2(exit_copy, 3) < 0
208 || libc::dup2(status_copy, 5) < 0
209 {
210 return Err(std::io::Error::last_os_error());
211 }
212 libc::close(exit_copy);
213 libc::close(status_copy);
214 Ok(())
215 });
216 }
217 let result = command.status().expect("run payload wrapper");
218 drop(exit_file);
219 drop(status_file);
220 assert!(result.success(), "wrapper failed for {kind}");
221 assert_eq!(std::fs::read_to_string(&status_path).unwrap(), "1\n0\n");
222 assert_eq!(std::fs::read_to_string(&exit_path).unwrap(), "0");
223 }
224 }
225
226 #[test]
227 fn is_process_alive_returns_false_for_dead_pid() {
228 #[cfg(unix)]
229 let mut child = std::process::Command::new("/bin/sh")
230 .args(["-c", "true"])
231 .spawn()
232 .expect("spawn true");
233
234 #[cfg(windows)]
235 let mut child = std::process::Command::new("cmd.exe")
236 .args(["/D", "/C", "exit 0"])
237 .spawn()
238 .expect("spawn cmd");
239
240 let pid = child.id();
241 child.wait().expect("wait for child");
242
243 assert!(!is_process_alive(pid));
244 }
245
246 #[cfg(unix)]
252 #[test]
253 fn terminate_pgid_kills_term_ignoring_descendant_after_leader_exits() {
254 use std::os::unix::process::CommandExt;
255
256 let dir = tempfile::tempdir().unwrap();
257 let pidfile = dir.path().join("desc.pid");
258 let ready = dir.path().join("ready");
259
260 let script = format!(
267 "sh -c \"trap '' TERM; echo \\$$ > '{pid}'; touch '{ready}'; sleep 30\" & \
268 while [ ! -f '{ready}' ]; do sleep 0.02; done; exit 0",
269 pid = pidfile.display(),
270 ready = ready.display(),
271 );
272 let mut leader = unsafe {
273 std::process::Command::new("/bin/sh")
274 .args(["-c", &script])
275 .pre_exec(|| {
276 libc::setsid();
277 Ok(())
278 })
279 .spawn()
280 .expect("spawn leader")
281 };
282 let pgid = leader.id() as i32;
283
284 let start = Instant::now();
286 while !ready.exists() && start.elapsed() < Duration::from_secs(5) {
287 thread::sleep(Duration::from_millis(20));
288 }
289 let desc_pid: u32 = std::fs::read_to_string(&pidfile)
290 .expect("descendant pid file")
291 .trim()
292 .parse()
293 .expect("parse descendant pid");
294 assert!(is_process_alive(desc_pid), "descendant should be alive");
295
296 terminate_pgid(pgid, Some(&mut leader));
297
298 let start = Instant::now();
300 while is_process_alive(desc_pid) && start.elapsed() < Duration::from_secs(5) {
301 thread::sleep(Duration::from_millis(20));
302 }
303 assert!(
304 !is_process_alive(desc_pid),
305 "TERM-ignoring descendant must be SIGKILLed when the group is terminated"
306 );
307 }
308}