saluki_core/runtime/dedicated.rs
1//! Dedicated runtime support for supervisors.
2
3use std::{
4 future::Future,
5 io,
6 pin::Pin,
7 sync::{
8 atomic::{AtomicUsize, Ordering},
9 Arc,
10 },
11 task::{ready, Context, Poll},
12 thread::JoinHandle,
13};
14
15use saluki_common::sync::shutdown::ShutdownHandle;
16use saluki_error::{generic_error, GenericError};
17use tokio::sync::oneshot;
18
19use super::{
20 halt::HaltWatch,
21 state::DataspaceRegistry,
22 supervisor::{Supervisor, SupervisorError},
23};
24
25/// Configuration for a dedicated Tokio runtime.
26#[derive(Clone, Debug)]
27pub struct RuntimeConfiguration {
28 /// Number of worker threads.
29 worker_threads: usize,
30}
31
32impl RuntimeConfiguration {
33 /// Creates a new single-threaded `RuntimeConfiguration`.
34 ///
35 /// The underlying executor implementation is based on `tokio`'s "current thread" runtime.
36 pub const fn single_threaded() -> Self {
37 Self { worker_threads: 1 }
38 }
39
40 /// Creates a new multi-threaded `RuntimeConfiguration` with the given number of worker threads.
41 ///
42 /// The underlying executor implementation is based on `tokio`'s multi-threaded runtime.
43 pub const fn multi_threaded(worker_threads: usize) -> Self {
44 Self { worker_threads }
45 }
46
47 pub(crate) fn worker_threads(&self) -> usize {
48 self.worker_threads
49 }
50
51 /// Builds the Tokio runtime from this configuration.
52 pub(crate) fn build(&self, supervisor_id: &str) -> io::Result<tokio::runtime::Runtime> {
53 let supervisor_id = supervisor_id.to_string();
54 let thread_id = Arc::new(AtomicUsize::new(0));
55
56 if self.worker_threads == 1 {
57 tokio::runtime::Builder::new_current_thread().enable_all().build()
58 } else {
59 tokio::runtime::Builder::new_multi_thread()
60 .enable_all()
61 .enable_alt_timer()
62 .worker_threads(self.worker_threads)
63 .thread_name_fn(move || {
64 let new_thread_id = thread_id.fetch_add(1, Ordering::SeqCst);
65 format!("{}-sup-{:02}", supervisor_id, new_thread_id)
66 })
67 .build()
68 }
69 }
70}
71
72/// Controls which runtime a supervisor runs on.
73#[derive(Clone, Debug, Default)]
74pub enum RuntimeMode {
75 /// Run on the ambient runtime (default).
76 ///
77 /// The supervisor runs on whatever Tokio runtime is currently active when it's spawned.
78 #[default]
79 Ambient,
80
81 /// Run on a dedicated runtime with the given configuration.
82 ///
83 /// The supervisor spawns its own OS threads and Tokio runtime, providing runtime isolation from the parent
84 /// supervisor.
85 Dedicated(RuntimeConfiguration),
86}
87
88/// A handle to a supervisor running in a dedicated runtime.
89///
90/// Allows capturing any runtime initialization failures as well as the result of the supervisor's execution.
91///
92/// Dropping the handle before the supervisor finishes tears the supervisor down. An abort of the task that awaits the
93/// handle cannot cancel the supervisor itself, because the supervisor runs on a thread of its own. Without this, an
94/// aborted parent would leave the supervisor running, with nothing waiting for it.
95pub(crate) struct DedicatedRuntimeHandle {
96 supervisor_id: String,
97 init_rx: Option<oneshot::Receiver<Result<(), GenericError>>>,
98 result_rx: oneshot::Receiver<Result<(), SupervisorError>>,
99 thread_handle: Option<JoinHandle<()>>,
100
101 /// Tears the supervisor down when dropped. The runtime thread stops the supervisor as soon as this sender is gone.
102 _teardown: oneshot::Sender<()>,
103}
104
105impl Future for DedicatedRuntimeHandle {
106 type Output = Result<(), SupervisorError>;
107
108 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
109 // First, check if initialization is still pending.
110 //
111 // NOTE: This is runtime-level initialization (building the Tokio runtime and OS thread), not
112 // supervisor child initialization. These errors are always fatal and non-restartable.
113 if let Some(init_rx) = self.init_rx.as_mut() {
114 let init_result = ready!(Pin::new(init_rx).poll(cx));
115 let maybe_init_error = match init_result {
116 Ok(Ok(())) => None,
117 Ok(Err(e)) => Some(e),
118 Err(_) => Some(generic_error!(
119 "no initialization result received; runtime creation likely panicked"
120 )),
121 };
122
123 self.init_rx = None;
124
125 if let Some(error) = maybe_init_error {
126 // Join on the thread to clean up.
127 if let Some(handle) = self.thread_handle.take() {
128 let _ = handle.join();
129 }
130
131 return Poll::Ready(Err(SupervisorError::FailedToInitialize {
132 child_name: self.supervisor_id.clone(),
133 source: error.into(),
134 }));
135 }
136 }
137
138 // Check for a final result from the supervisor. The structured `SupervisorError` is preserved
139 // so the parent can distinguish init failures from runtime failures.
140 //
141 // If the channel is closed without a result, the runtime thread panicked or exited after
142 // successful init — this is a runtime failure, not an initialization failure.
143 let result = ready!(Pin::new(&mut self.result_rx).poll(cx)).unwrap_or_else(|_| Err(SupervisorError::Shutdown));
144
145 // Join on the thread to clean up.
146 if let Some(handle) = self.thread_handle.take() {
147 let _ = handle.join();
148 }
149
150 Poll::Ready(result)
151 }
152}
153
154/// Spawns a supervisor in a dedicated runtime on a new OS thread, returning a handle to await for the result.
155///
156/// The returned handle will resolve if initialization of the runtime failed, or after the supervisor completes,
157/// whether due to shutdown or failure.
158///
159/// A background OS thread is spawned to run the supervisor, named after the supervisor's ID: `<id>-sup-rt`. For
160/// multi-threaded runtimes, additional threads will be spawned following a similar naming convention:
161/// `<id>-sup-[0-9]+`.
162///
163/// # Errors
164///
165/// If the OS thread can't be spawned, an error is returned.
166pub(crate) fn spawn_dedicated_runtime(
167 mut supervisor: Supervisor, config: RuntimeConfiguration, process_shutdown: ShutdownHandle, halt: HaltWatch,
168 dataspace: DataspaceRegistry,
169) -> Result<DedicatedRuntimeHandle, GenericError> {
170 let (init_tx, init_rx) = oneshot::channel();
171 let (result_tx, result_rx) = oneshot::channel();
172 let (teardown_tx, teardown_rx) = oneshot::channel::<()>();
173
174 let supervisor_id = supervisor.id().to_string();
175 let thread_name = format!("{}-sup-rt", supervisor_id);
176 let thread_handle = std::thread::Builder::new()
177 .name(thread_name.clone())
178 .spawn(move || {
179 // Build the runtime.
180 let runtime = match config.build(supervisor.id()) {
181 Ok(rt) => rt,
182 Err(e) => {
183 let _ = init_tx.send(Err(generic_error!("Failed to build dedicated runtime: {}", e)));
184 return;
185 }
186 };
187
188 // Signal that initialization succeeded.
189 if init_tx.send(Ok(())).is_err() {
190 // Parent is no longer listening, bail out.
191 return;
192 }
193
194 // Run the supervisor to completion and send the result back.
195 //
196 // We pass the parent's dataspace so the nested supervisor inherits it across the
197 // thread boundary rather than creating a new one.
198 //
199 // If the handle is dropped first, nothing waits for the result anymore. Then we drop the supervisor
200 // immediately, which aborts its children, and dropping the runtime below cancels whatever is left on it.
201 let result = runtime.block_on(async {
202 tokio::select! {
203 result = supervisor.run_with_shutdown_inner(process_shutdown, halt, Some(dataspace)) => Some(result),
204 _ = teardown_rx => None,
205 }
206 });
207 if let Some(result) = result {
208 let _ = result_tx.send(result);
209 }
210 })
211 .map_err(|e| generic_error!("Failed to spawn dedicated runtime thread '{}': {}", thread_name, e))?;
212
213 Ok(DedicatedRuntimeHandle {
214 supervisor_id,
215 init_rx: Some(init_rx),
216 result_rx,
217 thread_handle: Some(thread_handle),
218 _teardown: teardown_tx,
219 })
220}