Skip to main content

monarch_hyperactor/
bootstrap.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
9use std::collections::HashMap;
10use std::future::Future;
11
12use futures::future::try_join_all;
13use hyperactor::Gateway;
14use hyperactor::Location;
15use hyperactor::ProcAddr;
16use hyperactor::ProcId;
17use hyperactor::channel::ChannelAddr;
18use hyperactor::id::Label;
19use hyperactor_mesh::bootstrap::BootstrapCommand;
20use hyperactor_mesh::bootstrap::bootstrap;
21use hyperactor_mesh::bootstrap::host;
22use hyperactor_mesh::host_mesh::HostMesh;
23use hyperactor_mesh::mesh_id::HostMeshId;
24use monarch_types::MapPyErr;
25use pyo3::Bound;
26use pyo3::PyAny;
27use pyo3::PyResult;
28use pyo3::Python;
29use pyo3::exceptions::PyRuntimeError;
30use pyo3::exceptions::PyValueError;
31use pyo3::pyfunction;
32use pyo3::types::PyAnyMethods;
33use pyo3::types::PyModule;
34use pyo3::types::PyModuleMethods;
35use pyo3::wrap_pyfunction;
36
37use crate::handle::PyHandle;
38use crate::host_mesh::PyHostMesh;
39use crate::pytokio::PyPythonTask;
40use crate::runtime::GilSite;
41use crate::runtime::monarch_with_gil;
42
43const SERVICE_ADDRESS_FORMAT: &str = "expected a channel URL such as 'tcp://host:4444' or a ProcAddr such as 'service<2MuAHeDjLCEd>@tcp://host:4444'";
44
45fn legacy_service_proc_id() -> ProcId {
46    ProcId::singleton(Label::strip(hyperactor::proc::LEGACY_SERVICE_PROC_NAME))
47}
48
49fn service_address_parse_error(address: &str, error: impl std::fmt::Display) -> anyhow::Error {
50    anyhow::anyhow!("invalid worker address {address:?}: {error}; {SERVICE_ADDRESS_FORMAT}")
51}
52
53fn service_proc_id_and_location(address: &str) -> (ProcId, &str) {
54    // TODO: Remove this manual parse by either avoiding reserved-port addresses
55    // here or making listener-preserving ProcAddr parsing a first-class API.
56    if let Some((proc_id, location)) = address.split_once('@')
57        && let Ok(proc_id) = proc_id.parse::<ProcId>()
58    {
59        return (proc_id, location);
60    }
61
62    (legacy_service_proc_id(), address)
63}
64
65fn into_dial_location(location: Location) -> Location {
66    match location {
67        Location::Addr(addr) => addr.into_dial_addr().into(),
68        Location::Via(uid, inner) => into_dial_location(*inner).with_via(uid),
69    }
70}
71
72fn parse_service_proc_addr(address: &str) -> anyhow::Result<ProcAddr> {
73    let proc_addr = match address.parse::<ProcAddr>() {
74        Ok(proc_addr) => proc_addr,
75        Err(_) => address
76            .parse::<Location>()
77            .map(|location| ProcAddr::new(legacy_service_proc_id(), location))
78            .map_err(|error| service_address_parse_error(address, error))?,
79    };
80
81    Ok(ProcAddr::new(
82        proc_addr.id().clone(),
83        into_dial_location(proc_addr.location().clone()),
84    ))
85}
86
87/// Parses a service address without discarding serve-only resources.
88///
89/// [`ProcAddr`] contains the parsed [`ChannelAddr`], but it cannot retain the
90/// [`std::net::TcpListener`] adopted from an `fdNNN` address. Parsing the
91/// channel URL here preserves that listener for the host. We also cannot use
92/// [`ProcAddr::addr`] because it peels [`Location::Via`], which is not a valid
93/// frontend bind specification.
94fn parse_service_proc_addr_for_serve(
95    address: &str,
96) -> anyhow::Result<(ProcAddr, Option<std::net::TcpListener>)> {
97    let (proc_id, location) = service_proc_id_and_location(address);
98    let (addr, listener) = ChannelAddr::from_zmq_url_with_listener(location)
99        .map_err(|error| service_address_parse_error(address, error))?;
100    Ok((ProcAddr::new(proc_id, addr.into()), listener))
101}
102
103#[pyfunction]
104#[pyo3(signature = ())]
105pub fn bootstrap_main(py: Python) -> PyResult<Bound<PyAny>> {
106    #[cfg(fbcode_build)]
107    // SAFETY: this is a correct use of this function.
108    unsafe {
109        fbinit::perform_init();
110    };
111
112    hyperactor::internal_macro_support::tracing::debug!("entering async bootstrap");
113    crate::runtime::future_into_py::<_, i32>(py, async move {
114        // SAFETY:
115        // - Only one of these is ever created.
116        // - This is the entry point of this program, so this will be dropped when
117        // no more FB C++ code is running.
118        #[cfg(fbcode_build)]
119        let _destroy_guard = unsafe { fbinit::DestroyGuard::new() };
120        bootstrap()
121            .await
122            .map_err(|e| PyRuntimeError::new_err(format!("{:?}", e)))
123    })
124}
125
126fn prepare_worker_loop(
127    address: &str,
128    exit_on_shutdown: bool,
129) -> PyResult<impl Future<Output = PyResult<()>> + Send + 'static> {
130    let (service_proc_addr, listener) = parse_service_proc_addr_for_serve(address)
131        .map_err(|error| PyValueError::new_err(error.to_string()))?;
132    // Check if we're running in a PAR/XAR build by looking for FB_XAR_INVOKED_NAME environment variable
133    let invoked_name = std::env::var("FB_XAR_INVOKED_NAME");
134
135    let command = Some(if let Ok(invoked_name) = invoked_name {
136        // For PAR/XAR builds: use argv[0] from Python's sys.argv as the current executable
137        let current_exe = std::path::PathBuf::from(&invoked_name);
138
139        // For PAR/XAR builds: set PAR_MAIN_OVERRIDE and no additional args
140        BootstrapCommand {
141            program: current_exe,
142            arg0: Some(invoked_name),
143            args: vec![],
144            env: HashMap::from([(
145                "PAR_MAIN_OVERRIDE".to_string(),
146                "monarch._src.actor.bootstrap_main".to_string(),
147            )]),
148            ..Default::default()
149        }
150    } else {
151        // For regular Python builds: use argv[0] to preserve the original
152        // invocation path.  current_exe() resolves symlinks, which breaks
153        // virtual environments — the resolved path doesn't find pyvenv.cfg
154        // so site-packages aren't activated in subprocesses.
155        let current_exe = std::env::args()
156            .next()
157            .map(std::path::PathBuf::from)
158            .or_else(|| std::env::current_exe().ok())
159            .ok_or_else(|| {
160                pyo3::PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
161                    "Failed to determine current executable",
162                )
163            })?;
164        let current_exe_str = current_exe.to_string_lossy().to_string();
165        BootstrapCommand {
166            program: current_exe,
167            arg0: Some(current_exe_str),
168            args: vec![
169                "-m".to_string(),
170                "monarch._src.actor.bootstrap_main".to_string(),
171            ],
172            ..Default::default()
173        }
174    });
175
176    Ok(async move {
177        let (_agent_handle, shutdown) = host(
178            service_proc_addr,
179            command,
180            None,
181            exit_on_shutdown,
182            listener,
183            Gateway::new(),
184            None,
185        )
186        .await
187        .map_pyerr()?;
188        // No halt() here: `host` was given the same `exit_on_shutdown`, so
189        // `DrainedHostShutdown::stop_and_join` already calls
190        // `std::process::exit(0)` when it is true. Anything after this line
191        // only runs on the embedded (`exit_on_shutdown == false`) path.
192        shutdown.stop_and_join().await;
193        Ok(())
194    })
195}
196
197fn start_worker_loop(address: &str, exit_on_shutdown: bool) -> PyResult<PyHandle> {
198    Ok(PyHandle::spawn(prepare_worker_loop(
199        address,
200        exit_on_shutdown,
201    )?))
202}
203
204/// Start a worker loop that exits the process after host shutdown.
205#[pyfunction]
206pub fn start_worker_loop_forever(_py: Python<'_>, address: &str) -> PyResult<PyHandle> {
207    start_worker_loop(address, true)
208}
209
210/// Start an embedded worker loop and observe it through a Handle.
211#[pyfunction]
212pub fn start_worker_loop_until_shutdown(_py: Python<'_>, address: &str) -> PyResult<PyHandle> {
213    start_worker_loop(address, false)
214}
215
216#[pyfunction]
217#[pyo3(signature = (instance, workers, name=None))]
218pub fn attach_to_workers(
219    instance: &crate::context::PyInstance,
220    workers: Vec<Bound<'_, PyPythonTask>>,
221    name: Option<&str>,
222) -> PyResult<PyPythonTask> {
223    let tasks = workers
224        .into_iter()
225        .map(|x| x.borrow_mut().take_task())
226        .collect::<PyResult<Vec<_>>>()?;
227
228    // `Label::strip` (vs. `Label::new`) sanitizes user-supplied names — lowercases,
229    // drops illegal characters, falls back to "nil" if empty. Callers pass names
230    // derived from experiment / job names that may contain uppercase or punctuation;
231    // rejecting them surfaces as an opaque PyException far from the input site.
232    let name = HostMeshId::instance(Label::strip(name.unwrap_or("hosts")));
233    let instance = instance.clone();
234    PyPythonTask::new(async move {
235        let results = try_join_all(tasks).await?;
236
237        let service_procs: Result<Vec<ProcAddr>, anyhow::Error> =
238            monarch_with_gil(GilSite::Bootstrap, |py| {
239                results
240                    .into_iter()
241                    .map(|result| {
242                        let address: String = result.bind(py).extract()?;
243                        parse_service_proc_addr(&address)
244                    })
245                    .collect()
246            })
247            .await;
248        let service_procs = service_procs?;
249
250        let host_mesh = HostMesh::attach_with_service_procs(&*instance, name, service_procs)
251            .await
252            .map_err(|e| anyhow::anyhow!("attach failed: {}", e))?;
253        Ok(PyHostMesh::new_owned(host_mesh))
254    })
255}
256
257pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
258    let f = wrap_pyfunction!(bootstrap_main, hyperactor_mod)?;
259    f.setattr(
260        "__module__",
261        "monarch._rust_bindings.monarch_hyperactor.bootstrap",
262    )?;
263    hyperactor_mod.add_function(f)?;
264
265    let f = wrap_pyfunction!(start_worker_loop_forever, hyperactor_mod)?;
266    f.setattr(
267        "__module__",
268        "monarch._rust_bindings.monarch_hyperactor.bootstrap",
269    )?;
270    hyperactor_mod.add_function(f)?;
271
272    let f = wrap_pyfunction!(start_worker_loop_until_shutdown, hyperactor_mod)?;
273    f.setattr(
274        "__module__",
275        "monarch._rust_bindings.monarch_hyperactor.bootstrap",
276    )?;
277    hyperactor_mod.add_function(f)?;
278
279    let f = wrap_pyfunction!(attach_to_workers, hyperactor_mod)?;
280    f.setattr(
281        "__module__",
282        "monarch._rust_bindings.monarch_hyperactor.bootstrap",
283    )?;
284    hyperactor_mod.add_function(f)?;
285
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use std::os::fd::AsFd;
292    use std::os::fd::AsRawFd;
293    use std::os::fd::IntoRawFd;
294    use std::os::fd::OwnedFd;
295    use std::os::fd::RawFd;
296
297    use nix::errno::Errno;
298    use nix::libc;
299    use nix::sys::socket::AddressFamily;
300    use nix::sys::socket::SockFlag;
301    use nix::sys::socket::SockType;
302    use nix::sys::socket::SockaddrIn;
303    use nix::sys::socket::bind;
304    use nix::sys::socket::getsockname;
305    use nix::sys::socket::getsockopt;
306    use nix::sys::socket::socket;
307    use nix::sys::socket::sockopt;
308    use nix::unistd::dup;
309    use pyo3::PyErr;
310    use pyo3::PyTypeInfo;
311
312    use super::*;
313    use crate::runtime::monarch_with_gil_blocking;
314
315    #[test]
316    fn test_bare_worker_address_uses_legacy_service_proc() {
317        let service_proc = parse_service_proc_addr("tcp://127.0.0.1:1234").unwrap();
318
319        assert_eq!(service_proc.id(), &legacy_service_proc_id());
320        assert_eq!(service_proc.location().to_string(), "tcp://127.0.0.1:1234");
321    }
322
323    #[test]
324    fn test_proc_worker_address_preserves_service_identity() {
325        let service_proc_id = ProcId::instance(Label::strip("service"));
326        let service_proc = ProcAddr::new(
327            service_proc_id.clone(),
328            ChannelAddr::from_zmq_url("tcp://127.0.0.1:1234")
329                .unwrap()
330                .into(),
331        );
332
333        let parsed = parse_service_proc_addr(&service_proc.to_string()).unwrap();
334
335        assert_eq!(parsed, service_proc);
336    }
337
338    #[test]
339    fn test_worker_address_disambiguates_proc_addr_from_channel_alias() {
340        let alias = "tcp://127.0.0.1:1234@tcp://0.0.0.0:1234";
341        let dial_addr = ChannelAddr::from_zmq_url("tcp://127.0.0.1:1234").unwrap();
342        let legacy = parse_service_proc_addr(alias).unwrap();
343        assert_eq!(legacy.id(), &legacy_service_proc_id());
344        assert_eq!(legacy.addr(), &dial_addr);
345
346        let service_proc_id = ProcId::instance(Label::strip("service"));
347        let proc_address = format!("{service_proc_id}@{alias}");
348        let parsed = parse_service_proc_addr(&proc_address).unwrap();
349        assert_eq!(parsed.id(), &service_proc_id);
350        assert_eq!(parsed.addr(), &dial_addr);
351    }
352
353    #[test]
354    fn test_proc_worker_address_preserves_via_and_canonicalizes_alias() {
355        let service_proc_id = ProcId::instance(Label::strip("service"));
356        let via_uid = hyperactor::Uid::Instance(0x71a, Some(Label::strip("via")));
357        let alias = ChannelAddr::from_zmq_url("tcp://127.0.0.1:1234@tcp://0.0.0.0:1234").unwrap();
358        let service_proc = ProcAddr::new(
359            service_proc_id.clone(),
360            Location::from(alias).with_via(via_uid.clone()),
361        );
362
363        let parsed = parse_service_proc_addr(&service_proc.to_string()).unwrap();
364        let (parsed_via, inner) = parsed.location().as_via().unwrap();
365
366        assert_eq!(parsed.id(), &service_proc_id);
367        assert_eq!(parsed_via, &via_uid);
368        assert_eq!(inner.addr().to_zmq_url(), "tcp://127.0.0.1:1234");
369    }
370
371    #[test]
372    fn test_explicit_singleton_service_proc_is_accepted() {
373        let service_proc = parse_service_proc_addr("service@tcp://127.0.0.1:1234").unwrap();
374
375        assert_eq!(service_proc.id(), &legacy_service_proc_id());
376        assert_eq!(service_proc.location().to_string(), "tcp://127.0.0.1:1234");
377    }
378
379    #[test]
380    fn test_serve_worker_address_accepts_explicit_singleton() {
381        let (service_proc, listener) =
382            parse_service_proc_addr_for_serve("service@tcp://127.0.0.1:1234").unwrap();
383
384        assert_eq!(service_proc.id(), &legacy_service_proc_id());
385        assert_eq!(service_proc.location().to_string(), "tcp://127.0.0.1:1234");
386        assert!(listener.is_none());
387    }
388
389    /// A bound but deliberately not-yet-listening IPv4 socket, plus the address
390    /// it holds. Starting from a listening socket would make the
391    /// `SO_ACCEPTCONN` transition below vacuous.
392    fn bound_not_listening() -> (OwnedFd, SockaddrIn) {
393        let sock = socket(
394            AddressFamily::Inet,
395            SockType::Stream,
396            SockFlag::empty(),
397            None,
398        )
399        .expect("test socket should be creatable");
400        let wildcard = SockaddrIn::new(127, 0, 0, 1, 0);
401        bind(sock.as_raw_fd(), &wildcard).expect("binding an ephemeral port should succeed");
402        let bound = getsockname::<SockaddrIn>(sock.as_raw_fd()).expect("bound socket has a name");
403        (sock, bound)
404    }
405
406    /// What the kernel says when a fresh socket tries to take `addr`. A live
407    /// listener holds it; a released one does not. The error is preserved so a
408    /// caller can require the specific refusal rather than any failure.
409    fn rebind_result(addr: &SockaddrIn) -> nix::Result<()> {
410        let probe = socket(
411            AddressFamily::Inet,
412            SockType::Stream,
413            SockFlag::empty(),
414            None,
415        )
416        .expect("probe socket should be creatable");
417        bind(probe.as_raw_fd(), addr)
418    }
419
420    /// A non-alias TCP `host:fdN` location transfers descriptor ownership while
421    /// the worker-loop future is prepared, and that future holds the listener.
422    ///
423    /// The test exercises two spellings of this same path, each with its own
424    /// freshly bound socket: a bare channel URL using the legacy service proc id
425    /// and an explicit `ProcId@location` using a generated instance id. The tls,
426    /// metatls, quic, and metaquic schemes use the same `host:fdN` parser but are
427    /// not exercised here. A TCP alias is different: its nested listener is
428    /// discarded and closed during parsing rather than retained by the future.
429    ///
430    /// The address is the durable ownership witness. Rebinding is checked on
431    /// both sides of the drop, because a rebind that succeeds afterwards proves
432    /// nothing unless it fails while the future still owns the listener.
433    #[test]
434    fn prepare_worker_loop_retains_non_alias_tcp_fd_until_future_drop() {
435        pyo3::Python::initialize();
436
437        for build_address in [
438            (&|fd| format!("tcp://127.0.0.1:fd{fd}")) as &dyn Fn(RawFd) -> String,
439            &|fd| {
440                format!(
441                    "{}@tcp://127.0.0.1:fd{fd}",
442                    ProcId::instance(Label::strip("service"))
443                )
444            },
445        ] {
446            let (sock, bound) = bound_not_listening();
447            let observer = dup(sock.as_fd()).expect("duplicating for observation should succeed");
448            assert!(
449                !getsockopt(&observer, sockopt::AcceptConn).expect("SO_ACCEPTCONN is readable"),
450                "the fixture must hand over a bound socket that is not yet listening"
451            );
452
453            // Ownership leaves `OwnedFd` tracking here: the number is all that
454            // is passed, and worker-loop preparation adopts it.
455            //
456            // If preparation were to fail instead, this fixture cannot reclaim
457            // the descriptor. Whether it is still open depends on where the
458            // failure happened -- a parse failure leaves it untouched, while a
459            // failure after the listener is built has already closed it -- and
460            // the caller cannot tell those apart. Closing it here would risk a
461            // double close, which in a shared test process could take out an
462            // unrelated descriptor opened concurrently. Leaking one descriptor
463            // in a test that is failing anyway is the safer of the two.
464            let transferred = sock.into_raw_fd();
465            let address = build_address(transferred);
466
467            let future = prepare_worker_loop(&address, true)
468                .expect("a valid descriptor address should prepare a worker loop");
469
470            // SAFETY: `F_GETFD` only reads the flags of a descriptor number and
471            // never closes it. A number that is not open is defined input here:
472            // the call returns -1 and sets EBADF, which is exactly the outcome
473            // this assertion rules out. Taking a `BorrowedFd` first would
474            // instead assume the validity being tested.
475            let flags = unsafe { libc::fcntl(transferred, libc::F_GETFD) };
476            assert_ne!(
477                flags, -1,
478                "preparation must leave the transferred descriptor number open"
479            );
480            // Still open is not enough: the number could have been closed and
481            // handed back out by another thread. Requiring it to name the same
482            // socket rules that out.
483            assert_eq!(
484                getsockname::<SockaddrIn>(transferred)
485                    .expect("a retained descriptor names its socket"),
486                bound,
487                "the retained descriptor must still be the socket that was handed over"
488            );
489            assert!(
490                getsockopt(&observer, sockopt::AcceptConn).expect("SO_ACCEPTCONN is readable"),
491                "preparation must call listen before it returns"
492            );
493
494            // The future must be the only owner left before the address can say
495            // anything about what the future holds.
496            drop(observer);
497            assert_eq!(
498                rebind_result(&bound),
499                Err(Errno::EADDRINUSE),
500                "the undriven future must still hold the listener"
501            );
502
503            // Keep the future unpolled so the rebind below isolates the
504            // listener captured during preparation from anything `host()`
505            // would do.
506            drop(future);
507
508            // Deliberately not probing `transferred` here: once released, that
509            // number may already belong to another thread's descriptor.
510            assert_eq!(
511                rebind_result(&bound),
512                Ok(()),
513                "dropping the undriven future must release the adopted listener"
514            );
515        }
516    }
517
518    /// An `fdN` on the bind side of a TCP alias is listened on during parsing,
519    /// but the alias parser discards that listener before preparation returns.
520    /// The returned future therefore retains no ownership of the bind socket.
521    #[test]
522    fn prepare_worker_loop_alias_fd_listens_and_closes_during_preparation() {
523        pyo3::Python::initialize();
524
525        let (sock, bound) = bound_not_listening();
526        let observer = dup(sock.as_fd()).expect("duplicating for observation should succeed");
527        assert!(
528            !getsockopt(&observer, sockopt::AcceptConn).expect("SO_ACCEPTCONN is readable"),
529            "the fixture must hand over a bound socket that is not yet listening"
530        );
531
532        let transferred = sock.into_raw_fd();
533        let address = format!("tcp://127.0.0.1:4444@tcp://127.0.0.1:fd{transferred}");
534        let future = prepare_worker_loop(&address, true)
535            .expect("a valid alias descriptor address should prepare a worker loop");
536
537        assert!(
538            getsockopt(&observer, sockopt::AcceptConn).expect("SO_ACCEPTCONN is readable"),
539            "alias parsing must call listen before discarding the adopted listener"
540        );
541        drop(observer);
542        assert_eq!(
543            rebind_result(&bound),
544            Ok(()),
545            "the returned future must not retain the alias bind listener"
546        );
547
548        // Keep the future unpolled: this test covers resources acquired during
549        // alias parsing, not anything `host()` does when the future runs.
550        drop(future);
551    }
552
553    /// A textual address failure and a descriptor-syntax failure are both
554    /// raised during preparation, so no future is created. This says nothing
555    /// about a numeric descriptor that is syntactically valid; that precondition
556    /// belongs to the caller and is not exercised here.
557    #[test]
558    fn prepare_worker_loop_rejects_invalid_addresses_before_returning_future() {
559        pyo3::Python::initialize();
560
561        for (address, cause) in [
562            (
563                "tcp://127.0.0.1:fdnot-a-number",
564                "invalid file descriptor number: fdnot-a-number",
565            ),
566            ("zzz://127.0.0.1:1234", "unsupported ZMQ scheme: zzz"),
567        ] {
568            monarch_with_gil_blocking(GilSite::Test, |py| {
569                let error = match prepare_worker_loop(address, true) {
570                    Ok(_) => panic!("{address} must not produce a future"),
571                    Err(error) => error,
572                };
573
574                assert!(
575                    error.get_type(py).is(PyValueError::type_object(py)),
576                    "an address failure must be exactly ValueError"
577                );
578                let message = error.value(py).to_string();
579                assert!(
580                    message.contains(&format!("invalid worker address {address:?}")),
581                    "the message must name the rejected address, got {message}"
582                );
583                assert!(
584                    message.contains(cause),
585                    "the message must retain the underlying cause, got {message}"
586                );
587                Ok::<_, PyErr>(())
588            })
589            .expect("assertions should not fail");
590        }
591    }
592
593    #[test]
594    fn test_invalid_worker_address_error_includes_valid_formats() {
595        let error = parse_service_proc_addr("not an address").unwrap_err();
596        let message = error.to_string();
597        assert!(message.contains("invalid worker address \"not an address\""));
598        assert!(message.contains("tcp://host:4444"));
599        assert!(message.contains("service<2MuAHeDjLCEd>@tcp://host:4444"));
600    }
601}