Skip to main content

agentmux_srv/backend/
blocking.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Running a blocking section from code that may be on an async worker.
5//!
6//! A synchronous call that can take seconds (SQLite with a busy timeout, file
7//! I/O, per-process OS queries) must not run inline on a Tokio worker. It is
8//! not only that one worker that stops: the worker that happens to be driving
9//! the I/O driver stops polling it, and no other worker takes the driver over
10//! until that one parks again. For the length of the call no socket is
11//! serviced — the HTTP health endpoint included — while timers and
12//! already-queued tasks keep running, so the server looks alive in its logs
13//! and answers nothing. That is what the launcher's liveness probe saw before
14//! it recycled srv. See
15//! `docs/analysis/ANALYSIS_SRV_HTTP_STALL_IO_DRIVER_STARVATION_2026_09_26.md`.
16
17/// Run `f`, handing this worker's other tasks (and the I/O driver, if it holds
18/// it) to another thread first when called on a multi-thread runtime worker.
19///
20/// Synchronous and order-preserving, unlike `spawn_blocking`: the caller
21/// continues only after `f` returns, so a write that must land before the
22/// next one still does.
23///
24/// - Multi-thread runtime worker: `block_in_place`.
25/// - Blocking-pool thread, or no runtime: nothing to hand off — runs `f`.
26/// - Current-thread runtime (most `#[tokio::test]`s): `block_in_place` panics
27///   there and there is no other worker to hand off to — runs `f`.
28pub fn off_async_worker<R>(f: impl FnOnce() -> R) -> R {
29    let on_multi_thread = tokio::runtime::Handle::try_current()
30        .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread);
31    if on_multi_thread {
32        tokio::task::block_in_place(f)
33    } else {
34        f()
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::off_async_worker;
41
42    #[test]
43    fn runs_without_a_runtime() {
44        assert_eq!(off_async_worker(|| 7), 7);
45    }
46
47    #[tokio::test(flavor = "current_thread")]
48    async fn does_not_panic_on_a_current_thread_runtime() {
49        // block_in_place would panic here; the helper must fall back.
50        assert_eq!(off_async_worker(|| 7), 7);
51    }
52
53    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
54    async fn runs_on_a_multi_thread_worker() {
55        assert_eq!(off_async_worker(|| 7), 7);
56    }
57
58    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
59    async fn runs_on_a_blocking_pool_thread() {
60        let v = tokio::task::spawn_blocking(|| off_async_worker(|| 7)).await.unwrap();
61        assert_eq!(v, 7);
62    }
63
64    /// The point of the helper: while a worker sits in a long blocking section,
65    /// the runtime still services I/O. One worker makes this deterministic —
66    /// with the section inline, that worker IS the I/O driver and nothing else
67    /// can poll it, so the round-trip below would wait the full 1.5s;
68    /// `block_in_place` hands its core to another thread first.
69    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
70    async fn io_is_still_serviced_while_a_worker_blocks() {
71        use tokio::io::{AsyncReadExt, AsyncWriteExt};
72        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
73        let addr = listener.local_addr().unwrap();
74        tokio::spawn(async move {
75            loop {
76                let (mut s, _) = listener.accept().await.unwrap();
77                tokio::spawn(async move {
78                    let mut b = [0u8; 4];
79                    let _ = s.read(&mut b).await;
80                    let _ = s.write_all(b"pong").await;
81                });
82            }
83        });
84        let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
85        let blocker = tokio::spawn(async move {
86            off_async_worker(move || {
87                entered_tx.send(()).unwrap();
88                std::thread::sleep(std::time::Duration::from_millis(1500));
89            });
90        });
91        // Start the round-trip only once the worker is inside the section.
92        tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()).await.unwrap();
93        let started = std::time::Instant::now();
94        let mut c = tokio::net::TcpStream::connect(addr).await.unwrap();
95        c.write_all(b"ping").await.unwrap();
96        let mut b = [0u8; 4];
97        c.read_exact(&mut b).await.unwrap();
98        assert_eq!(&b, b"pong");
99        assert!(
100            started.elapsed() < std::time::Duration::from_millis(800),
101            "round-trip took {:?} while another worker was blocked",
102            started.elapsed()
103        );
104        blocker.await.unwrap();
105    }
106}