1use async_trait::async_trait;
14use hyperactor::Actor;
15use hyperactor::Context;
16use hyperactor::Endpoint as _;
17use hyperactor::HandleClient;
18use hyperactor::Handler;
19use hyperactor::OncePortRef;
20use hyperactor::RefClient;
21use serde::Deserialize;
22use serde::Serialize;
23use typeuri::Named;
24
25use crate::config::MESH_ADMIN_PYSPY_TIMEOUT;
26use crate::config::PYSPY_BIN;
27
28const SUBPROCESS_REAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
29
30#[derive(
32 Debug,
33 Clone,
34 Copy,
35 PartialEq,
36 Eq,
37 Serialize,
38 Deserialize,
39 schemars::JsonSchema
40)]
41#[serde(rename_all = "snake_case")]
42pub enum PySpyCaptureMode {
43 PythonOnly,
45 Native,
47 NativeAll,
49}
50
51#[derive(
55 Debug,
56 Clone,
57 PartialEq,
58 Serialize,
59 Deserialize,
60 Named,
61 schemars::JsonSchema
62)]
63pub enum PySpyResult {
64 Ok {
66 pid: u32,
68 binary: String,
70 capture_mode: PySpyCaptureMode,
72 stack_traces: Vec<PySpyStackTrace>,
74 warnings: Vec<String>,
78 },
79 BinaryNotFound {
81 searched: Vec<String>,
83 },
84 Failed {
86 pid: u32,
88 binary: String,
90 exit_code: Option<i32>,
92 stderr: String,
94 },
95}
96wirevalue::register_type!(PySpyResult);
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
100pub struct PySpyStackTrace {
101 pub pid: i32,
103 pub thread_id: u64,
105 pub thread_name: Option<String>,
107 pub os_thread_id: Option<u64>,
109 pub active: bool,
111 pub owns_gil: bool,
113 pub frames: Vec<PySpyFrame>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
119pub struct PySpyFrame {
120 pub name: String,
122 pub filename: String,
124 pub module: Option<String>,
126 pub short_filename: Option<String>,
128 pub line: i32,
130 pub locals: Option<Vec<PySpyLocalVariable>>,
132 pub is_entry: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct PySpyLocalVariable {
139 pub name: String,
141 pub addr: usize,
143 pub arg: bool,
145 pub repr: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct PySpyOpts {
152 pub threads: bool,
154 pub native: bool,
156 pub native_all: bool,
159 pub nonblocking: bool,
162}
163
164impl From<&PySpyOpts> for PySpyCaptureMode {
165 fn from(opts: &PySpyOpts) -> Self {
166 if opts.native_all {
167 Self::NativeAll
168 } else if opts.native {
169 Self::Native
170 } else {
171 Self::PythonOnly
172 }
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
183pub struct PySpyProfileOpts {
184 #[schemars(range(min = 1))]
188 pub duration_s: u32,
189 #[schemars(range(min = 1, max = 1000))]
191 pub rate_hz: u32,
192 pub native: bool,
194 pub threads: bool,
196 pub nonblocking: bool,
198}
199
200#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
202pub(crate) struct ProfileDurationSecs(std::num::NonZeroU32);
203
204impl ProfileDurationSecs {
205 pub fn get(self) -> u32 {
206 self.0.get()
207 }
208}
209
210#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
212pub(crate) struct SampleRateHz(std::num::NonZeroU32);
213
214impl SampleRateHz {
215 pub fn get(self) -> u32 {
216 self.0.get()
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
223pub(crate) struct ValidatedProfileRequest {
224 duration: ProfileDurationSecs,
226 rate: SampleRateHz,
228 native: bool,
230 threads: bool,
232 nonblocking: bool,
234 subprocess_timeout: std::time::Duration,
236 bridge_timeout: std::time::Duration,
238}
239
240impl ValidatedProfileRequest {
241 pub fn duration(&self) -> ProfileDurationSecs {
242 self.duration
243 }
244 pub fn rate(&self) -> SampleRateHz {
245 self.rate
246 }
247 pub fn native(&self) -> bool {
248 self.native
249 }
250 pub fn threads(&self) -> bool {
251 self.threads
252 }
253 pub fn nonblocking(&self) -> bool {
254 self.nonblocking
255 }
256 pub fn subprocess_timeout(&self) -> std::time::Duration {
257 self.subprocess_timeout
258 }
259 pub fn bridge_timeout(&self) -> std::time::Duration {
260 self.bridge_timeout
261 }
262
263 pub fn try_new(
264 opts: &PySpyProfileOpts,
265 max_duration: std::time::Duration,
266 ) -> Result<Self, String> {
267 let duration = std::num::NonZeroU32::new(opts.duration_s)
268 .map(ProfileDurationSecs)
269 .ok_or_else(|| "duration_s must be positive".to_string())?;
270 if std::time::Duration::from_secs(u64::from(duration.get())) > max_duration {
271 return Err(format!(
272 "duration_s {}s exceeds max {}s",
273 duration.get(),
274 max_duration.as_secs()
275 ));
276 }
277 let rate = std::num::NonZeroU32::new(opts.rate_hz)
278 .filter(|n| n.get() <= 1000)
279 .map(SampleRateHz)
280 .ok_or_else(|| format!("rate_hz must be 1..=1000, got {}", opts.rate_hz))?;
281 let subprocess_timeout = std::time::Duration::from_secs(u64::from(duration.get()) + 15);
282 let bridge_timeout = subprocess_timeout + std::time::Duration::from_secs(5);
283 Ok(Self {
284 duration,
285 rate,
286 native: opts.native,
287 threads: opts.threads,
288 nonblocking: opts.nonblocking,
289 subprocess_timeout,
290 bridge_timeout,
291 })
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Named)]
299pub enum PySpyProfileResult {
300 Ok {
301 pid: u32,
302 binary: String,
303 svg: Vec<u8>,
304 },
305 BinaryNotFound {
306 searched: Vec<String>,
307 },
308 TimedOut {
309 pid: u32,
310 binary: String,
311 timeout_s: u64,
312 stderr: String,
313 },
314 ExitFailure {
315 pid: u32,
316 binary: String,
317 exit_code: Option<i32>,
318 stderr: String,
319 },
320 OutputMissing {
321 pid: u32,
322 binary: String,
323 },
324 OutputEmpty {
325 pid: u32,
326 binary: String,
327 },
328 OutputReadFailure {
329 pid: u32,
330 binary: String,
331 error: String,
332 },
333 WorkerSpawnFailure {
334 error: String,
335 },
336 SubprocessSpawnFailure {
337 pid: u32,
338 binary: String,
339 error: String,
340 },
341 WaitFailure {
342 pid: u32,
343 binary: String,
344 error: String,
345 },
346 TempDirFailure {
347 pid: u32,
348 binary: String,
349 error: String,
350 },
351}
352wirevalue::register_type!(PySpyProfileResult);
353
354#[derive(Debug)]
357pub(crate) enum ProfileExecOutcome {
358 Ok {
359 pid: u32,
360 binary: String,
361 svg: Vec<u8>,
362 },
363 BinaryNotFound {
364 searched: Vec<String>,
365 },
366 TimedOut {
367 pid: u32,
368 binary: String,
369 timeout: std::time::Duration,
370 stderr: String,
371 },
372 ExitFailure {
373 pid: u32,
374 binary: String,
375 exit_code: Option<i32>,
376 stderr: String,
377 },
378 OutputMissing {
379 pid: u32,
380 binary: String,
381 },
382 OutputEmpty {
383 pid: u32,
384 binary: String,
385 },
386 OutputReadFailure {
387 pid: u32,
388 binary: String,
389 error: String,
390 },
391 SubprocessSpawnFailure {
392 pid: u32,
393 binary: String,
394 error: String,
395 },
396 WaitFailure {
397 pid: u32,
398 binary: String,
399 error: String,
400 },
401 TempDirFailure {
402 pid: u32,
403 binary: String,
404 error: String,
405 },
406}
407
408impl From<ProfileExecOutcome> for PySpyProfileResult {
409 fn from(outcome: ProfileExecOutcome) -> Self {
410 match outcome {
411 ProfileExecOutcome::Ok { pid, binary, svg } => {
412 PySpyProfileResult::Ok { pid, binary, svg }
413 }
414 ProfileExecOutcome::BinaryNotFound { searched } => {
415 PySpyProfileResult::BinaryNotFound { searched }
416 }
417 ProfileExecOutcome::TimedOut {
418 pid,
419 binary,
420 timeout,
421 stderr,
422 } => PySpyProfileResult::TimedOut {
423 pid,
424 binary,
425 timeout_s: timeout.as_secs(),
426 stderr,
427 },
428 ProfileExecOutcome::ExitFailure {
429 pid,
430 binary,
431 exit_code,
432 stderr,
433 } => PySpyProfileResult::ExitFailure {
434 pid,
435 binary,
436 exit_code,
437 stderr,
438 },
439 ProfileExecOutcome::OutputMissing { pid, binary } => {
440 PySpyProfileResult::OutputMissing { pid, binary }
441 }
442 ProfileExecOutcome::OutputEmpty { pid, binary } => {
443 PySpyProfileResult::OutputEmpty { pid, binary }
444 }
445 ProfileExecOutcome::OutputReadFailure { pid, binary, error } => {
446 PySpyProfileResult::OutputReadFailure { pid, binary, error }
447 }
448 ProfileExecOutcome::SubprocessSpawnFailure { pid, binary, error } => {
449 PySpyProfileResult::SubprocessSpawnFailure { pid, binary, error }
450 }
451 ProfileExecOutcome::WaitFailure { pid, binary, error } => {
452 PySpyProfileResult::WaitFailure { pid, binary, error }
453 }
454 ProfileExecOutcome::TempDirFailure { pid, binary, error } => {
455 PySpyProfileResult::TempDirFailure { pid, binary, error }
456 }
457 }
458 }
459}
460
461#[derive(Debug, Serialize, Deserialize, Named, Handler, HandleClient, RefClient)]
469pub struct PySpyDump {
470 pub opts: PySpyOpts,
472 #[reply]
474 pub result: OncePortRef<PySpyResult>,
475}
476wirevalue::register_type!(PySpyDump);
477
478#[allow(private_interfaces)] #[derive(Debug, Serialize, Deserialize, Named, Handler, HandleClient, RefClient)]
486pub struct PySpyProfile {
487 pub request: ValidatedProfileRequest,
489 #[reply]
491 pub result: OncePortRef<PySpyProfileResult>,
492}
493wirevalue::register_type!(PySpyProfile);
494
495pub struct PySpyRunner;
499
500impl PySpyRunner {
501 pub async fn dump_self(&self, opts: &PySpyOpts) -> PySpyResult {
511 let pid = std::process::id();
512 let pyspy_bin: String = hyperactor_config::global::get_cloned(PYSPY_BIN);
513 let candidates = resolve_candidates(if pyspy_bin.is_empty() {
514 None
515 } else {
516 Some(pyspy_bin)
517 });
518 let mut searched = vec![];
519
520 for (binary, label) in &candidates {
521 searched.push(label.clone());
522 if let Some(result) = try_exec(
523 binary,
524 label,
525 pid,
526 opts,
527 hyperactor_config::global::get(MESH_ADMIN_PYSPY_TIMEOUT),
528 )
529 .await
530 {
531 return result;
532 }
533 }
534
535 PySpyResult::BinaryNotFound { searched }
536 }
537
538 pub(crate) async fn profile_self(
541 &self,
542 request: &ValidatedProfileRequest,
543 ) -> ProfileExecOutcome {
544 let pid = std::process::id();
545 let pyspy_bin: String = hyperactor_config::global::get_cloned(PYSPY_BIN);
546 let candidates = resolve_candidates(if pyspy_bin.is_empty() {
547 None
548 } else {
549 Some(pyspy_bin)
550 });
551 let mut searched = vec![];
552
553 for (binary, label) in &candidates {
554 searched.push(label.clone());
555 if let Some(result) = try_profile(binary, pid, request).await {
556 return result;
557 }
558 }
559
560 ProfileExecOutcome::BinaryNotFound { searched }
561 }
562}
563
564#[derive(Debug, Serialize, Deserialize, Named)]
568pub struct RunPySpyDump {
569 pub opts: PySpyOpts,
570 pub reply_port: hyperactor::OncePortRef<PySpyResult>,
572}
573wirevalue::register_type!(RunPySpyDump);
574
575#[hyperactor::export(handlers = [RunPySpyDump])]
580pub struct PySpyWorker;
581
582impl Actor for PySpyWorker {}
583
584impl PySpyWorker {
585 pub(crate) fn spawn_and_forward(
588 cx: &impl hyperactor::context::Actor,
589 opts: PySpyOpts,
590 reply_port: hyperactor::OncePortRef<PySpyResult>,
591 ) -> Result<(), anyhow::Error> {
592 let worker = cx.spawn(Self);
593 worker.post(cx, RunPySpyDump { opts, reply_port });
594 Ok(())
595 }
596}
597
598#[async_trait]
599impl Handler<RunPySpyDump> for PySpyWorker {
600 async fn handle(
601 &mut self,
602 cx: &Context<Self>,
603 message: RunPySpyDump,
604 ) -> Result<(), anyhow::Error> {
605 let result = PySpyRunner.dump_self(&message.opts).await;
606 message.reply_port.post(cx, result);
607 cx.stop("pyspy dump complete")?;
608 Ok(())
609 }
610}
611
612#[allow(private_interfaces)] #[derive(Debug, Serialize, Deserialize, Named)]
615pub struct RunPySpyProfile {
616 pub request: ValidatedProfileRequest,
617 pub reply_port: hyperactor::OncePortRef<PySpyProfileResult>,
618}
619wirevalue::register_type!(RunPySpyProfile);
620
621#[hyperactor::export(handlers = [RunPySpyProfile])]
624pub struct PySpyProfileWorker;
625
626impl Actor for PySpyProfileWorker {}
627
628impl PySpyProfileWorker {
629 pub(crate) fn spawn_and_forward(
631 cx: &impl hyperactor::context::Actor,
632 request: ValidatedProfileRequest,
633 reply_port: hyperactor::OncePortRef<PySpyProfileResult>,
634 ) -> Result<(), anyhow::Error> {
635 let worker = cx.spawn(Self);
636 worker.post(
637 cx,
638 RunPySpyProfile {
639 request,
640 reply_port,
641 },
642 );
643 Ok(())
644 }
645}
646
647#[async_trait]
648impl Handler<RunPySpyProfile> for PySpyProfileWorker {
649 async fn handle(
650 &mut self,
651 cx: &Context<Self>,
652 message: RunPySpyProfile,
653 ) -> Result<(), anyhow::Error> {
654 let outcome = PySpyRunner.profile_self(&message.request).await;
655 message
656 .reply_port
657 .post(cx, PySpyProfileResult::from(outcome));
658 cx.stop("pyspy profile complete")?;
659 Ok(())
660 }
661}
662
663fn resolve_candidates(pyspy_bin_env: Option<String>) -> Vec<(String, String)> {
666 let mut candidates = vec![];
667 if let Some(path) = pyspy_bin_env
668 && !path.is_empty()
669 {
670 let label = format!("PYSPY_BIN={}", path);
671 candidates.push((path, label));
672 }
673 candidates.push(("py-spy".to_string(), "py-spy on PATH".to_string()));
674 candidates
675}
676
677fn build_command(binary: &str, pid: u32, opts: &PySpyOpts) -> tokio::process::Command {
679 let mut cmd = tokio::process::Command::new(binary);
680 cmd.arg("dump")
681 .arg("--pid")
682 .arg(pid.to_string())
683 .arg("--json");
684 if opts.threads {
685 cmd.arg("--threads");
686 }
687 if opts.native {
688 cmd.arg("--native");
689 }
690 if opts.native_all {
691 cmd.arg("--native-all");
692 }
693 if opts.nonblocking {
694 cmd.arg("--nonblocking");
695 }
696 cmd.stdout(std::process::Stdio::piped());
697 cmd.stderr(std::process::Stdio::piped());
698 cmd
699}
700
701struct GuardedSubprocess {
704 group: ProcessGroupGuard,
705 child: tokio::process::Child,
706}
707
708struct ProcessGroupGuard {
711 pgid: Option<i32>,
712}
713
714impl ProcessGroupGuard {
715 fn kill(&self) {
716 #[cfg(unix)]
717 if let Some(pgid) = self.pgid {
718 unsafe {
721 libc::killpg(pgid, libc::SIGKILL);
722 }
723 }
724 }
725
726 fn disarm(&mut self) {
727 self.pgid = None;
728 }
729}
730
731impl Drop for ProcessGroupGuard {
732 fn drop(&mut self) {
733 self.kill();
734 }
735}
736
737fn spawn_guarded_subprocess(
740 mut command: tokio::process::Command,
741) -> std::io::Result<GuardedSubprocess> {
742 #[cfg(unix)]
743 command.process_group(0);
744 command.kill_on_drop(true);
745 let child = command.spawn()?;
746 let group = ProcessGroupGuard {
747 pgid: child
748 .id()
749 .and_then(|pid| i32::try_from(pid).ok())
750 .filter(|pid| *pid > 0),
751 };
752 Ok(GuardedSubprocess { group, child })
753}
754
755async fn kill_and_reap(subprocess: &mut GuardedSubprocess) -> Option<String> {
757 subprocess.group.kill();
758 let _ = subprocess.child.start_kill();
759 match tokio::time::timeout(SUBPROCESS_REAP_TIMEOUT, subprocess.child.wait()).await {
760 Ok(Ok(_)) => {
761 subprocess.group.disarm();
762 None
763 }
764 Ok(Err(error)) => Some(format!("failed to reap killed py-spy subprocess: {error}")),
765 Err(_) => Some(format!(
766 "timed out after {}s waiting to reap killed py-spy subprocess",
767 SUBPROCESS_REAP_TIMEOUT.as_secs()
768 )),
769 }
770}
771
772fn map_output(
776 output: std::process::Output,
777 pid: u32,
778 binary: &str,
779 capture_mode: PySpyCaptureMode,
780) -> PySpyResult {
781 if output.status.success() {
782 match serde_json::from_slice::<Vec<PySpyStackTrace>>(&output.stdout) {
783 Ok(stack_traces) => PySpyResult::Ok {
784 pid,
785 binary: binary.to_string(),
786 capture_mode,
787 stack_traces,
788 warnings: vec![],
789 },
790 Err(e) => PySpyResult::Failed {
791 pid,
792 binary: binary.to_string(),
793 exit_code: None,
794 stderr: format!("failed to parse py-spy JSON output: {}", e),
795 },
796 }
797 } else {
798 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
799 PySpyResult::Failed {
800 pid,
801 binary: binary.to_string(),
802 exit_code: output.status.code(),
803 stderr,
804 }
805 }
806}
807
808fn is_unsupported_native_all(result: &PySpyResult) -> bool {
812 matches!(
813 result,
814 PySpyResult::Failed {
815 exit_code: Some(2),
816 stderr,
817 ..
818 } if stderr.contains("--native-all")
819 )
820}
821
822fn native_all_downgrade_warning(label: &str) -> String {
828 format!("--native-all unsupported by py-spy ({label}); fell back to --native")
829}
830
831fn native_downgrade_warning(label: &str) -> String {
837 format!(
838 "native capture failed; fell back to python-only frames. py-spy ({label}) could not \
839 unwind native frames on this target. py-spy 0.4.1 or newer (PyPI wheel) can; to restore \
840 them, point PYSPY_BIN on the dumped proc at such a binary"
841 )
842}
843
844enum ExecOnce {
846 Result(PySpyResult),
848 NotFound,
850}
851
852#[derive(Clone, Copy)]
853struct DumpAttempt<'a> {
854 pid: u32,
855 binary: &'a str,
856 capture_mode: PySpyCaptureMode,
857}
858
859async fn exec_once(
864 binary: &str,
865 pid: u32,
866 opts: &PySpyOpts,
867 deadline: tokio::time::Instant,
868 timeout: std::time::Duration,
869) -> ExecOnce {
870 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
871 if remaining.is_zero() {
872 return ExecOnce::Result(PySpyResult::Failed {
873 pid,
874 binary: binary.to_string(),
875 exit_code: None,
876 stderr: format!("py-spy subprocess timed out after {}s", timeout.as_secs()),
877 });
878 }
879 let subprocess = match spawn_guarded_subprocess(build_command(binary, pid, opts)) {
880 Ok(subprocess) => subprocess,
881 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ExecOnce::NotFound,
882 Err(e) => {
883 return ExecOnce::Result(PySpyResult::Failed {
884 pid,
885 binary: binary.to_string(),
886 exit_code: None,
887 stderr: format!("failed to execute: {}", e),
888 });
889 }
890 };
891 let attempt = DumpAttempt {
892 pid,
893 binary,
894 capture_mode: PySpyCaptureMode::from(opts),
895 };
896 ExecOnce::Result(collect_with_timeout(subprocess, attempt, remaining).await)
897}
898
899async fn try_exec(
919 binary: &str,
920 label: &str,
921 pid: u32,
922 opts: &PySpyOpts,
923 timeout: std::time::Duration,
924) -> Option<PySpyResult> {
925 let deadline = tokio::time::Instant::now() + timeout;
926 let retries = if opts.nonblocking { 3 } else { 1 };
927 let mut last_result = None;
928 let mut effective_opts = opts.clone();
929 let mut fallback_warnings = Vec::new();
930
931 for attempt in 0..retries {
932 if attempt > 0 {
933 let wake_at = tokio::time::Instant::now() + std::time::Duration::from_millis(100);
934 if wake_at >= deadline {
935 break;
936 }
937 tokio::time::sleep_until(wake_at).await;
938 }
939 let mut result = match exec_once(binary, pid, &effective_opts, deadline, timeout).await {
940 ExecOnce::NotFound => return None,
941 ExecOnce::Result(r) => r,
942 };
943 if is_unsupported_native_all(&result) && effective_opts.native_all {
947 effective_opts.native_all = false;
950 effective_opts.native = true;
953 fallback_warnings.push(native_all_downgrade_warning(label));
954 result = match exec_once(binary, pid, &effective_opts, deadline, timeout).await {
955 ExecOnce::NotFound => return None,
956 ExecOnce::Result(r) => r,
957 };
958 }
961 let native_requested = effective_opts.native || effective_opts.native_all;
965 if native_requested && matches!(result, PySpyResult::Failed { .. }) {
966 effective_opts.native = false;
969 effective_opts.native_all = false;
970 fallback_warnings.push(native_downgrade_warning(label));
971 result = match exec_once(binary, pid, &effective_opts, deadline, timeout).await {
972 ExecOnce::NotFound => return None,
973 ExecOnce::Result(r) => r,
974 };
975 }
976 if let PySpyResult::Ok { warnings, .. } = &mut result {
980 warnings.extend(fallback_warnings.iter().cloned());
981 }
982 match &result {
983 PySpyResult::Ok { .. } => return Some(result),
984 _ => {
985 last_result = Some(result);
986 }
987 }
988 }
989
990 last_result
991}
992
993async fn collect_with_timeout(
1003 mut subprocess: GuardedSubprocess,
1004 attempt: DumpAttempt<'_>,
1005 timeout: std::time::Duration,
1006) -> PySpyResult {
1007 let mut stdout_handle = subprocess.child.stdout.take();
1010 let mut stderr_handle = subprocess.child.stderr.take();
1011 let mut stdout_bytes = Vec::new();
1012 let mut stderr_bytes = Vec::new();
1013
1014 let collect = async {
1015 let stdout_fut = async {
1016 if let Some(ref mut r) = stdout_handle {
1017 let _ = tokio::io::AsyncReadExt::read_to_end(r, &mut stdout_bytes).await;
1018 }
1019 };
1020 let stderr_fut = async {
1021 if let Some(ref mut r) = stderr_handle {
1022 let _ = tokio::io::AsyncReadExt::read_to_end(r, &mut stderr_bytes).await;
1023 }
1024 };
1025 tokio::join!(stdout_fut, stderr_fut);
1026 let status = subprocess.child.wait().await;
1027 if status.is_ok() {
1028 subprocess.group.disarm();
1029 }
1030 status
1031 };
1032
1033 match tokio::time::timeout(timeout, collect).await {
1034 Ok(Ok(status)) => {
1035 let output = std::process::Output {
1036 status,
1037 stdout: stdout_bytes,
1038 stderr: stderr_bytes,
1039 };
1040 map_output(output, attempt.pid, attempt.binary, attempt.capture_mode)
1041 }
1042 Ok(Err(e)) => {
1043 let cleanup = kill_and_reap(&mut subprocess).await;
1044 PySpyResult::Failed {
1045 pid: attempt.pid,
1046 binary: attempt.binary.to_string(),
1047 exit_code: None,
1048 stderr: match cleanup {
1049 Some(cleanup) => format!("failed to wait for child: {e}; {cleanup}"),
1050 None => format!("failed to wait for child: {e}"),
1051 },
1052 }
1053 }
1054 Err(_) => {
1055 let cleanup = kill_and_reap(&mut subprocess).await;
1056 PySpyResult::Failed {
1057 pid: attempt.pid,
1058 binary: attempt.binary.to_string(),
1059 exit_code: None,
1060 stderr: match cleanup {
1061 Some(cleanup) => format!(
1062 "py-spy subprocess timed out after {}s; {cleanup}",
1063 timeout.as_secs()
1064 ),
1065 None => format!("py-spy subprocess timed out after {}s", timeout.as_secs()),
1066 },
1067 }
1068 }
1069 }
1070}
1071
1072fn build_record_command(
1074 binary: &str,
1075 pid: u32,
1076 request: &ValidatedProfileRequest,
1077 output_path: &std::path::Path,
1078) -> tokio::process::Command {
1079 let mut cmd = tokio::process::Command::new(binary);
1080 cmd.arg("record")
1081 .arg("--pid")
1082 .arg(pid.to_string())
1083 .arg("--duration")
1084 .arg(request.duration().get().to_string())
1085 .arg("--rate")
1086 .arg(request.rate().get().to_string())
1087 .arg("--format")
1088 .arg("flamegraph")
1089 .arg("--output")
1090 .arg(output_path);
1091 if request.native() {
1092 cmd.arg("--native");
1093 }
1094 if request.threads() {
1095 cmd.arg("--threads");
1096 }
1097 if request.nonblocking() {
1098 cmd.arg("--nonblocking");
1099 }
1100 cmd.stdout(std::process::Stdio::null());
1103 cmd.stderr(std::process::Stdio::piped());
1104 cmd
1105}
1106
1107async fn collect_profile_with_timeout(
1111 mut subprocess: GuardedSubprocess,
1112 pid: u32,
1113 binary: &str,
1114 timeout: std::time::Duration,
1115) -> Result<(std::process::ExitStatus, String), ProfileExecOutcome> {
1116 let mut stderr_handle = subprocess.child.stderr.take();
1118 let mut stderr_bytes = Vec::new();
1119 let collect = async {
1120 if let Some(ref mut stderr) = stderr_handle {
1121 let _ = tokio::io::AsyncReadExt::read_to_end(stderr, &mut stderr_bytes).await;
1122 }
1123 let status = subprocess.child.wait().await;
1124 if status.is_ok() {
1125 subprocess.group.disarm();
1126 }
1127 status
1128 };
1129
1130 match tokio::time::timeout(timeout, collect).await {
1131 Ok(Ok(status)) => {
1132 let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
1133 Ok((status, stderr))
1134 }
1135 Ok(Err(e)) => {
1136 let cleanup = kill_and_reap(&mut subprocess).await;
1137 Err(ProfileExecOutcome::WaitFailure {
1138 pid,
1139 binary: binary.to_string(),
1140 error: match cleanup {
1141 Some(cleanup) => format!("{e}; {cleanup}"),
1142 None => e.to_string(),
1143 },
1144 })
1145 }
1146 Err(_) => {
1147 let cleanup = kill_and_reap(&mut subprocess).await;
1148 let drain_stderr = async {
1149 if let Some(ref mut stderr) = stderr_handle {
1150 let _ = tokio::io::AsyncReadExt::read_to_end(stderr, &mut stderr_bytes).await;
1151 }
1152 };
1153 let _ = tokio::time::timeout(SUBPROCESS_REAP_TIMEOUT, drain_stderr).await;
1154 let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
1155 Err(ProfileExecOutcome::TimedOut {
1156 pid,
1157 binary: binary.to_string(),
1158 timeout,
1159 stderr: match cleanup {
1160 Some(cleanup) if stderr.is_empty() => cleanup,
1161 Some(cleanup) => format!("{stderr}; {cleanup}"),
1162 None => stderr,
1163 },
1164 })
1165 }
1166 }
1167}
1168
1169async fn try_profile(
1172 binary: &str,
1173 pid: u32,
1174 request: &ValidatedProfileRequest,
1175) -> Option<ProfileExecOutcome> {
1176 let timeout = request.subprocess_timeout();
1177 let tmp_dir = match tempfile::tempdir() {
1178 Ok(d) => d,
1179 Err(e) => {
1180 return Some(ProfileExecOutcome::TempDirFailure {
1181 pid,
1182 binary: binary.to_string(),
1183 error: e.to_string(),
1184 });
1185 }
1186 };
1187 let svg_path = tmp_dir.path().join("profile.svg");
1188
1189 let subprocess =
1190 match spawn_guarded_subprocess(build_record_command(binary, pid, request, &svg_path)) {
1191 Ok(subprocess) => subprocess,
1192 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
1193 Err(e) => {
1194 return Some(ProfileExecOutcome::SubprocessSpawnFailure {
1195 pid,
1196 binary: binary.to_string(),
1197 error: e.to_string(),
1198 });
1199 }
1200 };
1201
1202 let (status, stderr) =
1203 match collect_profile_with_timeout(subprocess, pid, binary, timeout).await {
1204 Ok(pair) => pair,
1205 Err(outcome) => return Some(outcome),
1206 };
1207
1208 if !status.success() {
1209 return Some(ProfileExecOutcome::ExitFailure {
1210 pid,
1211 binary: binary.to_string(),
1212 exit_code: status.code(),
1213 stderr,
1214 });
1215 }
1216
1217 match std::fs::read(&svg_path) {
1218 Ok(bytes) if bytes.is_empty() => Some(ProfileExecOutcome::OutputEmpty {
1219 pid,
1220 binary: binary.to_string(),
1221 }),
1222 Ok(svg) => Some(ProfileExecOutcome::Ok {
1223 pid,
1224 binary: binary.to_string(),
1225 svg,
1226 }),
1227 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1228 Some(ProfileExecOutcome::OutputMissing {
1229 pid,
1230 binary: binary.to_string(),
1231 })
1232 }
1233 Err(e) => Some(ProfileExecOutcome::OutputReadFailure {
1234 pid,
1235 binary: binary.to_string(),
1236 error: e.to_string(),
1237 }),
1238 }
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use std::io::Write;
1244 use std::os::unix::fs::PermissionsExt;
1245 use std::os::unix::process::ExitStatusExt;
1246 use std::path::Path;
1247 use std::time::Duration;
1248
1249 use tokio::process::Command;
1250
1251 use super::*;
1252
1253 #[test]
1266 fn pyspy_result_wirevalue_roundtrip() {
1267 let original = PySpyResult::Ok {
1272 pid: 42,
1273 binary: "py-spy".to_string(),
1274 capture_mode: PySpyCaptureMode::Native,
1275 stack_traces: vec![PySpyStackTrace {
1276 pid: 42,
1277 thread_id: 1,
1278 thread_name: Some("main".to_string()),
1279 os_thread_id: Some(100),
1280 active: true,
1281 owns_gil: true,
1282 frames: vec![PySpyFrame {
1283 name: "do_work".to_string(),
1284 filename: "test.py".to_string(),
1285 module: None,
1286 short_filename: None,
1287 line: 10,
1288 locals: None,
1289 is_entry: false,
1290 }],
1291 }],
1292 warnings: vec![],
1293 };
1294 let any: wirevalue::Any = wirevalue::Any::serialize(&original).expect("serialize");
1295 let restored: PySpyResult = any.deserialized().expect("deserialize");
1296 assert_eq!(original, restored);
1297 }
1298
1299 #[test]
1300 fn warnings_report_the_resolution_label() {
1301 let candidates = resolve_candidates(Some("/tmp/py-spy".to_string()));
1307 let env_label = candidates[0].1.clone();
1308 let path_label = candidates[1].1.clone();
1309
1310 let env = native_downgrade_warning(&env_label);
1311 assert!(
1312 env.contains("py-spy (PYSPY_BIN=/tmp/py-spy) could not unwind"),
1313 "unexpected: {env}"
1314 );
1315
1316 let looked_up = native_downgrade_warning(&path_label);
1317 assert!(
1318 looked_up.contains("py-spy (py-spy on PATH) could not unwind"),
1319 "unexpected: {looked_up}"
1320 );
1321
1322 assert_eq!(
1323 native_all_downgrade_warning(&path_label),
1324 "--native-all unsupported by py-spy (py-spy on PATH); fell back to --native"
1325 );
1326 }
1327
1328 #[test]
1329 fn candidates_no_env() {
1330 let candidates = resolve_candidates(None);
1332 assert_eq!(candidates.len(), 1);
1333 assert_eq!(candidates[0].0, "py-spy");
1334 assert_eq!(candidates[0].1, "py-spy on PATH");
1335 }
1336
1337 #[test]
1338 fn candidates_env_first_then_path() {
1339 let candidates = resolve_candidates(Some("/custom/py-spy".to_string()));
1341 assert_eq!(candidates.len(), 2);
1342 assert_eq!(candidates[0].0, "/custom/py-spy");
1343 assert!(candidates[0].1.contains("PYSPY_BIN=/custom/py-spy"));
1344 assert_eq!(candidates[1].0, "py-spy");
1345 }
1346
1347 #[test]
1348 fn candidates_empty_env_ignored() {
1349 let candidates = resolve_candidates(Some(String::new()));
1351 assert_eq!(candidates.len(), 1);
1352 assert_eq!(candidates[0].0, "py-spy");
1353 }
1354
1355 #[test]
1356 fn output_success_parses_json() {
1357 let json = serde_json::json!([{
1359 "pid": 42,
1360 "thread_id": 1234,
1361 "thread_name": "MainThread",
1362 "os_thread_id": 5678,
1363 "active": true,
1364 "owns_gil": true,
1365 "frames": [{
1366 "name": "do_work",
1367 "filename": "foo.py",
1368 "module": null,
1369 "short_filename": null,
1370 "line": 10,
1371 "locals": null,
1372 "is_entry": false
1373 }]
1374 }]);
1375 let output = std::process::Output {
1376 status: std::process::ExitStatus::default(),
1377 stdout: serde_json::to_vec(&json).unwrap(),
1378 stderr: vec![],
1379 };
1380 let result = map_output(output, 42, "/usr/bin/py-spy", PySpyCaptureMode::Native);
1381 match result {
1382 PySpyResult::Ok {
1383 pid,
1384 binary,
1385 capture_mode,
1386 stack_traces,
1387 ..
1388 } => {
1389 assert_eq!(pid, 42);
1390 assert_eq!(binary, "/usr/bin/py-spy");
1391 assert_eq!(capture_mode, PySpyCaptureMode::Native);
1392 assert_eq!(stack_traces.len(), 1);
1393 assert_eq!(stack_traces[0].thread_id, 1234);
1394 assert_eq!(stack_traces[0].thread_name.as_deref(), Some("MainThread"));
1395 assert!(stack_traces[0].owns_gil);
1396 assert_eq!(stack_traces[0].frames.len(), 1);
1397 assert_eq!(stack_traces[0].frames[0].name, "do_work");
1398 assert_eq!(stack_traces[0].frames[0].filename, "foo.py");
1399 assert_eq!(stack_traces[0].frames[0].line, 10);
1400 }
1401 other => panic!("expected Ok, got {:?}", other),
1402 }
1403 }
1404
1405 #[test]
1406 fn output_invalid_json_maps_to_failed() {
1407 let output = std::process::Output {
1409 status: std::process::ExitStatus::default(),
1410 stdout: b"not valid json".to_vec(),
1411 stderr: vec![],
1412 };
1413 let result = map_output(output, 42, "py-spy", PySpyCaptureMode::PythonOnly);
1414 match result {
1415 PySpyResult::Failed { pid, stderr, .. } => {
1416 assert_eq!(pid, 42);
1417 assert!(
1418 stderr.contains("failed to parse py-spy JSON output"),
1419 "unexpected stderr: {stderr}"
1420 );
1421 }
1422 other => panic!("expected Failed, got {:?}", other),
1423 }
1424 }
1425
1426 #[test]
1427 fn output_nonzero_exit_maps_to_failed() {
1428 let status = std::process::ExitStatus::from_raw(256); let output = std::process::Output {
1431 status,
1432 stdout: vec![],
1433 stderr: b"Permission denied".to_vec(),
1434 };
1435 let result = map_output(output, 99, "py-spy", PySpyCaptureMode::PythonOnly);
1436 match result {
1437 PySpyResult::Failed {
1438 pid,
1439 binary,
1440 exit_code,
1441 stderr,
1442 } => {
1443 assert_eq!(pid, 99);
1444 assert_eq!(binary, "py-spy");
1445 assert_eq!(exit_code, Some(1));
1446 assert_eq!(stderr, "Permission denied");
1447 }
1448 other => panic!("expected Failed, got {:?}", other),
1449 }
1450 }
1451
1452 #[test]
1453 fn output_preserves_caller_pid() {
1454 let json = serde_json::json!([]);
1456 let output = std::process::Output {
1457 status: std::process::ExitStatus::default(),
1458 stdout: serde_json::to_vec(&json).unwrap(),
1459 stderr: vec![],
1460 };
1461 let result = map_output(output, 12345, "bin", PySpyCaptureMode::PythonOnly);
1462 match result {
1463 PySpyResult::Ok { pid, .. } => assert_eq!(pid, 12345),
1464 other => panic!("expected Ok, got {:?}", other),
1465 }
1466 }
1467
1468 fn default_opts() -> PySpyOpts {
1469 PySpyOpts {
1470 threads: false,
1471 native: false,
1472 native_all: false,
1473 nonblocking: false,
1474 }
1475 }
1476
1477 #[test]
1478 fn capture_mode_matches_effective_options() {
1479 let mut opts = default_opts();
1480 assert_eq!(PySpyCaptureMode::from(&opts), PySpyCaptureMode::PythonOnly);
1481
1482 opts.native = true;
1483 assert_eq!(PySpyCaptureMode::from(&opts), PySpyCaptureMode::Native);
1484
1485 opts.native = false;
1486 opts.native_all = true;
1487 assert_eq!(PySpyCaptureMode::from(&opts), PySpyCaptureMode::NativeAll);
1488 }
1489
1490 #[tokio::test]
1491 async fn exec_missing_binary_returns_none() {
1492 let result = try_exec(
1494 "/definitely/not/a/real/binary",
1495 "PYSPY_BIN=/definitely/not/a/real/binary",
1496 1,
1497 &default_opts(),
1498 std::time::Duration::from_secs(5),
1499 )
1500 .await;
1501 assert!(result.is_none());
1502 }
1503
1504 #[tokio::test]
1505 async fn exec_present_binary_returns_some() {
1506 let result = try_exec(
1509 "true",
1510 "PYSPY_BIN=true",
1511 1,
1512 &default_opts(),
1513 std::time::Duration::from_secs(5),
1514 )
1515 .await;
1516 match result {
1517 Some(PySpyResult::Failed { stderr, .. }) => {
1518 assert!(
1519 stderr.contains("parse"),
1520 "expected JSON parse error, got: {stderr}"
1521 );
1522 }
1523 other => panic!("expected Some(Failed{{parse..}}), got: {other:?}"),
1524 }
1525 }
1526
1527 #[cfg(unix)]
1528 #[tokio::test]
1529 async fn collect_timeout_kills_child_and_returns_failed() {
1530 let dir = tempfile::tempdir().expect("tempdir");
1534 let ready = dir.path().join("grandchild-ready");
1535 let survived = dir.path().join("grandchild-survived");
1536 let mut target_command = Command::new("sleep");
1537 target_command.arg("60");
1538 let mut unrelated_target =
1539 spawn_guarded_subprocess(target_command).expect("spawn target-process fixture");
1540 let target_pid = unrelated_target
1541 .child
1542 .id()
1543 .expect("target process must have a pid");
1544 let child = spawn_process_tree(&ready, &survived).await;
1545
1546 let result = tokio::time::timeout(
1547 Duration::from_secs(2),
1548 collect_with_timeout(
1549 child,
1550 DumpAttempt {
1551 pid: target_pid,
1552 binary: "sh",
1553 capture_mode: PySpyCaptureMode::PythonOnly,
1554 },
1555 Duration::from_millis(200),
1556 ),
1557 )
1558 .await
1559 .expect("timeout cleanup must itself be bounded");
1560
1561 match result {
1562 PySpyResult::Failed { stderr, .. } => {
1563 assert!(
1564 stderr.contains("timed out"),
1565 "expected timeout message, got: {stderr}"
1566 );
1567 }
1568 other => panic!("expected Failed, got {:?}", other),
1569 }
1570 assert!(
1571 ready.exists(),
1572 "grandchild must start before timeout cleanup"
1573 );
1574 tokio::time::sleep(Duration::from_secs(3)).await;
1575 assert!(
1576 !survived.exists(),
1577 "grandchild outlived py-spy timeout cleanup"
1578 );
1579 assert!(
1580 unrelated_target
1581 .child
1582 .try_wait()
1583 .expect("inspect target process")
1584 .is_none(),
1585 "py-spy timeout cleanup killed a process in an unrelated group"
1586 );
1587 let cleanup = kill_and_reap(&mut unrelated_target).await;
1588 assert!(cleanup.is_none(), "target cleanup failed: {cleanup:?}");
1589 }
1590
1591 #[cfg(unix)]
1592 #[tokio::test]
1593 async fn collect_cancellation_kills_descendants() {
1594 let dir = tempfile::tempdir().expect("tempdir");
1595 let ready = dir.path().join("grandchild-ready");
1596 let survived = dir.path().join("grandchild-survived");
1597 let child = spawn_process_tree(&ready, &survived).await;
1598
1599 let task = tokio::spawn(collect_with_timeout(
1600 child,
1601 DumpAttempt {
1602 pid: std::process::id(),
1603 binary: "sh",
1604 capture_mode: PySpyCaptureMode::PythonOnly,
1605 },
1606 Duration::from_secs(30),
1607 ));
1608 task.abort();
1609 let join_error = tokio::time::timeout(Duration::from_secs(2), task)
1610 .await
1611 .expect("cancelled collector must return within the bound")
1612 .expect_err("collector task must be cancelled");
1613 assert!(
1614 join_error.is_cancelled(),
1615 "unexpected task error: {join_error}"
1616 );
1617
1618 tokio::time::sleep(Duration::from_secs(3)).await;
1619 assert!(
1620 !survived.exists(),
1621 "grandchild outlived collector cancellation"
1622 );
1623 }
1624
1625 #[cfg(unix)]
1626 async fn spawn_process_tree(ready: &Path, survived: &Path) -> GuardedSubprocess {
1627 let mut command = Command::new("sh");
1628 command
1629 .arg("-c")
1630 .arg("echo diag >&2; ( touch \"$1\"; sleep 2; touch \"$2\" ) & wait")
1631 .arg("pyspy-process-tree-test")
1632 .arg(ready)
1633 .arg(survived)
1634 .stdout(std::process::Stdio::piped())
1635 .stderr(std::process::Stdio::piped());
1636 let subprocess = spawn_guarded_subprocess(command).expect("spawn process-tree fixture");
1637 wait_for_file(ready).await;
1638 subprocess
1639 }
1640
1641 #[cfg(unix)]
1642 async fn wait_for_file(path: &Path) {
1643 tokio::time::timeout(Duration::from_secs(2), async {
1644 while tokio::fs::metadata(path).await.is_err() {
1645 tokio::time::sleep(Duration::from_millis(10)).await;
1646 }
1647 })
1648 .await
1649 .expect("grandchild did not start");
1650 }
1651
1652 #[tokio::test]
1653 async fn exec_failing_binary_returns_failed() {
1654 let result = try_exec(
1656 "false",
1657 "PYSPY_BIN=false",
1658 42,
1659 &default_opts(),
1660 std::time::Duration::from_secs(5),
1661 )
1662 .await;
1663 assert!(result.is_some());
1664 match result.unwrap() {
1665 PySpyResult::Failed {
1666 pid,
1667 binary,
1668 exit_code,
1669 ..
1670 } => {
1671 assert_eq!(pid, 42);
1672 assert_eq!(binary, "false");
1673 assert!(exit_code.is_some());
1674 }
1675 other => panic!("expected Failed, got {:?}", other),
1676 }
1677 }
1678
1679 fn write_fake_pyspy(script_body: &str) -> tempfile::TempPath {
1687 let mut f = tempfile::NamedTempFile::new().expect("create temp file");
1688 write!(f, "#!/bin/sh\n{script_body}").expect("write script");
1689 f.as_file().sync_all().expect("sync");
1690 std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o755))
1691 .expect("chmod +x");
1692 f.into_temp_path()
1693 }
1694
1695 fn read_log(script_path: &std::path::Path) -> Vec<String> {
1698 let log_path = format!("{}.log", script_path.display());
1699 match std::fs::read_to_string(&log_path) {
1700 Ok(contents) => contents.lines().map(String::from).collect(),
1701 Err(_) => vec![],
1702 }
1703 }
1704
1705 #[tokio::test]
1706 async fn native_all_downgrade_succeeds() {
1707 let script = write_fake_pyspy(
1711 r#"
1712echo "$@" >> "$0.log"
1713for arg in "$@"; do
1714 if [ "$arg" = "--native-all" ]; then
1715 echo "unrecognized option --native-all" >&2
1716 exit 2
1717 fi
1718done
1719echo "[]"
1720exit 0
1721"#,
1722 );
1723 let opts = PySpyOpts {
1724 threads: false,
1725 native: true,
1726 native_all: true,
1727 nonblocking: false,
1728 };
1729 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
1730 let result = try_exec(
1731 script.to_str().unwrap(),
1732 &label,
1733 1,
1734 &opts,
1735 std::time::Duration::from_secs(5),
1736 )
1737 .await;
1738 let result = result.expect("expected Some");
1740 match &result {
1741 PySpyResult::Ok {
1742 capture_mode,
1743 warnings,
1744 ..
1745 } => {
1746 assert_eq!(*capture_mode, PySpyCaptureMode::Native);
1747 let expected = native_all_downgrade_warning(&label);
1748 assert!(
1749 warnings.contains(&expected),
1750 "PS-11c: expected fallback warning naming the binary, got: {warnings:?}"
1751 );
1752 }
1753 other => panic!("expected Ok, got: {other:?}"),
1754 }
1755 let log = read_log(&script);
1757 assert_eq!(
1758 log.len(),
1759 2,
1760 "PS-11b: expected exactly 2 invocations, got {}",
1761 log.len()
1762 );
1763 assert!(
1764 log[0].contains("--native-all"),
1765 "PS-11a: first invocation must include --native-all, got: {}",
1766 log[0]
1767 );
1768 assert!(
1769 !log[1].contains("--native-all"),
1770 "PS-11a: second invocation must NOT include --native-all, got: {}",
1771 log[1]
1772 );
1773 assert!(
1774 log[1].contains("--native"),
1775 "PS-11a: second invocation must retain native capture, got: {}",
1776 log[1]
1777 );
1778 }
1779
1780 #[tokio::test]
1781 async fn native_all_downgrade_enables_native() {
1782 let script = write_fake_pyspy(
1783 r#"
1784echo "$@" >> "$0.log"
1785for arg in "$@"; do
1786 if [ "$arg" = "--native-all" ]; then
1787 echo "unrecognized option --native-all" >&2
1788 exit 2
1789 fi
1790done
1791echo "[]"
1792exit 0
1793"#,
1794 );
1795 let opts = PySpyOpts {
1796 threads: false,
1797 native: false,
1798 native_all: true,
1799 nonblocking: false,
1800 };
1801 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
1802 let result = try_exec(
1803 script.to_str().unwrap(),
1804 &label,
1805 1,
1806 &opts,
1807 Duration::from_secs(5),
1808 )
1809 .await
1810 .expect("expected Some");
1811 assert!(matches!(
1812 result,
1813 PySpyResult::Ok {
1814 capture_mode: PySpyCaptureMode::Native,
1815 ..
1816 }
1817 ));
1818
1819 let log = read_log(&script);
1820 assert_eq!(log.len(), 2, "expected one native-all downgrade");
1821 assert!(log[0].contains("--native-all"));
1822 assert!(
1823 log[1].contains("--native") && !log[1].contains("--native-all"),
1824 "native-all fallback must retain native capture: {}",
1825 log[1]
1826 );
1827 }
1828
1829 #[tokio::test]
1830 async fn native_all_downgrade_fails_retries_continue() {
1831 let script = write_fake_pyspy(
1834 r#"
1835echo "$@" >> "$0.log"
1836for arg in "$@"; do
1837 if [ "$arg" = "--native-all" ]; then
1838 echo "unrecognized option --native-all" >&2
1839 exit 2
1840 fi
1841done
1842echo "Permission denied" >&2
1843exit 1
1844"#,
1845 );
1846 let opts = PySpyOpts {
1847 threads: false,
1848 native: true,
1849 native_all: true,
1850 nonblocking: true, };
1852 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
1853 let result = try_exec(
1854 script.to_str().unwrap(),
1855 &label,
1856 1,
1857 &opts,
1858 std::time::Duration::from_secs(10),
1859 )
1860 .await;
1861 let result = result.expect("expected Some");
1863 match &result {
1864 PySpyResult::Failed {
1865 stderr, exit_code, ..
1866 } => {
1867 assert!(
1868 stderr.contains("Permission denied"),
1869 "PS-11d: expected generic failure, got: {stderr}"
1870 );
1871 assert_eq!(*exit_code, Some(1));
1872 }
1873 other => panic!("expected Failed, got: {other:?}"),
1874 }
1875 let log = read_log(&script);
1881 assert_eq!(log.len(), 5, "expected 5 invocations, got {}", log.len());
1882 assert!(
1883 log[0].contains("--native-all"),
1884 "PS-11a: first invocation must include --native-all, got: {}",
1885 log[0]
1886 );
1887 assert!(
1888 log[1].contains("--native") && !log[1].contains("--native-all"),
1889 "PS-11a: second invocation must keep --native but drop --native-all, got: {}",
1890 log[1]
1891 );
1892 for (i, line) in log[2..].iter().enumerate() {
1893 assert!(
1894 !line.contains("--native"),
1895 "PS-15d: invocation {} must be python-only, got: {}",
1896 i + 2,
1897 line
1898 );
1899 }
1900 }
1901
1902 #[tokio::test]
1903 async fn native_all_downgrade_warnings_survive_outer_retry() {
1904 let script = write_fake_pyspy(
1905 r#"
1906echo "$@" >> "$0.log"
1907for arg in "$@"; do
1908 if [ "$arg" = "--native-all" ]; then
1909 echo "unrecognized option --native-all" >&2
1910 exit 2
1911 fi
1912done
1913for arg in "$@"; do
1914 if [ "$arg" = "--native" ]; then
1915 echo "native capture failed" >&2
1916 exit 1
1917 fi
1918done
1919attempt=$(wc -l < "$0.log")
1920if [ "$attempt" -eq 3 ]; then
1921 echo "transient python-only failure" >&2
1922 exit 1
1923fi
1924echo "[]"
1925exit 0
1926"#,
1927 );
1928 let opts = PySpyOpts {
1929 threads: false,
1930 native: true,
1931 native_all: true,
1932 nonblocking: true,
1933 };
1934 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
1935 let result = try_exec(
1936 script.to_str().unwrap(),
1937 &label,
1938 1,
1939 &opts,
1940 Duration::from_secs(5),
1941 )
1942 .await
1943 .expect("expected Some");
1944
1945 match result {
1946 PySpyResult::Ok {
1947 capture_mode,
1948 warnings,
1949 ..
1950 } => {
1951 assert_eq!(capture_mode, PySpyCaptureMode::PythonOnly);
1952 assert_eq!(
1953 warnings,
1954 vec![
1955 native_all_downgrade_warning(&label),
1956 native_downgrade_warning(&label),
1957 ]
1958 );
1959 }
1960 other => panic!("expected Ok, got: {other:?}"),
1961 }
1962
1963 let log = read_log(&script);
1964 assert_eq!(
1965 log.len(),
1966 4,
1967 "expected native-all, native, and two Python-only attempts"
1968 );
1969 assert!(log[0].contains("--native-all"));
1970 assert!(log[1].contains("--native") && !log[1].contains("--native-all"));
1971 assert!(log[2..].iter().all(|line| !line.contains("--native")));
1972 }
1973
1974 #[tokio::test]
1979 async fn native_downgrade_succeeds() {
1980 let script = write_fake_pyspy(
1981 r#"
1982echo "$@" >> "$0.log"
1983for arg in "$@"; do
1984 if [ "$arg" = "--native" ]; then
1985 echo "Error: UNW_EINVAL: unsupported operation or bad value" >&2
1986 exit 1
1987 fi
1988done
1989echo "[]"
1990exit 0
1991"#,
1992 );
1993 let opts = PySpyOpts {
1994 threads: false,
1995 native: true,
1996 native_all: false,
1997 nonblocking: false,
1998 };
1999 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
2000 let result = try_exec(
2001 script.to_str().unwrap(),
2002 &label,
2003 1,
2004 &opts,
2005 std::time::Duration::from_secs(5),
2006 )
2007 .await;
2008 let result = result.expect("expected Some");
2009 match &result {
2010 PySpyResult::Ok {
2011 capture_mode,
2012 warnings,
2013 ..
2014 } => {
2015 assert_eq!(*capture_mode, PySpyCaptureMode::PythonOnly);
2016 let expected = native_downgrade_warning(&label);
2017 assert!(
2018 warnings.contains(&expected),
2019 "PS-15c: expected native fallback warning naming the binary, got: {warnings:?}"
2020 );
2021 }
2022 other => panic!("expected Ok, got: {other:?}"),
2023 }
2024 let log = read_log(&script);
2025 assert_eq!(
2026 log.len(),
2027 2,
2028 "PS-15b: expected exactly 2 invocations, got {}",
2029 log.len()
2030 );
2031 assert!(
2032 log[0].contains("--native"),
2033 "PS-15a: first invocation must include --native, got: {}",
2034 log[0]
2035 );
2036 assert!(
2037 !log[1].contains("--native"),
2038 "PS-15a: second invocation must be python-only, got: {}",
2039 log[1]
2040 );
2041 }
2042
2043 #[tokio::test]
2046 async fn native_downgrade_is_sticky_across_retries() {
2047 let script = write_fake_pyspy(
2048 r#"
2049echo "$@" >> "$0.log"
2050echo "Permission denied" >&2
2051exit 1
2052"#,
2053 );
2054 let opts = PySpyOpts {
2055 threads: false,
2056 native: true,
2057 native_all: false,
2058 nonblocking: true, };
2060 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
2061 let result = try_exec(
2062 script.to_str().unwrap(),
2063 &label,
2064 1,
2065 &opts,
2066 std::time::Duration::from_secs(10),
2067 )
2068 .await;
2069 assert!(
2070 matches!(result, Some(PySpyResult::Failed { .. })),
2071 "expected Failed, got: {result:?}"
2072 );
2073 let log = read_log(&script);
2076 assert_eq!(log.len(), 4, "expected 4 invocations, got {}", log.len());
2077 assert!(
2078 log[0].contains("--native"),
2079 "PS-15a: first invocation must include --native, got: {}",
2080 log[0]
2081 );
2082 for (i, line) in log[1..].iter().enumerate() {
2083 assert!(
2084 !line.contains("--native"),
2085 "PS-15d: invocation {} must be python-only, got: {}",
2086 i + 1,
2087 line
2088 );
2089 }
2090 }
2091
2092 #[tokio::test]
2093 async fn native_downgrade_warning_survives_outer_retry() {
2094 let script = write_fake_pyspy(
2095 r#"
2096echo "$@" >> "$0.log"
2097for arg in "$@"; do
2098 if [ "$arg" = "--native" ]; then
2099 echo "Error: UNW_EINVAL: unsupported operation or bad value" >&2
2100 exit 1
2101 fi
2102done
2103attempt=$(wc -l < "$0.log")
2104if [ "$attempt" -eq 2 ]; then
2105 echo "transient python-only failure" >&2
2106 exit 1
2107fi
2108echo "[]"
2109exit 0
2110"#,
2111 );
2112 let opts = PySpyOpts {
2113 threads: false,
2114 native: true,
2115 native_all: false,
2116 nonblocking: true,
2117 };
2118 let label = format!("PYSPY_BIN={}", script.to_str().unwrap());
2119 let result = try_exec(
2120 script.to_str().unwrap(),
2121 &label,
2122 1,
2123 &opts,
2124 Duration::from_secs(5),
2125 )
2126 .await
2127 .expect("expected Some");
2128 match result {
2129 PySpyResult::Ok {
2130 capture_mode,
2131 warnings,
2132 ..
2133 } => {
2134 assert_eq!(capture_mode, PySpyCaptureMode::PythonOnly);
2135 assert_eq!(warnings, vec![native_downgrade_warning(&label)]);
2136 }
2137 other => panic!("expected Ok, got: {other:?}"),
2138 }
2139
2140 let log = read_log(&script);
2141 assert_eq!(
2142 log.len(),
2143 3,
2144 "expected native plus two Python-only attempts"
2145 );
2146 assert!(log[0].contains("--native"));
2147 assert!(log[1..].iter().all(|line| !line.contains("--native")));
2148 }
2149
2150 #[cfg(unix)]
2153 #[tokio::test]
2154 async fn profile_collect_timeout_returns_timed_out() {
2155 let dir = tempfile::tempdir().expect("tempdir");
2156 let ready = dir.path().join("grandchild-ready");
2157 let survived = dir.path().join("grandchild-survived");
2158 let child = spawn_process_tree(&ready, &survived).await;
2159
2160 let result = tokio::time::timeout(
2161 Duration::from_secs(2),
2162 collect_profile_with_timeout(
2163 child,
2164 std::process::id(),
2165 "sh",
2166 Duration::from_millis(200),
2167 ),
2168 )
2169 .await
2170 .expect("profile timeout cleanup must itself be bounded");
2171
2172 match result {
2173 Err(ProfileExecOutcome::TimedOut { stderr, .. }) => {
2174 assert!(
2175 stderr.contains("diag"),
2176 "expected partial stderr captured after kill, got: {stderr}"
2177 );
2178 }
2179 other => panic!("expected TimedOut, got: {other:?}"),
2180 }
2181 assert!(
2182 ready.exists(),
2183 "grandchild must start before timeout cleanup"
2184 );
2185 tokio::time::sleep(Duration::from_secs(3)).await;
2186 assert!(
2187 !survived.exists(),
2188 "grandchild outlived py-spy profile timeout cleanup"
2189 );
2190 }
2191
2192 #[cfg(unix)]
2195 #[tokio::test]
2196 async fn profile_collect_cancellation_kills_descendants() {
2197 let dir = tempfile::tempdir().expect("tempdir");
2198 let ready = dir.path().join("grandchild-ready");
2199 let survived = dir.path().join("grandchild-survived");
2200 let child = spawn_process_tree(&ready, &survived).await;
2201
2202 let task = tokio::spawn(collect_profile_with_timeout(
2203 child,
2204 std::process::id(),
2205 "sh",
2206 Duration::from_secs(30),
2207 ));
2208 task.abort();
2209 let join_error = tokio::time::timeout(Duration::from_secs(2), task)
2210 .await
2211 .expect("cancelled profile collector must return within the bound")
2212 .expect_err("profile collector task must be cancelled");
2213 assert!(
2214 join_error.is_cancelled(),
2215 "unexpected task error: {join_error}"
2216 );
2217
2218 tokio::time::sleep(Duration::from_secs(3)).await;
2219 assert!(
2220 !survived.exists(),
2221 "grandchild outlived profile collector cancellation"
2222 );
2223 }
2224
2225 fn test_request() -> ValidatedProfileRequest {
2226 ValidatedProfileRequest::try_new(
2227 &PySpyProfileOpts {
2228 duration_s: 1,
2229 rate_hz: 100,
2230 native: false,
2231 threads: false,
2232 nonblocking: false,
2233 },
2234 std::time::Duration::from_secs(300),
2235 )
2236 .unwrap()
2237 }
2238
2239 #[tokio::test]
2241 async fn profile_try_missing_binary_returns_none() {
2242 let result = try_profile("/definitely/not/a/real/binary", 1, &test_request()).await;
2243 assert!(result.is_none(), "missing binary must return None");
2244 }
2245
2246 #[tokio::test]
2248 async fn profile_success_exit_empty_file_returns_output_empty() {
2249 let script = write_fake_pyspy(
2250 r#"
2251output=""
2252while [ $# -gt 0 ]; do
2253 case "$1" in
2254 --output) shift; output="$1" ;;
2255 esac
2256 shift
2257done
2258touch "$output"
2259exit 0
2260"#,
2261 );
2262 let result = try_profile(script.to_str().unwrap(), 1, &test_request()).await;
2263 assert!(
2264 matches!(result, Some(ProfileExecOutcome::OutputEmpty { .. })),
2265 "PP-3: expected OutputEmpty, got: {result:?}"
2266 );
2267 }
2268
2269 #[tokio::test]
2271 async fn profile_success_exit_missing_file_returns_output_missing() {
2272 let script = write_fake_pyspy("exit 0\n");
2273 let result = try_profile(script.to_str().unwrap(), 1, &test_request()).await;
2274 assert!(
2275 matches!(result, Some(ProfileExecOutcome::OutputMissing { .. })),
2276 "PP-3: expected OutputMissing, got: {result:?}"
2277 );
2278 }
2279
2280 #[test]
2282 fn validated_request_rejects_zero_duration() {
2283 let opts = PySpyProfileOpts {
2284 duration_s: 0,
2285 rate_hz: 100,
2286 native: false,
2287 threads: false,
2288 nonblocking: false,
2289 };
2290 let err = ValidatedProfileRequest::try_new(&opts, std::time::Duration::from_secs(300));
2291 assert!(err.is_err());
2292 assert!(err.unwrap_err().contains("positive"));
2293 }
2294
2295 #[test]
2297 fn validated_request_rejects_over_max_duration() {
2298 let opts = PySpyProfileOpts {
2299 duration_s: 999,
2300 rate_hz: 100,
2301 native: false,
2302 threads: false,
2303 nonblocking: false,
2304 };
2305 let err = ValidatedProfileRequest::try_new(&opts, std::time::Duration::from_secs(300));
2306 assert!(err.is_err());
2307 assert!(err.unwrap_err().contains("exceeds max"));
2308 }
2309
2310 #[test]
2312 fn validated_request_rejects_zero_rate() {
2313 let opts = PySpyProfileOpts {
2314 duration_s: 5,
2315 rate_hz: 0,
2316 native: false,
2317 threads: false,
2318 nonblocking: false,
2319 };
2320 let err = ValidatedProfileRequest::try_new(&opts, std::time::Duration::from_secs(300));
2321 assert!(err.is_err());
2322 assert!(err.unwrap_err().contains("rate_hz"));
2323 }
2324
2325 #[test]
2327 fn validated_request_rejects_excessive_rate() {
2328 let opts = PySpyProfileOpts {
2329 duration_s: 5,
2330 rate_hz: 9999,
2331 native: false,
2332 threads: false,
2333 nonblocking: false,
2334 };
2335 let err = ValidatedProfileRequest::try_new(&opts, std::time::Duration::from_secs(300));
2336 assert!(err.is_err());
2337 assert!(err.unwrap_err().contains("rate_hz"));
2338 }
2339
2340 #[test]
2342 fn validated_request_computes_exact_timeouts() {
2343 let opts = PySpyProfileOpts {
2344 duration_s: 30,
2345 rate_hz: 100,
2346 native: true,
2347 threads: false,
2348 nonblocking: false,
2349 };
2350 let req =
2351 ValidatedProfileRequest::try_new(&opts, std::time::Duration::from_secs(300)).unwrap();
2352 assert_eq!(req.duration().get(), 30);
2353 assert_eq!(req.rate().get(), 100);
2354 assert!(req.native());
2355 assert_eq!(req.subprocess_timeout(), std::time::Duration::from_secs(45));
2356 assert_eq!(req.bridge_timeout(), std::time::Duration::from_secs(50));
2357 }
2358
2359 #[test]
2361 fn profile_exec_outcome_conversion_is_identity() {
2362 let r = PySpyProfileResult::from(ProfileExecOutcome::Ok {
2364 pid: 1,
2365 binary: "b".into(),
2366 svg: vec![1],
2367 });
2368 assert!(matches!(r, PySpyProfileResult::Ok { pid: 1, .. }));
2369
2370 let r = PySpyProfileResult::from(ProfileExecOutcome::BinaryNotFound {
2371 searched: vec!["x".into()],
2372 });
2373 assert!(matches!(r, PySpyProfileResult::BinaryNotFound { .. }));
2374
2375 let r = PySpyProfileResult::from(ProfileExecOutcome::TimedOut {
2376 pid: 1,
2377 binary: "b".into(),
2378 timeout: Duration::from_secs(10),
2379 stderr: "s".into(),
2380 });
2381 assert!(matches!(
2382 r,
2383 PySpyProfileResult::TimedOut { timeout_s: 10, .. }
2384 ));
2385
2386 let r = PySpyProfileResult::from(ProfileExecOutcome::ExitFailure {
2387 pid: 1,
2388 binary: "b".into(),
2389 exit_code: Some(2),
2390 stderr: "e".into(),
2391 });
2392 assert!(matches!(
2393 r,
2394 PySpyProfileResult::ExitFailure {
2395 exit_code: Some(2),
2396 ..
2397 }
2398 ));
2399
2400 let r = PySpyProfileResult::from(ProfileExecOutcome::OutputMissing {
2401 pid: 1,
2402 binary: "b".into(),
2403 });
2404 assert!(matches!(
2405 r,
2406 PySpyProfileResult::OutputMissing { pid: 1, .. }
2407 ));
2408
2409 let r = PySpyProfileResult::from(ProfileExecOutcome::OutputEmpty {
2410 pid: 1,
2411 binary: "b".into(),
2412 });
2413 assert!(matches!(r, PySpyProfileResult::OutputEmpty { pid: 1, .. }));
2414
2415 let r = PySpyProfileResult::from(ProfileExecOutcome::OutputReadFailure {
2416 pid: 1,
2417 binary: "b".into(),
2418 error: "permission denied".into(),
2419 });
2420 assert!(matches!(r, PySpyProfileResult::OutputReadFailure { .. }));
2421
2422 let r = PySpyProfileResult::from(ProfileExecOutcome::SubprocessSpawnFailure {
2423 pid: 1,
2424 binary: "b".into(),
2425 error: "s".into(),
2426 });
2427 assert!(matches!(
2428 r,
2429 PySpyProfileResult::SubprocessSpawnFailure { .. }
2430 ));
2431
2432 let r = PySpyProfileResult::from(ProfileExecOutcome::WaitFailure {
2433 pid: 1,
2434 binary: "b".into(),
2435 error: "w".into(),
2436 });
2437 assert!(matches!(r, PySpyProfileResult::WaitFailure { .. }));
2438
2439 let r = PySpyProfileResult::from(ProfileExecOutcome::TempDirFailure {
2440 pid: 1,
2441 binary: "b".into(),
2442 error: "t".into(),
2443 });
2444 assert!(matches!(r, PySpyProfileResult::TempDirFailure { .. }));
2445 }
2446}