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::ActorEnvironment;
20use hyperactor::ActorHandle;
21use hyperactor::Context;
22use hyperactor::Endpoint as _;
23use hyperactor::HandleClient;
24use hyperactor::Handler;
25use hyperactor::Instance;
26use hyperactor::RefClient;
27use hyperactor::RemoteSpawn;
28use hyperactor::context;
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::handle::PyHandle;
48use crate::proc::PyActorAddr;
49use crate::proc_mesh::PyProcMesh;
50use crate::pytokio::PyPythonTask;
51use crate::runtime::GilSite;
52use crate::runtime::monarch_with_gil;
53
54#[derive(
55    Debug,
56    Clone,
57    Serialize,
58    Deserialize,
59    Named,
60    Handler,
61    HandleClient,
62    RefClient
63)]
64pub enum LoggerRuntimeMessage {
65    SetLogging { level: u8 },
66}
67
68/// Simple Rust actor that invokes python logger APIs. It needs a python runtime.
69#[derive(Debug)]
70#[hyperactor::export(handlers = [LoggerRuntimeMessage])]
71#[hyperactor::spawnable]
72pub struct LoggerRuntimeActor {
73    logger: Arc<Py<PyAny>>,
74}
75
76impl LoggerRuntimeActor {
77    fn get_logger(py: Python) -> PyResult<Py<PyAny>> {
78        // Import the Python AutoReloader class
79        let logging_module = py.import("logging")?;
80        let logger = logging_module.call_method0("getLogger")?;
81
82        Ok(logger.into())
83    }
84
85    fn set_logger_level(py: Python, logger: &Py<PyAny>, level: u8) -> PyResult<()> {
86        let logger = logger.bind(py);
87        logger.call_method1("setLevel", (level,))?;
88        Ok(())
89    }
90}
91#[async_trait]
92impl Actor for LoggerRuntimeActor {
93    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
94        this.set_system();
95        Ok(())
96    }
97}
98
99#[async_trait]
100impl RemoteSpawn for LoggerRuntimeActor {
101    type Params = ();
102
103    async fn new(_: (), _environment: &ActorEnvironment) -> Result<Self, anyhow::Error> {
104        let logger = monarch_with_gil(GilSite::Logging, |py| {
105            Self::get_logger(py).map_err(SerializablePyErr::from_fn(py))
106        })
107        .await?;
108        Ok(Self {
109            logger: Arc::new(logger),
110        })
111    }
112}
113
114#[async_trait]
115#[hyperactor::handle(LoggerRuntimeMessage)]
116impl LoggerRuntimeMessageHandler for LoggerRuntimeActor {
117    async fn set_logging(&mut self, _cx: &Context<Self>, level: u8) -> Result<(), anyhow::Error> {
118        let logger: Arc<_> = self.logger.clone();
119        monarch_with_gil(GilSite::Logging, |py| {
120            Self::set_logger_level(py, logger.as_ref(), level)
121                .map_err(SerializablePyErr::from_fn(py))
122        })
123        .await?;
124        Ok(())
125    }
126}
127
128/// `LoggingMeshClient` is the Python-facing handle for distributed
129/// logging over a `ProcMesh`.
130///
131/// Calling `spawn(...)` builds three pieces of logging infra:
132///
133///   - `client_actor`: a single `LogClientActor` running in the
134///     *local* process. It aggregates forwarded stdout/stderr,
135///     batches it, and coordinates sync flush barriers.
136///
137///   - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
138///     with one actor per remote proc. Each `LogForwardActor` sits in
139///     that proc and forwards its stdout/stderr back to the client.
140///     This mesh only exists if `MESH_ENABLE_LOG_FORWARDING` was `true`
141///     at startup; otherwise it's `None` and we never spawn any
142///     forwarders.
143///
144///   - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
145///     actor per remote proc. Each `LoggerRuntimeActor` controls that
146///     proc's Python logging runtime (log level, handlers, etc.).
147///     This mesh is always created, even if forwarding is disabled.
148///
149/// The Python object you get back holds references to all of this so
150/// that you can:
151///   - toggle streaming vs "stay quiet" (`set_mode(...)`),
152///   - adjust the per-proc Python log level (`set_mode(...)`),
153///   - force a sync flush of forwarded output and wait for completion
154///     (`flush(...)`).
155///
156/// Drop semantics:
157///   Dropping the Python handle runs `Drop` on this Rust struct,
158///   which drains/stops the local `LogClientActor` but does *not*
159///   synchronously tear down the per-proc meshes. The remote
160///   `LogForwardActor` / `LoggerRuntimeActor` instances keep running
161///   until the remote procs themselves are shut down (e.g. via
162///   `host_mesh.shutdown(...)` in tests).
163#[pyclass(
164    frozen,
165    name = "LoggingMeshClient",
166    module = "monarch._rust_bindings.monarch_hyperactor.logging"
167)]
168pub struct LoggingMeshClient {
169    // Per-proc LogForwardActor mesh (optional). When enabled, each
170    // remote proc forwards its stdout/stderr back to the client. This
171    // actor does not interact with the embedded Python runtime.
172    forwarder_mesh: Option<ActorMesh<LogForwardActor>>,
173
174    // Per-proc LoggerRuntimeActor mesh. One LoggerRuntimeActor runs
175    // on every proc in the mesh and is responsible for driving that
176    // proc's Python logging configuration (log level, handlers,
177    // etc.).
178    //
179    // `set_mode(..)` always broadcasts the requested log level to
180    // this mesh, regardless of whether stdout/stderr forwarding is
181    // enabled.
182    //
183    // Even on a proc that isn't meaningfully running Python code, we
184    // still spawn LoggerRuntimeActor and it will still apply the new
185    // level to that proc's Python logger. In that case, updating the
186    // level may have no visible effect simply because nothing on that
187    // proc ever emits logs through Python's `logging` module.
188    logger_mesh: ActorMesh<LoggerRuntimeActor>,
189
190    // Client-side LogClientActor. Lives in the client process;
191    // receives forwarded output, aggregates and buffers it, and
192    // coordinates sync flush barriers.
193    client_actor: ActorHandle<LogClientActor>,
194}
195
196impl LoggingMeshClient {
197    /// Drive a synchronous "drain all logs now" barrier across the
198    /// mesh.
199    ///
200    /// Protocol:
201    ///   1. Tell the local `LogClientActor` we're starting a sync
202    ///      flush. We give it:
203    ///      - how many procs we expect to hear from
204    ///        (`expected_procs`),
205    ///      - a `reply` port it will use to signal completion,
206    ///      - a `version` port it will use to hand us a flush version
207    ///        token. After this send, the client_actor is now in "sync
208    ///        flush vN" mode.
209    ///
210    ///   2. Wait for that version token from the client. This tells
211    ///      us which flush epoch we're coordinating
212    ///      (`version_rx.recv()`).
213    ///
214    ///   3. Broadcast `ForceSyncFlush { version }` to every
215    ///      `LogForwardActor` in the `forwarder_mesh`. Each forwarder
216    ///      tells its proc-local logger/forwarding loop: "flush
217    ///      everything you have for this version now, then report
218    ///      back."
219    ///
220    ///   4. Wait on `reply_rx`. The `LogClientActor` only replies
221    ///      once it has:
222    ///      - received the per-proc sync points for this version from
223    ///        all forwarders,
224    ///      - emitted/forwarded their buffered output,
225    ///      - and finished flushing its own buffers.
226    ///
227    /// When this returns `Ok(())`, all stdout/stderr that existed at
228    /// the moment we kicked off the flush has been forwarded to the
229    /// client and drained. This is used by
230    /// `LoggingMeshClient.flush()`.
231    async fn flush_internal(
232        cx: &impl context::Actor,
233        client_actor: ActorHandle<LogClientActor>,
234        forwarder_mesh: ActorMeshRef<LogForwardActor>,
235    ) -> Result<(), anyhow::Error> {
236        let (reply_tx, reply_rx) = cx.instance().open_once_port::<()>();
237        let (version_tx, version_rx) = cx.instance().open_once_port::<u64>();
238
239        // First initialize a sync flush.
240        client_actor.post(
241            cx,
242            LogClientMessage::StartSyncFlush {
243                expected_procs: forwarder_mesh.region().num_ranks(),
244                reply: reply_tx.bind(),
245                version: version_tx.bind(),
246            },
247        );
248
249        let version = version_rx.recv().await?;
250
251        // Then ask all the flushers to ask the log forwarders to sync
252        // flush
253        forwarder_mesh.cast(cx, LogForwardMessage::ForceSyncFlush { version })?;
254
255        // Finally the forwarder will send sync point back to the
256        // client, flush, and return.
257        reply_rx.recv().await?;
258
259        Ok(())
260    }
261}
262
263#[pymethods]
264impl LoggingMeshClient {
265    /// Initialize logging for a `ProcMesh` and return a
266    /// `LoggingMeshClient`.
267    ///
268    /// This wires up three pieces of logging infrastructure:
269    ///
270    /// 1. A single `LogClientActor` in the *client* process. This
271    ///    actor receives forwarded stdout/stderr, buffers and
272    ///    aggregates it, and coordinates sync flush barriers.
273    ///
274    /// 2. (Optional) A `LogForwardActor` on every remote proc in the
275    ///    mesh. These forwarders read that proc's stdout/stderr and
276    ///    stream it back to the client. We only spawn this mesh if
277    ///    `MESH_ENABLE_LOG_FORWARDING` was `true` in the config. If
278    ///    forwarding is disabled at startup, we do not spawn these
279    ///    actors and `forwarder_mesh` will be `None`.
280    ///
281    /// 3. A `LoggerRuntimeActor` on every remote proc in the mesh.
282    ///    This actor controls the Python logging runtime (log level,
283    ///    handlers, etc.) in that process. This is always spawned,
284    ///    even if log forwarding is disabled.
285    ///
286    /// The returned `LoggingMeshClient` holds handles to those
287    /// actors. Later, `set_mode(...)` can adjust per-proc log level
288    /// and (if forwarding was enabled) toggle whether remote output
289    /// is actually streamed back to the client. If forwarding was
290    /// disabled by config, requests to enable streaming will fail.
291    #[staticmethod]
292    fn spawn(instance: &PyInstance, proc_mesh: &PyProcMesh) -> PyResult<PyPythonTask> {
293        let proc_mesh = proc_mesh.mesh_ref()?;
294        let instance = instance.clone();
295
296        PyPythonTask::new(async move {
297            // 1. Spawn the client-side coordinator actor (lives in
298            // the caller's process).
299            static LOG_CLIENT_COUNTER: AtomicUsize = AtomicUsize::new(0);
300            let id = LOG_CLIENT_COUNTER.fetch_add(1, Ordering::Relaxed);
301            let name = if id == 0 {
302                "log_client".to_string()
303            } else {
304                format!("log_client_{}", id)
305            };
306            let client_actor: ActorHandle<LogClientActor> = instance
307                .proc()
308                .spawn_with_label(&name, LogClientActor::default());
309            let client_actor_ref = client_actor.bind();
310
311            // Read config to decide if we stand up per-proc
312            // stdout/stderr forwarding.
313            let forwarding_enabled = hyperactor_config::global::get(MESH_ENABLE_LOG_FORWARDING);
314
315            // 2. Optionally spawn per-proc `LogForwardActor` mesh
316            // (stdout/stderr forwarders).
317            let forwarder_mesh = if forwarding_enabled {
318                // Spawn a `LogFwdActor` on every proc.
319                let mesh = proc_mesh
320                    .spawn(instance.deref(), "log_forwarder", &client_actor_ref)
321                    .await
322                    .map_err(anyhow::Error::from)?;
323
324                Some(mesh)
325            } else {
326                None
327            };
328
329            // 3. Always spawn a `LoggerRuntimeActor` on every proc.
330            let logger_mesh = proc_mesh
331                .spawn(instance.deref(), "logger", &())
332                .await
333                .map_err(anyhow::Error::from)?;
334
335            Ok(Self {
336                forwarder_mesh,
337                logger_mesh,
338                client_actor,
339            })
340        })
341    }
342
343    /// Update logging behavior for this mesh.
344    ///
345    /// `stream_to_client` controls whether remote procs actively
346    /// stream their stdout/stderr back to the client process.
347    ///
348    /// - If log forwarding was enabled at startup, `forwarder_mesh`
349    ///   is `Some` and we propagate this flag to every per-proc
350    ///   `LogForwardActor`.
351    /// - If log forwarding was disabled at startup, `forwarder_mesh`
352    ///   is `None`.
353    ///   In that case:
354    ///     * requesting `stream_to_client = false` is a no-op
355    ///       (accepted),
356    ///     * requesting `stream_to_client = true` is rejected,
357    ///       because we did not spawn forwarders and we don't
358    ///       dynamically create them later.
359    ///
360    /// `aggregate_window_sec` configures how the client-side
361    /// `LogClientActor` batches forwarded output. It is only
362    /// meaningful when streaming is enabled. Calling this with
363    /// `Some(..)` while `stream_to_client == false` is invalid and
364    /// returns an error.
365    ///
366    /// `level` is the desired Python logging level. We always
367    /// broadcast this to the per-proc `LoggerRuntimeActor` mesh so
368    /// each remote process can update its own Python logger
369    /// configuration, regardless of whether stdout/stderr forwarding
370    /// is active.
371    fn set_mode(
372        &self,
373        instance: &PyInstance,
374        stream_to_client: bool,
375        aggregate_window_sec: Option<u64>,
376        level: u8,
377    ) -> PyResult<()> {
378        // We can't ask for an aggregation window if we're not
379        // streaming.
380        if aggregate_window_sec.is_some() && !stream_to_client {
381            return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
382                "cannot set aggregate window without streaming to client".to_string(),
383            ));
384        }
385
386        // Handle the forwarder side (stdout/stderr streaming back to
387        // client).
388        match (&self.forwarder_mesh, stream_to_client) {
389            // Forwarders exist (config enabled at startup). We can
390            // toggle live.
391            (Some(fwd_mesh), _) => {
392                fwd_mesh
393                    .cast(
394                        instance.deref(),
395                        LogForwardMessage::SetMode { stream_to_client },
396                    )
397                    .map_err(|e| {
398                        PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string())
399                    })?;
400            }
401
402            // Forwarders were never spawned (global forwarding
403            // disabled) and the caller is asking NOT to stream.
404            // That's effectively a no-op so we silently accept.
405            (None, false) => {
406                // Nothing to do.
407            }
408
409            // Forwarders were never spawned, but caller is asking to
410            // stream. We can't satisfy this request without
411            // re-spawning infra, which we deliberately don't do at
412            // runtime.
413            (None, true) => {
414                // return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
415                //     "log forwarding disabled by config at startup; cannot enable streaming_to_client",
416                // ));
417            }
418        }
419
420        // Always update the per-proc Python logging level.
421        self.logger_mesh
422            .cast_with_return_undeliverable(
423                instance.deref(),
424                &Default::default(),
425                LoggerRuntimeMessage::SetLogging { level },
426                false,
427            )
428            .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
429
430        // Always update the client actor's aggregation window.
431        self.client_actor.post(
432            instance.deref(),
433            LogClientMessage::SetAggregate {
434                aggregate_window_sec,
435            },
436        );
437
438        Ok(())
439    }
440
441    /// Start a sync flush of remote stdout/stderr back to the client
442    /// and return a `Handle` that completes when the flush has
443    /// finished.
444    ///
445    /// The flush starts before this returns. Dropping the `Handle`
446    /// does not cancel it.
447    ///
448    /// If log forwarding was disabled at startup (so we never spawned
449    /// any `LogForwardActor`s), this becomes a no-op success: there's
450    /// nothing to flush from remote procs in that mode, and we don't
451    /// try to manufacture it dynamically.
452    fn flush(&self, instance: &PyInstance) -> PyResult<PyHandle> {
453        let forwarder_mesh_opt = self
454            .forwarder_mesh
455            .as_ref()
456            .map(|mesh| mesh.deref().clone());
457        let client_actor = self.client_actor.clone();
458        let instance = instance.clone();
459
460        Ok(PyHandle::spawn(async move {
461            // If there's no forwarder mesh (forwarding disabled by
462            // config), we just succeed immediately.
463            let Some(forwarder_mesh) = forwarder_mesh_opt else {
464                return Ok(());
465            };
466
467            Self::flush_internal(instance.deref(), client_actor, forwarder_mesh)
468                .await
469                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
470        }))
471    }
472}
473
474// NOTE ON LIFECYCLE / CLEANUP
475//
476// `LoggingMeshClient` is a thin owner for three pieces of logging
477// infra:
478//
479//   - `client_actor`: a single `LogClientActor` in the *local*
480//     process.
481//   - `forwarder_mesh`: (optional) an `ActorMesh<LogForwardActor>`
482//     with one actor per remote proc in the `ProcMesh`, responsible for
483//     forwarding that proc's stdout/stderr back to the client.
484//   - `logger_mesh`: an `ActorMesh<LoggerRuntimeActor>` with one
485//     actor per remote proc, responsible for driving that proc's Python
486//     logging configuration.
487//
488// The Python-facing handle we hand back to callers is a
489// `Py<LoggingMeshClient>`. When that handle is dropped (or goes out
490// of scope in a test), PyO3 will run `Drop` for `LoggingMeshClient`.
491//
492// Important:
493//
494// - In `Drop` we *only* call `drain_and_stop()` on the local
495//   `LogClientActor`. This asks the client-side aggregator to
496//   flush/stop so we don't leave a local task running.
497// - We do NOT synchronously tear down the per-proc meshes here.
498//   Dropping `forwarder_mesh` / `logger_mesh` just releases our
499//   handles; the actual `LogForwardActor` / `LoggerRuntimeActor`
500//   instances keep running on the remote procs until those procs are
501//   shut down.
502//
503// This is fine in tests because we always shut the world down
504// afterward via `host_mesh.shutdown(&instance)`, which tears down the
505// spawned procs and all actors running in them. In other words:
506//
507//   drop(Py<LoggingMeshClient>)
508//     → stops the local `LogClientActor`, drops mesh handles
509//   host_mesh.shutdown(...)
510//     → kills the remote procs, which takes out the per-proc actors
511//
512// If you reuse this type outside tests, keep in mind that simply
513// dropping `LoggingMeshClient` does *not* on its own tear down the
514// remote logging actors; it only stops the local client actor.
515impl Drop for LoggingMeshClient {
516    fn drop(&mut self) {
517        // Use catch_unwind to guard against panics during interpreter shutdown.
518        // During Python teardown, the tokio runtime or channels may already be
519        // deallocated, and attempting to drain could cause a segfault.
520        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
521            match self.client_actor.drain_and_stop("logging client shutdown") {
522                Ok(_) => {}
523                Err(e) => {
524                    // it is ok as during shutdown, the channel might already be closed
525                    tracing::debug!("error draining logging client actor during shutdown: {}", e);
526                }
527            }
528        }));
529    }
530}
531
532/// Turns a python exception into a string with a traceback. If the traceback doesn't
533/// exist or can't be formatted, returns just the exception message.
534pub(crate) fn format_traceback(py: Python<'_>, err: &PyErr) -> String {
535    let traceback = err.traceback(py);
536    if traceback.is_some() {
537        let inner = || -> PyResult<String> {
538            let formatted = py
539                .import("traceback")?
540                .call_method1("format_exception", (err.clone_ref(py),))?;
541            Ok(PyString::new(py, "")
542                .call_method1("join", (formatted,))?
543                .to_string())
544        };
545        match inner() {
546            Ok(s) => s,
547            Err(e) => format!("{}: no traceback {}", err, e),
548        }
549    } else {
550        err.to_string()
551    }
552}
553
554#[pyfunction]
555fn log_endpoint_exception(
556    py: Python<'_>,
557    e: Py<PyAny>,
558    endpoint: Py<PyAny>,
559    actor_id: PyActorAddr,
560) {
561    let pyerr = PyErr::from_value(e.into_bound(py));
562    let exception_str = format_traceback(py, &pyerr);
563    let endpoint = endpoint.into_bound(py).to_string();
564    tracing::info!(
565        actor_id = actor_id.inner.to_string(),
566        %endpoint,
567        "exception occurred in endpoint: {}",
568        exception_str,
569    );
570}
571
572/// Register the Python-facing types for this module.
573///
574/// `pyo3` calls this when building `monarch._rust_bindings...`. We
575/// expose `LoggingMeshClient` so that Python can construct it and
576/// call its methods (`spawn`, `set_mode`, `flush`, ...).
577pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
578    module.add_class::<LoggingMeshClient>()?;
579    let log_endpoint_exception = wrap_pyfunction!(log_endpoint_exception, module.py())?;
580    log_endpoint_exception.setattr(
581        "__module__",
582        "monarch._rust_bindings.monarch_hyperactor.logging",
583    )?;
584    module.add_function(log_endpoint_exception)?;
585    Ok(())
586}
587
588#[cfg(test)]
589mod tests {
590    use std::time::Duration;
591
592    use anyhow::Result;
593    use hyperactor::Instance;
594    use hyperactor::channel::ChannelTransport;
595    use hyperactor::proc::Proc;
596    use hyperactor_mesh::ProcMesh;
597    use hyperactor_mesh::host_mesh::HostMesh;
598    use hyperactor_mesh::host_mesh::HostMeshShutdownGuard;
599    use hyperactor_mesh::logging::LogMessage;
600    use ndslice::Extent;
601    use tokio::time::timeout;
602
603    use super::*;
604    use crate::actor::PythonActor;
605    use crate::runtime::ensure_python;
606
607    const TEST_DEADLINE: Duration = Duration::from_secs(30);
608
609    /// The guard covers setup failures and panics. This helper gives ordinary
610    /// `Result` exits a bounded explicit shutdown.
611    async fn shutdown_after_test(
612        mut host_mesh: HostMeshShutdownGuard,
613        instance: &Instance<PythonActor>,
614        test_result: Result<()>,
615    ) -> Result<()> {
616        let shutdown_result = match timeout(TEST_DEADLINE, host_mesh.shutdown(instance)).await {
617            Ok(result) => result,
618            Err(_) => Err(anyhow::anyhow!("host shutdown exceeded {TEST_DEADLINE:?}")),
619        };
620
621        match (test_result, shutdown_result) {
622            (Ok(()), Ok(())) => Ok(()),
623            (Err(test_error), Ok(())) => Err(test_error),
624            (Ok(()), Err(shutdown_error)) => Err(shutdown_error),
625            (Err(test_error), Err(shutdown_error)) => Err(anyhow::anyhow!(
626                "test failed: {test_error:#}; host shutdown also failed: {shutdown_error:#}"
627            )),
628        }
629    }
630
631    /// Drive a logging-client task without the test-only conversion helper's
632    /// internal `expect`s, so an error remains available to the cleanup path.
633    async fn drive_logging_client(mut task: PyPythonTask) -> Result<Py<LoggingMeshClient>> {
634        let future = task.take_task()?;
635        let value = timeout(TEST_DEADLINE, future)
636            .await
637            .map_err(|_| anyhow::anyhow!("logging client spawn exceeded {TEST_DEADLINE:?}"))??;
638        Ok(monarch_with_gil(GilSite::Test, |py| {
639            value
640                .bind(py)
641                .extract::<Py<LoggingMeshClient>>()
642                .map_err(|error| pyo3::exceptions::PyTypeError::new_err(error.to_string()))
643        })
644        .await?)
645    }
646
647    fn log_client_ordinal(label: &str) -> Result<usize> {
648        if label == "log_client" {
649            return Ok(0);
650        }
651        let suffix = label
652            .strip_prefix("log_client_")
653            .ok_or_else(|| anyhow::anyhow!("unexpected logging client label {label:?}"))?;
654        Ok(suffix.parse()?)
655    }
656
657    /// Read the current sync-flush version without leaving a flush in progress.
658    ///
659    /// `StartSyncFlush` increments the version, so this probe changes the value it
660    /// observes. Posting the matching acknowledgement and awaiting the reply closes
661    /// that operation before another probe or production flush begins.
662    async fn completed_sync_flush_probe(
663        instance: &Instance<PythonActor>,
664        client_actor: &ActorHandle<LogClientActor>,
665    ) -> Result<u64> {
666        let (reply_tx, reply_rx) = instance.open_once_port::<()>();
667        let (version_tx, version_rx) = instance.open_once_port::<u64>();
668
669        client_actor.try_post(
670            instance,
671            LogClientMessage::StartSyncFlush {
672                expected_procs: 1,
673                reply: reply_tx.bind(),
674                version: version_tx.bind(),
675            },
676        )?;
677        let version = timeout(TEST_DEADLINE, version_rx.recv())
678            .await
679            .map_err(|_| {
680                anyhow::anyhow!("sync-flush version probe exceeded {TEST_DEADLINE:?}")
681            })??;
682
683        client_actor.try_post(
684            instance,
685            LogMessage::Flush {
686                sync_version: Some(version),
687            },
688        )?;
689        timeout(TEST_DEADLINE, reply_rx.recv())
690            .await
691            .map_err(|_| anyhow::anyhow!("sync-flush reply probe exceeded {TEST_DEADLINE:?}"))??;
692
693        Ok(version)
694    }
695
696    /// How long a probe waits for its completion before treating the attempt
697    /// as inconclusive.
698    const PROBE_REPLY_BOUND: Duration = Duration::from_secs(1);
699
700    /// Start one sync-flush probe and return its version and whether it
701    /// completed.
702    ///
703    /// The version receive runs until `deadline` and must succeed: the client
704    /// has incremented its version for this probe, so the caller must count it.
705    /// A later `StartSyncFlush` can replace this probe's reply port, so the
706    /// completion wait is bounded by `PROBE_REPLY_BOUND` and its timeout is
707    /// reported as `false`, not as an error.
708    async fn sync_flush_probe(
709        instance: &Instance<PythonActor>,
710        client_actor: &ActorHandle<LogClientActor>,
711        deadline: tokio::time::Instant,
712    ) -> Result<(u64, bool)> {
713        let (reply_tx, reply_rx) = instance.open_once_port::<()>();
714        let (version_tx, version_rx) = instance.open_once_port::<u64>();
715
716        client_actor.try_post(
717            instance,
718            LogClientMessage::StartSyncFlush {
719                expected_procs: 1,
720                reply: reply_tx.bind(),
721                version: version_tx.bind(),
722            },
723        )?;
724        let version = tokio::time::timeout_at(deadline, version_rx.recv())
725            .await
726            .map_err(|_| anyhow::anyhow!("sync-flush probe version exceeded the deadline"))??;
727
728        client_actor.try_post(
729            instance,
730            LogMessage::Flush {
731                sync_version: Some(version),
732            },
733        )?;
734        let completed = matches!(
735            timeout(PROBE_REPLY_BOUND, reply_rx.recv()).await,
736            Ok(Ok(()))
737        );
738
739        Ok((version, completed))
740    }
741
742    /// Bring up the actor topology exercised by the logging-client tests.
743    async fn logging_test_world()
744    -> Result<(Proc, Instance<PythonActor>, HostMeshShutdownGuard, ProcMesh)> {
745        ensure_python();
746
747        let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string())?;
748
749        let ai = proc.actor_instance("client")?;
750        let instance = ai.instance;
751
752        let host_mesh = HostMesh::local_in_process().await?.shutdown_guard();
753
754        let proc_mesh = host_mesh
755            .spawn(&instance, "p0", Extent::unity(), None, None)
756            .await?;
757
758        Ok((proc, instance, host_mesh, proc_mesh))
759    }
760
761    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
762    #[tokio::test]
763    async fn spawn_respects_forwarding_flag() {
764        let (_, instance, mut host_mesh, proc_mesh) =
765            logging_test_world().await.expect("world failed");
766
767        let py_instance = PyInstance::from(&instance);
768        let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
769        let lock = hyperactor_config::global::lock();
770
771        // Case 1: forwarding disabled => `forwarder_mesh` should be `None`.
772        {
773            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
774
775            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
776                .expect("spawn PyPythonTask (forwarding disabled)");
777
778            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
779                .await
780                .expect("spawn failed (forwarding disabled)");
781
782            monarch_with_gil(GilSite::Test, |py| {
783                let client_ref = client_py.borrow(py);
784                assert!(
785                    client_ref.forwarder_mesh.is_none(),
786                    "forwarder_mesh should be None when forwarding disabled"
787                );
788            })
789            .await;
790
791            drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
792        }
793
794        // Case 2: forwarding enabled => `forwarder_mesh` should be `Some`.
795        {
796            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
797
798            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
799                .expect("spawn PyPythonTask (forwarding enabled)");
800
801            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
802                .await
803                .expect("spawn failed (forwarding enabled)");
804
805            monarch_with_gil(GilSite::Test, |py| {
806                let client_ref = client_py.borrow(py);
807                assert!(
808                    client_ref.forwarder_mesh.is_some(),
809                    "forwarder_mesh should be Some(..) when forwarding is enabled"
810                );
811            })
812            .await;
813
814            drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
815        }
816
817        host_mesh.shutdown(&instance).await.expect("host shutdown");
818    }
819
820    /// Constructing the raw spawn task validates and captures its inputs but does
821    /// not spawn the local logging actor. The monotonic label counter is durable
822    /// even if a wrongly created actor stops before the assertion can inspect it.
823    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
824    #[tokio::test]
825    async fn discarded_spawn_task_creates_no_log_client_actor() -> Result<()> {
826        let (_proc, instance, host_mesh, proc_mesh) = timeout(TEST_DEADLINE, logging_test_world())
827            .await
828            .map_err(|_| anyhow::anyhow!("test world setup exceeded {TEST_DEADLINE:?}"))??;
829
830        let test_result = async {
831            let py_instance = PyInstance::from(&instance);
832            let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
833            let lock = hyperactor_config::global::lock();
834            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
835
836            let baseline_py = drive_logging_client(LoggingMeshClient::spawn(
837                &py_instance,
838                &py_proc_mesh,
839            )?)
840            .await?;
841            let baseline_label = monarch_with_gil(GilSite::Test, |py| {
842                baseline_py
843                    .borrow(py)
844                    .client_actor
845                    .actor_addr()
846                    .id()
847                    .label()
848                    .map(|label| label.as_str().to_owned())
849            })
850            .await
851            .ok_or_else(|| anyhow::anyhow!("baseline logging client has no label"))?;
852            let baseline_ordinal = log_client_ordinal(&baseline_label)?;
853            drop(baseline_py);
854
855            let discarded = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)?;
856            drop(discarded);
857
858            let control_py = drive_logging_client(LoggingMeshClient::spawn(
859                &py_instance,
860                &py_proc_mesh,
861            )?)
862            .await?;
863            let control_label = monarch_with_gil(GilSite::Test, |py| {
864                control_py
865                    .borrow(py)
866                    .client_actor
867                    .actor_addr()
868                    .id()
869                    .label()
870                    .map(|label| label.as_str().to_owned())
871            })
872            .await
873            .ok_or_else(|| anyhow::anyhow!("control logging client has no label"))?;
874            let control_ordinal = log_client_ordinal(&control_label)?;
875            anyhow::ensure!(
876                control_ordinal == baseline_ordinal + 1,
877                "dropping an undriven spawn task must not consume a LogClientActor id: baseline {baseline_label}, control {control_label}"
878            );
879
880            drop(control_py);
881            Ok(())
882        }
883        .await;
884
885        shutdown_after_test(host_mesh, &instance, test_result).await
886    }
887
888    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
889    #[tokio::test]
890    async fn set_mode_behaviors() {
891        let (_proc, instance, mut host_mesh, proc_mesh) =
892            logging_test_world().await.expect("world failed");
893
894        let py_instance = PyInstance::from(&instance);
895        let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
896        let lock = hyperactor_config::global::lock();
897
898        // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
899        {
900            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
901
902            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
903                .expect("spawn PyPythonTask (forwarding disabled)");
904
905            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
906                .await
907                .expect("spawn failed (forwarding disabled)");
908
909            monarch_with_gil(GilSite::Test, |py| {
910                let client_ref = client_py.borrow(py);
911
912                // (a) stream_to_client = false, no aggregate window
913                // -> OK
914                let res = client_ref.set_mode(&py_instance, false, None, 10);
915                assert!(res.is_ok(), "expected Ok(..), got {res:?}");
916
917                // (b) stream_to_client = false,
918                // aggregate_window_sec.is_some() -> Err = Some(..) ->
919                // Err
920                let res = client_ref.set_mode(&py_instance, false, Some(1), 10);
921                assert!(
922                    res.is_err(),
923                    "expected Err(..) for window without streaming"
924                );
925                if let Err(e) = res {
926                    let msg = e.to_string();
927                    assert!(
928                        msg.contains("cannot set aggregate window without streaming to client"),
929                        "unexpected err for aggregate_window without streaming: {msg}"
930                    );
931                }
932
933                /*
934                // Update (SF: 2025, 11, 13): We now ignore stream to client requests if
935                // log forwarding is enabled.
936                // (c) stream_to_client = true when forwarding was
937                //     never spawned -> Err
938                let res = client_ref.set_mode(&py_instance, true, None, 10);
939                assert!(
940                    res.is_err(),
941                    "expected Err(..) when enabling streaming but no forwarders"
942                );
943                if let Err(e) = res {
944                    let msg = e.to_string();
945                    assert!(
946                        msg.contains("log forwarding disabled by config at startup"),
947                        "unexpected err when enabling streaming with no forwarders: {msg}"
948                    );
949                }
950                */
951            })
952            .await;
953
954            drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
955        }
956
957        // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
958        {
959            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
960
961            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
962                .expect("spawn PyPythonTask (forwarding enabled)");
963
964            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
965                .await
966                .expect("spawn failed (forwarding enabled)");
967
968            monarch_with_gil(GilSite::Test, |py| {
969                let client_ref = client_py.borrow(py);
970
971                // (d) stream_to_client = true, aggregate_window_sec =
972                //     Some(..) -> OK now that we *do* have forwarders,
973                //     enabling streaming should succeed.
974                let res = client_ref.set_mode(&py_instance, true, Some(2), 20);
975                assert!(
976                    res.is_ok(),
977                    "expected Ok(..) enabling streaming w/ window: {res:?}"
978                );
979
980                // (e) aggregate_window_sec = Some(..) but
981                //     stream_to_client = false -> still Err (this
982                //     rule doesn't care about forwarding being
983                //     enabled or not).
984                let res = client_ref.set_mode(&py_instance, false, Some(2), 20);
985                assert!(
986                    res.is_err(),
987                    "expected Err(..) for window without streaming even w/ forwarders"
988                );
989                if let Err(e) = res {
990                    let msg = e.to_string();
991                    assert!(
992                        msg.contains("cannot set aggregate window without streaming to client"),
993                        "unexpected err when setting window but disabling streaming: {msg}"
994                    );
995                }
996            })
997            .await;
998
999            drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
1000        }
1001
1002        host_mesh.shutdown(&instance).await.expect("host shutdown");
1003    }
1004
1005    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
1006    #[tokio::test]
1007    async fn flush_behaviors() {
1008        let (_proc, instance, mut host_mesh, proc_mesh) =
1009            logging_test_world().await.expect("world failed");
1010
1011        let py_instance = PyInstance::from(&instance);
1012        let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
1013        let lock = hyperactor_config::global::lock();
1014
1015        // Case 1: forwarding disabled => `forwarder_mesh.is_none()`.
1016        {
1017            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, false);
1018
1019            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
1020                .expect("spawn PyPythonTask (forwarding disabled)");
1021
1022            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
1023                .await
1024                .expect("spawn failed (forwarding disabled)");
1025
1026            // flush() returns a Handle for an already-running flush.
1027            let flush_handle = monarch_with_gil(GilSite::Test, |py| {
1028                let client_ref = client_py.borrow(py);
1029                client_ref
1030                    .flush(&py_instance)
1031                    .expect("flush() Handle (forwarding disabled)")
1032            })
1033            .await;
1034
1035            // Observe the Handle's completion outside the GIL.
1036            timeout(TEST_DEADLINE, flush_handle.wait_completion())
1037                .await
1038                .expect("flush exceeded the deadline (forwarding disabled)")
1039                .expect("flush failed (forwarding disabled)");
1040
1041            drop(client_py); // See "NOTE ON LIFECYCLE / CLEANUP"
1042        }
1043
1044        // Case 2: forwarding enabled => `forwarder_mesh.is_some()`.
1045        {
1046            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
1047
1048            let client_task = LoggingMeshClient::spawn(&py_instance, &py_proc_mesh)
1049                .expect("spawn PyPythonTask (forwarding enabled)");
1050
1051            let client_py: Py<LoggingMeshClient> = drive_logging_client(client_task)
1052                .await
1053                .expect("spawn failed (forwarding enabled)");
1054
1055            // Call flush() to exercise the barrier path.
1056            let flush_handle = monarch_with_gil(GilSite::Test, |py| {
1057                client_py
1058                    .borrow(py)
1059                    .flush(&py_instance)
1060                    .expect("flush() Handle (forwarding enabled)")
1061            })
1062            .await;
1063
1064            // Observe the Handle's completion outside the GIL.
1065            timeout(TEST_DEADLINE, flush_handle.wait_completion())
1066                .await
1067                .expect("flush exceeded the deadline (forwarding enabled)")
1068                .expect("flush failed (forwarding enabled)");
1069
1070            drop(client_py); // See note "NOTE ON LIFECYCLE / CLEANUP"
1071        }
1072
1073        host_mesh.shutdown(&instance).await.expect("host shutdown");
1074    }
1075
1076    /// A flush whose only Handle is dropped still enters the native barrier.
1077    ///
1078    /// Every probe's `StartSyncFlush` increments the client's version. After a
1079    /// completed baseline probe at `baseline`, a completed probe at `baseline +
1080    /// probes` means the dropped flush has not started yet, and `baseline +
1081    /// probes + 1` means it has started exactly once. Its start may land before,
1082    /// between or after probes, so the loop accepts any order. A probe whose
1083    /// completion times out was replaced by a later start and is only counted.
1084    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
1085    #[tokio::test]
1086    async fn dropped_flush_handle_enters_the_sync_flush_barrier() -> Result<()> {
1087        let (_, instance, host_mesh, proc_mesh) = timeout(TEST_DEADLINE, logging_test_world())
1088            .await
1089            .map_err(|_| anyhow::anyhow!("test world setup exceeded {TEST_DEADLINE:?}"))??;
1090
1091        let test_result = async {
1092            let py_instance = PyInstance::from(&instance);
1093            let py_proc_mesh = PyProcMesh::new_owned(proc_mesh);
1094            // Held for the whole test, so no other test's logging operation can
1095            // reach this client.
1096            let lock = hyperactor_config::global::lock();
1097            let _guard = lock.override_key(MESH_ENABLE_LOG_FORWARDING, true);
1098
1099            let client_py = drive_logging_client(LoggingMeshClient::spawn(
1100                &py_instance,
1101                &py_proc_mesh,
1102            )?)
1103            .await?;
1104            let (forwarding_enabled, client_actor) =
1105                monarch_with_gil(GilSite::Test, |py| {
1106                    let client = client_py.borrow(py);
1107                    (client.forwarder_mesh.is_some(), client.client_actor.clone())
1108                })
1109                .await;
1110            anyhow::ensure!(
1111                forwarding_enabled,
1112                "the flush witness requires a real forwarding mesh"
1113            );
1114
1115            let baseline = completed_sync_flush_probe(&instance, &client_actor).await?;
1116
1117            let dropped = monarch_with_gil(GilSite::Test, |py| {
1118                client_py.borrow(py).flush(&py_instance)
1119            })
1120            .await?;
1121            drop(dropped);
1122
1123            let deadline = tokio::time::Instant::now() + TEST_DEADLINE;
1124            let mut probes = 0u64;
1125            loop {
1126                anyhow::ensure!(
1127                    tokio::time::Instant::now() < deadline,
1128                    "the dropped flush never reached the barrier: baseline probe {baseline}, {probes} probes"
1129                );
1130                let (version, completed) =
1131                    sync_flush_probe(&instance, &client_actor, deadline).await?;
1132                probes += 1;
1133                if !completed {
1134                    continue;
1135                }
1136                if version == baseline + probes {
1137                    continue;
1138                }
1139                anyhow::ensure!(
1140                    version == baseline + probes + 1,
1141                    "expected exactly one real flush: baseline probe {baseline}, {probes} probes, completed probe {version}"
1142                );
1143                break;
1144            }
1145
1146            drop(client_py);
1147            Ok(())
1148        }
1149        .await;
1150
1151        shutdown_after_test(host_mesh, &instance, test_result).await
1152    }
1153}