Skip to main content

meerkat_runtime/
stack_relief.rs

1//! Fresh-task stack relief for deep async construction chains.
2//!
3//! At opt-level=0 LLVM performs no stack-slot coloring, so the poll function
4//! of a large async fn reserves a separate stack slot for every local in
5//! every branch. Deep construction chains (session runtime registration,
6//! agent build, mob machine commands) therefore carry enormous poll frames,
7//! and awaiting them inline stacks those frames beneath the caller's own
8//! poll chain — e.g. an agent's run loop → tool dispatch → mob spawn →
9//! child-session registration. That sum is what overflowed the 2 MiB
10//! production worker-stack budget asserted by
11//! `tools_full_with_explicit_auth_binding_can_spawn_within_production_stack_budget`.
12//!
13//! [`relieve_caller_stack`] moves such a chain onto its own tokio task, so
14//! its frames start near the top of a fresh task poll rather than on top of
15//! the caller's. It takes a future-*maker* rather than a future because
16//! `tokio::spawn` moves its argument by value: a large future would
17//! otherwise transit the caller's stack (and the spawn call's frame) at its
18//! full size. The maker closure is small (its captures), and the future it
19//! makes is materialized and boxed on the fresh task's stack instead.
20
21use std::future::Future;
22
23/// Runs the future produced by `make_future` on its own tokio task and
24/// awaits its completion, aborting the task if the caller is dropped first.
25///
26/// Semantics relative to an inline `make_future().await`:
27/// - completion and output are identical;
28/// - a panic inside the future is resumed on the caller;
29/// - cancellation is WEAKER than inline drop. Dropping an inline future
30///   destroys it synchronously — it can never execute again. `AbortHandle`
31///   is cooperative: the spawned task stops at its next await point, so a
32///   synchronous section already past its last await (sending on channels,
33///   committing to a store handle) can still complete AFTER the caller
34///   observed cancellation, concurrently with whatever the caller does next.
35///
36/// Because of that weaker cancellation contract, wrap only work whose late
37/// completion is harmless: pure construction that hands its result to nobody
38/// (the receiver is gone with the caller), or effects that are themselves
39/// fenced/idempotent at the machine boundary they target. Do not wrap a
40/// chain whose synchronous tail publishes state the caller assumes is
41/// unpublished after cancellation.
42#[cfg(not(target_arch = "wasm32"))]
43pub async fn relieve_caller_stack<T, F, Fut>(make_future: F) -> T
44where
45    F: FnOnce() -> Fut + Send + 'static,
46    Fut: Future<Output = T> + Send + 'static,
47    T: Send + 'static,
48{
49    /// Aborts the spawned task when the caller's future is dropped
50    /// mid-await. Aborting an already-finished task is a no-op, so the
51    /// guard is safe to hold across the successful path too.
52    struct AbortOnDrop(tokio::task::AbortHandle);
53    impl Drop for AbortOnDrop {
54        fn drop(&mut self) {
55            self.0.abort();
56        }
57    }
58
59    let handle = tokio::spawn(async move {
60        // Materialize the (potentially large) future on this fresh task's
61        // stack — not the caller's — and box it so this wrapper's own
62        // generator stays at closure-capture size.
63        let future: std::pin::Pin<Box<Fut>> = Box::pin(make_future());
64        future.await
65    });
66    let _guard = AbortOnDrop(handle.abort_handle());
67    match handle.await {
68        Ok(value) => value,
69        Err(join_error) => match join_error.try_into_panic() {
70            Ok(panic) => std::panic::resume_unwind(panic),
71            // Cancellation is only possible via the guard above (not yet
72            // dropped) or runtime shutdown. Under shutdown the caller is
73            // being torn down as well; parking mirrors the inline-await
74            // behavior of a future that will never be polled to completion.
75            Err(_) => std::future::pending().await,
76        },
77    }
78}
79
80/// wasm32: single-threaded, no worker-stack budget to defend — await inline.
81#[cfg(target_arch = "wasm32")]
82pub async fn relieve_caller_stack<T, F, Fut>(make_future: F) -> T
83where
84    F: FnOnce() -> Fut + 'static,
85    Fut: Future<Output = T> + 'static,
86    T: 'static,
87{
88    make_future().await
89}
90
91#[cfg(all(test, not(target_arch = "wasm32")))]
92mod tests {
93    use super::relieve_caller_stack;
94    use std::sync::Arc;
95    use std::sync::atomic::{AtomicBool, Ordering};
96
97    #[tokio::test]
98    async fn resolves_with_the_future_output() {
99        let value = relieve_caller_stack(|| async { 6 * 7 }).await;
100        assert_eq!(value, 42);
101    }
102
103    #[tokio::test]
104    async fn propagates_panics_to_the_caller() {
105        let result = tokio::spawn(async {
106            relieve_caller_stack(|| async { panic!("stack relief panic probe") }).await
107        })
108        .await;
109        let join_error = result.expect_err("panic must propagate");
110        assert!(join_error.is_panic());
111    }
112
113    #[tokio::test]
114    async fn dropping_the_caller_aborts_the_spawned_work() {
115        let entered = Arc::new(AtomicBool::new(false));
116        let finished = Arc::new(AtomicBool::new(false));
117        let entered_clone = Arc::clone(&entered);
118        let finished_clone = Arc::clone(&finished);
119        let caller = tokio::spawn(async move {
120            relieve_caller_stack(move || async move {
121                entered_clone.store(true, Ordering::SeqCst);
122                tokio::time::sleep(std::time::Duration::from_secs(300)).await;
123                finished_clone.store(true, Ordering::SeqCst);
124            })
125            .await;
126        });
127        while !entered.load(Ordering::SeqCst) {
128            tokio::task::yield_now().await;
129        }
130        caller.abort();
131        let _ = caller.await;
132        // Give the abort a scheduling opportunity, then confirm the inner
133        // future never ran to completion.
134        for _ in 0..64 {
135            tokio::task::yield_now().await;
136        }
137        assert!(!finished.load(Ordering::SeqCst));
138    }
139}