saluki_core/runtime/
spawn.rs

1//! Ambient supervisor context.
2//!
3//! Every supervised process runs with a handle to the supervisor that supervises it installed as a task-local value.
4//! That makes [`spawn`] possible: code anywhere inside a supervised process -- the worker's body, its
5//! [`initialize`][crate::runtime::Supervisable::initialize], or any helper it calls -- can add a child to its own
6//! supervisor without a handle being threaded to it.
7//!
8//! The value is installed per supervised task, so it doesn't leak into tasks started with [`tokio::spawn`]: those run
9//! outside supervision, and [`spawn`] panics there rather than silently attaching the child somewhere unexpected. Use
10//! [`SupervisorHandle::scope`] to deliberately establish an ambient supervisor for such a task.
11
12use std::future::Future;
13
14use tokio::task::futures::TaskLocalFuture;
15
16use super::supervisor::{ChildId, ChildSpecification, ChildState, SupervisorHandle};
17
18tokio::task_local! {
19    /// Handle to the supervisor supervising the currently running process.
20    pub(super) static CURRENT_SUPERVISOR: SupervisorHandle;
21}
22
23/// Spawns a child on the ambient supervisor.
24///
25/// The ambient supervisor is the one supervising the currently running process, so the child becomes a _sibling_ of
26/// the caller rather than its descendant.
27///
28/// Accepts anything [`Supervisor::add_worker`][crate::runtime::Supervisor::add_worker] accepts: a bare
29/// [`Supervisable`][crate::runtime::Supervisable], a [`Supervisor`][crate::runtime::Supervisor] to run as a nested
30/// supervision subtree, or a [`ChildSpecification`] configured in detail. Unless the specification says otherwise, the
31/// child is [`temporary`][crate::runtime::RestartType::Temporary]; see [`SupervisorHandle::spawn`] for what that
32/// implies.
33///
34/// This mirrors [`tokio::spawn`] in both shape and guarantees: it always succeeds, and success means the child was
35/// accepted, not that it will run. A supervisor that shuts down before it reaches the queued child never starts it.
36///
37/// # Panics
38///
39/// Panics if there is no ambient supervisor, which means the caller isn't running as (or within) a supervised process.
40/// Establish one with [`SupervisorHandle::scope`], or spawn through a [`SupervisorHandle`] directly.
41///
42/// # Examples
43///
44/// ```no_run
45/// # use saluki_core::runtime::{spawn, FnWorker};
46/// # async fn refresh() {}
47/// spawn(FnWorker::new("refresher", refresh()));
48/// ```
49pub fn spawn<S, T>(child: T) -> ChildId
50where
51    S: ChildState,
52    T: Into<ChildSpecification<S>>,
53{
54    CURRENT_SUPERVISOR
55        .try_with(|supervisor| supervisor.spawn(child))
56        .unwrap_or_else(|_| {
57            panic!(
58                "`runtime::spawn` called outside of a supervised process: there is no ambient supervisor to spawn on. \
59                 Spawn through a `SupervisorHandle` directly, or establish an ambient supervisor with \
60                 `SupervisorHandle::scope`."
61            )
62        })
63}
64
65impl SupervisorHandle {
66    /// Runs `fut` with this supervisor installed as the ambient supervisor.
67    ///
68    /// Anything `fut` spawns through [`spawn`] becomes a child of this supervisor. Supervised processes already have
69    /// their own supervisor installed, so this is for code that runs outside supervision -- a test driving a component
70    /// directly, or a task started with [`tokio::spawn`] that needs to attach children to a known supervisor.
71    ///
72    /// The ambient supervisor applies only for the duration of `fut`, and shadows any supervisor already installed.
73    pub fn scope<F>(&self, fut: F) -> TaskLocalFuture<SupervisorHandle, F>
74    where
75        F: Future,
76    {
77        CURRENT_SUPERVISOR.scope(self.clone(), fut)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use std::sync::{
84        atomic::{AtomicUsize, Ordering},
85        Arc,
86    };
87    use std::time::Duration;
88
89    use async_trait::async_trait;
90    use saluki_common::sync::shutdown::ShutdownHandle;
91    use tokio::sync::oneshot;
92
93    use super::*;
94    use crate::runtime::{
95        ChildSpecification, FnWorker, InitializationError, ShutdownStrategy, Supervisable, Supervisor, SupervisorError,
96        SupervisorFuture,
97    };
98    use crate::test_support::wait_until;
99
100    /// A worker that runs a caller-supplied action and then waits for shutdown.
101    ///
102    /// `when` decides whether the action runs during initialization or once the worker is running, which is the
103    /// distinction these tests care about: the ambient supervisor has to be in place for both.
104    struct ActionWorker {
105        name: &'static str,
106        during_init: bool,
107        action: std::sync::Mutex<Option<Box<dyn FnOnce() + Send>>>,
108    }
109
110    impl ActionWorker {
111        fn new<F>(name: &'static str, during_init: bool, action: F) -> Self
112        where
113            F: FnOnce() + Send + 'static,
114        {
115            Self {
116                name,
117                during_init,
118                action: std::sync::Mutex::new(Some(Box::new(action))),
119            }
120        }
121
122        fn take_action(&self) -> Box<dyn FnOnce() + Send> {
123            self.action
124                .lock()
125                .expect("action mutex poisoned")
126                .take()
127                .expect("worker should only run once")
128        }
129    }
130
131    #[async_trait]
132    impl Supervisable for ActionWorker {
133        fn name(&self) -> &str {
134            self.name
135        }
136
137        async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
138            let action = self.take_action();
139            if self.during_init {
140                action();
141
142                return Ok(Box::pin(async move {
143                    process_shutdown.await;
144                    Ok(())
145                }));
146            }
147
148            Ok(Box::pin(async move {
149                action();
150                process_shutdown.await;
151                Ok(())
152            }))
153        }
154    }
155
156    /// Wraps an endless worker so the supervisor aborts it at shutdown instead of waiting for a terminal condition
157    /// it doesn't have.
158    fn endless(worker: FnWorker) -> ChildSpecification {
159        ChildSpecification::worker(worker).with_shutdown_strategy(ShutdownStrategy::Brutal)
160    }
161
162    /// Runs a supervisor holding a single worker that performs `action`, then shuts it down and returns the result.
163    ///
164    /// Waits for `action` to have actually run before signalling shutdown. A fixed sleep would work most of the time
165    /// and fail on a loaded runner, which is the whole reason [`wait_until`] exists.
166    async fn run_worker_that<F>(during_init: bool, action: F) -> Result<(), SupervisorError>
167    where
168        F: FnOnce() + Send + 'static,
169    {
170        let acted = Arc::new(AtomicUsize::new(0));
171        let worker_acted = Arc::clone(&acted);
172
173        let mut sup = Supervisor::new("ambient-sup").expect("supervisor name should be valid");
174        sup.add_worker(ActionWorker::new("actor", during_init, move || {
175            action();
176            worker_acted.fetch_add(1, Ordering::SeqCst);
177        }));
178
179        let (shutdown_tx, shutdown_rx) = oneshot::channel();
180        let handle = sup.handle();
181        let run = tokio::spawn(async move { sup.run_with_shutdown(shutdown_rx).await });
182
183        wait_until("supervisor is running", || handle.is_running()).await;
184        wait_until("the worker has run its action", || acted.load(Ordering::SeqCst) == 1).await;
185
186        let _ = shutdown_tx.send(());
187        tokio::time::timeout(Duration::from_secs(5), run)
188            .await
189            .expect("supervisor should stop promptly")
190            .expect("supervisor task should not panic")
191    }
192
193    #[tokio::test]
194    async fn worker_spawns_onto_its_own_supervisor() {
195        // The ambient supervisor of a running worker is the one supervising it, so what it spawns becomes its sibling
196        // -- and is drained when that supervisor stops.
197        let started = Arc::new(AtomicUsize::new(0));
198        let child_started = Arc::clone(&started);
199
200        let result = run_worker_that(false, move || {
201            spawn(endless(FnWorker::new("sibling", async move {
202                child_started.fetch_add(1, Ordering::SeqCst);
203                std::future::pending::<()>().await;
204            })));
205        })
206        .await;
207
208        assert!(result.is_ok(), "supervisor should have stopped cleanly: {result:?}");
209        assert_eq!(started.load(Ordering::SeqCst), 1, "the spawned sibling should have run");
210    }
211
212    #[tokio::test]
213    async fn worker_can_spawn_during_initialization() {
214        // Initialization runs inside the worker's own task, so the ambient supervisor is already in place there. A
215        // worker that sets up helpers before it starts running shouldn't have to defer them until after.
216        let started = Arc::new(AtomicUsize::new(0));
217        let child_started = Arc::clone(&started);
218
219        let result = run_worker_that(true, move || {
220            spawn(endless(FnWorker::new("helper", async move {
221                child_started.fetch_add(1, Ordering::SeqCst);
222                std::future::pending::<()>().await;
223            })));
224        })
225        .await;
226
227        assert!(result.is_ok(), "supervisor should have stopped cleanly: {result:?}");
228        assert_eq!(
229            started.load(Ordering::SeqCst),
230            1,
231            "a child spawned during initialization should have run"
232        );
233    }
234
235    #[tokio::test]
236    #[should_panic(expected = "outside of a supervised process")]
237    async fn spawning_without_an_ambient_supervisor_panics() {
238        // Nothing sensible can be done with a child here, and silently dropping it would hide the mistake until
239        // whatever was spawned turned out never to have run.
240        spawn(endless(FnWorker::new("orphan", std::future::pending::<()>())));
241    }
242
243    #[tokio::test]
244    async fn ambient_supervisor_is_not_inherited_by_tokio_spawn() {
245        // A task started with `tokio::spawn` is outside supervision entirely, even when its parent was supervised.
246        // Inheriting the ambient supervisor there would quietly attach children to a supervisor that has no
247        // relationship to the task's actual lifetime.
248        let escaped = Arc::new(AtomicUsize::new(0));
249        let observed = Arc::clone(&escaped);
250
251        let result = run_worker_that(false, move || {
252            tokio::spawn(async move {
253                if CURRENT_SUPERVISOR.try_with(|_| ()).is_err() {
254                    observed.fetch_add(1, Ordering::SeqCst);
255                }
256            });
257        })
258        .await;
259
260        assert!(result.is_ok(), "supervisor should have stopped cleanly: {result:?}");
261        assert_eq!(
262            escaped.load(Ordering::SeqCst),
263            1,
264            "a `tokio::spawn`ed task must not see an ambient supervisor"
265        );
266    }
267
268    #[tokio::test]
269    async fn scope_establishes_an_ambient_supervisor() {
270        // The escape hatch for code that isn't running under supervision: tests, and tasks bridging back into a known
271        // supervisor.
272        let started = Arc::new(AtomicUsize::new(0));
273        let child_started = Arc::clone(&started);
274
275        let mut sup = Supervisor::new("scoped-sup").expect("supervisor name should be valid");
276        // A supervisor with no children at all still idles until shutdown, so nothing else is needed here.
277        let handle = sup.handle();
278        let (shutdown_tx, shutdown_rx) = oneshot::channel();
279        let run = tokio::spawn(async move { sup.run_with_shutdown(shutdown_rx).await });
280        wait_until("supervisor is running", || handle.is_running()).await;
281
282        handle
283            .scope(async {
284                spawn(endless(FnWorker::new("scoped", async move {
285                    child_started.fetch_add(1, Ordering::SeqCst);
286                    std::future::pending::<()>().await;
287                })));
288            })
289            .await;
290
291        wait_until("the scoped child has started", || started.load(Ordering::SeqCst) == 1).await;
292
293        let _ = shutdown_tx.send(());
294        let result = tokio::time::timeout(Duration::from_secs(5), run)
295            .await
296            .expect("supervisor should stop promptly")
297            .expect("supervisor task should not panic");
298        assert!(result.is_ok(), "supervisor should have stopped cleanly: {result:?}");
299    }
300}