Skip to main content

hyperactor_mesh/
pyspy.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! py-spy integration for remote Python stack dumps and profiles.
10//!
11//! See PS-* and PP-* invariants in `introspect` module doc.
12
13use 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/// py-spy mode used by the successful stack-dump attempt.
31#[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    /// The successful attempt used neither `--native` nor `--native-all`.
44    PythonOnly,
45    /// The successful attempt used `--native` but not `--native-all`.
46    Native,
47    /// The successful attempt used `--native-all`.
48    NativeAll,
49}
50
51/// Result of a py-spy stack dump request.
52///
53/// See PS-2, PS-4 in `introspect` module doc.
54#[derive(
55    Debug,
56    Clone,
57    PartialEq,
58    Serialize,
59    Deserialize,
60    Named,
61    schemars::JsonSchema
62)]
63pub enum PySpyResult {
64    /// Successful stack dump with structured traces.
65    Ok {
66        /// OS process ID that was dumped.
67        pid: u32,
68        /// Path or name of the py-spy binary that produced the dump.
69        binary: String,
70        /// Invocation mode used for the successful attempt.
71        capture_mode: PySpyCaptureMode,
72        /// Per-thread stack traces from py-spy.
73        stack_traces: Vec<PySpyStackTrace>,
74        /// Non-fatal warnings from the capture (e.g., flag
75        /// fallbacks). Empty when the capture completed without
76        /// caveats.
77        warnings: Vec<String>,
78    },
79    /// py-spy binary not found in environment.
80    BinaryNotFound {
81        /// Candidate paths that were tried before giving up.
82        searched: Vec<String>,
83    },
84    /// py-spy exited with an error.
85    Failed {
86        /// OS process ID that was targeted.
87        pid: u32,
88        /// Path or name of the py-spy binary that failed.
89        binary: String,
90        /// Exit code from the py-spy process, if available.
91        exit_code: Option<i32>,
92        /// Captured stderr output.
93        stderr: String,
94    },
95}
96wirevalue::register_type!(PySpyResult);
97
98/// A single thread's stack trace from py-spy `--json` output.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
100pub struct PySpyStackTrace {
101    /// OS process ID that owns this thread.
102    pub pid: i32,
103    /// Python-level thread identifier (`threading.get_ident()`).
104    pub thread_id: u64,
105    /// Python thread name, if set.
106    pub thread_name: Option<String>,
107    /// OS-level thread ID (e.g., `gettid()` on Linux).
108    pub os_thread_id: Option<u64>,
109    /// Whether the thread is actively running (not idle/waiting).
110    pub active: bool,
111    /// Whether the thread currently holds the GIL.
112    pub owns_gil: bool,
113    /// Stack frames, innermost first.
114    pub frames: Vec<PySpyFrame>,
115}
116
117/// A single frame in a py-spy stack trace.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
119pub struct PySpyFrame {
120    /// Function or method name.
121    pub name: String,
122    /// Absolute path to the source file.
123    pub filename: String,
124    /// Python module name, if known.
125    pub module: Option<String>,
126    /// Basename or abbreviated path.
127    pub short_filename: Option<String>,
128    /// Source line number.
129    pub line: i32,
130    /// Local variables captured in this frame, if available.
131    pub locals: Option<Vec<PySpyLocalVariable>>,
132    /// Whether this frame is an entry point (e.g., module `__main__`).
133    pub is_entry: bool,
134}
135
136/// A local variable captured in a py-spy frame.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct PySpyLocalVariable {
139    /// Variable name.
140    pub name: String,
141    /// Memory address of the Python object.
142    pub addr: usize,
143    /// Whether this variable is a function argument.
144    pub arg: bool,
145    /// `repr()` of the value, if captured.
146    pub repr: Option<String>,
147}
148
149/// Options controlling py-spy capture behavior.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct PySpyOpts {
152    /// Include per-thread stacks (`--threads`).
153    pub threads: bool,
154    /// Include native C/C++ frames (`--native`).
155    pub native: bool,
156    /// Include native frames for all threads, not just those with
157    /// Python frames (`--native-all`).
158    pub native_all: bool,
159    /// Use nonblocking mode — py-spy reads without pausing the
160    /// target process (`--nonblocking`). Enables retry logic (PS-10).
161    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/// Public JSON-facing options for a py-spy profile capture.
177///
178/// Deserialized from the HTTP POST body. Validated and converted to
179/// `ValidatedProfileRequest` before any actor messaging.
180///
181/// See PP-1 in `introspect` module doc.
182#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
183pub struct PySpyProfileOpts {
184    /// Sampling duration in whole seconds. py-spy `--duration`
185    /// accepts integers only. Must be >= 1; upper bound enforced
186    /// at runtime by `MESH_ADMIN_PYSPY_MAX_PROFILE_DURATION`.
187    #[schemars(range(min = 1))]
188    pub duration_s: u32,
189    /// Sampling rate in Hz. Must be 1..=1000.
190    #[schemars(range(min = 1, max = 1000))]
191    pub rate_hz: u32,
192    /// Include native C/C++ frames.
193    pub native: bool,
194    /// Include per-thread stacks.
195    pub threads: bool,
196    /// Use nonblocking mode.
197    pub nonblocking: bool,
198}
199
200/// Validated profile duration. Guaranteed non-zero.
201#[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/// Validated sample rate. Guaranteed 1..=1000.
211#[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/// Validated profile request. If this exists, it is valid.
221/// Construct only via `try_new`. See PP-1, PP-2.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub(crate) struct ValidatedProfileRequest {
224    /// Sampling duration (guaranteed non-zero, within max).
225    duration: ProfileDurationSecs,
226    /// Sampling rate (guaranteed 1..=1000).
227    rate: SampleRateHz,
228    /// Include native C/C++ frames.
229    native: bool,
230    /// Include per-thread stacks.
231    threads: bool,
232    /// Use nonblocking mode.
233    nonblocking: bool,
234    /// Kill deadline for the py-spy subprocess.
235    subprocess_timeout: std::time::Duration,
236    /// Bridge reply wait deadline (subprocess + margin).
237    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/// Wire result of a py-spy profile capture. The HTTP handler
296/// unwraps this to produce `image/svg+xml` or `ApiError`.
297/// Not a public JSON contract. See PP-2, PP-3.
298#[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/// Internal profile execution outcome. Converted to
355/// `PySpyProfileResult` at the actor reply boundary.
356#[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/// Request a py-spy stack dump from this process.
462///
463/// Both ProcAgent and HostAgent handle this message. The handler
464/// delegates to [`PySpyWorker::spawn_and_forward`] which runs py-spy
465/// against `std::process::id()`.
466///
467/// See PS-1 in `introspect` module doc.
468#[derive(Debug, Serialize, Deserialize, Named, Handler, HandleClient, RefClient)]
469pub struct PySpyDump {
470    /// Capture options (threads, native frames, nonblocking mode).
471    pub opts: PySpyOpts,
472    /// Reply port for the result.
473    #[reply]
474    pub result: OncePortRef<PySpyResult>,
475}
476wirevalue::register_type!(PySpyDump);
477
478/// Request a py-spy profile capture from this process.
479///
480/// Runs `py-spy record` for the requested duration. Separate contract
481/// from `PySpyDump` — does not affect the existing dump pipeline.
482///
483/// See PP-4, PP-5 in `introspect` module doc.
484#[allow(private_interfaces)] // pub required by hyperactor macros; actual use is crate-internal
485#[derive(Debug, Serialize, Deserialize, Named, Handler, HandleClient, RefClient)]
486pub struct PySpyProfile {
487    /// Validated profile request (opts + derived timeouts).
488    pub request: ValidatedProfileRequest,
489    /// Reply port for the result.
490    #[reply]
491    pub result: OncePortRef<PySpyProfileResult>,
492}
493wirevalue::register_type!(PySpyProfile);
494
495/// Runs py-spy against the current process.
496///
497/// See PS-1, PS-3 in `introspect` module doc.
498pub struct PySpyRunner;
499
500impl PySpyRunner {
501    /// Dump Python stacks for this process.
502    ///
503    /// Resolves the py-spy binary (PS-3), attaches to
504    /// `std::process::id()` (PS-1), and returns structured JSON
505    /// output (PS-4).
506    ///
507    /// PS-1 is structurally enforced: `PySpyDump` carries no PID
508    /// field, and this method hardcodes `std::process::id()`. There
509    /// is no code path that could substitute a different PID.
510    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    /// Profile Python stacks for this process over a duration.
539    /// See PP-3, PP-4.
540    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/// Internal message from ProcAgent to a spawned PySpyWorker.
565/// Carries the original caller's reply port so the worker
566/// responds directly without routing back through ProcAgent.
567#[derive(Debug, Serialize, Deserialize, Named)]
568pub struct RunPySpyDump {
569    pub opts: PySpyOpts,
570    /// The original caller's reply port, forwarded from PySpyDump.
571    pub reply_port: hyperactor::OncePortRef<PySpyResult>,
572}
573wirevalue::register_type!(RunPySpyDump);
574
575/// Short-lived child actor that runs py-spy off the ProcAgent
576/// handler path. Spawned per-request; self-terminates after reply.
577/// Concurrent instances are permitted — py-spy attaches read-only
578/// via `process_vm_readv` and multiple concurrent dumps are safe.
579#[hyperactor::export(handlers = [RunPySpyDump])]
580pub struct PySpyWorker;
581
582impl Actor for PySpyWorker {}
583
584impl PySpyWorker {
585    /// Spawn a PySpyWorker, forward the py-spy request, and let
586    /// the worker reply directly to the caller.
587    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/// Internal forwarded message for profile capture.
613#[allow(private_interfaces)] // pub required by hyperactor macros; actual use is crate-internal
614#[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/// Short-lived child actor for profile capture. Separate from
622/// `PySpyWorker` (PP-5).
623#[hyperactor::export(handlers = [RunPySpyProfile])]
624pub struct PySpyProfileWorker;
625
626impl Actor for PySpyProfileWorker {}
627
628impl PySpyProfileWorker {
629    /// Spawn a profile worker and forward the request.
630    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
663/// Return the ordered list of py-spy binary candidates to try.
664/// See PS-3 in `introspect` module doc.
665fn 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
677/// Build the py-spy command for a given binary path.
678fn 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
701/// A spawned subprocess whose process-group cleanup invariant is established
702/// by construction.
703struct GuardedSubprocess {
704    group: ProcessGroupGuard,
705    child: tokio::process::Child,
706}
707
708/// Keeps the process group killable until the direct child has been reaped.
709/// Dropping this guard covers cancellation of the async collector.
710struct ProcessGroupGuard {
711    pgid: Option<i32>,
712}
713
714impl ProcessGroupGuard {
715    fn kill(&self) {
716        #[cfg(unix)]
717        if let Some(pgid) = self.pgid {
718            // SAFETY: the child was spawned with `process_group(0)`, so its
719            // positive pid names a process group owned by this invocation.
720            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
737/// Spawn a subprocess in its own process group and return it coupled to the
738/// guard that owns group cleanup.
739fn 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
755/// Kill the process tree and wait a bounded interval for the direct child.
756async 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
772/// Map a process::Output to a PySpyResult, parsing the `--json`
773/// output into structured `PySpyStackTrace` values.
774/// See PS-2, PS-4 in `introspect` module doc.
775fn 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
808/// Returns true if the failure indicates the py-spy binary does not
809/// support `--native-all` (exit code 2, stderr mentions the flag).
810/// Used by `try_exec` to downgrade and retry (PS-11).
811fn 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
822/// The warning attached to a result that dropped `--native-all`
823/// because the py-spy binary does not support it (PS-11c). Takes the
824/// candidate's resolution label (`PYSPY_BIN=…` or `py-spy on PATH`)
825/// rather than the bare argv0, so the reader learns which candidate
826/// PS-3 picked and how.
827fn native_all_downgrade_warning(label: &str) -> String {
828    format!("--native-all unsupported by py-spy ({label}); fell back to --native")
829}
830
831/// The warning attached to a result that fell back to Python-only
832/// frames after native capture failed (PS-15c). The result's
833/// `capture_mode` field identifies the mode; this message names the
834/// candidate that fell short -- by resolution label, so a `PYSPY_BIN`
835/// that is already set is visible -- and the remedy.
836fn 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
844/// Result of a single spawn → collect execution step.
845enum ExecOnce {
846    /// py-spy produced a result (success or failure).
847    Result(PySpyResult),
848    /// The binary was not found (NotFound from spawn).
849    NotFound,
850}
851
852#[derive(Clone, Copy)]
853struct DumpAttempt<'a> {
854    pid: u32,
855    binary: &'a str,
856    capture_mode: PySpyCaptureMode,
857}
858
859/// Spawn the py-spy binary once, collect output, and return the
860/// result. Factored out of `try_exec` so both the normal attempt
861/// path and the PS-11 native-all downgrade path share one
862/// implementation of deadline check → spawn → collect.
863async 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
899/// Try to execute py-spy with the given binary path. Returns `None`
900/// if the binary was not found (NotFound error), allowing the caller
901/// to try the next candidate.
902///
903/// In nonblocking mode, retries up to 3 times with 100ms backoff
904/// because py-spy can segfault reading mutating process memory
905/// (PS-10). Attempts and backoff share one deadline; timeout cleanup
906/// has a separate bounded reap interval (PS-5).
907///
908/// If `native_all` is requested but the py-spy binary does not
909/// support `--native-all` (exit code 2), the flag is dropped and the
910/// command is retried immediately within the same attempt (PS-11a
911/// through PS-11e).
912///
913/// If native capture fails for any other reason — libunwind cannot
914/// unwind the target, say — both native flags are dropped and the
915/// command is retried Python-only (PS-15a through PS-15d). Native
916/// frames are not optional to a caller debugging a stuck proc, but a
917/// partial dump beats no dump, and the result says which it is.
918async 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        // PS-11a: py-spy too old for --native-all; downgrade and
944        // retry immediately within the same attempt (PS-11b: no
945        // backoff, no retry slot consumed).
946        if is_unsupported_native_all(&result) && effective_opts.native_all {
947            // PS-11e: sticky downgrade — later outer retries keep
948            // native_all = false.
949            effective_opts.native_all = false;
950            // `native_all` implies native capture. Preserve that intent when
951            // an older py-spy requires the flags to be expressed separately.
952            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            // PS-11d: if the downgraded retry also failed, fall
959            // through to the native downgrade below.
960        }
961        // PS-15a: native capture failed; drop the native flags and
962        // retry Python-only in the same attempt (PS-15b: no backoff,
963        // no retry slot consumed).
964        let native_requested = effective_opts.native || effective_opts.native_all;
965        if native_requested && matches!(result, PySpyResult::Failed { .. }) {
966            // PS-15d: sticky downgrade — later outer retries stay
967            // Python-only.
968            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        // PS-11c, PS-15c: warnings describe the effective capture mode, so
977        // they survive failed downgraded attempts and are attached to any
978        // later successful outer retry.
979        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
993/// Drain stdout/stderr concurrently and wait for the child, all bounded by
994/// `timeout`. On expiry the child process group is killed and the direct child
995/// is given a separate bounded interval to be reaped.
996///
997/// Draining both pipes before waiting prevents pipe-buffer deadlock while
998/// keeping an exited group leader unreaped until inherited descriptors close.
999/// The process-group guard handles cancellation before explicit cleanup runs.
1000///
1001/// See PS-5 in `introspect` module doc.
1002async fn collect_with_timeout(
1003    mut subprocess: GuardedSubprocess,
1004    attempt: DumpAttempt<'_>,
1005    timeout: std::time::Duration,
1006) -> PySpyResult {
1007    // `subprocess` already owns its guard before this future is created. If a
1008    // caller cancels before the first poll, dropping it still kills the group.
1009    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
1072/// Build a `py-spy record --format flamegraph` command.
1073fn 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    // py-spy record writes output to a file, not stdout. Do NOT
1101    // pipe stdout — an undrained pipe can deadlock the child.
1102    cmd.stdout(std::process::Stdio::null());
1103    cmd.stderr(std::process::Stdio::piped());
1104    cmd
1105}
1106
1107/// Drain stderr and wait for exit, bounded by `timeout`. On expiry the child
1108/// process group is killed and the direct child is given a separate bounded
1109/// interval to be reaped. See PP-2, PP-3.
1110async 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    // As with dump collection, the guard exists before the first poll.
1117    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
1169/// Try to run a profile capture with the given binary. Returns `None`
1170/// if the binary was not found (caller tries next candidate).
1171async 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    // PS-* verification map:
1254    // - PS-1: `output_preserves_caller_pid`; actor target locality is fixed by
1255    //   `dump_self` and exercised through the mesh-admin integration tests.
1256    // - PS-2, PS-4: `output_*` and `exec_*` result-shape tests.
1257    // - PS-3: `candidates_*` and `exec_missing_binary_returns_none`.
1258    // - PS-5: dump/profile timeout and immediate-cancellation tests.
1259    // - PS-6 through PS-9 and PS-12 through PS-14: source-grounded actor and
1260    //   bridge contracts exercised by the mesh-admin integration tests.
1261    // - PS-10: the sticky downgrade tests exercise outer retries.
1262    // - PS-11a through PS-11e: `native_all_downgrade_*`.
1263    // - PS-15a through PS-15d: `native_downgrade_*`.
1264
1265    #[test]
1266    fn pyspy_result_wirevalue_roundtrip() {
1267        // Regression test: #[serde(skip_serializing_if)] is
1268        // incompatible with bincode (positional format). Empty
1269        // warnings must still round-trip correctly through wirevalue
1270        // Multipart encoding.
1271        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        // Both candidate labels from resolve_candidates must read as a
1302        // sentence subject, and an already-set PYSPY_BIN must be
1303        // visible -- the remedy text tells the reader to point that
1304        // variable somewhere, which is confusing if it is already set
1305        // and they cannot tell.
1306        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        // PS-3: no PYSPY_BIN → only PATH candidate.
1331        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        // PS-3: PYSPY_BIN first, then PATH.
1340        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        // PS-3: empty PYSPY_BIN is treated as unset.
1350        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        // PS-4: py-spy --json stdout is parsed into structured traces.
1358        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        // PS-4: unparseable JSON maps to Failed.
1408        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        // PS-2: nonzero exit → Failed with stderr.
1429        let status = std::process::ExitStatus::from_raw(256); // exit code 1
1430        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        // PS-1: pid in result is exactly what the caller passes.
1455        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        // PS-3: NotFound from exec → None (triggers fallback).
1493        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        // "true" exits 0 with empty stdout, which is not valid
1507        // py-spy JSON. We expect a Failed result from parse error.
1508        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        // PS-5: a subprocess that hangs past its timeout returns even when a
1531        // descendant inherited both output descriptors. Killing only the
1532        // direct shell would leave those descriptors open and strand cleanup.
1533        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        // "false" exists on all unix systems and exits 1.
1655        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    /// Write a fake py-spy shell script to a temp file, make it
1680    /// executable, and return the path. The script logs each
1681    /// invocation's argv to `<script>.log`.
1682    ///
1683    /// Returns a `TempPath` (not `NamedTempFile`) so the write fd is
1684    /// closed before exec — Linux returns ETXTBSY if a file with an
1685    /// open write fd is executed.
1686    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    /// Read the argv log written by the fake script. Each line is one
1696    /// invocation's `$@`.
1697    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        // PS-11a, PS-11b, PS-11c: unsupported --native-all triggers
1708        // immediate downgrade in the same attempt, and the successful
1709        // result carries the fallback warning.
1710        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        // Must succeed with the downgraded result.
1739        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        // Check invocation log.
1756        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        // PS-11d, PS-11e: downgraded retry fails, outer nonblocking
1832        // retries continue with native_all = false.
1833        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, // 3 outer retries
1851        };
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        // Must be a generic failure, not the native-all error.
1862        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        // Check invocation log: 5 calls total.
1876        //   Attempt 0: --native-all (fail) → --native (fail)
1877        //              → python-only (fail)
1878        //   Attempt 1: python-only (fail)
1879        //   Attempt 2: python-only (fail)
1880        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    /// PS-15a, PS-15b, PS-15c: a native capture that fails for a
1975    /// reason other than the unsupported-flag signature downgrades to
1976    /// python-only in the same attempt, and the successful result
1977    /// carries the fallback warning.
1978    #[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    /// PS-15d: once native capture has failed, later nonblocking
2044    /// retries stay python-only rather than re-testing native.
2045    #[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, // 3 outer retries
2059        };
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        // Attempt 0: --native (fail) → python-only (fail).
2074        // Attempts 1 and 2: python-only only.
2075        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    /// PP-2: subprocess timeout yields `TimedOut` with partial stderr and
2151    /// kills descendants that inherited the stderr descriptor.
2152    #[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    /// PP-3: cancellation before the collector's first poll still kills the
2193    /// process group, including descendants that inherited stderr.
2194    #[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    /// PP-4, PS-3: missing binary yields `None` (try next candidate).
2240    #[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    /// PP-3: successful exit with empty output yields `OutputEmpty`.
2247    #[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    /// PP-3: successful exit with missing output yields `OutputMissing`.
2270    #[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    /// PP-1: zero duration rejected.
2281    #[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    /// PP-1: over-max duration rejected.
2296    #[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    /// PP-1: zero rate rejected.
2311    #[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    /// PP-1: excessive rate rejected.
2326    #[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    /// PP-2: timeout arithmetic is correct and deterministic.
2341    #[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    /// PP-6: internal-to-wire conversion is near-identity.
2360    #[test]
2361    fn profile_exec_outcome_conversion_is_identity() {
2362        // Each internal outcome maps to the identically-named wire variant.
2363        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}