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. A step may execute one
207    /// inbox command and then give one local runnable an opportunity to poll.
208    /// The budget check does not preempt either phase, so time spent in either
209    /// may cause `elapsed` to exceed `budget`. A zero budget starts no work.
210    pub fn run_for(&self, budget: Duration) -> RunStats {
211        let started = Instant::now();
212        let mut stats = RunStats::default();
213        while started.elapsed() < budget {
214            let progress = self.try_drive_step();
215            if !progress.made_progress {
216                break;
217            }
218            stats.drive_steps += 1;
219            stats.inbox_commands += progress.inbox_commands;
220        }
221        stats.elapsed = started.elapsed();
222        stats
223    }
224
225    fn try_drive_step(&self) -> DriveProgress {
226        if let Ok(command) = self.inbox.try_recv() {
227            command.run(&self.executor, self.shared.lifecycle.load() != CLOSED);
228            // A continuously supplied inbox must not starve already-materialized
229            // local runnables. Give the executor one opportunity per command.
230            let _ = self.executor.try_tick();
231            DriveProgress {
232                made_progress: true,
233                inbox_commands: 1,
234            }
235        } else {
236            DriveProgress {
237                made_progress: self.executor.try_tick(),
238                inbox_commands: 0,
239            }
240        }
241    }
242
243    /// Waits until a remote command or local runnable can make progress.
244    pub async fn tick(&self) {
245        if self.try_tick() {
246            return;
247        }
248        future::race(async { self.executor.tick().await }, async {
249            if let Ok(command) = self.inbox.recv().await {
250                command.run(&self.executor, self.shared.lifecycle.load() != CLOSED);
251            }
252        })
253        .await;
254    }
255
256    /// Drives this domain until `future` completes.
257    pub async fn run<F: Future>(&self, future: F) -> F::Output {
258        future::race(future, async {
259            loop {
260                self.tick().await;
261            }
262        })
263        .await
264    }
265
266    /// Rejects new work and drives accepted tasks to completion.
267    pub async fn shutdown_graceful(mut self) {
268        self.begin_close();
269        while self.shared.accepted_tasks.load(Ordering::Acquire) != 0 {
270            self.tick().await;
271        }
272        self.shared.lifecycle.finish_close();
273        self.cancel_inbox();
274    }
275
276    /// Gracefully drains until `deadline` resolves, then cancels remaining work.
277    ///
278    /// The deadline future is supplied by the host, so this executor does not
279    /// require or drive a particular timer or I/O reactor.
280    pub async fn shutdown_until<D>(mut self, deadline: D) -> ShutdownOutcome
281    where
282        D: Future,
283    {
284        self.begin_close();
285        let drained = async {
286            while self.shared.accepted_tasks.load(Ordering::Acquire) != 0 {
287                self.tick().await;
288            }
289        };
290        let completed = future::race(
291            async {
292                drained.await;
293                true
294            },
295            async {
296                deadline.await;
297                false
298            },
299        )
300        .await;
301        if completed {
302            self.shared.lifecycle.finish_close();
303            self.cancel_inbox();
304            ShutdownOutcome::Completed
305        } else {
306            let remaining = self.shared.accepted_tasks.load(Ordering::Acquire);
307            self.shutdown_now_inner();
308            ShutdownOutcome::TimedOut {
309                remaining_tasks: remaining,
310            }
311        }
312    }
313
314    /// Rejects new work and drops the executor's remaining local tasks.
315    pub fn shutdown_now(mut self) {
316        self.shutdown_now_inner();
317    }
318
319    fn begin_close(&self) {
320        let _gate = self
321            .shared
322            .gate
323            .lock()
324            .expect("local lifecycle mutex poisoned");
325        self.shared.lifecycle.begin_close();
326    }
327
328    fn cancel_inbox(&mut self) {
329        while let Ok(command) = self.inbox.try_recv() {
330            command.cancel();
331        }
332    }
333
334    fn shutdown_now_inner(&mut self) {
335        self.begin_close();
336        self.cancel_inbox();
337        self.shared.lifecycle.finish_close();
338    }
339}
340
341impl Drop for LocalDomain {
342    fn drop(&mut self) {
343        self.shutdown_now_inner();
344    }
345}
346
347impl LocalSpawner {
348    /// Submits `Send` work for execution on the local domain's owner thread.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
353    /// begun shutting down.
354    ///
355    /// # Panics
356    ///
357    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
358    pub fn spawn<F, T>(&self, future: F) -> Result<Task<T>, SpawnError>
359    where
360        F: Future<Output = T> + Send + 'static,
361        T: Send + 'static,
362    {
363        let Some(shared) = self.shared.upgrade() else {
364            return Err(SpawnError::Closed);
365        };
366        let _gate = shared.gate.lock().expect("local lifecycle mutex poisoned");
367        if shared.lifecycle.load() != RUNNING {
368            return Err(SpawnError::Closed);
369        }
370
371        shared.accepted_tasks.fetch_add(1, Ordering::AcqRel);
372        let completed_shared = Arc::clone(&shared);
373        let (task, driver) = Task::bridge(move || completed_shared.complete_one());
374        let command = remote_command(future, driver);
375        match self.sender.try_send(command) {
376            Ok(()) => Ok(task),
377            Err(error) => {
378                error.into_inner().cancel();
379                Err(SpawnError::Closed)
380            }
381        }
382    }
383
384    /// Dispatches a callback for fire-and-forget execution on the owner thread.
385    ///
386    /// This avoids allocating a result-bearing [`Task`]. Panics are isolated so
387    /// they do not unwind through the owner drive loop; the process panic hook
388    /// still runs normally.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
393    /// begun shutting down.
394    ///
395    /// # Panics
396    ///
397    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
398    pub fn dispatch<F>(&self, callback: F) -> Result<(), SpawnError>
399    where
400        F: FnOnce() + Send + 'static,
401    {
402        self.dispatch_future(async move { callback() })
403    }
404
405    /// Dispatches a future for fire-and-forget execution on the owner thread.
406    ///
407    /// This avoids the result and cancellation bridge used by [`Self::spawn`].
408    /// Panics are isolated so they do not unwind through the owner drive loop;
409    /// the process panic hook still runs normally.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`SpawnError::Closed`] if the domain no longer exists or has
414    /// begun shutting down.
415    ///
416    /// # Panics
417    ///
418    /// Panics if the internal lifecycle mutex was poisoned by an earlier panic.
419    pub fn dispatch_future<F>(&self, future: F) -> Result<(), SpawnError>
420    where
421        F: Future<Output = ()> + Send + 'static,
422    {
423        let Some(shared) = self.shared.upgrade() else {
424            return Err(SpawnError::Closed);
425        };
426        let _gate = shared.gate.lock().expect("local lifecycle mutex poisoned");
427        if shared.lifecycle.load() != RUNNING {
428            return Err(SpawnError::Closed);
429        }
430
431        shared.accepted_tasks.fetch_add(1, Ordering::AcqRel);
432        let guard = AcceptedGuard::new(Arc::clone(&shared));
433        let command = dispatch_command(future, guard);
434        match self.sender.try_send(command) {
435            Ok(()) => Ok(()),
436            Err(error) => {
437                error.into_inner().cancel();
438                Err(SpawnError::Closed)
439            }
440        }
441    }
442}
443
444fn dispatch_command<F>(future: F, guard: AcceptedGuard) -> InboxCommand
445where
446    F: Future<Output = ()> + Send + 'static,
447{
448    InboxCommand {
449        run: Some(Box::new(move |executor| {
450            executor
451                .spawn(async move {
452                    let _guard = guard;
453                    let _ = AssertUnwindSafe(future).catch_unwind().await;
454                })
455                .detach();
456        })),
457        // Dropping the uncalled `run` closure drops the future and accepted
458        // guard, so fire-and-forget cancellation needs no separate bridge state.
459        cancel: None,
460    }
461}
462
463fn remote_command<F, T>(future: F, driver: BridgeDriver<T>) -> InboxCommand
464where
465    F: Future<Output = T> + Send + 'static,
466    T: Send + 'static,
467{
468    let state = Arc::new(Mutex::new(Some((future, driver))));
469    let run_state = Arc::clone(&state);
470    let cancel_state = Arc::clone(&state);
471    InboxCommand {
472        run: Some(Box::new(move |executor| {
473            let Some((future, driver)) = run_state
474                .lock()
475                .expect("remote command mutex poisoned")
476                .take()
477            else {
478                return;
479            };
480            if driver.is_cancel_requested() {
481                driver.complete(Completion::Cancelled);
482                return;
483            }
484            executor
485                .spawn(async move {
486                    let guard = BridgeCompletionGuard::new(driver.clone());
487                    let user = async move {
488                        match AssertUnwindSafe(future).catch_unwind().await {
489                            Ok(value) => Completion::Completed(value),
490                            Err(payload) => Completion::Panicked(payload),
491                        }
492                    };
493                    let cancelled = async move {
494                        driver.clone().cancelled().await;
495                        Completion::Cancelled
496                    };
497                    guard.finish(user.race(cancelled).await);
498                })
499                .detach();
500        })),
501        cancel: Some(Box::new(move || {
502            if let Some((_future, driver)) = cancel_state
503                .lock()
504                .expect("remote command mutex poisoned")
505                .take()
506            {
507                driver.complete(Completion::Cancelled);
508            }
509        })),
510    }
511}
512
513impl Default for LocalDomain {
514    fn default() -> Self {
515        Self::new()
516    }
517}
518
519// Keep this compile-time-only import local to document the intended auto traits.
520#[allow(dead_code)]
521fn _local_domain_is_not_send_or_sync(_: &RefCell<LocalDomain>, _: NonZeroUsize) {}