Skip to main content

geph5_rt/
lib.rs

1//! tokio-based runtime helpers for geph5.
2//!
3//! This crate replaces the smol/smolscale runtime surface (spawning, blocking,
4//! timeouts, the immortal/respawn pattern, and the task reaper) while preserving
5//! the property the codebase relies on for structured concurrency: smol's
6//! `Task<T>` **cancels the task when the handle is dropped**. tokio's
7//! `JoinHandle` detaches on drop instead, so [`Task`] reintroduces drop-cancel.
8
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::LazyLock;
12use std::task::{Context, Poll, ready};
13
14use tokio::runtime::{Builder, Runtime};
15use tokio::task::JoinHandle;
16
17mod bufpool;
18pub mod immortal;
19pub mod reaper;
20mod timeout;
21
22pub use bufpool::{pooled_read, pooled_read_callback};
23pub use immortal::{Immortal, RespawnStrategy};
24pub use reaper::TaskReaper;
25pub use timeout::TimeoutExt;
26
27/// The global multi-threaded tokio runtime that drives all geph5 async work.
28///
29/// Lazily initialized on first use, mirroring smolscale's global executor so
30/// that [`spawn`] and [`block_on`] work from any thread — including foreign FFI
31/// threads that are not themselves runtime workers.
32static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
33    Builder::new_multi_thread()
34        .enable_all()
35        .build()
36        .expect("could not build the global tokio runtime")
37});
38
39/// Returns a cloneable handle to the global runtime.
40pub fn handle() -> tokio::runtime::Handle {
41    RUNTIME.handle().clone()
42}
43
44/// Spawns a future onto the global runtime, returning a cancel-on-drop handle.
45pub fn spawn<F>(future: F) -> Task<F::Output>
46where
47    F: Future + Send + 'static,
48    F::Output: Send + 'static,
49{
50    Task(Some(RUNTIME.spawn(future)))
51}
52
53/// Runs a blocking closure on the global runtime's blocking pool.
54pub fn spawn_blocking<F, R>(f: F) -> Task<R>
55where
56    F: FnOnce() -> R + Send + 'static,
57    R: Send + 'static,
58{
59    Task(Some(RUNTIME.spawn_blocking(f)))
60}
61
62/// Blocks the current thread on a future, driving it on the global runtime.
63///
64/// Must **not** be called from within a runtime worker thread (tokio panics on
65/// nested block-on). Use it only at process entry points, FFI boundaries, and
66/// tests.
67pub fn block_on<F: Future>(future: F) -> F::Output {
68    RUNTIME.block_on(future)
69}
70
71/// A cancel-on-drop task handle, mirroring `smol::Task`.
72///
73/// * Dropping the handle aborts the underlying task (structured concurrency).
74/// * `.await`ing it yields the task's output `T` directly, propagating a panic
75///   if the task panicked.
76/// * [`Task::detach`] lets it run to completion in the background instead.
77#[must_use = "dropping a Task cancels the task; use .detach() to let it run in the background"]
78pub struct Task<T>(Option<JoinHandle<T>>);
79
80impl<T> Task<T> {
81    /// Detaches the task, letting it run to completion in the background
82    /// (equivalent to `smol::Task::detach`).
83    pub fn detach(mut self) {
84        self.0.take();
85    }
86
87    /// Aborts the task and waits until it has fully stopped.
88    ///
89    /// Prefer simply dropping the handle unless you must wait for the task to
90    /// finish stopping before continuing.
91    pub async fn cancel(mut self) {
92        if let Some(handle) = self.0.take() {
93            handle.abort();
94            let _ = handle.await;
95        }
96    }
97}
98
99impl<T> Drop for Task<T> {
100    fn drop(&mut self) {
101        if let Some(handle) = &self.0 {
102            handle.abort();
103        }
104    }
105}
106
107impl<T> Future for Task<T> {
108    type Output = T;
109    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
110        let handle = self
111            .0
112            .as_mut()
113            .expect("Task polled after being detached or cancelled");
114        match ready!(Pin::new(handle).poll(cx)) {
115            Ok(value) => Poll::Ready(value),
116            Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()),
117            // The task was aborted while still being awaited. This cannot happen
118            // in the normal drop-cancel flow (nobody awaits an aborted handle);
119            // it only arises from explicit cancel paths, where staying pending
120            // is the least-surprising behavior.
121            Err(_) => Poll::Pending,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::sync::Arc;
130    use std::sync::atomic::{AtomicBool, Ordering};
131    use std::time::Duration;
132
133    #[test]
134    fn drop_cancels_the_task() {
135        block_on(async {
136            let flag = Arc::new(AtomicBool::new(false));
137            let f2 = flag.clone();
138            let task = spawn(async move {
139                tokio::time::sleep(Duration::from_millis(100)).await;
140                f2.store(true, Ordering::SeqCst);
141            });
142            drop(task);
143            tokio::time::sleep(Duration::from_millis(300)).await;
144            assert!(
145                !flag.load(Ordering::SeqCst),
146                "a dropped Task must be cancelled"
147            );
148        });
149    }
150
151    #[test]
152    fn detach_runs_to_completion() {
153        block_on(async {
154            let flag = Arc::new(AtomicBool::new(false));
155            let f2 = flag.clone();
156            spawn(async move {
157                tokio::time::sleep(Duration::from_millis(50)).await;
158                f2.store(true, Ordering::SeqCst);
159            })
160            .detach();
161            tokio::time::sleep(Duration::from_millis(300)).await;
162            assert!(
163                flag.load(Ordering::SeqCst),
164                "a detached Task must run to completion"
165            );
166        });
167    }
168
169    #[test]
170    fn await_yields_output() {
171        block_on(async {
172            let task = spawn(async { 42u32 });
173            assert_eq!(task.await, 42);
174        });
175    }
176
177    #[test]
178    fn timeout_returns_none_on_elapse() {
179        block_on(async {
180            let r = tokio::time::sleep(Duration::from_secs(10))
181                .timeout(Duration::from_millis(50))
182                .await;
183            assert!(r.is_none());
184        });
185    }
186
187    #[test]
188    fn reaper_cancels_on_drop() {
189        block_on(async {
190            let flag = Arc::new(AtomicBool::new(false));
191            let f2 = flag.clone();
192            let reaper = TaskReaper::new();
193            reaper.attach(spawn(async move {
194                tokio::time::sleep(Duration::from_millis(100)).await;
195                f2.store(true, Ordering::SeqCst);
196            }));
197            // give the reaper a moment to receive the task, then drop it
198            tokio::time::sleep(Duration::from_millis(10)).await;
199            drop(reaper);
200            tokio::time::sleep(Duration::from_millis(300)).await;
201            assert!(
202                !flag.load(Ordering::SeqCst),
203                "dropping the reaper must cancel attached tasks"
204            );
205        });
206    }
207}