Skip to main content

lattice_runtime/
runtime.rs

1//! Tokio runtime singleton shared across the process.
2//!
3//! Exactly one multi-threaded `tokio::runtime::Runtime` is created
4//! lazily on first call to [`shared_runtime`]. All document actors
5//! spawn onto it; sync callers (the TUI input loop, tests) bridge
6//! into async via [`block_on`] which forwards to
7//! [`tokio::runtime::Handle::block_on`].
8//!
9//! Why a singleton:
10//!
11//! - The runtime owns its own threadpool. Spawning per-test or
12//!   per-App runtimes is wasteful and serialises tests poorly.
13//! - All actor-bound code paths share the same scheduler; cross-
14//!   actor interactions (post-Phase-7 plugin host invoking the
15//!   document actor) work without extra plumbing.
16//! - Dropping a `Runtime` blocks until its tasks finish; a global
17//!   one stays alive for the process lifetime.
18//!
19//! Why isolation across tests still works: each
20//! [`crate::spawn_document`] call creates its own actor task with
21//! its own mailbox. Tests don't share state at the actor level even
22//! though they share the runtime.
23
24use std::sync::OnceLock;
25
26use tokio::runtime::{Builder, Handle, Runtime};
27
28static SHARED: OnceLock<Runtime> = OnceLock::new();
29
30// Phase 5.5.LSP.1: a second multi-threaded runtime dedicated to
31// LSP supervisor + per-server actors + read/write loops +
32// diagnostic pumps. Kept separate from `SHARED` so a slow LSP
33// server can't starve the document-actor scheduling band. The
34// helper used to live in `lattice_ui_tui::runtime` -- moving it
35// here lets `lattice_host` host-side dispatchers spawn LSP
36// requests without a back-edge through the renderer crate.
37// Consolidating onto a single shared runtime is a deliberate
38// post-1.0 decision; for now the two-runtime topology is
39// preserved verbatim.
40static LSP_RUNTIME: OnceLock<Runtime> = OnceLock::new();
41
42/// Get (or lazily build) the shared runtime's `Handle`. Cheap to
43/// call repeatedly; the underlying `Runtime` is built once and
44/// stays alive for the process lifetime.
45///
46/// The runtime is multi-threaded with the default worker count
47/// (`num_cpus::get()` per tokio's defaults). Two workers would be
48/// enough for v1, but matching tokio's default keeps behaviour
49/// predictable when LSP / plugin tasks land in Phase 4 / 7.
50pub fn shared_runtime() -> &'static Handle {
51    SHARED
52        .get_or_init(|| {
53            Builder::new_multi_thread()
54                .enable_all()
55                .thread_name("lattice-runtime")
56                .build()
57                .expect("tokio runtime build failed")
58        })
59        .handle()
60}
61
62/// Fire-and-forget spawn onto the shared runtime. The future
63/// runs on a tokio worker thread; the returned `JoinHandle` is
64/// detached (the caller doesn't await). Used by the mode
65/// dispatcher (M-async.2): activation validation runs
66/// synchronously on the App thread, then the lifecycle future
67/// is `spawn_task`'d so the App thread doesn't block on the
68/// future's `.await` points.
69pub fn spawn_task<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
70where
71    F: std::future::Future + Send + 'static,
72    F::Output: Send + 'static,
73{
74    shared_runtime().spawn(fut)
75}
76
77/// Phase 5.5.LSP.1: shared LSP runtime accessor. Multi-threaded,
78/// thread-name `lattice-lsp`. Owns the supervisor actor + per-
79/// server actors + read/write loops + diagnostic pumps + the
80/// debounced flush task. Survives for the editor's lifetime.
81///
82/// Used by `App::new` to hand the runtime's handle to
83/// `LspSupervisor::spawn` (the supervisor's command-mailbox
84/// semantics require an explicit runtime affinity) and by every
85/// per-feature dispatcher (hover, definition, references, ...)
86/// that needs to fire a request *off* the UI thread.
87pub fn lsp_runtime() -> &'static Runtime {
88    LSP_RUNTIME.get_or_init(|| {
89        Builder::new_multi_thread()
90            .enable_all()
91            .thread_name("lattice-lsp")
92            .build()
93            .expect("LSP tokio runtime should build")
94    })
95}
96
97/// Phase 5.5.LSP.1: spawn a fire-and-forget future on the shared
98/// LSP runtime. Used by the App's + host's per-feature LSP
99/// dispatchers so the request awaits the actor's response *off*
100/// the main UI thread; the result flows back through a per-
101/// feature mpsc channel that the App drains before each draw.
102///
103/// Returning a `JoinHandle` lets the caller cancel by dropping
104/// it -- though for LSP cooperative cancellation runs through
105/// the `CancellationToken` plumbed into the typed wrappers, so
106/// the handle is mostly informational.
107pub fn spawn_on_lsp_runtime<F>(future: F) -> tokio::task::JoinHandle<F::Output>
108where
109    F: std::future::Future + Send + 'static,
110    F::Output: Send + 'static,
111{
112    lsp_runtime().spawn(future)
113}
114
115/// IN.8b: the blocking sibling of [`spawn_on_lsp_runtime`], for work
116/// that is genuinely synchronous rather than merely off-thread —
117/// spawning a formatter and waiting on its pipes.
118///
119/// `spawn_blocking` rather than `spawn` because a `Command` +
120/// `wait_with_output` would occupy a runtime worker for the whole run;
121/// on a `current_thread` runtime that is the actor thread, which is the
122/// no-UI-thread-work rule violated by a different route.
123pub fn spawn_blocking_on_lsp_runtime<F, R>(f: F) -> tokio::task::JoinHandle<R>
124where
125    F: FnOnce() -> R + Send + 'static,
126    R: Send + 'static,
127{
128    lsp_runtime().spawn_blocking(f)
129}
130
131/// Sync-to-async bridge. Forwards to the shared multi-thread
132/// runtime's `block_on`. Used by the TUI input loop and by App
133/// methods that need to wait on a [`crate::Pending`] from outside
134/// an async context.
135///
136/// **Three execution contexts** to handle:
137///
138/// 1. **Non-tokio caller** (sync `main`, sync test): no current
139///    handle, fall through to `target.block_on(fut)` directly.
140/// 2. **Multi-thread tokio caller** (e.g. spawned task on the
141///    shared LSP runtime): relinquish the worker via
142///    [`tokio::task::block_in_place`] so other tasks keep running
143///    while we block on `target`.
144/// 3. **Non-multi-thread tokio caller** (e.g. the editor actor's
145///    dedicated `current_thread` runtime per slice
146///    `3c.final.E.swap`): `block_in_place` panics here because
147///    the current runtime isn't `MultiThread`; we instead escape
148///    to a fresh OS thread via [`std::thread::scope`] and drive
149///    the future on `target` from outside any tokio context.
150///
151/// The third case is the fix for slice `3c.fixup.actor-block-on`:
152/// before this, `block_on` calls from inside the editor actor's
153/// runtime (file save, `document.dispatch_with_cancel`, LSP
154/// completion-resolve, code-action apply, synthetic-buffer seed)
155/// panicked with "can call blocking only when running on the
156/// multi-threaded runtime" — caught only by `cargo bench`
157/// (release builds), not by `cargo test` (which preserves direct
158/// `App.editor: Editor` via the `cfg(test)` escape hatch and
159/// thus never spawns the actor).
160///
161/// **Send bound**: `F: Send` + `F::Output: Send` are required by
162/// `std::thread::scope` in the third case. Every existing caller
163/// satisfies these (Arc-backed handles, owned move-captures); if
164/// a future caller doesn't, the type system catches it at the
165/// call site.
166pub fn block_on<F>(fut: F) -> F::Output
167where
168    F: std::future::Future + Send,
169    F::Output: Send,
170{
171    let target = shared_runtime();
172    match Handle::try_current() {
173        Ok(handle)
174            if matches!(
175                handle.runtime_flavor(),
176                tokio::runtime::RuntimeFlavor::MultiThread
177            ) =>
178        {
179            // Already inside a multi-thread runtime -- relinquish
180            // the worker before driving `fut` on `target`.
181            tokio::task::block_in_place(|| target.block_on(fut))
182        }
183        Ok(_) => {
184            // Inside a non-multi-thread runtime (e.g. the editor
185            // actor's `current_thread`). `block_in_place` would
186            // panic; re-entering `target.block_on` from inside
187            // the current runtime would also panic. Escape to a
188            // fresh OS thread (no tokio context) so `target.block_on`
189            // runs cleanly. `std::thread::scope` lets us borrow
190            // non-`'static` data from the future without copying.
191            std::thread::scope(|s| {
192                s.spawn(|| target.block_on(fut))
193                    .join()
194                    .expect("nested-block_on bridge thread completed")
195            })
196        }
197        Err(_) => target.block_on(fut),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    #![allow(clippy::unwrap_used)]
204    use super::*;
205
206    #[test]
207    fn block_on_runs_a_future_and_returns_its_value() {
208        let value = block_on(async { 1 + 2 });
209        assert_eq!(value, 3);
210    }
211
212    #[test]
213    fn shared_runtime_is_idempotent() {
214        let h1 = shared_runtime();
215        let h2 = shared_runtime();
216        // Both handles refer to the same runtime; their `Handle::id`
217        // representations are equal.
218        assert_eq!(format!("{h1:?}"), format!("{h2:?}"));
219    }
220}