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