linera_base/task.rs
1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5Abstractions over tasks that can be used natively or on the Web.
6 */
7
8use futures::{future, Future, FutureExt as _};
9
10/// `Send` on native targets; no bound on web (where there's only one thread).
11///
12/// Use this in generic bounds that need `Send` on native but should compile on
13/// web without the bound. Combined with [`run_detached`], this lets a single
14/// function body support both targets.
15#[cfg(not(web))]
16pub trait MaybeSend: Send {}
17#[cfg(not(web))]
18impl<T: Send> MaybeSend for T {}
19
20/// `Sync` on native targets; no bound on web (where there's only one thread).
21///
22/// Use this in generic bounds that need `Sync` on native but should compile on
23/// web without the bound.
24#[cfg(not(web))]
25pub trait MaybeSync: Sync {}
26#[cfg(not(web))]
27impl<T: Sync> MaybeSync for T {}
28
29/// `Send` on native targets; no bound on web (where there's only one thread).
30#[cfg(web)]
31pub trait MaybeSend {}
32#[cfg(web)]
33impl<T> MaybeSend for T {}
34
35/// `Sync` on native targets; no bound on web (where there's only one thread).
36#[cfg(web)]
37pub trait MaybeSync {}
38#[cfg(web)]
39impl<T> MaybeSync for T {}
40
41/// Spawns `future` on the runtime and awaits its completion.
42///
43/// Dropping the returned future does *not* cancel the spawned task — it runs
44/// to completion in the background. Use this when the spawned work (e.g. a
45/// storage write paired with its in-memory finalization) must not be torn
46/// apart mid-flight by caller cancellation.
47pub async fn run_detached<F, R>(future: F) -> R
48where
49 F: Future<Output = R> + MaybeSend + 'static,
50 R: MaybeSend + 'static,
51{
52 // On native, `tokio::task::spawn` returns a `JoinHandle` that already
53 // detaches on drop. On web, `wasm_bindgen_futures::spawn_local` is
54 // fire-and-forget, so we deliver the output through a oneshot channel.
55 #[cfg(not(web))]
56 {
57 join_detached(tokio::task::spawn(future)).await
58 }
59 #[cfg(web)]
60 {
61 let (tx, rx) = futures::channel::oneshot::channel();
62 wasm_bindgen_futures::spawn_local(async move {
63 if tx.send(future.await).is_err() {
64 tracing::debug!("run_detached: receiver dropped before result was delivered");
65 }
66 });
67 rx.await
68 .expect("spawned task dropped without sending its result")
69 }
70}
71
72/// Awaits a detached task, propagating its panic but not its cancellation.
73///
74/// [`run_detached`] never lets the `JoinHandle` escape, so nothing can abort the task: a
75/// cancellation means `spawn` bound the task to an already-closed runtime, which hands back a
76/// handle that is dead on arrival. There is no value left to return and the shutdown that closed
77/// that list is about to drop this future too, so it waits rather than reporting a teardown as a
78/// failure — but it says so first, because that reasoning only holds while the awaiting task
79/// lives on the runtime that died.
80#[cfg(not(web))]
81async fn join_detached<R>(handle: tokio::task::JoinHandle<R>) -> R {
82 match handle.await {
83 Ok(output) => output,
84 Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
85 Err(error) => {
86 tracing::warn!(%error, "detached task cancelled; waiting for the shutdown that caused it");
87 future::pending().await
88 }
89 }
90}
91
92/// The type of a future awaiting another task.
93///
94/// On drop, the remote task will be asynchronously cancelled, but will remain
95/// alive until it reaches a yield point.
96///
97/// To wait for the task to be fully cancelled, use [`Task::cancel`].
98pub struct Task<R> {
99 abort_handle: future::AbortHandle,
100 output: future::RemoteHandle<Result<R, future::Aborted>>,
101}
102
103impl<R: 'static> Task<R> {
104 fn spawn_<F: Future<Output = R>, T>(
105 future: F,
106 spawn: impl FnOnce(future::Remote<future::Abortable<F>>) -> T,
107 ) -> Self {
108 let (abortable_future, abort_handle) = future::abortable(future);
109 let (task, output) = abortable_future.remote_handle();
110 spawn(task);
111 Self {
112 abort_handle,
113 output,
114 }
115 }
116
117 /// Spawns a new task, potentially on the current thread.
118 #[cfg(not(web))]
119 pub fn spawn<F: Future<Output = R> + Send + 'static>(future: F) -> Self
120 where
121 R: Send,
122 {
123 Self::spawn_(future, tokio::task::spawn)
124 }
125
126 /// Spawns a new task on the current thread.
127 #[cfg(web)]
128 pub fn spawn<F: Future<Output = R> + 'static>(future: F) -> Self {
129 Self::spawn_(future, wasm_bindgen_futures::spawn_local)
130 }
131
132 /// Creates a [`Task`] that is immediately ready.
133 pub fn ready(value: R) -> Self {
134 Self::spawn_(async { value }, |fut| {
135 fut.now_or_never().expect("the future is ready")
136 })
137 }
138
139 /// Cancels the task, resolving only when the wrapped future is completely dropped.
140 pub async fn cancel(self) {
141 self.abort_handle.abort();
142 // We just want to wait for the task to finish unwinding; an `Aborted` error is the expected outcome.
143 self.output.await.ok();
144 }
145
146 /// Forgets the task. The task will continue to run to completion in the
147 /// background, but will no longer be joinable or cancelable.
148 pub fn forget(self) {
149 self.output.forget();
150 }
151}
152
153impl<R: 'static> std::future::IntoFuture for Task<R> {
154 type Output = R;
155 type IntoFuture = future::Map<
156 future::RemoteHandle<Result<R, future::Aborted>>,
157 fn(Result<R, future::Aborted>) -> R,
158 >;
159
160 fn into_future(self) -> Self::IntoFuture {
161 self.output
162 .map(|result| result.expect("we have the only AbortHandle"))
163 }
164}
165
166#[cfg(all(test, not(web)))]
167mod tests {
168 use std::time::Duration;
169
170 use super::*;
171
172 /// A cancelled detached task must not be reported as a panic.
173 ///
174 /// Cancellation is provoked with `abort` because the runtime shutdown that causes it in
175 /// production cannot be staged inside a test that still needs the runtime alive. Reverting
176 /// `join_detached` to `into_panic` makes this fail with "`JoinError` reason is not a panic".
177 #[tokio::test]
178 async fn a_cancelled_detached_task_waits_instead_of_panicking() {
179 let handle = tokio::task::spawn(future::pending::<()>());
180 handle.abort();
181 assert!(
182 tokio::time::timeout(Duration::from_millis(50), join_detached(handle))
183 .await
184 .is_err(),
185 "a cancelled task has no value to yield, so joining it must not resolve",
186 );
187 }
188
189 /// The panic of a detached task must still reach whoever awaited it.
190 #[tokio::test]
191 async fn a_panicking_detached_task_still_propagates() {
192 let joined = tokio::task::spawn(async {
193 run_detached(async { panic!("the detached task failed") }).await
194 })
195 .await;
196 let error = joined.expect_err("the panic must not be swallowed");
197 let payload = error.into_panic();
198 assert_eq!(
199 payload.downcast_ref::<&str>().copied(),
200 Some("the detached task failed"),
201 "the original panic payload must survive the join",
202 );
203 }
204}