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}