1use 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 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
87fn 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 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 #[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 let invoked_name = std::env::var("FB_XAR_INVOKED_NAME");
134
135 let command = Some(if let Ok(invoked_name) = invoked_name {
136 let current_exe = std::path::PathBuf::from(&invoked_name);
138
139 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 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 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#[pyfunction]
206pub fn start_worker_loop_forever(_py: Python<'_>, address: &str) -> PyResult<PyHandle> {
207 start_worker_loop(address, true)
208}
209
210#[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 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 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 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 #[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 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 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 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 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 drop(future);
507
508 assert_eq!(
511 rebind_result(&bound),
512 Ok(()),
513 "dropping the undriven future must release the adopted listener"
514 );
515 }
516 }
517
518 #[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 drop(future);
551 }
552
553 #[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}