monarch_hyperactor/logging.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#![allow(unsafe_op_in_unsafe_fn)]
10
11use std::ops::Deref;
12use std::sync::Arc;
13use std::sync::atomic::AtomicUsize;
14use std::sync::atomic::Ordering;
15
16use anyhow::Result;
17use async_trait::async_trait;
18use hyperactor::Actor;
19use hyperactor::ActorHandle;
20use hyperactor::Context;
21use hyperactor::Endpoint as _;
22use hyperactor::HandleClient;
23use hyperactor::Handler;
24use hyperactor::Instance;
25use hyperactor::RefClient;
26use hyperactor::RemoteSpawn;
27use hyperactor::context;
28use hyperactor_config::Flattrs;
29use hyperactor_mesh::ActorMesh;
30use hyperactor_mesh::actor_mesh::ActorMeshRef;
31use hyperactor_mesh::bootstrap::MESH_ENABLE_LOG_FORWARDING;
32use hyperactor_mesh::logging::LogClientActor;
33use hyperactor_mesh::logging::LogClientMessage;
34use hyperactor_mesh::logging::LogForwardActor;
35use hyperactor_mesh::logging::LogForwardMessage;
36use monarch_types::SerializablePyErr;
37use ndslice::View;
38use pyo3::Bound;
39use pyo3::prelude::*;
40use pyo3::types::PyModule;
41use pyo3::types::PyString;
42use serde::Deserialize;
43use serde::Serialize;
44use typeuri::Named;
45
46use crate::context::PyInstance;
47use crate::proc::PyActorAddr;
48use crate::proc_mesh::PyProcMesh;
49use crate::pytokio::PyPythonTask;
50use crate::runtime::GilSite;
51use crate::runtime::monarch_with_gil;
52
53#[derive(
54 Debug,
55 Clone,
56 Serialize,
57 Deserialize,
58 Named,
59 Handler,
60 HandleClient,
61 RefClient
62)]
63pub enum LoggerRuntimeMessage {
64 SetLogging { level: u8 },
65}
66
67/// Simple Rust actor that invokes python logger APIs. It needs a python runtime.
68#[derive(Debug)]
69#[hyperactor::export(handlers = [LoggerRuntimeMessage])]
70#[hyperactor::spawnable]
71pub struct LoggerRuntimeActor {
72 logger: Arc<Py<PyAny>>,
73}
74
75impl LoggerRuntimeActor {
76 fn get_logger(py: Python) -> PyResult<Py<PyAny>> {
77 // Import the Python AutoReloader class
78 let logging_module = py.import("logging")?;
79 let logger = logging_module.call_method0("getLogger")?;
80
81 Ok(logger.into())
82 }
83
84 fn set_logger_level(py: Python, logger: &Py<PyAny>, level: u8) -> PyResult<()> {
85 let logger = logger.bind(py);
86 logger.call_method1("setLevel", (level,))?;
87 Ok(())
88 }
89}
90#[async_trait]
91impl Actor for LoggerRuntimeActor {
92 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
93 this.set_system();
94 Ok(())
95 }
96}
97
98#[async_trait]
99impl RemoteSpawn for LoggerRuntimeActor {
100 type Params = ();
101
102 async fn new(_: (), _environment: Flattrs) -> Result<Self, anyhow::Error> {
103 let logger = monarch_with_gil(GilSite::Logging, |py| {
104 Self::get_logger(py).map_err(SerializablePyErr::from_fn(py))
105 })
106 .await?;
107 Ok(Self {
108 logger: Arc::new(logger),
109 })
110 }
111}
112
113#[async_trait]
114#[hyperactor::handle(LoggerRuntimeMessage)]
115impl LoggerRuntimeMessageHandler for LoggerRuntimeActor {
116 async fn set_logging(&mut self, _cx: &Context<Self>, level: u8) -> Result<(), anyhow::Error> {
117 let logger: Arc<_> = self.logger.clone();
118 monarch_with_gil(GilSite::Logging, |py| {
119 Self::set_logger_level(py, logger.as_ref(), level)
120 .map_err(SerializablePyErr::from_fn(py))
121 })
122 .await?;
123 Ok(())
124 }
125}
126
127/// `LoggingMeshClient` is the Python-facing handle for distributed
128/// logging over a `ProcMesh`.
129///
130/// Calling `spawn(...)` builds three pieces of logging infra:
131///
132/// - `client_actor`: a single `LogClientActor` running in the
133/// *local* process. It aggregates forwarded stdout/stderr,
134/// batches it, and coordinates sync flush barriers.
135///
136/// - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
137/// with one actor per remote proc. Each `LogForwardActor` sits in
138/// that proc and forwards its stdout/stderr back to the client.
139/// This mesh only exists if `MESH_ENABLE_LOG_FORWARDING` was `true`
140/// at startup; otherwise it's `None` and we never spawn any
141/// forwarders.
142///
143/// - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
144/// actor per remote proc. Each `LoggerRuntimeActor` controls that
145/// proc's Python logging runtime (log level, handlers, etc.).
146/// This mesh is always created, even if forwarding is disabled.
147///
148/// The Python object you get back holds references to all of this so
149/// that you can:
150/// - toggle streaming vs "stay quiet" (`set_mode(...)`),
151/// - adjust the per-proc Python log level (`set_mode(...)`),
152/// - force a sync flush of forwarded output and wait for completion
153/// (`flush(...)`).
154///
155/// Drop semantics:
156/// Dropping the Python handle runs `Drop` on this Rust struct,
157/// which drains/stops the local `LogClientActor` but does *not*
158/// synchronously tear down the per-proc meshes. The remote
159/// `LogForwardActor` / `LoggerRuntimeActor` instances keep running
160/// until the remote procs themselves are shut down (e.g. via
161/// `host_mesh.shutdown(...)` in tests).
162#[pyclass(
163 frozen,
164 name = "LoggingMeshClient",
165 module = "monarch._rust_bindings.monarch_hyperactor.logging"
166)]
167pub struct LoggingMeshClient {
168 // Per-proc LogForwardActor mesh (optional). When enabled, each
169 // remote proc forwards its stdout/stderr back to the client. This
170 // actor does not interact with the embedded Python runtime.
171 forwarder_mesh: Option<ActorMesh<LogForwardActor>>,
172
173 // Per-proc LoggerRuntimeActor mesh. One LoggerRuntimeActor runs
174 // on every proc in the mesh and is responsible for driving that
175 // proc's Python logging configuration (log level, handlers,
176 // etc.).
177 //
178 // `set_mode(..)` always broadcasts the requested log level to
179 // this mesh, regardless of whether stdout/stderr forwarding is
180 // enabled.
181 //
182 // Even on a proc that isn't meaningfully running Python code, we
183 // still spawn LoggerRuntimeActor and it will still apply the new
184 // level to that proc's Python logger. In that case, updating the
185 // level may have no visible effect simply because nothing on that
186 // proc ever emits logs through Python's `logging` module.
187 logger_mesh: ActorMesh<LoggerRuntimeActor>,
188
189 // Client-side LogClientActor. Lives in the client process;
190 // receives forwarded output, aggregates and buffers it, and
191 // coordinates sync flush barriers.
192 client_actor: ActorHandle<LogClientActor>,
193}
194
195impl LoggingMeshClient {
196 /// Drive a synchronous "drain all logs now" barrier across the
197 /// mesh.
198 ///
199 /// Protocol:
200 /// 1. Tell the local `LogClientActor` we're starting a sync
201 /// flush. We give it:
202 /// - how many procs we expect to hear from
203 /// (`expected_procs`),
204 /// - a `reply` port it will use to signal completion,
205 /// - a `version` port it will use to hand us a flush version
206 /// token. After this send, the client_actor is now in "sync
207 /// flush vN" mode.
208 ///
209 /// 2. Wait for that version token from the client. This tells
210 /// us which flush epoch we're coordinating
211 /// (`version_rx.recv()`).
212 ///
213 /// 3. Broadcast `ForceSyncFlush { version }` to every
214 /// `LogForwardActor` in the `forwarder_mesh`. Each forwarder
215 /// tells its proc-local logger/forwarding loop: "flush
216 /// everything you have for this version now, then report
217 /// back."
218 ///
219 /// 4. Wait on `reply_rx`. The `LogClientActor` only replies
220 /// once it has:
221 /// - received the per-proc sync points for this version from
222 /// all forwarders,
223 /// - emitted/forwarded their buffered output,
224 /// - and finished flushing its own buffers.
225 ///
226 /// When this returns `Ok(())`, all stdout/stderr that existed at
227 /// the moment we kicked off the flush has been forwarded to the
228 /// client and drained. This is used by
229 /// `LoggingMeshClient.flush()`.
230 async fn flush_internal(
231 cx: &impl context::Actor,
232 client_actor: ActorHandle<LogClientActor>,
233 forwarder_mesh: ActorMeshRef<LogForwardActor>,
234 ) -> Result<(), anyhow::Error> {
235 let (reply_tx, reply_rx) = cx.instance().open_once_port::<()>();
236 let (version_tx, version_rx) = cx.instance().open_once_port::<u64>();
237
238 // First initialize a sync flush.
239 client_actor.post(
240 cx,
241 LogClientMessage::StartSyncFlush {
242 expected_procs: forwarder_mesh.region().num_ranks(),
243 reply: reply_tx.bind(),
244 version: version_tx.bind(),
245 },
246 );
247
248 let version = version_rx.recv().await?;
249
250 // Then ask all the flushers to ask the log forwarders to sync
251 // flush
252 forwarder_mesh.cast(cx, LogForwardMessage::ForceSyncFlush { version })?;
253
254 // Finally the forwarder will send sync point back to the
255 // client, flush, and return.
256 reply_rx.recv().await?;
257
258 Ok(())
259 }
260}
261
262#[pymethods]
263impl LoggingMeshClient {
264 /// Initialize logging for a `ProcMesh` and return a
265 /// `LoggingMeshClient`.
266 ///
267 /// This wires up three pieces of logging infrastructure:
268 ///
269 /// 1. A single `LogClientActor` in the *client* process. This
270 /// actor receives forwarded stdout/stderr, buffers and
271 /// aggregates it, and coordinates sync flush barriers.
272 ///
273 /// 2. (Optional) A `LogForwardActor` on every remote proc in the
274 /// mesh. These forwarders read that proc's stdout/stderr and
275 /// stream it back to the client. We only spawn this mesh if
276 /// `MESH_ENABLE_LOG_FORWARDING` was `true` in the config. If
277 /// forwarding is disabled at startup, we do not spawn these
278 /// actors and `forwarder_mesh` will be `None`.
279 ///
280 /// 3. A `LoggerRuntimeActor` on every remote proc in the mesh.
281 /// This actor controls the Python logging runtime (log level,
282 /// handlers, etc.) in that process. This is always spawned,
283 /// even if log forwarding is disabled.
284 ///
285 /// The returned `LoggingMeshClient` holds handles to those
286 /// actors. Later, `set_mode(...)` can adjust per-proc log level
287 /// and (if forwarding was enabled) toggle whether remote output
288 /// is actually streamed back to the client. If forwarding was
289 /// disabled by config, requests to enable streaming will fail.
290 #[staticmethod]
291 fn spawn(instance: &PyInstance, proc_mesh: &PyProcMesh) -> PyResult<PyPythonTask> {
292 let proc_mesh = proc_mesh.mesh_ref()?;
293 let instance = instance.clone();
294
295 PyPythonTask::new(async move {
296 // 1. Spawn the client-side coordinator actor (lives in
297 // the caller's process).
298 static LOG_CLIENT_COUNTER: AtomicUsize = AtomicUsize::new(0);
299 let id = LOG_CLIENT_COUNTER.fetch_add(1, Ordering::Relaxed);
300 let name = if id == 0 {
301 "log_client".to_string()
302 } else {
303 format!("log_client_{}", id)
304 };
305 let client_actor: ActorHandle<LogClientActor> = instance
306 .proc()
307 .spawn_with_label(&name, LogClientActor::default());
308 let client_actor_ref = client_actor.bind();
309
310 // Read config to decide if we stand up per-proc
311 // stdout/stderr forwarding.
312 let forwarding_enabled = hyperactor_config::global::get(MESH_ENABLE_LOG_FORWARDING);
313
314 // 2. Optionally spawn per-proc `LogForwardActor` mesh
315 // (stdout/stderr forwarders).
316 let forwarder_mesh = if forwarding_enabled {
317 // Spawn a `LogFwdActor` on every proc.
318 let mesh = proc_mesh
319 .spawn(instance.deref(), "log_forwarder", &client_actor_ref)
320 .await
321 .map_err(anyhow::Error::from)?;
322
323 Some(mesh)
324 } else {
325 None
326 };
327
328 // 3. Always spawn a `LoggerRuntimeActor` on every proc.
329 let logger_mesh = proc_mesh
330 .spawn(instance.deref(), "logger", &())
331 .await
332 .map_err(anyhow::Error::from)?;
333
334 Ok(Self {
335 forwarder_mesh,
336 logger_mesh,
337 client_actor,
338 })
339 })
340 }
341
342 /// Update logging behavior for this mesh.
343 ///
344 /// `stream_to_client` controls whether remote procs actively
345 /// stream their stdout/stderr back to the client process.
346 ///
347 /// - If log forwarding was enabled at startup, `forwarder_mesh`
348 /// is `Some` and we propagate this flag to every per-proc
349 /// `LogForwardActor`.
350 /// - If log forwarding was disabled at startup, `forwarder_mesh`
351 /// is `None`.
352 /// In that case:
353 /// * requesting `stream_to_client = false` is a no-op
354 /// (accepted),
355 /// * requesting `stream_to_client = true` is rejected,
356 /// because we did not spawn forwarders and we don't
357 /// dynamically create them later.
358 ///
359 /// `aggregate_window_sec` configures how the client-side
360 /// `LogClientActor` batches forwarded output. It is only
361 /// meaningful when streaming is enabled. Calling this with
362 /// `Some(..)` while `stream_to_client == false` is invalid and
363 /// returns an error.
364 ///
365 /// `level` is the desired Python logging level. We always
366 /// broadcast this to the per-proc `LoggerRuntimeActor` mesh so
367 /// each remote process can update its own Python logger
368 /// configuration, regardless of whether stdout/stderr forwarding
369 /// is active.
370 fn set_mode(
371 &self,
372 instance: &PyInstance,
373 stream_to_client: bool,
374 aggregate_window_sec: Option<u64>,
375 level: u8,
376 ) -> PyResult<()> {
377 // We can't ask for an aggregation window if we're not
378 // streaming.
379 if aggregate_window_sec.is_some() && !stream_to_client {
380 return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
381 "cannot set aggregate window without streaming to client".to_string(),
382 ));
383 }
384
385 // Handle the forwarder side (stdout/stderr streaming back to
386 // client).
387 match (&self.forwarder_mesh, stream_to_client) {
388 // Forwarders exist (config enabled at startup). We can
389 // toggle live.
390 (Some(fwd_mesh), _) => {
391 fwd_mesh
392 .cast(
393 instance.deref(),
394 LogForwardMessage::SetMode { stream_to_client },
395 )
396 .map_err(|e| {
397 PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string())
398 })?;
399 }
400
401 // Forwarders were never spawned (global forwarding
402 // disabled) and the caller is asking NOT to stream.
403 // That's effectively a no-op so we silently accept.
404 (None, false) => {
405 // Nothing to do.
406 }
407
408 // Forwarders were never spawned, but caller is asking to
409 // stream. We can't satisfy this request without
410 // re-spawning infra, which we deliberately don't do at
411 // runtime.
412 (None, true) => {
413 // return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
414 // "log forwarding disabled by config at startup; cannot enable streaming_to_client",
415 // ));
416 }
417 }
418
419 // Always update the per-proc Python logging level.
420 self.logger_mesh
421 .cast(instance.deref(), LoggerRuntimeMessage::SetLogging { level })
422 .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
423
424 // Always update the client actor's aggregation window.
425 self.client_actor.post(
426 instance.deref(),
427 LogClientMessage::SetAggregate {
428 aggregate_window_sec,
429 },
430 );
431
432 Ok(())
433 }
434
435 /// Force a sync flush of remote stdout/stderr back to the client,
436 /// and wait for completion.
437 ///
438 /// If log forwarding was disabled at startup (so we never spawned
439 /// any `LogForwardActor`s), this becomes a no-op success: there's
440 /// nothing to flush from remote procs in that mode, and we don't
441 /// try to manufacture it dynamically.
442 fn flush(&self, instance: &PyInstance) -> PyResult<PyPythonTask> {
443 let forwarder_mesh_opt = self
444 .forwarder_mesh
445 .as_ref()
446 .map(|mesh| mesh.deref().clone());
447 let client_actor = self.client_actor.clone();
448 let instance = instance.clone();
449
450 PyPythonTask::new(async move {
451 // If there's no forwarer mesh (forwarding disabled by
452 // config), we just succeed immediately.
453 let Some(forwarder_mesh) = forwarder_mesh_opt else {
454 return Ok(());
455 };
456
457 Self::flush_internal(instance.deref(), client_actor, forwarder_mesh)
458 .await
459 .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
460 })
461 }
462}
463
464// NOTE ON LIFECYCLE / CLEANUP
465//
466// `LoggingMeshClient` is a thin owner for three pieces of logging
467// infra:
468//
469// - `client_actor`: a single `LogClientActor` in the *local*
470// process.
471// - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
472// with one actor per remote proc in the `ProcMesh`, responsible for
473// forwarding that proc's stdout/stderr back to the client.
474// - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
475// actor per remote proc, responsible for driving that proc's Python
476// logging configuration.
477//
478// The Python-facing handle we hand back to callers is a
479// `Py<LoggingMeshClient>`. When that handle is dropped (or goes out
480// of scope in a test), PyO3 will run `Drop` for `LoggingMeshClient`.
481//
482// Important:
483//
484// - In `Drop` we *only* call `drain_and_stop()` on the local
485// `LogClientActor`. This asks the client-side aggregator to
486// flush/stop so we don't leave a local task running.
487// - We do NOT synchronously tear down the per-proc meshes here.
488// Dropping `forwarder_mesh` / `logger_mesh` just releases our
489// handles; the actual `LogForwardActor` / `LoggerRuntimeActor`
490// instances keep running on the remote procs until those procs are
491// shut down.
492//
493// This is fine in tests because we always shut the world down
494// afterward via `host_mesh.shutdown(&instance)`, which tears down the
495// spawned procs and all actors running in them. In other words:
496//
497// drop(Py<LoggingMeshClient>)
498// → stops the local `LogClientActor`, drops mesh handles
499// host_mesh.shutdown(...)
500// → kills the remote procs, which takes out the per-proc actors
501//
502// If you reuse this type outside tests, keep in mind that simply
503// dropping `LoggingMeshClient` does *not* on its own tear down the
504// remote logging actors; it only stops the local client actor.
505impl Drop for LoggingMeshClient {
506 fn drop(&mut self) {
507 // Use catch_unwind to guard against panics during interpreter shutdown.
508 // During Python teardown, the tokio runtime or channels may already be
509 // deallocated, and attempting to drain could cause a segfault.
510 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
511 match self.client_actor.drain_and_stop("logging client shutdown") {
512 Ok(_) => {}
513 Err(e) => {
514 // it is ok as during shutdown, the channel might already be closed
515 tracing::debug!("error draining logging client actor during shutdown: {}", e);
516 }
517 }
518 }));
519 }
520}
521
522/// Turns a python exception into a string with a traceback. If the traceback doesn't
523/// exist or can't be formatted, returns just the exception message.
524pub(crate) fn format_traceback(py: Python<'_>, err: &PyErr) -> String {
525 let traceback = err.traceback(py);
526 if traceback.is_some() {
527 let inner = || -> PyResult<String> {
528 let formatted = py
529 .import("traceback")?
530 .call_method1("format_exception", (err.clone_ref(py),))?;
531 Ok(PyString::new(py, "")
532 .call_method1("join", (formatted,))?
533 .to_string())
534 };
535 match inner() {
536 Ok(s) => s,
537 Err(e) => format!("{}: no traceback {}", err, e),
538 }
539 } else {
540 err.to_string()
541 }
542}
543
544#[pyfunction]
545fn log_endpoint_exception(
546 py: Python<'_>,
547 e: Py<PyAny>,
548 endpoint: Py<PyAny>,
549 actor_id: PyActorAddr,
550) {
551 let pyerr = PyErr::from_value(e.into_bound(py));
552 let exception_str = format_traceback(py, &pyerr);
553 let endpoint = endpoint.into_bound(py).to_string();
554 tracing::info!(
555 actor_id = actor_id.inner.to_string(),
556 %endpoint,
557 "exception occurred in endpoint: {}",
558 exception_str,
559 );
560}
561
562/// Register the Python-facing types for this module.
563///
564/// `pyo3` calls this when building `monarch._rust_bindings...`. We
565/// expose `LoggingMeshClient` so that Python can construct it and
566/// call its methods (`spawn`, `set_mode`, `flush`, ...).
567pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
568 module.add_class::<LoggingMeshClient>()?;
569 let log_endpoint_exception = wrap_pyfunction!(log_endpoint_exception, module.py())?;
570 log_endpoint_exception.setattr(
571 "__module__",
572 "monarch._rust_bindings.monarch_hyperactor.logging",
573 )?;
574 module.add_function(log_endpoint_exception)?;
575 Ok(())
576}
577
578#[cfg(test)]
579mod tests {
580 use anyhow::Result;
581 use hyperactor::Instance;
582 use hyperactor::channel::ChannelTransport;
583 use hyperactor::proc::Proc;
584 use hyperactor_mesh::ProcMesh;
585 use hyperactor_mesh::host_mesh::HostMesh;
586 use ndslice::Extent;
587 use ndslice::View; // .region(), .num_ranks() etc.
588
589 use super::*;
590 use crate::actor::PythonActor;
591 use crate::pytokio::AwaitPyExt;
592 use crate::pytokio::ensure_python;
593
594 /// Bring up a minimal "world" suitable for integration-style
595 /// tests.
596 pub async fn test_world() -> Result<(Proc, Instance<PythonActor>, HostMesh, ProcMesh)> {
597 ensure_python();
598
599 let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string())
600 .expect("failed to start root Proc");
601
602 let ai = proc
603 .actor_instance("client")
604 .expect("failed to create proc Instance");
605 let instance = ai.instance;
606
607 let host_mesh = HostMesh::local_with_bootstrap(
608 crate::testresource::get("monarch/monarch_hyperactor/bootstrap").into(),
609 )
610 .await
611 .expect("failed to bootstrap HostMesh");
612
613 let proc_mesh = host_mesh
614 .spawn(&instance, "p0", Extent::unity(), None, None)
615 .await
616 .expect("failed to spawn ProcMesh");
617
618 Ok((proc, instance, host_mesh, proc_mesh))
619 }
620
621 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
622 #[tokio::test]
623 async fn test_world_smoke() {
624 let (proc, instance, mut host_mesh, proc_mesh) = test_world().await.expect("world failed");
625
626 assert_eq!(
627 host_mesh.region().num_ranks(),
628 1,
629 "should allocate exactly one host"
630 );
631 assert_eq!(
632 proc_mesh.region().num_ranks(),
633 1,
634 "should spawn exactly one proc"
635 );
636 assert_eq!(
637 instance.self_addr().proc_addr(),
638 proc.proc_addr().clone(),
639 "returned Instance<()> should be bound to the root Proc"
640 );
641
642 host_mesh.shutdown(&instance).await.expect("host shutdown");
643 }
644
645 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
646 #[tokio::test]
647 async fn spawn_respects_forwarding_flag() {
648 let (_, instance, mut host_mesh, proc_mesh) = test_world().await.expect("world failed");
649
650 let py_instance = PyInstance::from(&instance);
651 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
652 let lock = hyperactor_config::global::lock();
653
654 // Case 1: forwarding disabled => `forwarder_mesh` should be `None`.
655 {
656 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
657
658 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
659 .expect("spawn PyPythonTask (forwarding disabled)");
660
661 let client_py: Py<LoggingMeshClient> = client_task
662 .await_py()
663 .await
664 .expect("spawn failed (forwarding disabled)");
665
666 monarch_with_gil(GilSite::Test, |py| {
667 let client_ref = client_py.borrow(py);
668 assert!(
669 client_ref.forwarder_mesh.is_none(),
670 "forwarder_mesh should be None when forwarding disabled"
671 );
672 })
673 .await;
674
675 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
676 }
677
678 // Case 2: forwarding enabled => `forwarder_mesh` should be `Some`.
679 {
680 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
681
682 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
683 .expect("spawn PyPythonTask (forwarding enabled)");
684
685 let client_py: Py<LoggingMeshClient> = client_task
686 .await_py()
687 .await
688 .expect("spawn failed (forwarding enabled)");
689
690 monarch_with_gil(GilSite::Test, |py| {
691 let client_ref = client_py.borrow(py);
692 assert!(
693 client_ref.forwarder_mesh.is_some(),
694 "forwarder_mesh should be Some(..) when forwarding is enabled"
695 );
696 })
697 .await;
698
699 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
700 }
701
702 host_mesh.shutdown(&instance).await.expect("host shutdown");
703 }
704
705 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
706 #[tokio::test]
707 async fn set_mode_behaviors() {
708 let (_proc, instance, mut host_mesh, proc_mesh) = test_world().await.expect("world failed");
709
710 let py_instance = PyInstance::from(&instance);
711 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
712 let lock = hyperactor_config::global::lock();
713
714 // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
715 {
716 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
717
718 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
719 .expect("spawn PyPythonTask (forwarding disabled)");
720
721 let client_py: Py<LoggingMeshClient> = client_task
722 .await_py()
723 .await
724 .expect("spawn failed (forwarding disabled)");
725
726 monarch_with_gil(GilSite::Test, |py| {
727 let client_ref = client_py.borrow(py);
728
729 // (a) stream_to_client = false, no aggregate window
730 // -> OK
731 let res = client_ref.set_mode(&py_instance, false, None, 10);
732 assert!(res.is_ok(), "expected Ok(..), got {res:?}");
733
734 // (b) stream_to_client = false,
735 // aggregate_window_sec.is_some() -> Err = Some(..) ->
736 // Err
737 let res = client_ref.set_mode(&py_instance, false, Some(1), 10);
738 assert!(
739 res.is_err(),
740 "expected Err(..) for window without streaming"
741 );
742 if let Err(e) = res {
743 let msg = e.to_string();
744 assert!(
745 msg.contains("cannot set aggregate window without streaming to client"),
746 "unexpected err for aggregate_window without streaming: {msg}"
747 );
748 }
749
750 /*
751 // Update (SF: 2025, 11, 13): We now ignore stream to client requests if
752 // log forwarding is enabled.
753 // (c) stream_to_client = true when forwarding was
754 // never spawned -> Err
755 let res = client_ref.set_mode(&py_instance, true, None, 10);
756 assert!(
757 res.is_err(),
758 "expected Err(..) when enabling streaming but no forwarders"
759 );
760 if let Err(e) = res {
761 let msg = e.to_string();
762 assert!(
763 msg.contains("log forwarding disabled by config at startup"),
764 "unexpected err when enabling streaming with no forwarders: {msg}"
765 );
766 }
767 */
768 })
769 .await;
770
771 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
772 }
773
774 // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
775 {
776 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
777
778 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
779 .expect("spawn PyPythonTask (forwarding enabled)");
780
781 let client_py: Py<LoggingMeshClient> = client_task
782 .await_py()
783 .await
784 .expect("spawn failed (forwarding enabled)");
785
786 monarch_with_gil(GilSite::Test, |py| {
787 let client_ref = client_py.borrow(py);
788
789 // (d) stream_to_client = true, aggregate_window_sec =
790 // Some(..) -> OK now that we *do* have forwarders,
791 // enabling streaming should succeed.
792 let res = client_ref.set_mode(&py_instance, true, Some(2), 20);
793 assert!(
794 res.is_ok(),
795 "expected Ok(..) enabling streaming w/ window: {res:?}"
796 );
797
798 // (e) aggregate_window_sec = Some(..) but
799 // stream_to_client = false -> still Err (this
800 // rule doesn't care about forwarding being
801 // enabled or not).
802 let res = client_ref.set_mode(&py_instance, false, Some(2), 20);
803 assert!(
804 res.is_err(),
805 "expected Err(..) for window without streaming even w/ forwarders"
806 );
807 if let Err(e) = res {
808 let msg = e.to_string();
809 assert!(
810 msg.contains("cannot set aggregate window without streaming to client"),
811 "unexpected err when setting window but disabling streaming: {msg}"
812 );
813 }
814 })
815 .await;
816
817 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
818 }
819
820 host_mesh.shutdown(&instance).await.expect("host shutdown");
821 }
822
823 #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
824 #[tokio::test]
825 async fn flush_behaviors() {
826 let (_proc, instance, mut host_mesh, proc_mesh) = test_world().await.expect("world failed");
827
828 let py_instance = PyInstance::from(&instance);
829 let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
830 let lock = hyperactor_config::global::lock();
831
832 // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
833 {
834 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
835
836 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
837 .expect("spawn PyPythonTask (forwarding disabled)");
838
839 let client_py: Py<LoggingMeshClient> = client_task
840 .await_py()
841 .await
842 .expect("spawn failed (forwarding disabled)");
843
844 // Call flush() and bring the PyPythonTask back out.
845 let flush_task = monarch_with_gil(GilSite::Test, |py| {
846 let client_ref = client_py.borrow(py);
847 client_ref
848 .flush(&py_instance)
849 .expect("flush() PyPythonTask (forwarding disabled)")
850 })
851 .await;
852
853 // Await the returned PyPythonTask's future outside the
854 // GIL.
855 flush_task
856 .await_unit()
857 .await
858 .expect("flush failed (forwarding disabled)");
859
860 drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
861 }
862
863 // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
864 {
865 let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
866
867 let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
868 .expect("spawn PyPythonTask (forwarding enabled)");
869
870 let client_py: Py<LoggingMeshClient> = client_task
871 .await_py()
872 .await
873 .expect("spawn failed (forwarding enabled)");
874
875 // Call flush() to exercise the barrier path, and pull the
876 // PyPythonTask out.
877 let flush_task = monarch_with_gil(GilSite::Test, |py| {
878 client_py
879 .borrow(py)
880 .flush(&py_instance)
881 .expect("flush() PyPythonTask (forwarding enabled)")
882 })
883 .await;
884
885 // Await the returned PyPythonTask's future outside the
886 // GIL.
887 flush_task
888 .await_unit()
889 .await
890 .expect("flush failed (forwarding enabled)");
891
892 drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
893 }
894
895 host_mesh.shutdown(&instance).await.expect("host shutdown");
896 }
897}