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}