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 an error if the runtime is not multi-threaded or a runtime was already initialized. The
121/// error contains `runtime`.
122pub fn set_runtime(runtime: tokio::runtime::Runtime) -> Result<(), tokio::runtime::Runtime> {
123    if RUNTIME.get().is_some()
124        || !matches!(
125            runtime.handle().runtime_flavor(),
126            tokio::runtime::RuntimeFlavor::MultiThread
127        )
128    {
129        return Err(runtime);
130    }
131
132    RUNTIME
133        .set(NautilusRuntime {
134            runtime,
135            injected: true,
136        })
137        .map_err(|runtime| runtime.runtime)
138}
139
140/// Returns a reference to the global Nautilus Tokio runtime.
141///
142/// The runtime is lazily initialized on the first call and reused thereafter.
143/// If a custom runtime was previously installed via [`set_runtime`], that
144/// runtime is returned instead.
145pub fn get_runtime() -> &'static tokio::runtime::Runtime {
146    &RUNTIME.get_or_init(initialize_runtime).runtime
147}
148
149/// Runs `f` with `block_in_place` on a thread owned by the Nautilus runtime.
150///
151/// # Panics
152///
153/// Panics if called from a `LocalSet` driven on a Nautilus-owned thread, including any `LocalSet`
154/// driven against an injected Nautilus runtime, because Tokio does not permit `block_in_place`
155/// while polling local tasks.
156pub fn block_in_place_on_nautilus<F, R>(f: F) -> R
157where
158    F: FnOnce() -> R,
159{
160    let Ok(handle) = tokio::runtime::Handle::try_current() else {
161        return f();
162    };
163
164    if is_on_nautilus_runtime(&handle) {
165        tokio::task::block_in_place(f)
166    } else {
167        f()
168    }
169}
170
171/// Blocks on `future` using the global Nautilus runtime.
172///
173/// The future must not contain tasks, timers, or I/O resources already bound to an ambient
174/// runtime. Use [`block_on_nautilus_with`] when the operation can be constructed lazily.
175///
176/// # Panics
177///
178/// Panics if called from a current-thread runtime or a `LocalSet`. Moving a potentially non-`Send`
179/// future out of those contexts is not possible; use [`block_on_nautilus_with`] for operations
180/// whose future and output can cross a scoped thread boundary.
181pub fn block_on_nautilus<F>(future: F) -> F::Output
182where
183    F: Future,
184{
185    let Ok(handle) = tokio::runtime::Handle::try_current() else {
186        return get_runtime().block_on(future);
187    };
188
189    assert!(
190        matches!(
191            handle.runtime_flavor(),
192            tokio::runtime::RuntimeFlavor::MultiThread
193        ),
194        "block_on_nautilus cannot run inside a current-thread Tokio runtime; use block_on_nautilus_with"
195    );
196
197    tokio::task::block_in_place(|| get_runtime().block_on(future))
198}
199
200/// Constructs and blocks on a future using the global Nautilus runtime.
201///
202/// The factory runs after entering the Nautilus runtime so Tokio resources created by the
203/// operation bind to that runtime rather than an ambient caller runtime. Resources captured by
204/// the factory must not depend on the ambient runtime making progress.
205///
206/// Calls from a foreign Tokio runtime synchronously park the calling thread while the operation
207/// runs. Tokio does not expose whether a foreign runtime is polling a `LocalSet`, so this bridge
208/// cannot use `block_in_place` there without breaking supported `LocalSet` callers.
209///
210/// # Panics
211///
212/// Panics if called from a `LocalSet` driven on a Nautilus-owned thread. Tokio does not expose
213/// whether the current multi-thread runtime context is polling local tasks, so the bridge cannot
214/// both preserve scheduler progress with `block_in_place` and support that context. A `LocalSet`
215/// hosted by a foreign runtime, or driven from an external thread against the default Nautilus
216/// runtime, is supported. Same-runtime `LocalSet` calls are not supported with an injected runtime
217/// because its already-built runtime has no thread-ownership callbacks.
218pub fn block_on_nautilus_with<C, F>(create_future: C) -> F::Output
219where
220    C: FnOnce() -> F + Send,
221    F: Future,
222    F::Output: Send,
223{
224    let run = move || get_runtime().block_on(async move { create_future().await });
225    let Ok(handle) = tokio::runtime::Handle::try_current() else {
226        return run();
227    };
228
229    if is_on_nautilus_runtime(&handle) {
230        return tokio::task::block_in_place(run);
231    }
232
233    std::thread::scope(|scope| {
234        let task = scope.spawn(run);
235        match task.join() {
236            Ok(output) => output,
237            Err(payload) => std::panic::resume_unwind(payload),
238        }
239    })
240}
241
242fn is_on_nautilus_runtime(handle: &tokio::runtime::Handle) -> bool {
243    RUNTIME.get().is_some_and(|runtime| {
244        handle.id() == runtime.runtime.handle().id()
245            && (runtime.injected || NAUTILUS_RUNTIME_THREAD.get())
246    })
247}
248
249/// Provides a best-effort flush for runtime tasks during shutdown.
250///
251/// The function yields once to the Tokio scheduler and gives outstanding tasks a chance
252/// to observe shutdown signals before Python finalizes the interpreter, which calls this via
253/// an `atexit` hook.
254pub fn shutdown_runtime(wait: Duration) {
255    if let Some(runtime) = RUNTIME.get() {
256        runtime.runtime.block_on(async {
257            let _ = timeout(wait, async {
258                task::yield_now().await;
259            })
260            .await;
261        });
262    }
263}
264
265#[cfg(test)]
266#[expect(
267    clippy::disallowed_types,
268    reason = "tests exercise direct Tokio LocalSet interoperability"
269)]
270mod tests {
271    use std::process::Command;
272
273    use rstest::rstest;
274
275    use super::*;
276
277    const RUNTIME_CHILD_ENV: &str = "NAUTILUS_COMMON_RUNTIME_CHILD";
278
279    #[rstest]
280    fn test_custom_runtime_installation_and_rejection() {
281        const MARKER: &str = "custom-runtime-installation";
282        if !in_runtime_child(MARKER) {
283            run_runtime_child("test_custom_runtime_installation_and_rejection", MARKER);
284            return;
285        }
286
287        let runtime = Builder::new_multi_thread()
288            .worker_threads(1)
289            .enable_all()
290            .build()
291            .expect("custom runtime should build");
292        let installed_id = runtime.handle().id();
293
294        assert!(set_runtime(runtime).is_ok());
295        assert_eq!(get_runtime().handle().id(), installed_id);
296
297        let duplicate = Builder::new_multi_thread()
298            .worker_threads(1)
299            .enable_all()
300            .build()
301            .expect("duplicate runtime should build");
302        let duplicate_id = duplicate.handle().id();
303        assert_ne!(duplicate_id, installed_id);
304
305        let rejected = set_runtime(duplicate).expect_err("duplicate runtime should be rejected");
306        assert_eq!(rejected.handle().id(), duplicate_id);
307        assert_eq!(get_runtime().handle().id(), installed_id);
308    }
309
310    fn in_runtime_child(marker: &str) -> bool {
311        std::env::var(RUNTIME_CHILD_ENV).as_deref() == Ok(marker)
312    }
313
314    fn run_runtime_child(test_name: &str, marker: &str) {
315        let output = Command::new(std::env::current_exe().expect("test executable must exist"))
316            .arg(test_name)
317            .arg("--nocapture")
318            .arg("--test-threads=1")
319            .env(RUNTIME_CHILD_ENV, marker)
320            .output()
321            .expect("runtime child process must start");
322
323        assert!(
324            output.status.success(),
325            "runtime child failed with {}\nstdout:\n{}\nstderr:\n{}",
326            output.status,
327            String::from_utf8_lossy(&output.stdout),
328            String::from_utf8_lossy(&output.stderr),
329        );
330    }
331
332    #[rstest]
333    fn set_runtime_rejects_current_thread_runtime() {
334        const MARKER: &str = "reject-current-thread";
335        if std::env::var(RUNTIME_CHILD_ENV).as_deref() != Ok(MARKER) {
336            run_runtime_child("set_runtime_rejects_current_thread_runtime", MARKER);
337            return;
338        }
339        let runtime = tokio::runtime::Builder::new_current_thread()
340            .enable_all()
341            .build()
342            .unwrap();
343
344        let rejected = set_runtime(runtime).unwrap_err();
345
346        assert_eq!(
347            rejected.handle().runtime_flavor(),
348            tokio::runtime::RuntimeFlavor::CurrentThread
349        );
350    }
351
352    #[rstest]
353    fn injected_runtime_drives_bridge_future() {
354        const MARKER: &str = "injected-bridge";
355        if std::env::var(RUNTIME_CHILD_ENV).as_deref() != Ok(MARKER) {
356            run_runtime_child("injected_runtime_drives_bridge_future", MARKER);
357            return;
358        }
359        let runtime = tokio::runtime::Builder::new_multi_thread()
360            .worker_threads(2)
361            .enable_all()
362            .build()
363            .unwrap();
364        let expected_id = runtime.handle().id();
365        set_runtime(runtime).unwrap();
366
367        let actual_id = block_on_nautilus_with(|| async { tokio::runtime::Handle::current().id() });
368
369        assert_eq!(actual_id, expected_id);
370    }
371
372    #[rstest]
373    fn block_on_nautilus_with_works_without_current_runtime() {
374        let value = block_on_nautilus_with(|| async { 42 });
375
376        assert_eq!(value, 42);
377    }
378
379    #[rstest]
380    fn block_on_nautilus_with_works_inside_multi_thread_runtime() {
381        let runtime = tokio::runtime::Builder::new_multi_thread()
382            .worker_threads(2)
383            .enable_all()
384            .build()
385            .unwrap();
386        let value = runtime.block_on(async {
387            block_on_nautilus_with(|| async {
388                tokio::time::sleep(Duration::from_millis(1)).await;
389                42
390            })
391        });
392
393        assert_eq!(value, 42);
394    }
395
396    #[rstest]
397    fn block_on_nautilus_with_works_inside_current_thread_runtime() {
398        let runtime = tokio::runtime::Builder::new_current_thread()
399            .enable_all()
400            .build()
401            .unwrap();
402        let value = runtime.block_on(async {
403            block_on_nautilus_with(|| async {
404                tokio::time::sleep(Duration::from_millis(1)).await;
405                42
406            })
407        });
408
409        assert_eq!(value, 42);
410    }
411
412    #[rstest]
413    fn block_on_nautilus_with_works_inside_multi_thread_local_set() {
414        let runtime = tokio::runtime::Builder::new_multi_thread()
415            .worker_threads(2)
416            .enable_all()
417            .build()
418            .unwrap();
419        let local_set = tokio::task::LocalSet::new();
420        let value = runtime.block_on(local_set.run_until(async {
421            block_on_nautilus_with(|| async {
422                tokio::time::sleep(Duration::from_millis(1)).await;
423                42
424            })
425        }));
426
427        assert_eq!(value, 42);
428    }
429
430    #[rstest]
431    fn block_on_nautilus_works_inside_foreign_multi_thread_runtime() {
432        let runtime = tokio::runtime::Builder::new_multi_thread()
433            .worker_threads(2)
434            .enable_all()
435            .build()
436            .unwrap();
437        let value = runtime.block_on(async { block_on_nautilus(async { 42 }) });
438
439        assert_eq!(value, 42);
440    }
441
442    #[rstest]
443    #[should_panic(expected = "block_on_nautilus cannot run inside a current-thread Tokio runtime")]
444    fn block_on_nautilus_rejects_current_thread_runtime() {
445        let runtime = tokio::runtime::Builder::new_current_thread()
446            .enable_all()
447            .build()
448            .unwrap();
449
450        runtime.block_on(async { block_on_nautilus(async { 42 }) });
451    }
452
453    #[rstest]
454    fn block_on_nautilus_with_works_inside_nautilus_worker() {
455        let (caller_thread, factory_thread, value) = get_runtime().block_on(async {
456            get_runtime()
457                .spawn(async {
458                    let caller_thread = std::thread::current().id();
459                    let (factory_thread, value) = block_on_nautilus_with(|| async {
460                        let factory_thread = std::thread::current().id();
461                        tokio::time::sleep(Duration::from_millis(1)).await;
462                        (factory_thread, 42)
463                    });
464                    (caller_thread, factory_thread, value)
465                })
466                .await
467                .unwrap()
468        });
469
470        assert_eq!(factory_thread, caller_thread);
471        assert_eq!(value, 42);
472    }
473
474    #[rstest]
475    fn block_on_nautilus_with_works_inside_nautilus_blocking_thread() {
476        let value = get_runtime().block_on(async {
477            get_runtime()
478                .spawn_blocking(|| {
479                    block_on_nautilus_with(|| async {
480                        tokio::time::sleep(Duration::from_millis(1)).await;
481                        42
482                    })
483                })
484                .await
485                .unwrap()
486        });
487
488        assert_eq!(value, 42);
489    }
490
491    #[rstest]
492    fn block_in_place_on_nautilus_works_inside_nautilus_local_set() {
493        let local_set = tokio::task::LocalSet::new();
494        let value = get_runtime()
495            .block_on(local_set.run_until(async { block_in_place_on_nautilus(|| 42) }));
496
497        assert_eq!(value, 42);
498    }
499}