1use crate::util::{err, Result};
5use rightkit_child_process::{OwnedChild, OwnedCommand, ReapOutcome};
6use std::collections::BTreeMap;
7use std::io::Read;
8use std::path::PathBuf;
9use std::process::{Command, ExitStatus, Stdio};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13pub const KILL_HELPER_TIMEOUT: Duration = Duration::from_secs(10);
15pub const TERMINATE_REAP_TIMEOUT: Duration = Duration::from_secs(5);
17const HELPER_REAP: Duration = Duration::from_secs(2);
19
20fn poll_exit(
21 child: &mut std::process::Child,
22 deadline: Instant,
23) -> std::io::Result<Option<ExitStatus>> {
24 loop {
25 if let Some(st) = child.try_wait()? {
26 return Ok(Some(st));
27 }
28 if Instant::now() >= deadline {
29 return Ok(None);
30 }
31 std::thread::sleep(Duration::from_millis(15));
32 }
33}
34
35fn abandon(child: &mut std::process::Child) {
37 let _ = child.kill();
38 let _ = poll_exit(child, Instant::now() + HELPER_REAP);
39}
40
41pub fn status_bounded(cmd: &mut Command, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
44 let mut child = cmd.spawn()?;
45 match poll_exit(&mut child, Instant::now() + timeout)? {
46 Some(st) => Ok(Some(st)),
47 None => {
48 abandon(&mut child);
49 Ok(None)
50 }
51 }
52}
53
54pub fn output_bounded(
58 cmd: &mut Command,
59 timeout: Duration,
60) -> std::io::Result<Option<std::process::Output>> {
61 cmd.stdin(Stdio::null())
62 .stdout(Stdio::piped())
63 .stderr(Stdio::piped());
64 let mut child = cmd.spawn()?;
65 let drain = |stream: Option<Box<dyn Read + Send>>| {
66 let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
67 std::thread::spawn(move || {
68 let mut buf = Vec::new();
69 if let Some(mut s) = stream {
70 let _ = s.read_to_end(&mut buf);
71 }
72 let _ = tx.send(buf);
73 });
74 rx
75 };
76 let out_rx = drain(
77 child
78 .stdout
79 .take()
80 .map(|s| Box::new(s) as Box<dyn Read + Send>),
81 );
82 let err_rx = drain(
83 child
84 .stderr
85 .take()
86 .map(|s| Box::new(s) as Box<dyn Read + Send>),
87 );
88 match poll_exit(&mut child, Instant::now() + timeout)? {
89 Some(status) => Ok(Some(std::process::Output {
90 status,
91 stdout: out_rx.recv_timeout(HELPER_REAP).unwrap_or_default(),
92 stderr: err_rx.recv_timeout(HELPER_REAP).unwrap_or_default(),
93 })),
94 None => {
95 abandon(&mut child);
96 Ok(None)
97 }
98 }
99}
100
101#[cfg(unix)]
103fn quiet_ok(cmd: &mut Command) -> bool {
104 cmd.stdin(Stdio::null())
105 .stdout(Stdio::null())
106 .stderr(Stdio::null());
107 matches!(status_bounded(cmd, KILL_HELPER_TIMEOUT), Ok(Some(s)) if s.success())
108}
109
110pub fn kill_with_fallback(
115 helper: &mut Command,
116 pid: u32,
117 timeout: Duration,
118 fallback: impl FnOnce(u32) -> bool,
119) -> Option<String> {
120 helper
121 .stdin(Stdio::null())
122 .stdout(Stdio::null())
123 .stderr(Stdio::null());
124 let why = match status_bounded(helper, timeout) {
125 Ok(Some(_)) => return None,
126 Ok(None) => format!("timed out after {:.1}s", timeout.as_secs_f32()),
127 Err(e) => format!("could not run ({e})"),
128 };
129 let ok = fallback(pid);
130 Some(format!(
131 "[rightkit-qa] kill helper for pid {pid} {why}; fell back to direct termination of the known pid ({})",
132 if ok { "terminated" } else { "not terminated" }
133 ))
134}
135
136#[cfg(windows)]
138fn terminate_pid(pid: u32) -> bool {
139 use windows_sys::Win32::Foundation::CloseHandle;
140 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
141 unsafe {
142 let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
143 if h.is_null() {
144 return false;
145 }
146 let ok = TerminateProcess(h, 1) != 0;
147 CloseHandle(h);
148 ok
149 }
150}
151
152#[cfg(windows)]
153fn taskkill_tree(pid: u32) -> Option<String> {
154 kill_with_fallback(
155 hidden(Command::new("taskkill.exe").args(["/PID", &pid.to_string(), "/T", "/F"])),
156 pid,
157 KILL_HELPER_TIMEOUT,
158 terminate_pid,
159 )
160}
161
162pub fn terminate_bounded(child: &mut OwnedChild) -> (Option<ExitStatus>, Option<String>) {
165 match child.terminate_tree_bounded(TERMINATE_REAP_TIMEOUT) {
166 Ok(ReapOutcome::Exited(st)) => (Some(st), None),
167 Ok(ReapOutcome::TimedOut) => (
168 None,
169 Some(format!(
170 "[rightkit-qa] pid {} was not reaped within {}s of tree termination; moving on",
171 child.id(),
172 TERMINATE_REAP_TIMEOUT.as_secs()
173 )),
174 ),
175 Err(e) => (
176 None,
177 Some(format!(
178 "[rightkit-qa] tree termination of pid {} failed: {e}",
179 child.id()
180 )),
181 ),
182 }
183}
184
185pub fn is_alive(pid: u32) -> bool {
186 if pid == 0 {
187 return false;
188 }
189 #[cfg(unix)]
190 {
191 if !quiet_ok(Command::new("kill").args(["-0", &pid.to_string()])) {
192 return false;
193 }
194 let stat = output_bounded(
198 Command::new("ps").args(["-o", "stat=", "-p", &pid.to_string()]),
199 KILL_HELPER_TIMEOUT,
200 );
201 !matches!(
202 stat,
203 Ok(Some(o)) if String::from_utf8_lossy(&o.stdout).trim_start().starts_with('Z')
204 )
205 }
206 #[cfg(windows)]
207 {
208 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ACCESS_DENIED};
212 use windows_sys::Win32::System::Threading::{
213 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
214 };
215 const STILL_ACTIVE: u32 = 259;
216 unsafe {
217 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
218 if h.is_null() {
219 return GetLastError() == ERROR_ACCESS_DENIED;
220 }
221 let mut code = 0u32;
222 let ok = GetExitCodeProcess(h, &mut code) != 0;
223 CloseHandle(h);
224 !ok || code == STILL_ACTIVE
225 }
226 }
227}
228
229#[cfg(windows)]
230fn hidden(cmd: &mut Command) -> &mut Command {
231 use std::os::windows::process::CommandExt;
232 cmd.creation_flags(0x0800_0000)
233}
234
235#[cfg(unix)]
236fn children_of(pid: u32) -> Vec<u32> {
237 let out = output_bounded(
238 Command::new("pgrep").args(["-P", &pid.to_string()]),
239 KILL_HELPER_TIMEOUT,
240 );
241 match out {
242 Ok(Some(o)) => String::from_utf8_lossy(&o.stdout)
243 .lines()
244 .filter_map(|l| l.trim().parse().ok())
245 .collect(),
246 _ => Vec::new(),
247 }
248}
249
250pub fn kill_tree(pid: u32) {
252 if !is_alive(pid) {
253 return;
254 }
255 #[cfg(windows)]
256 {
257 if let Some(note) = taskkill_tree(pid) {
258 eprintln!("{note}");
259 }
260 }
261 #[cfg(unix)]
262 {
263 let mut all = vec![pid];
264 let mut i = 0;
265 while i < all.len() {
266 let kids = children_of(all[i]);
267 all.extend(kids);
268 i += 1;
269 }
270 for sig in ["-TERM", "-KILL"] {
271 for p in all.iter().rev() {
272 let _ = quiet_ok(Command::new("kill").args([sig, &p.to_string()]));
273 }
274 let deadline = std::time::Instant::now() + Duration::from_millis(1500);
275 while std::time::Instant::now() < deadline && all.iter().any(|p| is_alive(*p)) {
276 crate::util::sleep_ms(25);
277 }
278 if !all.iter().any(|p| is_alive(*p)) {
279 break;
280 }
281 }
282 }
283}
284
285fn kill_group(pid: u32) -> Option<String> {
288 #[cfg(unix)]
289 {
290 let _ = quiet_ok(Command::new("kill").args(["-KILL", "--", &format!("-{pid}")]));
291 None
292 }
293 #[cfg(windows)]
294 {
295 taskkill_tree(pid)
296 }
297}
298
299#[derive(Clone, Default)]
301pub struct Tracker {
302 inner: Arc<Mutex<BTreeMap<u32, String>>>,
303 registry: Arc<Mutex<Option<PathBuf>>>,
304}
305
306impl Tracker {
307 pub fn new() -> Self {
308 Self::default()
309 }
310 pub fn register(&self, pid: u32, label: &str) {
311 self.inner.lock().unwrap().insert(pid, label.to_string());
312 self.persist();
313 }
314 pub fn forget(&self, pid: u32) {
315 self.inner.lock().unwrap().remove(&pid);
316 self.persist();
317 }
318 pub fn alive(&self) -> Vec<(u32, String)> {
319 self.inner
320 .lock()
321 .unwrap()
322 .iter()
323 .filter(|(p, _)| is_alive(**p))
324 .map(|(p, l)| (*p, l.clone()))
325 .collect()
326 }
327 pub fn pids(&self) -> Vec<u32> {
328 self.inner.lock().unwrap().keys().copied().collect()
329 }
330 pub fn kill_all(&self) {
331 for (pid, _) in self.alive() {
332 kill_tree(pid);
333 }
334 }
335 pub fn assert_no_orphans(&self) -> Result<()> {
338 let alive = self.alive();
339 if alive.is_empty() {
340 return Ok(());
341 }
342 self.kill_all();
343 let list = alive
344 .iter()
345 .map(|(p, l)| format!("{p} ({l})"))
346 .collect::<Vec<_>>()
347 .join(", ");
348 err(format!("orphan processes still running: {list}"))
349 }
350}
351
352#[derive(Debug, Clone, Default)]
353pub struct RunOptions {
354 pub cwd: Option<PathBuf>,
355 pub env: Vec<(String, String)>,
356 pub clear_env: bool,
358 pub timeout: Option<Duration>,
359 pub label: String,
360}
361
362#[derive(Debug, Clone)]
363pub struct Output {
364 pub pid: u32,
365 pub code: Option<i32>,
366 pub stdout: String,
367 pub stderr: String,
368 pub timed_out: bool,
369}
370
371pub fn run_owned(
373 program: &str,
374 args: &[String],
375 opts: &RunOptions,
376 tracker: &Tracker,
377) -> Result<Output> {
378 let mut cmd = Command::new(program);
379 cmd.args(args)
380 .stdin(Stdio::null())
381 .stdout(Stdio::piped())
382 .stderr(Stdio::piped());
383 #[cfg(windows)]
384 {
385 use std::os::windows::process::CommandExt;
386 cmd.creation_flags(0x0800_0000); }
388 if let Some(cwd) = &opts.cwd {
389 cmd.current_dir(cwd);
390 }
391 if opts.clear_env {
392 cmd.env_clear();
393 }
394 for (k, v) in &opts.env {
395 cmd.env(k, v);
396 }
397 let mut owned = OwnedCommand::from_command(cmd);
398 owned.windows_hide();
399 let mut child = owned
400 .spawn()
401 .map_err(|e| crate::util::Error(format!("failed to start {program}: {e}")))?;
402 let pid = child.id();
403 tracker.register(
404 pid,
405 if opts.label.is_empty() {
406 program
407 } else {
408 &opts.label
409 },
410 );
411 let out_buf: Arc<Mutex<Vec<u8>>> = Arc::default();
412 let err_buf: Arc<Mutex<Vec<u8>>> = Arc::default();
413 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
414 for (stream, buf) in [
415 (
416 child
417 .take_stdout()
418 .map(|s| Box::new(s) as Box<dyn Read + Send>),
419 out_buf.clone(),
420 ),
421 (
422 child
423 .take_stderr()
424 .map(|s| Box::new(s) as Box<dyn Read + Send>),
425 err_buf.clone(),
426 ),
427 ] {
428 let tx = done_tx.clone();
429 std::thread::spawn(move || {
430 if let Some(mut s) = stream {
431 let mut chunk = [0u8; 8192];
432 while let Ok(n) = s.read(&mut chunk) {
433 if n == 0 {
434 break;
435 }
436 buf.lock().unwrap().extend_from_slice(&chunk[..n]);
437 }
438 }
439 let _ = tx.send(());
440 });
441 }
442 drop(done_tx);
443 let mut notes: Vec<String> = Vec::new();
444 let timeout = opts.timeout.unwrap_or(Duration::from_secs(120));
445 let status = child.wait_timeout(timeout)?;
446 let (code, timed_out) = match status {
447 Some(s) => {
448 notes.extend(kill_group(pid));
450 (s.code(), false)
451 }
452 None => {
453 let (st, note) = terminate_bounded(&mut child);
454 notes.extend(note);
455 notes.extend(kill_group(pid));
456 (st.and_then(|s| s.code()), true)
457 }
458 };
459 let mut finished = 0;
463 let grace = Instant::now() + Duration::from_secs(2);
464 while finished < 2 {
465 match done_rx.recv_timeout(grace.saturating_duration_since(Instant::now())) {
466 Ok(()) => finished += 1,
467 Err(_) => break,
468 }
469 }
470 let mut leaked_descendants = false;
471 if finished < 2 {
472 leaked_descendants = true;
473 notes.extend(kill_group(pid));
474 let last = Instant::now() + Duration::from_secs(3);
475 while finished < 2 {
476 match done_rx.recv_timeout(last.saturating_duration_since(Instant::now())) {
477 Ok(()) => finished += 1,
478 Err(_) => break,
479 }
480 }
481 }
482 drop(child);
483 tracker.forget(pid);
484 let mut stderr = String::from_utf8_lossy(&err_buf.lock().unwrap()).into_owned();
485 if leaked_descendants {
486 stderr.push_str("\n[rightkit-qa] descendant processes outlived the command and held its output open; the process group was killed");
487 }
488 for note in ¬es {
489 stderr.push('\n');
490 stderr.push_str(note);
491 }
492 let stdout = String::from_utf8_lossy(&out_buf.lock().unwrap()).into_owned();
493 Ok(Output {
494 pid,
495 code,
496 stdout,
497 stderr,
498 timed_out,
499 })
500}
501
502pub fn fingerprint(pid: u32) -> Option<String> {
507 #[cfg(unix)]
508 {
509 let out = output_bounded(
510 Command::new("ps").args(["-o", "command=", "-p", &pid.to_string()]),
511 KILL_HELPER_TIMEOUT,
512 )
513 .ok()??;
514 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
515 if s.is_empty() {
516 None
517 } else {
518 Some(s)
519 }
520 }
521 #[cfg(windows)]
522 {
523 let _ = pid;
524 None
525 }
526}
527
528impl Tracker {
529 pub fn with_registry(path: PathBuf) -> Self {
532 let t = Self::default();
533 *t.registry.lock().unwrap() = Some(path);
534 t
535 }
536
537 pub(crate) fn persist(&self) {
538 let Some(path) = self.registry.lock().unwrap().clone() else {
539 return;
540 };
541 let rows: Vec<serde_json::Value> = self
542 .inner
543 .lock()
544 .unwrap()
545 .iter()
546 .map(|(pid, label)| serde_json::json!({"pid": pid, "label": label, "command": fingerprint(*pid)}))
547 .collect();
548 if let Some(p) = path.parent() {
549 let _ = std::fs::create_dir_all(p);
550 }
551 let _ = std::fs::write(path, serde_json::to_vec_pretty(&rows).unwrap_or_default());
552 }
553}
554
555pub fn sweep_registries(runs_root: &std::path::Path, skip_run: &str) -> Vec<u32> {
558 let mut killed = vec![];
559 let Ok(entries) = std::fs::read_dir(runs_root) else {
560 return killed;
561 };
562 for e in entries.flatten() {
563 if e.file_name().to_string_lossy() == skip_run {
564 continue;
565 }
566 let Ok(ft) = e.file_type() else { continue };
567 if !ft.is_dir() || ft.is_symlink() {
568 continue;
569 }
570 let Some(marker) = crate::storage::read_marker(&e.path().join(crate::storage::RUN_MARKER))
571 else {
572 continue;
573 };
574 if marker.run_id != e.file_name().to_string_lossy()
575 || crate::storage::marker_active(&marker, crate::util::now_ms())
576 {
577 continue;
578 }
579 let file = e.path().join("pids.json");
580 let Ok(bytes) = std::fs::read(&file) else {
581 continue;
582 };
583 let rows: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap_or_default();
584 for r in rows {
585 let (Some(pid), Some(cmd)) =
586 (r["pid"].as_u64().map(|p| p as u32), r["command"].as_str())
587 else {
588 continue;
589 };
590 if is_alive(pid) && fingerprint(pid).as_deref() == Some(cmd) {
591 kill_tree(pid);
592 killed.push(pid);
593 }
594 }
595 let _ = std::fs::remove_file(&file);
596 }
597 killed
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn slow() -> Command {
606 #[cfg(windows)]
607 {
608 let mut c = Command::new("ping");
609 c.args(["-n", "60", "127.0.0.1"]);
610 c
611 }
612 #[cfg(unix)]
613 {
614 let mut c = Command::new("sleep");
615 c.arg("60");
616 c
617 }
618 }
619
620 fn fast() -> Command {
621 #[cfg(windows)]
622 {
623 let mut c = Command::new("cmd");
624 c.args(["/C", "exit 0"]);
625 c
626 }
627 #[cfg(unix)]
628 {
629 Command::new("true")
630 }
631 }
632
633 #[test]
634 fn slow_helper_is_bounded_and_falls_back() {
635 let called = std::cell::Cell::new(None);
636 let started = Instant::now();
637 let note = kill_with_fallback(&mut slow(), 4242, Duration::from_millis(300), |pid| {
638 called.set(Some(pid));
639 true
640 });
641 assert!(started.elapsed() < Duration::from_secs(10), "bounded wait");
642 assert_eq!(called.get(), Some(4242), "fallback ran on the known pid");
643 let note = note.expect("timeout is reported");
644 assert!(note.contains("timed out"), "{note}");
645 assert!(note.contains("4242"), "{note}");
646 assert!(note.contains("(terminated)"), "{note}");
647 }
648
649 #[test]
650 fn finished_helper_skips_fallback_and_reports_nothing() {
651 let called = std::cell::Cell::new(false);
652 let note = kill_with_fallback(&mut fast(), 1, Duration::from_secs(20), |_| {
653 called.set(true);
654 true
655 });
656 assert!(note.is_none());
657 assert!(!called.get());
658 }
659
660 #[test]
661 fn unstartable_helper_falls_back_and_reports() {
662 let called = std::cell::Cell::new(false);
663 let mut missing = Command::new("rightkit-qa-no-such-helper-binary");
664 let note = kill_with_fallback(&mut missing, 7, Duration::from_secs(1), |_| {
665 called.set(true);
666 false
667 })
668 .expect("start failure is reported");
669 assert!(called.get());
670 assert!(note.contains("could not run"), "{note}");
671 assert!(note.contains("(not terminated)"), "{note}");
672 }
673
674 #[test]
675 fn status_bounded_times_out_and_reaps() {
676 let started = Instant::now();
677 let st = status_bounded(
678 slow().stdout(Stdio::null()).stderr(Stdio::null()),
679 Duration::from_millis(200),
680 )
681 .unwrap();
682 assert!(st.is_none());
683 assert!(started.elapsed() < Duration::from_secs(10));
684 }
685
686 #[test]
687 fn output_bounded_times_out_and_returns_output_when_fast() {
688 let started = Instant::now();
689 assert!(output_bounded(&mut slow(), Duration::from_millis(200))
690 .unwrap()
691 .is_none());
692 assert!(started.elapsed() < Duration::from_secs(10));
693 let out = output_bounded(&mut fast(), Duration::from_secs(20))
694 .unwrap()
695 .expect("finishes");
696 assert!(out.status.success());
697 }
698}