Skip to main content

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}