Skip to main content

async_runtime/
local.rs

1//! Host-driven, thread-affine local execution domains.
2
3use std::cell::RefCell;
4use std::future::Future;
5use std::marker::PhantomData;
6use std::num::NonZeroUsize;
7use std::panic::AssertUnwindSafe;
8use std::rc::Rc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Arc, Mutex, Weak};
11use std::time::{Duration, Instant};
12
13use async_channel::{Receiver, Sender};
14use async_executor::LocalExecutor;
15use futures_lite::future::{self, FutureExt};
16
17use crate::error::{ShutdownOutcome, SpawnError};
18use crate::lifecycle::{Lifecycle, CLOSED, RUNNING};
19use crate::task::{BridgeCompletionGuard, BridgeDriver, Completion, Task};
20
21/// A thread-affine executor driven explicitly by the thread that creates it.
22///
23/// The `Rc` marker deliberately makes this type `!Send + !Sync`. In particular,
24/// a `LocalExecutor` runnable is never sent through the cross-thread inbox.
25pub struct LocalDomain {
26    executor: LocalExecutor<'static>,
27    inbox: Receiver<InboxCommand>,
28    sender: Sender<InboxCommand>,
29    shared: Arc<Shared>,
30    _not_send_or_sync: PhantomData<Rc<()>>,
31}
32
33/// A capability for submitting `Send` work to a particular [`LocalDomain`].
34#[derive(Clone)]
35pub struct LocalSpawner {
36    sender: Sender<InboxCommand>,
37    shared: Weak<Shared>,
38}
39
40/// Statistics from a time-budgeted [`LocalDomain`] drive.
41///
42/// A drive step has the same scheduling semantics as [`LocalDomain::try_tick`]:
43/// it may materialize one remote inbox command and gives an already-materialized
44/// local runnable one opportunity to make progress. It is not a count of
45/// completed tasks or individual `Future::poll` calls.
46#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
47#[non_exhaustive]
48pub struct RunStats {
49    /// Number of non-blocking drive steps that made progress.
50    pub drive_steps: usize,
51    /// Wall-clock time spent in this drive call.
52    pub elapsed: Duration,
53    /// Number of remote inbox commands materialized by these drive steps.
54    pub inbox_commands: usize,
55}
56
57struct Shared {
58    lifecycle: Lifecycle,
59    /// Serializes the running check, accepted-task increment, and inbox submission.
60    gate: Mutex<()>,
61    accepted_tasks: AtomicUsize,
62}
63
64impl Shared {
65    fn complete_one(&self) {
66        let previous = self.accepted_tasks.fetch_sub(1, Ordering::AcqRel);
67        debug_assert!(previous > 0, "local accepted task count underflow");
68    }
69}
70
71struct AcceptedGuard(Option<Arc<Shared>>);
72
73type RunCommand = Box<dyn FnOnce(&LocalExecutor<'static>) + Send + 'static>;
74
75impl AcceptedGuard {
76    fn new(shared: Arc<Shared>) -> Self {
77        Self(Some(shared))
78    }
79}
80
81impl Drop for AcceptedGuard {
82    fn drop(&mut self) {
83        if let Some(shared) = self.0.take() {
84            shared.complete_one();
85        }
86    }
87}
88
89/// A command is `Send` by construction. It contains a remote `Send` future and
90/// bridge state only; it never contains a local runnable or `!Send` payload.
91struct InboxCommand {
92    run: Option<RunCommand>,
93    cancel: Option<Box<dyn FnOnce() + Send + 'static>>,
94}
95
96#[derive(Clone, Copy)]
97struct DriveProgress {
98    made_progress: bool,
99    inbox_commands: usize,
100}
101
102impl InboxCommand {
103    fn run(mut self, executor: &LocalExecutor<'static>, running: bool) {
104        if running {
105            if let Some(run) = self.run.take() {
106                run(executor);
107            }
108        } else if let Some(cancel) = self.cancel.take() {
109            cancel();
110        }
111    }
112
113    fn cancel(mut self) {
114        if let Some(cancel) = self.cancel.take() {
115            cancel();
116        }
117    }
118}
119
120impl LocalDomain {
121    /// Creates a new domain bound to the current host thread.
122    pub fn new() -> Self {
123        let (sender, inbox) = async_channel::unbounded();
124        Self {
125            executor: LocalExecutor::new(),
126            inbox,
127            sender,
128            shared: Arc::new(Shared {
129                lifecycle: Lifecycle::new(),
130                gate: Mutex::new(()),
131                accepted_tasks: AtomicUsize::new(0),
132            }),
133            _not_send_or_sync: PhantomData,
134        }
135    }
136
137    /// Returns a cross-thread capability that accepts `Send` futures only.
138    pub fn spawner(&self) -> LocalSpawner {
139        LocalSpawner {
140            sender: self.sender.clone(),
141            shared: Arc::downgrade(&self.shared),
142        }
143    }
144
145    /// Spawns a future that is allowed to borrow only this local thread's affinity.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`SpawnError::Closed`] after this domain begins shutting down.
150    ///
151    /// # Panics
152    ///
153    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
154    pub fn spawn_local<F, T>(&self, future: F) -> Result<Task<T>, SpawnError>
155    where
156        F: Future<Output = T> + 'static,
157        T: 'static,
158    {
159        let _gate = self
160            .shared
161            .gate
162            .lock()
163            .expect("local lifecycle mutex poisoned");
164        if self.shared.lifecycle.load() != RUNNING {
165            return Err(SpawnError::Closed);
166        }
167        self.shared.accepted_tasks.fetch_add(1, Ordering::AcqRel);
168        let guard = AcceptedGuard::new(Arc::clone(&self.shared));
169        let task = self.executor.spawn(async move {
170            let _guard = guard;
171            future.await
172        });
173        Ok(Task::direct(task))
174    }
175
176    /// Returns whether the domain currently has no accepted, queued, or runnable work.
177    pub fn is_empty(&self) -> bool {
178        self.shared.accepted_tasks.load(Ordering::Acquire) == 0
179            && self.inbox.is_empty()
180            && self.executor.is_empty()
181    }
182
183    /// Processes a pending remote command or one scheduled local runnable.
184    pub fn try_tick(&self) -> bool {
185        self.try_drive_step().made_progress
186    }
187
188    /// Performs at most `max_steps` non-blocking drive steps.
189    ///
190    /// Returns the number of steps that made progress. A step has the same
191    /// scheduling semantics as [`Self::try_tick`]; it is not a completed-task
192    /// count. Passing zero does not poll work.
193    pub fn run_n(&self, max_steps: usize) -> usize {
194        let mut drive_steps = 0;
195        while drive_steps < max_steps {
196            if !self.try_drive_step().made_progress {
197                break;
198            }
199            drive_steps += 1;
200        }
201        drive_steps
202    }
203
204    /// Drives ready work without blocking until the soft time budget expires.
205    ///
206    /// The clock is checked before every drive step. Rust futures cannot be
207    /// preempted during a single `poll`, so one slow poll may make `elapsed`
208    /// exceed `budget`. A zero budget does not poll work.
209    pub fn run_for(&self, budget: Duration) -> RunStats {
210        let started = Instant::now();
211        let mut stats = RunStats::default();
212        while started.elapsed() < budget {
213            let progress = self.try_drive_step();
214            if !progress.made_progress {
215                break;
216            }
217            stats.drive_steps += 1;
218            stats.inbox_commands += progress.inbox_commands;
219        }
220        stats.elapsed = started.elapsed();
221        stats
222    }
223
224    fn try_drive_step(&self) -> DriveProgress {
225        if let Ok(command) = self.inbox.try_recv() {
226            command.run(&self.executor, self.shared.lifecycle.load() != CLOSED);
227            // A continuously supplied inbox must not starve already-materialized
228            // local runnables. Give the executor one opportunity per command.
229            let _ = self.executor.try_tick();
230            DriveProgress {
231                made_progress: true,
232                inbox_commands: 1,
233            }
234        } else {
235            DriveProgress {
236                made_progress: self.executor.try_tick(),
237                inbox_commands: 0,
238            }
239        }
240    }
241
242    /// Waits until a remote command or local runnable can make progress.
243    pub async fn tick(&self) {
244        if self.try_tick() {
245            return;
246        }
247        future::race(async { self.executor.tick().await }, async {
248            if let Ok(command) = self.inbox.recv().await {
249                command.run(&self.executor, self.shared.lifecycle.load() != CLOSED);
250            }
251        })
252        .await;
253    }
254
255    /// Drives this domain until `future` completes.
256    pub async fn run<F: Future>(&self, future: F) -> F::Output {
257        future::race(future, async {
258            loop {
259                self.tick().await;
260            }
261        })
262        .await
263    }
264
265    /// Rejects new work and drives accepted tasks to completion.
266    pub async fn shutdown_graceful(mut self) {
267        self.begin_close();
268        while self.shared.accepted_tasks.load(Ordering::Acquire) != 0 {
269            self.tick().await;
270        }
271        self.shared.lifecycle.finish_close();
272        self.cancel_inbox();
273    }
274
275    /// Gracefully drains until `deadline` resolves, then cancels remaining work.
276    ///
277    /// The deadline future is supplied by the host, so this executor does not
278    /// require or drive a particular timer or I/O reactor.
279    pub async fn shutdown_until<D>(mut self, deadline: D) -> ShutdownOutcome
280    where
281        D: Future,
282    {
283        self.begin_close();
284        let drained = async {
285            while self.shared.accepted_tasks.load(Ordering::Acquire) != 0 {
286                self.tick().await;
287            }
288        };
289        let completed = future::race(
290            async {
291                drained.await;
292                true
293            },
294            async {
295                deadline.await;
296                false
297            },
298        )
299        .await;
300        if completed {
301            self.shared.lifecycle.finish_close();
302            self.cancel_inbox();
303            ShutdownOutcome::Completed
304        } else {
305            let remaining = self.shared.accepted_tasks.load(Ordering::Acquire);
306            self.shutdown_now_inner();
307            ShutdownOutcome::TimedOut {
308                remaining_tasks: remaining,
309            }
310        }
311    }
312
313    /// Rejects new work and drops the executor's remaining local tasks.
314    pub fn shutdown_now(mut self) {
315        self.shutdown_now_inner();
316    }
317
318    fn begin_close(&self) {
319        let _gate = self
320            .shared
321            .gate
322            .lock()
323            .expect("local lifecycle mutex poisoned");
324        self.shared.lifecycle.begin_close();
325    }
326
327    fn cancel_inbox(&mut self) {
328        while let Ok(command) = self.inbox.try_recv() {
329            command.cancel();
330        }
331    }
332
333    fn shutdown_now_inner(&mut self) {
334        self.begin_close();
335        self.cancel_inbox();
336        self.shared.lifecycle.finish_close();
337    }
338}
339
340impl Drop for LocalDomain {
341    fn drop(&mut self) {
342        self.shutdown_now_inner();
343    }
344}
345
346impl LocalSpawner {
347    /// Submits `Send` work for execution on the local domain's owner thread.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
352    /// begun shutting down.
353    ///
354    /// # Panics
355    ///
356    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
357    pub fn spawn<F, T>(&self, future: F) -> Result<Task<T>, SpawnError>
358    where
359        F: Future<Output = T> + Send + 'static,
360        T: Send + 'static,
361    {
362        let Some(shared) = self.shared.upgrade() else {
363            return Err(SpawnError::Closed);
364        };
365        let _gate = shared.gate.lock().expect("local lifecycle mutex poisoned");
366        if shared.lifecycle.load() != RUNNING {
367            return Err(SpawnError::Closed);
368        }
369
370        shared.accepted_tasks.fetch_add(1, Ordering::AcqRel);
371        let completed_shared = Arc::clone(&shared);
372        let (task, driver) = Task::bridge(move || completed_shared.complete_one());
373        let command = remote_command(future, driver);
374        match self.sender.try_send(command) {
375            Ok(()) => Ok(task),
376            Err(error) => {
377                error.into_inner().cancel();
378                Err(SpawnError::Closed)
379            }
380        }
381    }
382
383    /// Dispatches a callback for fire-and-forget execution on the owner thread.
384    ///
385    /// This avoids allocating a result-bearing [`Task`]. Panics are isolated so
386    /// they do not unwind through the owner drive loop; the process panic hook
387    /// still runs normally.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
392    /// begun shutting down.
393    ///
394    /// # Panics
395    ///
396    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
397    pub fn dispatch<F>(&self, callback: F) -> Result<(), SpawnError>
398    where
399        F: FnOnce() + Send + 'static,
400    {
401        self.dispatch_future(async move { callback() })
402    }
403
404    /// Dispatches a future for fire-and-forget execution on the owner thread.
405    ///
406    /// This avoids the result and cancellation bridge used by [`Self::spawn`].
407    /// Panics are isolated so they do not unwind through the owner drive loop;
408    /// the process panic hook still runs normally.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
413    /// begun shutting down.
414    ///
415    /// # Panics
416    ///
417    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
418    pub fn dispatch_future<F>(&self, future: F) -> Result<(), SpawnError>
419    where
420        F: Future<Output = ()> + Send + 'static,
421    {
422        let Some(shared) = self.shared.upgrade() else {
423            return Err(SpawnError::Closed);
424        };
425        let _gate = shared.gate.lock().expect("local lifecycle mutex poisoned");
426        if shared.lifecycle.load() != RUNNING {
427            return Err(SpawnError::Closed);
428        }
429
430        shared.accepted_tasks.fetch_add(1, Ordering::AcqRel);
431        let guard = AcceptedGuard::new(Arc::clone(&shared));
432        let command = dispatch_command(future, guard);
433        match self.sender.try_send(command) {
434            Ok(()) => Ok(()),
435            Err(error) => {
436                error.into_inner().cancel();
437                Err(SpawnError::Closed)
438            }
439        }
440    }
441}
442
443fn dispatch_command<F>(future: F, guard: AcceptedGuard) -> InboxCommand
444where
445    F: Future<Output = ()> + Send + 'static,
446{
447    InboxCommand {
448        run: Some(Box::new(move |executor| {
449            executor
450                .spawn(async move {
451                    let _guard = guard;
452                    let _ = AssertUnwindSafe(future).catch_unwind().await;
453                })
454                .detach();
455        })),
456        // Dropping the uncalled `run` closure drops the future and accepted
457        // guard, so fire-and-forget cancellation needs no separate bridge state.
458        cancel: None,
459    }
460}
461
462fn remote_command<F, T>(future: F, driver: BridgeDriver<T>) -> InboxCommand
463where
464    F: Future<Output = T> + Send + 'static,
465    T: Send + 'static,
466{
467    let state = Arc::new(Mutex::new(Some((future, driver))));
468    let run_state = Arc::clone(&state);
469    let cancel_state = Arc::clone(&state);
470    InboxCommand {
471        run: Some(Box::new(move |executor| {
472            let Some((future, driver)) = run_state
473                .lock()
474                .expect("remote command mutex poisoned")
475                .take()
476            else {
477                return;
478            };
479            if driver.is_cancel_requested() {
480                driver.complete(Completion::Cancelled);
481                return;
482            }
483            executor
484                .spawn(async move {
485                    let guard = BridgeCompletionGuard::new(driver.clone());
486                    let user = async move {
487                        match AssertUnwindSafe(future).catch_unwind().await {
488                            Ok(value) => Completion::Completed(value),
489                            Err(payload) => Completion::Panicked(payload),
490                        }
491                    };
492                    let cancelled = async move {
493                        driver.clone().cancelled().await;
494                        Completion::Cancelled
495                    };
496                    guard.finish(user.race(cancelled).await);
497                })
498                .detach();
499        })),
500        cancel: Some(Box::new(move || {
501            if let Some((_future, driver)) = cancel_state
502                .lock()
503                .expect("remote command mutex poisoned")
504                .take()
505            {
506                driver.complete(Completion::Cancelled);
507            }
508        })),
509    }
510}
511
512impl Default for LocalDomain {
513    fn default() -> Self {
514        Self::new()
515    }
516}
517
518// Keep this compile-time-only import local to document the intended auto traits.
519#[allow(dead_code)]
520fn _local_domain_is_not_send_or_sync(_: &RefCell<LocalDomain>, _: NonZeroUsize) {}