vortex_io/runtime/handle.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::any::Any;
5use std::panic::AssertUnwindSafe;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Weak;
9use std::task::Context;
10use std::task::Poll;
11use std::task::ready;
12
13use futures::FutureExt;
14use futures::channel::oneshot;
15use tracing::Instrument;
16use vortex_error::vortex_panic;
17
18use crate::runtime::AbortHandleRef;
19use crate::runtime::Executor;
20use crate::runtime::platform;
21
22/// A handle to an active Vortex runtime.
23///
24/// Users should obtain a handle from one of the Vortex runtime's and use it to spawn new async
25/// tasks, blocking I/O tasks, CPU-heavy tasks, or to open files for reading or writing.
26///
27/// Note that a [`Handle`] is a weak reference to the underlying runtime. If the associated
28/// runtime has been dropped, then any requests to spawn new tasks will panic.
29#[derive(Clone)]
30pub struct Handle {
31 runtime: Weak<dyn Executor>,
32}
33
34impl Handle {
35 pub fn new(runtime: Weak<dyn Executor>) -> Self {
36 Self { runtime }
37 }
38
39 fn runtime(&self) -> Arc<dyn Executor> {
40 self.runtime.upgrade().unwrap_or_else(|| {
41 vortex_panic!("Attempted to use a Handle after its runtime was dropped")
42 })
43 }
44
45 /// Returns a handle to the current runtime, if such a reasonable choice exists.
46 ///
47 /// For example, if called from within a Tokio context this will return a
48 /// `TokioRuntime` handle. On browser WebAssembly with the `wasm-bindgen` feature this returns
49 /// the global `WasmRuntime` handle; without it there is no event loop to schedule onto, and
50 /// callers must drive a `SingleThreadRuntime` and install its handle themselves.
51 pub fn find() -> Option<Self> {
52 #[cfg(feature = "tokio")]
53 {
54 use tokio::runtime::Handle as TokioHandle;
55
56 use crate::runtime::tokio::TokioRuntime;
57 if TokioHandle::try_current().is_ok() {
58 return Some(TokioRuntime::current());
59 }
60 }
61
62 platform::default_handle()
63 }
64
65 /// Spawn a new future onto the runtime.
66 ///
67 /// These futures are expected to not perform expensive CPU work and instead simply schedule
68 /// either CPU tasks or I/O tasks. See [`Handle::spawn_cpu`] for spawning CPU-bound work.
69 ///
70 /// See [`Task`] for details on cancelling or detaching the spawned task.
71 pub fn spawn<Fut, R>(&self, f: Fut) -> Task<R>
72 where
73 Fut: Future<Output = R> + Send + 'static,
74 R: Send + 'static,
75 {
76 let (send, recv) = oneshot::channel();
77 // Instrument with a dedicated, named span on its own target rather than
78 // re-entering `Span::current()`. `Instrumented::poll` enters and exits the
79 // span on every poll, so re-entering the caller's span makes its cost scale
80 // with poll count. A distinct target lets subscribers opt these spans in or
81 // out via filtering; when filtered out the span is disabled and enter/exit
82 // are no-ops, which keeps frequently-polled spawned futures cheap.
83 let span = tracing::trace_span!(target: "vortex_io::spawn", "spawn");
84 let abort_handle = self.runtime().spawn(
85 async move {
86 // Catch a panic so it can be re-raised on the joining side (see `Task::poll`)
87 // rather than being lost, matching `tokio::JoinError` / `smol::Task` semantics.
88 let output = AssertUnwindSafe(f).catch_unwind().await;
89 // Task::detach allows the receiver to be dropped, so we ignore send errors.
90 drop(send.send(output));
91 }
92 .instrument(span)
93 .boxed(),
94 );
95 Task {
96 recv,
97 abort_handle: Some(abort_handle),
98 }
99 }
100
101 /// A helper function to avoid manually cloning the handle when spawning nested tasks.
102 pub fn spawn_nested<F, Fut, R>(&self, f: F) -> Task<R>
103 where
104 F: FnOnce(Handle) -> Fut,
105 Fut: Future<Output = R> + Send + 'static,
106 R: Send + 'static,
107 {
108 self.spawn(f(Handle::new(Weak::clone(&self.runtime))))
109 }
110
111 /// Spawn a new I/O future onto the runtime.
112 ///
113 /// See [`Executor::spawn_io`] for more details about how this future is expected to run.
114 // See [`Task`] for details on cancelling or detaching the spawned task.
115 pub fn spawn_io<Fut, R>(&self, f: Fut) -> Task<R>
116 where
117 Fut: Future<Output = R> + Send + 'static,
118 R: Send + 'static,
119 {
120 let (send, recv) = oneshot::channel();
121 // See `spawn` above: a dedicated target rather than `Span::current()` so
122 // subscribers can filter these spans in or out. I/O futures are polled
123 // frequently, so disabling the span (the default for an unconfigured
124 // target) avoids per-poll enter/exit cost.
125 let span = tracing::trace_span!(target: "vortex_io::spawn_io", "spawn_io");
126 let abort_handle = self.runtime().spawn_io(
127 async move {
128 // See `spawn`: catch a panic so it re-raises on the joining side.
129 let output = AssertUnwindSafe(f).catch_unwind().await;
130 // Task::detach allows the receiver to be dropped, so we ignore send errors.
131 drop(send.send(output));
132 }
133 .instrument(span)
134 .boxed(),
135 );
136 Task {
137 recv,
138 abort_handle: Some(abort_handle),
139 }
140 }
141
142 /// Spawn a CPU-bound task for execution on the runtime.
143 ///
144 /// Note that many runtimes will interleave this work on the same async runtime. See the
145 /// documentation for each runtime implementation for details.
146 ///
147 /// See [`Task`] for details on cancelling or detaching the spawned work, although note that
148 /// once started, CPU work cannot be cancelled.
149 pub fn spawn_cpu<F, R>(&self, f: F) -> Task<R>
150 where
151 // Unlike scheduling futures, the CPU task should have a static lifetime because it
152 // doesn't need to access to handle to spawn more work.
153 F: FnOnce() -> R + Send + 'static,
154 R: Send + 'static,
155 {
156 let (send, recv) = oneshot::channel();
157 let span = tracing::Span::current();
158 let abort_handle = self.runtime().spawn_cpu(Box::new(move || {
159 let _guard = span.enter();
160 // Optimistically avoid the work if the result won't be used.
161 if !send.is_canceled() {
162 // Catch a panic so it re-raises on the joining side (see `Task::poll`).
163 let output = std::panic::catch_unwind(AssertUnwindSafe(f));
164 // Task::detach allows the receiver to be dropped, so we ignore send errors.
165 drop(send.send(output));
166 }
167 }));
168 Task {
169 recv,
170 abort_handle: Some(abort_handle),
171 }
172 }
173
174 /// Spawn a blocking I/O task for execution on the runtime.
175 pub fn spawn_blocking<F, R>(&self, f: F) -> Task<R>
176 where
177 F: FnOnce() -> R + Send + 'static,
178 R: Send + 'static,
179 {
180 let (send, recv) = oneshot::channel();
181 let span = tracing::Span::current();
182 let abort_handle = self.runtime().spawn_blocking_io(Box::new(move || {
183 let _guard = span.enter();
184 // Optimistically avoid the work if the result won't be used.
185 if !send.is_canceled() {
186 // Catch a panic so it re-raises on the joining side (see `Task::poll`).
187 let output = std::panic::catch_unwind(AssertUnwindSafe(f));
188 // Task::detach allows the receiver to be dropped, so we ignore send errors.
189 drop(send.send(output));
190 }
191 }));
192 Task {
193 recv,
194 abort_handle: Some(abort_handle),
195 }
196 }
197}
198
199/// The value carried from a spawned task back to its [`Task`] handle: either the task's output,
200/// or the panic payload if the task panicked, so it can be re-raised on the joining side.
201type TaskOutput<T> = Result<T, Box<dyn Any + Send>>;
202
203/// The terminal outcome of joining a spawned [`Task`] with [`Task::poll_join`], without
204/// re-raising a panic.
205pub enum JoinOutcome<T> {
206 /// The task ran to completion and produced this value.
207 Completed(T),
208 /// The task panicked. Carries the original panic payload, ready to be re-raised with
209 /// [`std::panic::resume_unwind`].
210 Panicked(Box<dyn Any + Send>),
211 /// The runtime dropped the task's future before it completed, for example because the task
212 /// was aborted or the runtime shut down. No value or panic payload is available.
213 Aborted,
214}
215
216/// A handle to a spawned Task.
217///
218/// If this handle is dropped, the task is cancelled where possible. In order to allow the task to
219/// continue running in the background, call [`Task::detach`].
220#[must_use = "When a Task is dropped without being awaited, it is cancelled"]
221pub struct Task<T> {
222 recv: oneshot::Receiver<TaskOutput<T>>,
223 abort_handle: Option<AbortHandleRef>,
224}
225
226impl<T> Task<T> {
227 /// Detach the task, allowing it to continue running in the background after being dropped.
228 /// This is only possible if the underlying runtime has a 'static lifetime.
229 pub fn detach(mut self) {
230 drop(self.abort_handle.take());
231 }
232
233 /// Poll the task to completion, reporting the terminal state as a [`JoinOutcome`] instead of
234 /// re-raising a panic or panicking on abort.
235 ///
236 /// This lets a caller distinguish a task panic (which it may re-raise via
237 /// [`std::panic::resume_unwind`]) from the runtime dropping the task before it completed — for
238 /// example a runtime shutting down while work is in flight — which is benign. The [`Future`]
239 /// implementation, by contrast, re-raises a panic and itself panics on abort.
240 pub fn poll_join(&mut self, cx: &mut Context<'_>) -> Poll<JoinOutcome<T>> {
241 match ready!(self.recv.poll_unpin(cx)) {
242 Ok(Ok(output)) => Poll::Ready(JoinOutcome::Completed(output)),
243 Ok(Err(panic)) => Poll::Ready(JoinOutcome::Panicked(panic)),
244 // The result channel closed without delivering a value: the runtime dropped the task's
245 // future before it completed (for example the task was aborted or the runtime shut
246 // down).
247 Err(_recv_err) => Poll::Ready(JoinOutcome::Aborted),
248 }
249 }
250}
251
252impl<T> Future for Task<T> {
253 type Output = T;
254
255 #[expect(clippy::panic)]
256 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
257 match ready!(self.get_mut().poll_join(cx)) {
258 JoinOutcome::Completed(output) => Poll::Ready(output),
259 // The task panicked: re-raise the original panic on the joining side, preserving its
260 // message and payload rather than collapsing it into a generic error.
261 JoinOutcome::Panicked(panic) => std::panic::resume_unwind(panic),
262 JoinOutcome::Aborted => {
263 // The runtime dropped the task's future before it completed. If the caller aborted
264 // this task by dropping it, they wouldn't be able to poll it anymore, so we
265 // consider a closed channel a runtime programming error and panic.
266
267 // NOTE(ngates): we don't use vortex_panic to avoid printing a useless backtrace.
268 panic!("Runtime dropped task without completing it")
269 }
270 }
271 }
272}
273
274impl<T> Drop for Task<T> {
275 fn drop(&mut self) {
276 // Optimistically abort the task if it's still running.
277 if let Some(handle) = self.abort_handle.take() {
278 handle.abort();
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use futures::task::noop_waker;
286
287 use super::*;
288
289 // A task whose result channel closed without a value — the runtime dropped its future before
290 // it sent a result — must report `Aborted`, so callers can treat a benign teardown as a
291 // recoverable stop rather than a propagated panic.
292 #[test]
293 fn poll_join_reports_aborted_when_channel_closed_without_value() {
294 let (send, recv) = oneshot::channel::<TaskOutput<()>>();
295 // Simulate the runtime dropping the task's future before it sent a result.
296 drop(send);
297
298 let mut task = Task::<()> {
299 recv,
300 abort_handle: None,
301 };
302
303 let waker = noop_waker();
304 let mut cx = Context::from_waker(&waker);
305 assert!(matches!(
306 task.poll_join(&mut cx),
307 Poll::Ready(JoinOutcome::Aborted)
308 ));
309 }
310
311 // A completed task reports its value through `poll_join` rather than re-raising or panicking.
312 #[test]
313 fn poll_join_reports_completed_value() {
314 let (send, recv) = oneshot::channel::<TaskOutput<u32>>();
315 // Ignore the send result: the payload type is not `Debug`, and the receiver is alive.
316 drop(send.send(Ok(7)));
317
318 let mut task = Task::<u32> {
319 recv,
320 abort_handle: None,
321 };
322
323 let waker = noop_waker();
324 let mut cx = Context::from_waker(&waker);
325 assert!(matches!(
326 task.poll_join(&mut cx),
327 Poll::Ready(JoinOutcome::Completed(7))
328 ));
329 }
330}