phantom_protocol/runtime/mod.rs
1//! Async runtime abstraction (Phase 3.1).
2//!
3//! `phantom_protocol` historically hard-coded `tokio` everywhere: every
4//! `tokio::spawn`, every `tokio::time::sleep`, every `tokio::sync::Mutex`,
5//! every `tokio::net::TcpStream`. That couples the library to a
6//! multi-threaded executor that does not exist on `wasm32-unknown-unknown`
7//! (single-threaded, JS event loop) or on bare-metal embedded targets
8//! (no executor at all without `embassy`/RTIC).
9//!
10//! This module introduces a small trait surface — `Runtime` — that the
11//! rest of the crate can use *in place of* direct tokio calls. The default
12//! implementation, [`TokioRuntime`], preserves today's behavior verbatim.
13//! Follow-up commits will gradually migrate call sites; this commit lands
14//! only the trait + the default impl + tests.
15//!
16//! ## What the trait covers
17//!
18//! - **Task spawning** — `spawn(boxed_future) -> SpawnHandle`. The handle
19//! exposes a non-blocking `abort()` so callers can cancel the task.
20//! - **Sleep** — `sleep(duration) -> BoxFuture<()>` for delay loops.
21//! - **Monotonic clock** — `now_monotonic() -> Instant` for RTT and
22//! expiry math.
23//! - **Wall-clock** — `now_wall_clock() -> SystemTime` for cookie buckets
24//! and timestamp-bound material.
25//!
26//! ## What the trait deliberately does NOT cover (yet)
27//!
28//! - **Channels** — `tokio::sync::mpsc` is portable enough across `tokio`
29//! and `tokio` derivatives that we keep using it directly.
30//! - **Mutexes** — see above.
31//! - **Network I/O** — this is the [`crate::transport::legs`] trait's job.
32//! `TcpStream` / `UdpSocket` are leg-impl details, not runtime-level.
33//!
34//! ## Implementations
35//!
36//! | Impl | Status | Target |
37//! | --- | --- | --- |
38//! | [`TokioRuntime`] | ✅ | Linux / macOS / Windows / iOS / Android servers and clients |
39//! | `WasmRuntime` | ✅ (`cfg(target_arch = "wasm32")`) | `wasm32-unknown-unknown` browsers via `wasm-bindgen-futures` |
40//! | `EmbeddedRuntime` | scaffold | `embassy` / bare metal |
41//!
42//! The non-tokio implementations are scaffolded under future feature
43//! flags; this commit only ships the trait and the tokio default so the
44//! call-site migration can begin against a stable abstraction.
45
46use std::future::Future;
47use std::pin::Pin;
48use std::time::{Duration, Instant, SystemTime};
49
50mod tokio_runtime;
51
52pub use tokio_runtime::TokioRuntime;
53
54#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
55pub mod wasm_runtime;
56
57#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
58pub use wasm_runtime::WasmRuntime;
59
60// Phase 3.1 scaffold — see `embedded_runtime.rs` for what this is and is not
61// good for. Gated on `embedded` + `std` for now; pure-no_std support is a
62// follow-up that also has to refactor the `Runtime` trait off
63// `std::time::{Instant, SystemTime}`.
64#[cfg(all(feature = "embedded", feature = "std"))]
65pub mod embedded_runtime;
66
67#[cfg(all(feature = "embedded", feature = "std"))]
68pub use embedded_runtime::EmbeddedRuntime;
69
70// Section B / B2 — WASI Preview 2 runtime. Available only when the
71// `wasi-leg` feature is enabled AND the build target is a WASI triple
72// (`wasm32-wasi*`) — `cfg(target_os = "wasi")` matches all of
73// `wasm32-wasi`, `wasm32-wasip1`, `wasm32-wasip2`. Host builds and
74// `wasm32-unknown-unknown` never see this module; the `compile_error!`
75// in `core/src/lib.rs` rejects `--features wasi-leg` on the browser
76// target.
77#[cfg(all(feature = "wasi-leg", target_os = "wasi"))]
78pub mod wasi_runtime;
79
80#[cfg(all(feature = "wasi-leg", target_os = "wasi"))]
81pub use wasi_runtime::WasiRuntime;
82
83/// Boxed, owned, `Send` future of unit output — the shape `Runtime::spawn`
84/// and `Runtime::sleep` work in.
85///
86/// `'static` lifetime because spawned futures outlive any borrow at the
87/// call site; `Send` because the default tokio impl is multi-threaded and
88/// the futures cross thread boundaries. On future WASM impls the Send
89/// bound is harmless — single-threaded executors accept Send futures.
90pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
91
92/// Async runtime abstraction.
93///
94/// Every method takes `&self` so a single runtime handle (typically wrapped
95/// in `Arc<dyn Runtime>`) can be shared across spawned tasks.
96pub trait Runtime: Send + Sync + 'static {
97 /// Spawn a future to run on the runtime. The returned [`SpawnHandle`]
98 /// can be `abort()`-ed to request cancellation; dropping the handle
99 /// detaches the task without cancelling it.
100 fn spawn(&self, fut: BoxFuture<()>) -> SpawnHandle;
101
102 /// Yield control for at least `duration`.
103 fn sleep(&self, duration: Duration) -> BoxFuture<()>;
104
105 /// Monotonic instant — strictly non-decreasing across calls on the
106 /// same runtime. Used for RTT measurement, retry timers, and any
107 /// duration arithmetic that must not be affected by wall-clock skew.
108 fn now_monotonic(&self) -> Instant;
109
110 /// Wall-clock time. May jump forward or backward as the system clock
111 /// is adjusted. Used for timestamp-bound material (cookie buckets,
112 /// PoW challenge expiry).
113 fn now_wall_clock(&self) -> SystemTime;
114}
115
116/// Opaque handle to a spawned task. Created by [`Runtime::spawn`].
117///
118/// Calling [`abort`](Self::abort) requests the runtime cancel the task at
119/// the next `.await` point. The cancellation is cooperative — a task that
120/// never yields will run to completion regardless.
121///
122/// Dropping a `SpawnHandle` without calling `abort` detaches the task: it
123/// continues running independently. This matches `tokio::task::JoinHandle`
124/// semantics.
125pub struct SpawnHandle {
126 inner: Box<dyn SpawnHandleInner>,
127}
128
129impl SpawnHandle {
130 /// Build a handle from any inner abort-capable implementation.
131 /// Used by runtime adapters (e.g. [`TokioRuntime`]) to wrap their
132 /// concrete `JoinHandle`-equivalent into the trait object.
133 pub fn from_inner<T: SpawnHandleInner>(inner: T) -> Self {
134 Self {
135 inner: Box::new(inner),
136 }
137 }
138
139 /// Request cancellation of the spawned task. Idempotent.
140 pub fn abort(&self) {
141 self.inner.abort();
142 }
143
144 /// Whether the task has finished (success or cancellation).
145 pub fn is_finished(&self) -> bool {
146 self.inner.is_finished()
147 }
148}
149
150impl std::fmt::Debug for SpawnHandle {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 f.debug_struct("SpawnHandle")
153 .field("is_finished", &self.is_finished())
154 .finish()
155 }
156}
157
158/// Implementation detail of [`SpawnHandle`]. Runtime adapters implement
159/// this on their concrete handle type.
160pub trait SpawnHandleInner: Send + 'static {
161 fn abort(&self);
162 fn is_finished(&self) -> bool;
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::sync::Arc;
169
170 /// Spawn → sleep → see the side effect.
171 #[tokio::test]
172 async fn tokio_runtime_spawn_and_sleep() {
173 let rt: Arc<dyn Runtime> = Arc::new(TokioRuntime);
174 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
175 let c = counter.clone();
176 let rt_for_task = rt.clone();
177 let handle = rt.spawn(Box::pin(async move {
178 rt_for_task.sleep(Duration::from_millis(10)).await;
179 c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
180 }));
181
182 // Wait long enough for the task to run.
183 rt.sleep(Duration::from_millis(100)).await;
184 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
185 assert!(handle.is_finished());
186 }
187
188 /// Aborting a long-running task must prevent its side effect.
189 #[tokio::test]
190 async fn tokio_runtime_abort_cancels_task() {
191 let rt: Arc<dyn Runtime> = Arc::new(TokioRuntime);
192 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
193 let c = counter.clone();
194 let rt_for_task = rt.clone();
195
196 let handle = rt.spawn(Box::pin(async move {
197 rt_for_task.sleep(Duration::from_secs(60)).await;
198 c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
199 }));
200
201 // Give the task a moment to actually start awaiting the sleep,
202 // then abort. The 60-second sleep will never elapse.
203 rt.sleep(Duration::from_millis(20)).await;
204 handle.abort();
205 rt.sleep(Duration::from_millis(20)).await;
206
207 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 0);
208 }
209
210 /// `now_monotonic` must be non-decreasing within a single thread.
211 #[test]
212 fn monotonic_clock_does_not_go_backwards() {
213 let rt = TokioRuntime;
214 let a = rt.now_monotonic();
215 // Spin for a few microseconds.
216 for _ in 0..1000 {
217 std::hint::black_box(a);
218 }
219 let b = rt.now_monotonic();
220 assert!(b >= a, "monotonic clock went backwards: {:?} → {:?}", a, b);
221 }
222
223 /// `now_wall_clock` returns a `SystemTime` that's at or after the
224 /// UNIX epoch (this is essentially "the host clock is sane").
225 #[test]
226 fn wall_clock_is_after_unix_epoch() {
227 let rt = TokioRuntime;
228 let now = rt.now_wall_clock();
229 assert!(now > SystemTime::UNIX_EPOCH);
230 }
231
232 /// Object-safety check — the trait must be usable as `dyn Runtime`.
233 #[test]
234 fn runtime_is_object_safe() {
235 fn assert_runtime_obj_safe(_: &dyn Runtime) {}
236 let rt = TokioRuntime;
237 assert_runtime_obj_safe(&rt);
238 }
239}