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}