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)]
71pub(crate) fn start_new_session(command: &mut std::process::Command) {
72 use std::os::unix::process::CommandExt;
73
74 unsafe {
75 command.pre_exec(|| {
76 if libc::setsid() == -1 {
77 Err(std::io::Error::last_os_error())
78 } else {
79 Ok(())
80 }
81 });
82 }
83}
84
85#[cfg(unix)]
86pub fn terminate_process(child: &mut Child) {
87 let pgid = child.id() as i32;
88 terminate_pgid(pgid, Some(child));
89}
90
91#[cfg(unix)]
92pub fn terminate_pgid(pgid: i32, mut child: Option<&mut Child>) {
93 unsafe {
94 libc::killpg(pgid, libc::SIGTERM);
95 }
96 let grace_started = Instant::now();
97 while grace_started.elapsed() < TERMINATE_GRACE {
98 if let Some(child) = child.as_deref_mut() {
99 if matches!(child.try_wait(), Ok(Some(_))) {
100 break;
106 }
107 }
108 thread::sleep(Duration::from_millis(50));
109 }
110 unsafe {
111 libc::killpg(pgid, libc::SIGKILL);
112 }
113}
114
115#[cfg(windows)]
116pub fn terminate_process(child: &mut Child) {
117 terminate_pid(child.id());
118}
119
120#[cfg(windows)]
121pub fn terminate_pid(pid: u32) {
122 let pid = pid.to_string();
123 let _ = Command::new("taskkill")
124 .args(["/PID", &pid, "/T", "/F"])
125 .stdout(Stdio::null())
126 .stderr(Stdio::null())
127 .status();
128}
129
130#[cfg(unix)]
131pub fn is_process_alive(pid: u32) -> bool {
132 let Ok(pid) = i32::try_from(pid) else {
133 return false;
134 };
135 if pid <= 0 {
136 return false;
137 }
138 (unsafe { libc::kill(pid, 0) == 0 })
139 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
140}
141
142#[cfg(windows)]
143pub fn is_process_alive(pid: u32) -> bool {
144 use std::ffi::c_void;
145
146 type Handle = *mut c_void;
147
148 extern "system" {
149 fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
150 fn GetExitCodeProcess(hProcess: Handle, lpExitCode: *mut u32) -> i32;
151 fn CloseHandle(hObject: Handle) -> i32;
152 }
153
154 const FALSE: i32 = 0;
155 const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
156 const STILL_ACTIVE: u32 = 0x103;
157
158 if pid == 0 {
159 return false;
160 }
161
162 unsafe {
163 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
164 if handle.is_null() {
165 return false;
166 }
167 let mut exit_code = 0;
168 let ok = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
169 let _ = CloseHandle(handle);
170 ok
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn is_process_alive_returns_true_for_self() {
180 assert!(is_process_alive(std::process::id()));
181 }
182
183 #[cfg(unix)]
184 #[test]
185 fn payload_wrapper_captures_bash_and_zsh_pipeline_statuses() {
186 use std::os::fd::AsRawFd;
187 use std::os::unix::process::CommandExt;
188 use std::process::Command;
189
190 let mut shells = vec![("/bin/bash", "bash")];
191 if Path::new("/bin/zsh").is_file() {
192 shells.push(("/bin/zsh", "zsh"));
193 }
194
195 for (shell, kind) in shells {
196 let temp = tempfile::tempdir().expect("create wrapper test directory");
197 let exit_path = temp.path().join("exit");
198 let status_path = temp.path().join("pipeline-status");
199 let exit_file = std::fs::File::create(&exit_path).expect("create exit marker");
200 let status_file = std::fs::File::create(&status_path).expect("create status file");
201 let exit_fd = exit_file.as_raw_fd();
202 let status_fd = status_file.as_raw_fd();
203 let mut command = Command::new("/bin/sh");
204 command.args([
205 "-c",
206 std::str::from_utf8(PAYLOAD_WRAPPER).expect("wrapper is UTF-8"),
207 "aft-payload-wrapper",
208 shell,
209 "false | true",
210 "3",
211 "5",
212 kind,
213 ]);
214 unsafe {
215 command.pre_exec(move || {
216 let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 6);
222 let status_copy = libc::fcntl(status_fd, libc::F_DUPFD_CLOEXEC, 6);
223 if exit_copy < 0
224 || status_copy < 0
225 || libc::dup2(exit_copy, 3) < 0
226 || libc::dup2(status_copy, 5) < 0
227 {
228 return Err(std::io::Error::last_os_error());
229 }
230 libc::close(exit_copy);
231 libc::close(status_copy);
232 Ok(())
233 });
234 }
235 let result = command.status().expect("run payload wrapper");
236 drop(exit_file);
237 drop(status_file);
238 assert!(result.success(), "wrapper failed for {kind}");
239 assert_eq!(std::fs::read_to_string(&status_path).unwrap(), "1\n0\n");
240 assert_eq!(std::fs::read_to_string(&exit_path).unwrap(), "0");
241 }
242 }
243
244 #[cfg(unix)]
245 #[test]
246 fn non_pty_child_starts_as_its_own_session_and_process_group_leader() {
247 use std::process::Command;
248
249 let mut command = Command::new("/bin/sh");
250 command.args(["-c", "sleep 30"]);
251 start_new_session(&mut command);
252 let mut child = command.spawn().expect("spawn isolated child");
253 let pid = child.id() as libc::pid_t;
254
255 assert_eq!(unsafe { libc::getsid(pid) }, pid);
256 assert_eq!(unsafe { libc::getpgid(pid) }, pid);
257
258 terminate_process(&mut child);
259 let _ = child.wait();
260 }
261
262 #[test]
263 fn is_process_alive_returns_false_for_dead_pid() {
264 #[cfg(unix)]
265 let mut child = std::process::Command::new("/bin/sh")
266 .args(["-c", "true"])
267 .spawn()
268 .expect("spawn true");
269
270 #[cfg(windows)]
271 let mut child = std::process::Command::new("cmd.exe")
272 .args(["/D", "/C", "exit 0"])
273 .spawn()
274 .expect("spawn cmd");
275
276 let pid = child.id();
277 child.wait().expect("wait for child");
278
279 assert!(!is_process_alive(pid));
280 }
281
282 #[cfg(unix)]
288 #[test]
289 fn terminate_pgid_kills_term_ignoring_descendant_after_leader_exits() {
290 use std::os::unix::process::CommandExt;
291
292 let dir = tempfile::tempdir().unwrap();
293 let pidfile = dir.path().join("desc.pid");
294 let ready = dir.path().join("ready");
295
296 let script = format!(
303 "sh -c \"trap '' TERM; echo \\$$ > '{pid}'; touch '{ready}'; sleep 30\" & \
304 while [ ! -f '{ready}' ]; do sleep 0.02; done; exit 0",
305 pid = pidfile.display(),
306 ready = ready.display(),
307 );
308 let mut leader = unsafe {
309 std::process::Command::new("/bin/sh")
310 .args(["-c", &script])
311 .pre_exec(|| {
312 libc::setsid();
313 Ok(())
314 })
315 .spawn()
316 .expect("spawn leader")
317 };
318 let pgid = leader.id() as i32;
319
320 let start = Instant::now();
322 while !ready.exists() && start.elapsed() < Duration::from_secs(5) {
323 thread::sleep(Duration::from_millis(20));
324 }
325 let desc_pid: u32 = std::fs::read_to_string(&pidfile)
326 .expect("descendant pid file")
327 .trim()
328 .parse()
329 .expect("parse descendant pid");
330 assert!(is_process_alive(desc_pid), "descendant should be alive");
331
332 terminate_pgid(pgid, Some(&mut leader));
333
334 let start = Instant::now();
336 while is_process_alive(desc_pid) && start.elapsed() < Duration::from_secs(5) {
337 thread::sleep(Duration::from_millis(20));
338 }
339 assert!(
340 !is_process_alive(desc_pid),
341 "TERM-ignoring descendant must be SIGKILLed when the group is terminated"
342 );
343 }
344}
345
346pub fn is_recorded_process_alive(pid: u32, task_started_at_ms: u64) -> bool {
350 const PROCESS_START_GRACE_MS: u64 = 30_000;
351
352 if !is_process_alive(pid) {
353 return false;
354 }
355 crate::root_cache::process_start_time_ms(pid).is_none_or(|started_at_ms| {
356 started_at_ms <= task_started_at_ms.saturating_add(PROCESS_START_GRACE_MS)
357 })
358}