Skip to main content

monarch_hyperactor/
proc_launcher.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//! Actor-based proc launcher implementation.
10//!
11//! This module provides [`ActorProcLauncher`], a [`ProcLauncher`]
12//! that delegates proc spawning to a Python actor implementing the
13//! `ProcLauncher` ABC from `monarch._src.actor.proc_launcher`.
14//!
15//! This enables custom spawning strategies (Docker, VMs, etc.) while
16//! reusing the existing lifecycle management in
17//! `BootstrapProcManager`.
18
19use std::collections::HashSet;
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use hyperactor::ActorHandle;
25use hyperactor::Endpoint as _;
26use hyperactor::Instance;
27use hyperactor::Mailbox;
28use hyperactor_mesh::proc_launcher::LaunchOptions;
29use hyperactor_mesh::proc_launcher::LaunchResult;
30use hyperactor_mesh::proc_launcher::ProcExitKind;
31use hyperactor_mesh::proc_launcher::ProcExitResult;
32use hyperactor_mesh::proc_launcher::ProcLauncher;
33use hyperactor_mesh::proc_launcher::ProcLauncherError;
34use hyperactor_mesh::proc_launcher::StdioHandling;
35use pyo3::prelude::*;
36use tokio::sync::Mutex;
37use tokio::sync::oneshot;
38
39use crate::actor::MethodSpecifier;
40use crate::actor::PythonActor;
41use crate::actor::PythonMessage;
42use crate::actor::PythonMessageKind;
43use crate::mailbox::EitherPortRef;
44use crate::mailbox::PythonOncePortRef;
45use crate::runtime::GilSite;
46use crate::runtime::monarch_with_gil_blocking;
47
48/// Python / PyO3 helpers used by the actor-based proc launcher.
49///
50/// This module contains small utilities that:
51/// - format Python objects for diagnostics, and
52/// - perform common imports under the GIL with consistent error
53///   mapping.
54mod py {
55    use hyperactor_mesh::proc_launcher::ProcLauncherError;
56    use pyo3::prelude::*;
57
58    /// Format a Python object for inclusion in error messages.
59    ///
60    /// Prefers `str(obj)` (more user-friendly for exceptions), and
61    /// falls back to `repr(obj)` if `str()` fails. If both fail,
62    /// returns a fixed placeholder.
63    pub(super) fn pyany_to_error_string(obj: &Bound<'_, PyAny>) -> String {
64        obj.str()
65            .map(|s| s.to_string())
66            .or_else(|_| obj.repr().map(|r| r.to_string()))
67            .unwrap_or_else(|_| "<error formatting exception>".into())
68    }
69
70    /// Import the `cloudpickle` module under the GIL.
71    ///
72    /// Errors are mapped into `ProcLauncherError` with context so
73    /// callers can report import failures as protocol/interop errors.
74    pub(super) fn import_cloudpickle(
75        py: Python<'_>,
76    ) -> Result<Bound<'_, pyo3::types::PyModule>, ProcLauncherError> {
77        py.import("cloudpickle")
78            .map_err(|e| ProcLauncherError::Other(format!("import cloudpickle: {e}")))
79    }
80}
81
82/// Decoding of exit results returned by the Python proc spawner.
83///
84/// The spawner replies on an explicit exit port with a pickled
85/// payload. That payload must be a `ProcExitResult` dataclass
86/// (from `monarch._src.actor.proc_launcher`) with the standard
87/// exit-reporting attributes.
88///
89/// Decoding is *strict*: all required attributes must be present
90/// with correct types.
91mod decode {
92    use hyperactor_mesh::proc_launcher::ProcExitKind;
93    use hyperactor_mesh::proc_launcher::ProcExitResult;
94    use hyperactor_mesh::proc_launcher::ProcLauncherError;
95    use pyo3::prelude::*;
96
97    use crate::runtime::GilSite;
98    use crate::runtime::monarch_with_gil_blocking;
99
100    /// Field names for the `ProcExitResult` dataclass attributes.
101    const K_EXIT_CODE: &str = "exit_code";
102    const K_SIGNAL: &str = "signal";
103    const K_CORE_DUMPED: &str = "core_dumped";
104    const K_FAILED_REASON: &str = "failed_reason";
105    const K_STDERR_TAIL: &str = "stderr_tail";
106
107    /// Required attributes for a valid `ProcExitResult` dataclass.
108    const REQUIRED_ATTRS: [&str; 5] = [
109        K_EXIT_CODE,
110        K_SIGNAL,
111        K_CORE_DUMPED,
112        K_FAILED_REASON,
113        K_STDERR_TAIL,
114    ];
115
116    /// Intermediate representation of exit information decoded from
117    /// the Python spawner.
118    ///
119    /// This struct separates:
120    /// (1) parsing/validation of a Python dataclass payload, from
121    /// (2) policy decisions when mapping into [`ProcExitResult`] /
122    ///     [`ProcExitKind`].
123    #[derive(Debug)]
124    pub(super) struct DecodedExit {
125        /// Process exit code, if known.
126        pub exit_code: Option<i32>,
127        /// Terminating signal number, if the process was killed by a signal.
128        pub signal: Option<i32>,
129        /// Whether a core dump was produced for a signaled termination.
130        pub core_dumped: bool,
131        /// Failure reason reported by the spawner.
132        pub failed_reason: Option<String>,
133        /// Trailing stderr lines captured by the spawner.
134        pub stderr_tail: Vec<String>,
135    }
136
137    /// Validate that the object has all required attributes.
138    fn validate_shape(obj: &Bound<'_, PyAny>) -> Result<(), ProcLauncherError> {
139        for k in REQUIRED_ATTRS {
140            if !obj
141                .hasattr(k)
142                .map_err(|e| ProcLauncherError::Other(format!("hasattr {k}: {e}")))?
143            {
144                return Err(ProcLauncherError::Other(format!(
145                    "ProcExitResult must be a ProcExitResult dataclass; missing attribute {k}"
146                )));
147            }
148        }
149        Ok(())
150    }
151
152    /// Extract fields from a validated `ProcExitResult` dataclass.
153    fn extract_fields(obj: &Bound<'_, PyAny>) -> Result<DecodedExit, ProcLauncherError> {
154        let exit_code = obj
155            .getattr(K_EXIT_CODE)
156            .map_err(|e| ProcLauncherError::Other(format!("getattr {K_EXIT_CODE}: {e}")))?
157            .extract::<Option<i32>>()
158            .map_err(|e| ProcLauncherError::Other(format!("extract {K_EXIT_CODE}: {e}")))?;
159
160        let signal = obj
161            .getattr(K_SIGNAL)
162            .map_err(|e| ProcLauncherError::Other(format!("getattr {K_SIGNAL}: {e}")))?
163            .extract::<Option<i32>>()
164            .map_err(|e| ProcLauncherError::Other(format!("extract {K_SIGNAL}: {e}")))?;
165
166        let core_dumped = obj
167            .getattr(K_CORE_DUMPED)
168            .map_err(|e| ProcLauncherError::Other(format!("getattr {K_CORE_DUMPED}: {e}")))?
169            .extract::<bool>()
170            .map_err(|e| ProcLauncherError::Other(format!("extract {K_CORE_DUMPED}: {e}")))?;
171
172        let failed_reason = obj
173            .getattr(K_FAILED_REASON)
174            .map_err(|e| ProcLauncherError::Other(format!("getattr {K_FAILED_REASON}: {e}")))?
175            .extract::<Option<String>>()
176            .map_err(|e| ProcLauncherError::Other(format!("extract {K_FAILED_REASON}: {e}")))?;
177
178        let stderr_tail = obj
179            .getattr(K_STDERR_TAIL)
180            .map_err(|e| ProcLauncherError::Other(format!("getattr {K_STDERR_TAIL}: {e}")))?
181            .extract::<Vec<String>>()
182            .map_err(|e| ProcLauncherError::Other(format!("extract {K_STDERR_TAIL}: {e}")))?;
183
184        Ok(DecodedExit {
185            exit_code,
186            signal,
187            core_dumped,
188            failed_reason,
189            stderr_tail,
190        })
191    }
192
193    /// Decode exit data from a `ProcExitResult` dataclass.
194    ///
195    /// Decoding is *strict*:
196    /// - All required attributes must exist (missing attributes are protocol errors).
197    /// - Attribute values must have the expected types (or be `None`
198    ///   for optional fields).
199    pub(super) fn decode_exit_obj(
200        obj: &Bound<'_, PyAny>,
201    ) -> Result<DecodedExit, ProcLauncherError> {
202        validate_shape(obj)?;
203        extract_fields(obj)
204    }
205
206    /// Convert a decoded Python exit payload into a
207    /// [`ProcExitResult`].
208    ///
209    /// Mapping rules:
210    /// - If `failed_reason` is set, the proc is treated as
211    ///   [`ProcExitKind::Failed`].
212    /// - Else if `signal` is set, the proc is treated as
213    ///   [`ProcExitKind::Signaled`] (propagating `core_dumped`).
214    /// - Else the proc is treated as [`ProcExitKind::Exited`]; if
215    ///   `exit_code` is missing, `-1` is used as a sentinel for
216    ///   "unknown".
217    ///
218    /// `stderr_tail` is always populated from the decoded payload
219    /// (possibly empty).
220    fn decoded_to_exit_result(d: DecodedExit) -> ProcExitResult {
221        let kind = if let Some(reason) = d.failed_reason {
222            ProcExitKind::Failed { reason }
223        } else if let Some(sig) = d.signal {
224            ProcExitKind::Signaled {
225                signal: sig,
226                core_dumped: d.core_dumped,
227            }
228        } else {
229            // -1 is a sentinel for "exit_code missing/unknown"
230            ProcExitKind::Exited {
231                code: d.exit_code.unwrap_or(-1),
232            }
233        };
234
235        ProcExitResult {
236            kind,
237            stderr_tail: Some(d.stderr_tail),
238        }
239    }
240
241    /// Map a Python exception value into a failed [`ProcExitResult`].
242    ///
243    /// This is used when the spawner reports an exception (rather than a
244    /// normal exit payload). The exception is formatted using
245    /// [`super::py::pyany_to_error_string`] and embedded in
246    /// [`ProcExitKind::Failed`]. No stderr tail is attached because the
247    /// failure originated in the spawner logic rather than the launched
248    /// process.
249    fn exception_to_exit_result(err_obj: &Bound<'_, PyAny>) -> ProcExitResult {
250        let reason = format!(
251            "spawner raised: {}",
252            super::py::pyany_to_error_string(err_obj)
253        );
254        ProcExitResult {
255            kind: ProcExitKind::Failed { reason },
256            stderr_tail: None,
257        }
258    }
259
260    /// Convert a spawner response [`PythonMessage`] into a
261    /// [`ProcExitResult`].
262    ///
263    /// The spawner replies on the exit port with a pickled payload:
264    /// - [`PythonMessageKind::Result`]: a pickled `ProcExitResult`-shaped
265    ///   object (dataclass), which is decoded via [`decode_exit_obj`]
266    ///   and mapped with [`decoded_to_exit_result`].
267    /// - [`PythonMessageKind::Exception`]: a pickled exception object,
268    ///   which is mapped to [`ProcExitKind::Failed`] via
269    ///   [`exception_to_exit_result`].
270    ///
271    /// Messages carrying a pending pickle state are rejected (exit
272    /// results must be fully materialized), and any unexpected message
273    /// kind is treated as a protocol error.
274    pub(super) fn convert_py_exit_result(
275        msg: crate::actor::PythonMessage,
276    ) -> Result<ProcExitResult, ProcLauncherError> {
277        use crate::actor::PythonMessageKind;
278
279        monarch_with_gil_blocking(GilSite::Convert, |py| {
280            let cloudpickle = super::py::import_cloudpickle(py)?;
281
282            match msg.kind {
283                PythonMessageKind::Result { .. } => {
284                    let obj = cloudpickle
285                        .call_method1("loads", (msg.message.to_bytes().as_ref(),))
286                        .map_err(|e| ProcLauncherError::Other(format!("cloudpickle.loads: {e}")))?;
287                    let decoded = decode_exit_obj(&obj)?;
288                    Ok(decoded_to_exit_result(decoded))
289                }
290                PythonMessageKind::Exception { .. } => {
291                    let err_obj = cloudpickle
292                        .call_method1("loads", (msg.message.to_bytes().as_ref(),))
293                        .map_err(|e| {
294                            ProcLauncherError::Other(format!("cloudpickle.loads exception: {e}"))
295                        })?;
296                    Ok(exception_to_exit_result(&err_obj))
297                }
298                _ => Err(ProcLauncherError::Other(
299                    "unexpected message kind in exit result".into(),
300                )),
301            }
302        })
303    }
304
305    #[cfg(test)]
306    mod tests {
307        use std::ffi::CStr;
308
309        use super::*;
310
311        // --
312        // Pure Rust tests for decoded_to_exit_result
313
314        // If `failed_reason` is present, it takes priority over
315        // signal/exit_code and the stderr tail is preserved.
316        #[test]
317        fn test_decoded_to_exit_result_failed_reason() {
318            let decoded = DecodedExit {
319                exit_code: Some(1),
320                signal: Some(9),
321                core_dumped: true,
322                failed_reason: Some("spawn failed".into()),
323                stderr_tail: vec!["error line".into()],
324            };
325            let result = decoded_to_exit_result(decoded);
326            // failed_reason takes priority over everything else
327            assert!(matches!(
328                result.kind,
329                ProcExitKind::Failed { reason } if reason == "spawn failed"
330            ));
331            assert_eq!(result.stderr_tail, Some(vec!["error line".into()]));
332        }
333
334        // If `failed_reason` is absent but a signal is present, we
335        // produce a `Signaled` exit result (including the
336        // core_dumped bit).
337        #[test]
338        fn test_decoded_to_exit_result_signal() {
339            let decoded = DecodedExit {
340                exit_code: Some(128 + 9),
341                signal: Some(9),
342                core_dumped: true,
343                failed_reason: None,
344                stderr_tail: vec![],
345            };
346            let result = decoded_to_exit_result(decoded);
347            assert!(matches!(
348                result.kind,
349                ProcExitKind::Signaled {
350                    signal: 9,
351                    core_dumped: true
352                }
353            ));
354        }
355
356        // If neither `failed_reason` nor `signal` is present, we
357        // produce an `Exited` result using the provided exit code
358        // and preserve stderr tail.
359        #[test]
360        fn test_decoded_to_exit_result_exit_code() {
361            let decoded = DecodedExit {
362                exit_code: Some(42),
363                signal: None,
364                core_dumped: false,
365                failed_reason: None,
366                stderr_tail: vec!["line1".into(), "line2".into()],
367            };
368            let result = decoded_to_exit_result(decoded);
369            assert!(matches!(result.kind, ProcExitKind::Exited { code: 42 }));
370            assert_eq!(
371                result.stderr_tail,
372                Some(vec!["line1".into(), "line2".into()])
373            );
374        }
375
376        // If no `failed_reason`, no `signal`, and `exit_code` is
377        // missing, we use the sentinel `-1` to mean "unknown exit
378        // code".
379        #[test]
380        fn test_decoded_to_exit_result_missing_exit_code_sentinel() {
381            // When no failed_reason, no signal, and no exit_code, we
382            // use -1 sentinel
383            let decoded = DecodedExit {
384                exit_code: None,
385                signal: None,
386                core_dumped: false,
387                failed_reason: None,
388                stderr_tail: vec![],
389            };
390            let result = decoded_to_exit_result(decoded);
391            assert!(matches!(result.kind, ProcExitKind::Exited { code: -1 }));
392        }
393
394        // --
395        // GIL-based tests for validate_shape and decode_exit_obj
396
397        // Helper: Run a small Python snippet and return its locals
398        // dict.
399        //
400        // The snippet should assign any values it wants to assert on
401        // into `locals`, e.g. `obj = FakeExit()`, so Rust can pull
402        // them out by name.
403        fn run_py_code<'py>(py: Python<'py>, code: &CStr) -> Bound<'py, pyo3::types::PyDict> {
404            let locals = pyo3::types::PyDict::new(py);
405            py.run(code, None, Some(&locals)).unwrap();
406            locals
407        }
408
409        // A Python object with all required attributes should pass
410        // shape validation.
411        #[test]
412        fn test_validate_shape_valid_dataclass() {
413            Python::initialize();
414            monarch_with_gil_blocking(GilSite::Test, |py| {
415                // Create a simple class with all required attributes
416                let locals = run_py_code(
417                    py,
418                    c"
419class FakeExit:
420    exit_code = 0
421    signal = None
422    core_dumped = False
423    failed_reason = None
424    stderr_tail = []
425obj = FakeExit()
426",
427                );
428                let obj = locals.get_item("obj").unwrap().unwrap();
429                assert!(validate_shape(&obj).is_ok());
430            });
431        }
432
433        // Missing required attributes should be rejected, and the
434        // error should mention which attribute is missing to aid
435        // debugging.
436        #[test]
437        fn test_validate_shape_missing_attribute() {
438            Python::initialize();
439            monarch_with_gil_blocking(GilSite::Test, |py| {
440                // Missing stderr_tail
441                let locals = run_py_code(
442                    py,
443                    c"
444class IncompleteExit:
445    exit_code = 0
446    signal = None
447    core_dumped = False
448    failed_reason = None
449obj = IncompleteExit()
450",
451                );
452                let obj = locals.get_item("obj").unwrap().unwrap();
453                let err = validate_shape(&obj).unwrap_err();
454                assert!(
455                    err.to_string().contains("stderr_tail"),
456                    "error should mention missing attribute: {err}"
457                );
458            });
459        }
460
461        // A well-formed Python exit object should decode into a
462        // `DecodedExit` with the expected field values.
463        #[test]
464        fn test_decode_exit_obj_valid() {
465            Python::initialize();
466            monarch_with_gil_blocking(GilSite::Test, |py| {
467                let locals = run_py_code(
468                    py,
469                    c"
470class FakeExit:
471    exit_code = 42
472    signal = None
473    core_dumped = False
474    failed_reason = None
475    stderr_tail = ['line1', 'line2']
476obj = FakeExit()
477",
478                );
479                let obj = locals.get_item("obj").unwrap().unwrap();
480                let decoded = decode_exit_obj(&obj).unwrap();
481                assert_eq!(decoded.exit_code, Some(42));
482                assert_eq!(decoded.signal, None);
483                assert!(!decoded.core_dumped);
484                assert_eq!(decoded.failed_reason, None);
485                assert_eq!(decoded.stderr_tail, vec!["line1", "line2"]);
486            });
487        }
488
489        // Type mismatches in the Python payload should fail
490        // decoding, and the error should mention the field that
491        // could not be extracted.
492        #[test]
493        fn test_decode_exit_obj_wrong_type() {
494            Python::initialize();
495            monarch_with_gil_blocking(GilSite::Test, |py| {
496                // exit_code is a string instead of int
497                let locals = run_py_code(
498                    py,
499                    c"
500class BadExit:
501    exit_code = 'not an int'
502    signal = None
503    core_dumped = False
504    failed_reason = None
505    stderr_tail = []
506obj = BadExit()
507",
508                );
509                let obj = locals.get_item("obj").unwrap().unwrap();
510                let err = decode_exit_obj(&obj).unwrap_err();
511                assert!(
512                    err.to_string().contains("exit_code"),
513                    "error should mention field: {err}"
514                );
515            });
516        }
517    }
518}
519
520use decode::convert_py_exit_result;
521use py::import_cloudpickle;
522
523/// A [`ProcLauncher`] implemented by delegating proc lifecycle
524/// operations to a Python actor.
525///
526/// The `spawner` actor must implement the `ProcLauncher` ABC from
527/// `monarch._src.actor.proc_launcher`, and is responsible for
528/// actually spawning and controlling OS processes (Docker, VMs,
529/// etc.). Rust retains the *lifecycle wiring* expected by
530/// [`BootstrapProcManager`]: it initiates launch/terminate/kill
531/// requests and exposes an [`oneshot::Receiver`] (`exit_rx`) that
532/// resolves when the spawner reports exit.
533///
534/// ## Semantics
535///
536/// - **PID is optional**: the Python spawner may not expose a real
537///   PID, so [`LaunchResult::pid`] is `None`.
538/// - **Exit reporting is required**: the spawner must send exactly
539///   one exit result on the provided exit port. If the port is closed
540///   or the payload cannot be decoded, the receiver resolves to a
541///   [`ProcExitKind::Failed`] result.
542/// - **Termination is best-effort**: `terminate` and `kill` are
543///   forwarded to the spawner; success only means the request was
544///   delivered.
545///
546/// ## Context requirement
547///
548/// [`ProcLauncher`] methods don't take a context parameter, but
549/// sending actor messages does. This launcher stores an
550/// [`Instance<()>`] ("client-only" actor) to use as the send context.
551/// The instance is created via [`Proc::client()`] and must remain
552/// valid for the lifetime of the launcher.
553#[derive(Debug)]
554pub struct ActorProcLauncher {
555    /// Handle to the Python spawner actor that implements the
556    /// ProcLauncher ABC.
557    spawner: ActorHandle<PythonActor>,
558
559    /// Mailbox used to allocate the one-shot exit port per launched
560    /// proc.
561    mailbox: Mailbox,
562
563    /// Client-only actor instance used as the send context for all
564    /// messages to `spawner`.
565    ///
566    /// Created via `Proc::client()`. The `()` type indicates this
567    /// is not a real actor—just a sending context. Must outlive the
568    /// launcher.
569    instance: Instance<()>,
570
571    /// Debug-only tracking of procs launched via this instance.
572    ///
573    /// Not used for correctness; used for diagnostics and sanity
574    /// checks.
575    active_procs: Arc<Mutex<HashSet<hyperactor::ProcAddr>>>,
576}
577
578impl ActorProcLauncher {
579    /// Create a new actor-based proc launcher.
580    ///
581    /// # Arguments
582    ///
583    /// * `spawner` - Handle to the Python actor implementing the
584    ///   `ProcLauncher` ABC.
585    /// * `mailbox` - Mailbox used to create one-shot exit ports.
586    /// * `instance` - Send context for `ActorHandle::send` (typically
587    ///   from `Proc::client()`). Any valid instance granting send
588    ///   capability is sufficient; it need not be
589    ///   `Instance<PythonActor>`. Must remain valid for the
590    ///   launcher's lifetime.
591    pub fn new(
592        spawner: ActorHandle<PythonActor>,
593        mailbox: Mailbox,
594        instance: Instance<()>,
595    ) -> Self {
596        Self {
597            spawner,
598            mailbox,
599            instance,
600            active_procs: Arc::new(Mutex::new(HashSet::new())),
601        }
602    }
603}
604
605#[async_trait]
606impl ProcLauncher for ActorProcLauncher {
607    /// Spawn a proc by delegating to the Python spawner actor.
608    ///
609    /// This method:
610    /// 1) opens a one-shot mailbox port used for the spawner's exit
611    ///    notification,
612    /// 2) serializes `(proc_id, LaunchOptions)` with `cloudpickle`,
613    /// 3) sends a `CallMethod { launch, ExplicitPort(..) }` message
614    ///    to the spawner,
615    /// 4) returns immediately with a [`LaunchResult`] whose `exit_rx`
616    ///    completes once the spawner reports process termination (or the
617    ///    port closes).
618    ///
619    /// ## Notes
620    /// - `pid` is always `None`: the Rust side does not assume an OS
621    ///   PID exists.
622    /// - Exit is observed asynchronously via `exit_rx`;
623    ///   termination/kill are best-effort requests to the spawner actor
624    ///   rather than direct OS signals.
625    /// - If decoding the exit payload fails, the returned `exit_rx`
626    ///   resolves to `ProcExitKind::Failed` with a decode error reason.
627    async fn launch(
628        &self,
629        proc_id: &hyperactor::ProcAddr,
630        opts: LaunchOptions,
631    ) -> Result<LaunchResult, ProcLauncherError> {
632        let (exit_port, exit_port_rx) = self.mailbox.open_once_port::<PythonMessage>();
633
634        let pickled_args = monarch_with_gil_blocking(
635            GilSite::Bootstrap,
636            |py| -> Result<Vec<u8>, ProcLauncherError> {
637                let cloudpickle = import_cloudpickle(py)?;
638
639                let mod_ = py
640                    .import("monarch._src.actor.proc_launcher")
641                    .map_err(|e| ProcLauncherError::Other(format!("import proc_launcher: {e}")))?;
642                let launch_opts_cls = mod_
643                    .getattr("LaunchOptions")
644                    .map_err(|e| ProcLauncherError::Other(format!("getattr LaunchOptions: {e}")))?;
645
646                let program = opts.command.program.to_str().ok_or_else(|| {
647                    ProcLauncherError::Other("program path is not valid UTF-8".into())
648                })?;
649
650                let env = pyo3::types::PyDict::new(py);
651                for (k, v) in &opts.command.env {
652                    env.set_item(k, v)
653                        .map_err(|e| ProcLauncherError::Other(format!("set env item: {e}")))?;
654                }
655
656                let py_proc_bind = opts.proc_bind.as_ref().map(|bind| {
657                    let d = pyo3::types::PyDict::new(py);
658                    if let Some(v) = &bind.cpunodebind {
659                        d.set_item("cpunodebind", v).unwrap();
660                    }
661                    if let Some(v) = &bind.membind {
662                        d.set_item("membind", v).unwrap();
663                    }
664                    if let Some(v) = &bind.physcpubind {
665                        d.set_item("physcpubind", v).unwrap();
666                    }
667                    if let Some(v) = &bind.cpus {
668                        d.set_item("cpus", v).unwrap();
669                    }
670                    d
671                });
672
673                let py_opts = launch_opts_cls
674                    .call1((
675                        &opts.bootstrap_payload,
676                        &opts.process_name,
677                        program,
678                        opts.command.arg0.as_deref(),
679                        &opts.command.args,
680                        env,
681                        opts.want_stdio,
682                        opts.tail_lines,
683                        opts.log_channel.as_ref().map(|a| a.to_string()),
684                        py_proc_bind,
685                    ))
686                    .map_err(|e| {
687                        ProcLauncherError::Other(format!("construct LaunchOptions: {e}"))
688                    })?;
689
690                let args = (proc_id.to_string(), py_opts);
691                let kwargs = pyo3::types::PyDict::new(py);
692                let pickled = cloudpickle
693                    .call_method1("dumps", ((args, kwargs),))
694                    .map_err(|e| ProcLauncherError::Other(format!("cloudpickle: {e}")))?;
695
696                pickled
697                    .extract::<Vec<u8>>()
698                    .map_err(|e| ProcLauncherError::Other(format!("extract bytes: {e}")))
699            },
700        )?;
701
702        let bound_port = exit_port.bind();
703        let message = PythonMessage {
704            kind: PythonMessageKind::CallMethod {
705                name: MethodSpecifier::ExplicitPort {
706                    name: "launch".into(),
707                },
708                response_port: Some(EitherPortRef::Once(PythonOncePortRef::from(bound_port))),
709            },
710            message: pickled_args.into(),
711            refs: Vec::new(),
712        };
713
714        self.spawner.post(&self.instance, message);
715
716        let mut active_procs: tokio::sync::MutexGuard<'_, HashSet<hyperactor::ProcAddr>> =
717            self.active_procs.lock().await;
718        active_procs.insert(proc_id.clone());
719        drop(active_procs);
720
721        let (exit_tx, exit_rx) = oneshot::channel();
722        let active_procs: Arc<Mutex<HashSet<hyperactor::ProcAddr>>> =
723            Arc::clone(&self.active_procs);
724        let proc_id_clone = proc_id.clone();
725
726        tokio::spawn(async move {
727            let result = match exit_port_rx.recv().await {
728                Ok(py_message) => {
729                    convert_py_exit_result(py_message).unwrap_or_else(|e| ProcExitResult {
730                        kind: ProcExitKind::Failed {
731                            reason: format!("failed to decode exit result: {e}"),
732                        },
733                        stderr_tail: None,
734                    })
735                }
736                Err(_) => ProcExitResult {
737                    kind: ProcExitKind::Failed {
738                        reason: "exit port closed (spawner crashed or forgot to send)".into(),
739                    },
740                    stderr_tail: None,
741                },
742            };
743            active_procs.lock().await.remove(&proc_id_clone);
744            let _ = exit_tx.send(result);
745        });
746
747        Ok(LaunchResult {
748            pid: None,
749            started_at: std::time::SystemTime::now(),
750            stdio: StdioHandling::ManagedByLauncher,
751            exit_rx,
752        })
753    }
754
755    /// Request graceful termination of a proc, with a best-effort
756    /// timeout.
757    ///
758    /// This delegates to the Python spawner actor's
759    /// `terminate(proc_id, timeout_secs)` method. The request is sent
760    /// fire-and-forget: we do not wait for an acknowledgment, and
761    /// there is no guarantee the proc will actually exit within
762    /// `timeout`.
763    ///
764    /// ## Errors
765    ///
766    /// Returns `ProcLauncherError::Terminate` if we fail to:
767    /// - import/serialize the request via `cloudpickle`, or
768    /// - send the message to the spawner actor.
769    async fn terminate(
770        &self,
771        proc_id: &hyperactor::ProcAddr,
772        timeout: Duration,
773    ) -> Result<(), ProcLauncherError> {
774        let pickled =
775            monarch_with_gil_blocking(GilSite::Stop, |py| -> Result<Vec<u8>, ProcLauncherError> {
776                let cloudpickle = import_cloudpickle(py)
777                    .map_err(|e| ProcLauncherError::Terminate(format!("{e}")))?;
778                let args = (proc_id.to_string(), timeout.as_secs_f64());
779                let kwargs = pyo3::types::PyDict::new(py);
780                cloudpickle
781                    .call_method1("dumps", ((args, kwargs),))
782                    .map_err(|e| ProcLauncherError::Terminate(format!("cloudpickle: {e}")))?
783                    .extract()
784                    .map_err(|e| ProcLauncherError::Terminate(format!("extract: {e}")))
785            })?;
786
787        let message = PythonMessage {
788            kind: PythonMessageKind::CallMethod {
789                name: MethodSpecifier::ReturnsResponse {
790                    name: "terminate".into(),
791                },
792                response_port: None,
793            },
794            message: pickled.into(),
795            refs: Vec::new(),
796        };
797
798        self.spawner.post(&self.instance, message);
799        Ok(())
800    }
801
802    /// Forcefully kill a proc.
803    ///
804    /// This delegates to the Python spawner actor's `kill(proc_id)`
805    /// method. Like `terminate`, this is best-effort and
806    /// fire-and-forget: success here means the request was serialized
807    /// and delivered to the spawner actor, not that the process is
808    /// already dead.
809    ///
810    /// ## Errors
811    ///
812    /// Returns `ProcLauncherError::Kill` if we fail to:
813    /// - import/serialize the request via `cloudpickle`, or
814    /// - send the message to the spawner actor.
815    async fn kill(&self, proc_id: &hyperactor::ProcAddr) -> Result<(), ProcLauncherError> {
816        let pickled =
817            monarch_with_gil_blocking(GilSite::Stop, |py| -> Result<Vec<u8>, ProcLauncherError> {
818                let cloudpickle =
819                    import_cloudpickle(py).map_err(|e| ProcLauncherError::Kill(format!("{e}")))?;
820                let args = (proc_id.to_string(),);
821                let kwargs = pyo3::types::PyDict::new(py);
822                cloudpickle
823                    .call_method1("dumps", ((args, kwargs),))
824                    .map_err(|e| ProcLauncherError::Kill(format!("cloudpickle: {e}")))?
825                    .extract()
826                    .map_err(|e| ProcLauncherError::Kill(format!("extract: {e}")))
827            })?;
828
829        let message = PythonMessage {
830            kind: PythonMessageKind::CallMethod {
831                name: MethodSpecifier::ReturnsResponse {
832                    name: "kill".into(),
833                },
834                response_port: None,
835            },
836            message: pickled.into(),
837            refs: Vec::new(),
838        };
839
840        self.spawner.post(&self.instance, message);
841        Ok(())
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    // Verifies that `pyany_to_error_string` formats Python objects
850    // using `str()` (falling back to `repr()`).
851    #[test]
852    fn test_pyany_to_error_string() {
853        Python::initialize();
854        monarch_with_gil_blocking(GilSite::Test, |py| {
855            // A Python string should round-trip through `str()`
856            // unchanged.
857            let s = pyo3::types::PyString::new(py, "hello");
858            assert_eq!(py::pyany_to_error_string(s.as_any()), "hello");
859
860            // Non-strings should format via `str()` when possible.
861            let i = 42i32.into_pyobject(py).unwrap();
862            assert_eq!(py::pyany_to_error_string(i.as_any()), "42");
863        });
864    }
865}