Skip to main content

nautilus_common/live/
runtime.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! The centralized Tokio runtime for a running Nautilus system.
17//!
18//! # Design Rationale
19//!
20//! NautilusTrader uses a single global Tokio runtime because:
21//! - A single long-lived runtime avoids repeated startup/shutdown overhead.
22//! - The runtime is lazily initialized on first call to `get_runtime()` via `OnceLock`.
23//! - Worker thread count is configurable via the `NAUTILUS_WORKER_THREADS` environment variable.
24//! - Rust-native hosts can install a pre-built runtime via [`set_runtime`] before first use.
25//!
26//! # Custom Runtime Injection
27//!
28//! Callers who use [`set_runtime`] must supply a multi-threaded runtime built with
29//! `tokio::runtime::Builder::new_multi_thread()` and `enable_all()`. Adapters assume I/O,
30//! timers, spawning, and `tokio::task::block_in_place()` are available.
31//!
32//! # Python Support
33//!
34//! When the `python` feature is enabled, the runtime initializes the Python interpreter
35//! before starting worker threads. The PyO3 module registers an `atexit` handler via
36//! `shutdown_runtime()` to cleanly shut down when Python exits.
37//!
38//! A runtime passed to [`set_runtime`] is already built, so this module cannot run the default
39//! Python initialization hook before its worker threads start. Hosts using custom runtimes with
40//! Python support must prepare Python before building the runtime.
41//!
42//! # Testing Considerations
43//!
44//! The global runtime pattern makes it harder to inject test doubles. For testing:
45//! - Unit tests can use `#[tokio::test]` which creates its own runtime.
46//! - Integration tests should be aware they share the global runtime state.
47
48use std::{cell::Cell, future::Future, sync::OnceLock, time::Duration};
49
50use tokio::{runtime::Builder, task, time::timeout};
51
52struct NautilusRuntime {
53    runtime: tokio::runtime::Runtime,
54    injected: bool,
55}
56
57static RUNTIME: OnceLock<NautilusRuntime> = OnceLock::new();
58
59thread_local! {
60    static NAUTILUS_RUNTIME_THREAD: Cell<bool> = const { Cell::new(false) };
61}
62
63/// Environment variable name to configure the number of OS threads for the common runtime.
64/// If not set or if the value cannot be parsed as a positive integer, Tokio's default is used.
65const NAUTILUS_WORKER_THREADS: &str = "NAUTILUS_WORKER_THREADS";
66
67/// Creates and configures a new multi-threaded Tokio runtime.
68///
69/// The number of OS threads is configured using the `NAUTILUS_WORKER_THREADS`
70/// environment variable. If not set, all available logical CPUs will be used.
71///
72/// # Panics
73///
74/// Panics if the runtime could not be created, which typically indicates
75/// an inability to spawn threads or allocate necessary resources.
76fn initialize_runtime() -> NautilusRuntime {
77    // Initialize Python if running as a Python extension module
78    #[cfg(feature = "python")]
79    {
80        crate::python::runtime::initialize_python();
81    }
82
83    let worker_threads = std::env::var(NAUTILUS_WORKER_THREADS)
84        .ok()
85        .and_then(|val| val.parse::<usize>().ok())
86        .unwrap_or_default();
87
88    let mut builder = Builder::new_multi_thread();
89
90    if worker_threads > 0 {
91        builder.worker_threads(worker_threads);
92    }
93
94    let runtime = builder
95        .on_thread_start(|| NAUTILUS_RUNTIME_THREAD.set(true))
96        .on_thread_stop(|| NAUTILUS_RUNTIME_THREAD.set(false))
97        .enable_all()
98        .build()
99        .expect("Failed to create tokio runtime");
100    NautilusRuntime {
101        runtime,
102        injected: false,
103    }
104}
105
106/// Sets a custom pre-built Tokio runtime as the global Nautilus runtime.
107///
108/// Must be called before the first [`get_runtime`] invocation (i.e. before
109/// `LiveNode::build()` or any adapter/client usage). This gives callers who
110/// own `main()` full control over worker threads, blocking threads, thread
111/// names, stack sizes, and any other [`tokio::runtime::Builder`] options.
112///
113/// # Runtime Requirements
114///
115/// The supplied runtime must be multi-threaded and have all Tokio drivers
116/// enabled with `tokio::runtime::Builder::enable_all()`.
117///
118/// # Errors
119///
120/// Returns `Err(runtime)` if the runtime is not multi-threaded or a runtime was already initialized.
121pub fn set_runtime(runtime: tokio::runtime::Runtime) -> Result<(), tokio::runtime::Runtime> {
122    if RUNTIME.get().is_some()
123        || !matches!(
124            runtime.handle().runtime_flavor(),
125            tokio::runtime::RuntimeFlavor::MultiThread
126        )
127    {
128        return Err(runtime);
129    }
130
131    RUNTIME
132        .set(NautilusRuntime {
133            runtime,
134            injected: true,
135        })
136        .map_err(|runtime| runtime.runtime)
137}
138
139/// Returns a reference to the global Nautilus Tokio runtime.
140///
141/// The runtime is lazily initialized on the first call and reused thereafter.
142/// If a custom runtime was previously installed via [`set_runtime`], that
143/// runtime is returned instead.
144pub fn get_runtime() -> &'static tokio::runtime::Runtime {
145    &RUNTIME.get_or_init(initialize_runtime).runtime
146}
147
148/// Runs `f` with `block_in_place` on a thread owned by the Nautilus runtime.
149///
150/// # Panics
151///
152/// Panics from a `LocalSet` driven on a Nautilus-owned thread, including any `LocalSet` driven
153/// against an injected Nautilus runtime, because Tokio does not permit `block_in_place` while
154/// polling local tasks.
155pub fn block_in_place_on_nautilus<F, R>(f: F) -> R
156where
157    F: FnOnce() -> R,
158{
159    let Ok(handle) = tokio::runtime::Handle::try_current() else {
160        return f();
161    };
162
163    if is_on_nautilus_runtime(&handle) {
164        tokio::task::block_in_place(f)
165    } else {
166        f()
167    }
168}
169
170/// Blocks on `future` using the global Nautilus runtime.
171///
172/// The future must not contain tasks, timers, or I/O resources already bound to an ambient
173/// runtime. Use [`block_on_nautilus_with`] when the operation can be constructed lazily.
174///
175/// # Panics
176///
177/// Panics when called from a current-thread runtime or a `LocalSet`. Moving a potentially
178/// non-`Send` future out of those contexts is not possible; use [`block_on_nautilus_with`] for
179/// operations whose future and output can cross a scoped thread boundary.
180pub fn block_on_nautilus<F>(future: F) -> F::Output
181where
182    F: Future,
183{
184    let Ok(handle) = tokio::runtime::Handle::try_current() else {
185        return get_runtime().block_on(future);
186    };
187
188    assert!(
189        matches!(
190            handle.runtime_flavor(),
191            tokio::runtime::RuntimeFlavor::MultiThread
192        ),
193        "block_on_nautilus cannot run inside a current-thread Tokio runtime; use block_on_nautilus_with"
194    );
195
196    tokio::task::block_in_place(|| get_runtime().block_on(future))
197}
198
199/// Constructs and blocks on a future using the global Nautilus runtime.
200///
201/// The factory runs after entering the Nautilus runtime so Tokio resources created by the
202/// operation bind to that runtime rather than an ambient caller runtime. Resources captured by
203/// the factory must not depend on the ambient runtime making progress.
204///
205/// Calls from a foreign Tokio runtime synchronously park the calling thread while the operation
206/// runs. Tokio does not expose whether a foreign runtime is polling a `LocalSet`, so this bridge
207/// cannot use `block_in_place` there without breaking supported `LocalSet` callers.
208///
209/// # Panics
210///
211/// Panics from a `LocalSet` driven on a Nautilus-owned thread. Tokio does not expose whether the
212/// current multi-thread runtime context is polling local tasks, so the bridge cannot both preserve
213/// scheduler progress with `block_in_place` and support that context. A `LocalSet` hosted by a
214/// foreign runtime, or driven from an external thread against the default Nautilus runtime, is
215/// supported. Same-runtime `LocalSet` calls are not supported with an injected runtime because its
216/// already-built runtime has no thread-ownership callbacks.
217pub fn block_on_nautilus_with<C, F>(create_future: C) -> F::Output
218where
219    C: FnOnce() -> F + Send,
220    F: Future,
221    F::Output: Send,
222{
223    let run = move || get_runtime().block_on(async move { create_future().await });
224    let Ok(handle) = tokio::runtime::Handle::try_current() else {
225        return run();
226    };
227
228    if is_on_nautilus_runtime(&handle) {
229        return tokio::task::block_in_place(run);
230    }
231
232    std::thread::scope(|scope| {
233        let task = scope.spawn(run);
234        match task.join() {
235            Ok(output) => output,
236            Err(payload) => std::panic::resume_unwind(payload),
237        }
238    })
239}
240
241fn is_on_nautilus_runtime(handle: &tokio::runtime::Handle) -> bool {
242    RUNTIME.get().is_some_and(|runtime| {
243        handle.id() == runtime.runtime.handle().id()
244            && (runtime.injected || NAUTILUS_RUNTIME_THREAD.get())
245    })
246}
247
248/// Provides a best-effort flush for runtime tasks during shutdown.
249///
250/// The function yields once to the Tokio scheduler and gives outstanding tasks a chance
251/// to observe shutdown signals before Python finalizes the interpreter, which calls this via
252/// an `atexit` hook.
253pub fn shutdown_runtime(wait: Duration) {
254    if let Some(runtime) = RUNTIME.get() {
255        runtime.runtime.block_on(async {
256            let _ = timeout(wait, async {
257                task::yield_now().await;
258            })
259            .await;
260        });
261    }
262}
263
264#[cfg(test)]
265#[expect(
266    clippy::disallowed_types,
267    reason = "tests exercise direct Tokio LocalSet interoperability"
268)]
269mod tests {
270    use std::process::Command;
271
272    use rstest::rstest;
273
274    use super::*;
275
276    const RUNTIME_CHILD_ENV: &str = "NAUTILUS_COMMON_RUNTIME_CHILD";
277
278    #[rstest]
279    fn test_custom_runtime_installation_and_rejection() {
280        const MARKER: &str = "custom-runtime-installation";
281        if !in_runtime_child(MARKER) {
282            run_runtime_child("test_custom_runtime_installation_and_rejection", MARKER);
283            return;
284        }
285
286        let runtime = Builder::new_multi_thread()
287            .worker_threads(1)
288            .enable_all()
289            .build()
290            .expect("custom runtime should build");
291        let installed_id = runtime.handle().id();
292
293        assert!(set_runtime(runtime).is_ok());
294        assert_eq!(get_runtime().handle().id(), installed_id);
295
296        let duplicate = Builder::new_multi_thread()
297            .worker_threads(1)
298            .enable_all()
299            .build()
300            .expect("duplicate runtime should build");
301        let duplicate_id = duplicate.handle().id();
302        assert_ne!(duplicate_id, installed_id);
303
304        let rejected = set_runtime(duplicate).expect_err("duplicate runtime should be rejected");
305        assert_eq!(rejected.handle().id(), duplicate_id);
306        assert_eq!(get_runtime().handle().id(), installed_id);
307    }
308
309    fn in_runtime_child(marker: &str) -> bool {
310        std::env::var(RUNTIME_CHILD_ENV).as_deref() == Ok(marker)
311    }
312
313    fn run_runtime_child(test_name: &str, marker: &str) {
314        let output = Command::new(std::env::current_exe().expect("test executable must exist"))
315            .arg(test_name)
316            .arg("--nocapture")
317            .arg("--test-threads=1")
318            .env(RUNTIME_CHILD_ENV, marker)
319            .output()
320            .expect("runtime child process must start");
321
322        assert!(
323            output.status.success(),
324            "runtime child failed with {}\nstdout:\n{}\nstderr:\n{}",
325            output.status,
326            String::from_utf8_lossy(&output.stdout),
327            String::from_utf8_lossy(&output.stderr),
328        );
329    }
330
331    #[rstest]
332    fn set_runtime_rejects_current_thread_runtime() {
333        const MARKER: &str = "reject-current-thread";
334        if std::env::var(RUNTIME_CHILD_ENV).as_deref() != Ok(MARKER) {
335            run_runtime_child("set_runtime_rejects_current_thread_runtime", MARKER);
336            return;
337        }
338        let runtime = tokio::runtime::Builder::new_current_thread()
339            .enable_all()
340            .build()
341            .unwrap();
342
343        let rejected = set_runtime(runtime).unwrap_err();
344
345        assert_eq!(
346            rejected.handle().runtime_flavor(),
347            tokio::runtime::RuntimeFlavor::CurrentThread
348        );
349    }
350
351    #[rstest]
352    fn injected_runtime_drives_bridge_future() {
353        const MARKER: &str = "injected-bridge";
354        if std::env::var(RUNTIME_CHILD_ENV).as_deref() != Ok(MARKER) {
355            run_runtime_child("injected_runtime_drives_bridge_future", MARKER);
356            return;
357        }
358        let runtime = tokio::runtime::Builder::new_multi_thread()
359            .worker_threads(2)
360            .enable_all()
361            .build()
362            .unwrap();
363        let expected_id = runtime.handle().id();
364        set_runtime(runtime).unwrap();
365
366        let actual_id = block_on_nautilus_with(|| async { tokio::runtime::Handle::current().id() });
367
368        assert_eq!(actual_id, expected_id);
369    }
370
371    #[rstest]
372    fn block_on_nautilus_with_works_without_current_runtime() {
373        let value = block_on_nautilus_with(|| async { 42 });
374
375        assert_eq!(value, 42);
376    }
377
378    #[rstest]
379    fn block_on_nautilus_with_works_inside_multi_thread_runtime() {
380        let runtime = tokio::runtime::Builder::new_multi_thread()
381            .worker_threads(2)
382            .enable_all()
383            .build()
384            .unwrap();
385        let value = runtime.block_on(async {
386            block_on_nautilus_with(|| async {
387                tokio::time::sleep(Duration::from_millis(1)).await;
388                42
389            })
390        });
391
392        assert_eq!(value, 42);
393    }
394
395    #[rstest]
396    fn block_on_nautilus_with_works_inside_current_thread_runtime() {
397        let runtime = tokio::runtime::Builder::new_current_thread()
398            .enable_all()
399            .build()
400            .unwrap();
401        let value = runtime.block_on(async {
402            block_on_nautilus_with(|| async {
403                tokio::time::sleep(Duration::from_millis(1)).await;
404                42
405            })
406        });
407
408        assert_eq!(value, 42);
409    }
410
411    #[rstest]
412    fn block_on_nautilus_with_works_inside_multi_thread_local_set() {
413        let runtime = tokio::runtime::Builder::new_multi_thread()
414            .worker_threads(2)
415            .enable_all()
416            .build()
417            .unwrap();
418        let local_set = tokio::task::LocalSet::new();
419        let value = runtime.block_on(local_set.run_until(async {
420            block_on_nautilus_with(|| async {
421                tokio::time::sleep(Duration::from_millis(1)).await;
422                42
423            })
424        }));
425
426        assert_eq!(value, 42);
427    }
428
429    #[rstest]
430    fn block_on_nautilus_works_inside_foreign_multi_thread_runtime() {
431        let runtime = tokio::runtime::Builder::new_multi_thread()
432            .worker_threads(2)
433            .enable_all()
434            .build()
435            .unwrap();
436        let value = runtime.block_on(async { block_on_nautilus(async { 42 }) });
437
438        assert_eq!(value, 42);
439    }
440
441    #[rstest]
442    #[should_panic(expected = "block_on_nautilus cannot run inside a current-thread Tokio runtime")]
443    fn block_on_nautilus_rejects_current_thread_runtime() {
444        let runtime = tokio::runtime::Builder::new_current_thread()
445            .enable_all()
446            .build()
447            .unwrap();
448
449        runtime.block_on(async { block_on_nautilus(async { 42 }) });
450    }
451
452    #[rstest]
453    fn block_on_nautilus_with_works_inside_nautilus_worker() {
454        let (caller_thread, factory_thread, value) = get_runtime().block_on(async {
455            get_runtime()
456                .spawn(async {
457                    let caller_thread = std::thread::current().id();
458                    let (factory_thread, value) = block_on_nautilus_with(|| async {
459                        let factory_thread = std::thread::current().id();
460                        tokio::time::sleep(Duration::from_millis(1)).await;
461                        (factory_thread, 42)
462                    });
463                    (caller_thread, factory_thread, value)
464                })
465                .await
466                .unwrap()
467        });
468
469        assert_eq!(factory_thread, caller_thread);
470        assert_eq!(value, 42);
471    }
472
473    #[rstest]
474    fn block_on_nautilus_with_works_inside_nautilus_blocking_thread() {
475        let value = get_runtime().block_on(async {
476            get_runtime()
477                .spawn_blocking(|| {
478                    block_on_nautilus_with(|| async {
479                        tokio::time::sleep(Duration::from_millis(1)).await;
480                        42
481                    })
482                })
483                .await
484                .unwrap()
485        });
486
487        assert_eq!(value, 42);
488    }
489
490    #[rstest]
491    fn block_in_place_on_nautilus_works_inside_nautilus_local_set() {
492        let local_set = tokio::task::LocalSet::new();
493        let value = get_runtime()
494            .block_on(local_set.run_until(async { block_in_place_on_nautilus(|| 42) }));
495
496        assert_eq!(value, 42);
497    }
498}