liminal/channel/supervisor.rs
1//! LIM-002 R4: channel supervision.
2//!
3//! [`ChannelSupervisor`] owns the beamr [`Scheduler`] every channel actor runs
4//! on, the bytecode module they share, and the per-scheduler [`ActorRuntime`]
5//! that maps actor pids to their cores. It spawns each channel actor as a
6//! `trap_exit` process and re-spawns one on demand if its pid is no longer live,
7//! so a crashed channel is restarted WITHOUT affecting any other channel (each
8//! channel is an independent process with its own pid and subscriber list). The
9//! restart strategy is configurable through [`ChannelRestartPolicy`].
10//!
11//! A process-global default supervisor (`shared_supervisor`) backs the
12//! infallible [`crate::channel::ChannelHandle::new`] constructor so existing
13//! call-sites keep working; tests and the registry can construct dedicated
14//! supervisors for isolation.
15
16use std::sync::atomic::{AtomicU32, Ordering};
17use std::sync::{Arc, OnceLock};
18
19use beamr::atom::{Atom, AtomTable};
20use beamr::distribution::{DistributionConfig, Resolver};
21use beamr::module::ModuleRegistry;
22use beamr::scheduler::{NativeBifs, Scheduler, SchedulerConfig};
23
24use crate::channel::actor::{
25 ActorRuntime, ChannelActorCore, Departures, actor_module, private_data,
26};
27use crate::channel::exit_watcher::ChannelExitWatcher;
28use crate::channel::observer::ClusterObserver;
29use crate::channel::schema::Schema;
30use crate::error::LiminalError;
31
32/// How a supervised channel actor is restarted after its process dies.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct ChannelRestartPolicy {
35 /// Maximum number of restarts permitted before the actor is left dead.
36 pub max_restarts: u32,
37 /// Whether a dead actor is restarted at all (one-for-one when `true`).
38 pub restart: bool,
39}
40
41impl ChannelRestartPolicy {
42 /// One-for-one restart with a bounded restart budget.
43 #[must_use]
44 pub const fn one_for_one(max_restarts: u32) -> Self {
45 Self {
46 max_restarts,
47 restart: true,
48 }
49 }
50
51 /// No automatic restart (the actor stays dead once it exits).
52 #[must_use]
53 pub const fn never() -> Self {
54 Self {
55 max_restarts: 0,
56 restart: false,
57 }
58 }
59}
60
61impl Default for ChannelRestartPolicy {
62 fn default() -> Self {
63 Self::one_for_one(8)
64 }
65}
66
67/// Number of scheduler threads channel actors share. One thread keeps every
68/// actor's mailbox processing serialized per-process while remaining cheap.
69const CHANNEL_SCHEDULER_THREADS: usize = 1;
70
71/// Supervises channel actor processes on a shared beamr scheduler.
72#[derive(Clone)]
73pub struct ChannelSupervisor {
74 inner: Arc<SupervisorInner>,
75}
76
77struct SupervisorInner {
78 scheduler: Arc<Scheduler>,
79 runtime: Arc<ActorRuntime>,
80 policy: ChannelRestartPolicy,
81 module_name: Atom,
82 entry_function: Atom,
83 /// The exit watcher every channel actor is monitored from (LIM-009): an
84 /// actor's exit is told to it as `DOWN`, and it retires that actor's queue.
85 watcher: u64,
86 /// Optional cluster observer installed once, after construction, by the
87 /// standalone server when clustering is configured (SRV-005). The library
88 /// itself never installs one — clustering is an out-of-library concern.
89 observer: Arc<OnceLock<Arc<dyn ClusterObserver>>>,
90}
91
92impl std::fmt::Debug for ChannelSupervisor {
93 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 formatter
95 .debug_struct("ChannelSupervisor")
96 .field("policy", &self.inner.policy)
97 .finish_non_exhaustive()
98 }
99}
100
101impl ChannelSupervisor {
102 /// Builds a supervisor with its own scheduler and the default restart policy.
103 ///
104 /// # Errors
105 /// Returns [`LiminalError::ConversationFailed`] when the scheduler cannot start.
106 pub fn new() -> Result<Self, LiminalError> {
107 Self::with_policy(ChannelRestartPolicy::default())
108 }
109
110 /// Builds a supervisor with an explicit restart policy on a non-clustered
111 /// scheduler.
112 ///
113 /// # Errors
114 /// Returns [`LiminalError::ConversationFailed`] when the scheduler cannot start.
115 pub fn with_policy(policy: ChannelRestartPolicy) -> Result<Self, LiminalError> {
116 Self::build(policy, None, None, None)
117 }
118
119 /// Builds a supervisor whose scheduler is distribution-enabled (SRV-005), so
120 /// every channel actor and every subscriber process this supervisor spawns
121 /// shares ONE clustered scheduler. This is the scheduler the cluster attaches
122 /// its process-group transport to: a subscriber pid joined to a channel's pg
123 /// group MUST live on the same scheduler that owns the distribution
124 /// connections, or cross-node delivery cannot reach it.
125 ///
126 /// `node_name`/`creation` form this node's distribution identity; `cookie`
127 /// and `resolver` are handed verbatim to the scheduler's
128 /// [`DistributionConfig`] (the resolver MUST be the same instance the cluster
129 /// uses to dial seeds, so handshake-established names resolve consistently).
130 ///
131 /// # Errors
132 /// Returns [`LiminalError::ConversationFailed`] when the scheduler cannot start.
133 pub fn with_distribution(
134 node_name: String,
135 creation: u32,
136 cookie: String,
137 resolver: Resolver,
138 policy: ChannelRestartPolicy,
139 ) -> Result<Self, LiminalError> {
140 let distribution = DistributionConfig { resolver, cookie };
141 Self::build(policy, Some(node_name), Some(creation), Some(distribution))
142 }
143
144 fn build(
145 policy: ChannelRestartPolicy,
146 node_name: Option<String>,
147 creation: Option<u32>,
148 distribution: Option<DistributionConfig>,
149 ) -> Result<Self, LiminalError> {
150 let atoms = AtomTable::with_common_atoms();
151 let module_name = atoms.intern("liminal_channel_actor");
152 let entry_function = atoms.intern("main");
153 let command_function = atoms.intern("process_command");
154 let command_atom = atoms.intern("liminal_channel_command");
155 let runtime = Arc::new(ActorRuntime::new(command_atom));
156 let registry = Arc::new(ModuleRegistry::new());
157 registry.insert(actor_module(module_name, entry_function, command_function));
158 let scheduler = Scheduler::new(
159 SchedulerConfig {
160 thread_count: Some(CHANNEL_SCHEDULER_THREADS),
161 nif_private_data: Some(private_data(Arc::clone(&runtime))),
162 node_name,
163 creation,
164 distribution,
165 ..SchedulerConfig::default()
166 },
167 registry,
168 // This scheduler runs ONLY native actors and loads no bytecode, so
169 // it resolves no native BIFs. beamr 0.19 removed the ability to
170 // inherit this answer (`NativeBifs` has no `Default` and no `From`),
171 // so it is written down here deliberately. The declaration is safe
172 // by construction, not by hope: the only module in `registry` is
173 // `actor_module` above, whose entire instruction set is Label /
174 // LoopRec / RemoveMessage / CallExt / CallOnly / Wait, with an empty
175 // `function_table` (channel/actor/beam.rs:141). `NativeBifs::none()`
176 // only bites at guard-BIF execution -- arithmetic, comparison or
177 // type guards -- and this module emits none of those, so
178 // `ExecError::GuardBifUnavailable` is unreachable here. The single
179 // `CallExt` resolves through module-level native resolution
180 // (`ResolvedImportTarget::Native`), which is a different mechanism
181 // from the BIF registry and is unaffected by this declaration.
182 NativeBifs::none(),
183 )
184 .map_err(|message| LiminalError::ConversationFailed { message })?;
185 let watched = Arc::downgrade(&runtime);
186 let exit_watcher = scheduler
187 .spawn_native(Box::new(move || {
188 Box::new(ChannelExitWatcher::new(watched.clone()))
189 as Box<dyn beamr::native::native_process::NativeHandler>
190 }))
191 .map_err(|error| LiminalError::ConversationFailed {
192 message: format!("failed to spawn the channel exit watcher: {error}"),
193 })?;
194 Ok(Self {
195 inner: Arc::new(SupervisorInner {
196 scheduler: Arc::new(scheduler),
197 runtime,
198 policy,
199 module_name,
200 entry_function,
201 watcher: exit_watcher,
202 observer: Arc::new(OnceLock::new()),
203 }),
204 })
205 }
206
207 /// The scheduler channel actors and their subscribers run on.
208 #[must_use]
209 pub fn scheduler(&self) -> Arc<Scheduler> {
210 Arc::clone(&self.inner.scheduler)
211 }
212
213 /// The test-only hold at the channel actors' command NIF entry.
214 #[cfg(test)]
215 pub(crate) fn entry_hold(&self) -> &crate::channel::actor::beam::EntryHold {
216 self.inner.runtime.entry_hold()
217 }
218
219 /// Test-only: a receiver told the pid of every channel actor retirement from now on.
220 #[cfg(test)]
221 pub(crate) fn retirements(&self) -> Result<std::sync::mpsc::Receiver<u64>, LiminalError> {
222 self.inner.runtime.retirements()
223 }
224
225 /// Test-only: the exit watcher's slices so far (LIM-009 R3).
226 #[cfg(test)]
227 pub(crate) fn watcher_wakes(&self) -> u64 {
228 self.inner.runtime.watcher_wakes()
229 }
230
231 /// Test-only: retires `pid` again through the one retirement path.
232 #[cfg(test)]
233 pub(crate) fn retire_again(
234 &self,
235 pid: u64,
236 core: &ChannelActorCore,
237 ) -> Result<(), LiminalError> {
238 self.inner.runtime.retire(pid, core)
239 }
240
241 /// Installs the cluster observer (SRV-005). Idempotent: the first install
242 /// wins and later attempts are ignored, so the observer can be wired exactly
243 /// once after the supervisor (and its scheduler) exist.
244 pub fn install_observer(&self, observer: Arc<dyn ClusterObserver>) {
245 if self.inner.observer.set(observer).is_err() {
246 tracing::debug!(
247 "a cluster observer is already installed; this later install is ignored"
248 );
249 }
250 }
251
252 /// The installed cluster observer, if any.
253 #[must_use]
254 pub(crate) fn observer(&self) -> Option<&Arc<dyn ClusterObserver>> {
255 self.inner.observer.get()
256 }
257
258 /// The configured restart policy.
259 #[must_use]
260 pub fn policy(&self) -> &ChannelRestartPolicy {
261 &self.inner.policy
262 }
263
264 /// Spawns a fresh channel actor for `schema` and returns its shared core.
265 ///
266 /// # Errors
267 /// Returns [`LiminalError`] when the actor process cannot be spawned.
268 pub(crate) fn spawn_channel(
269 &self,
270 name: &str,
271 schema: Schema,
272 ) -> Result<Arc<ChannelActorCore>, LiminalError> {
273 let core = Arc::new(ChannelActorCore::new(
274 self.scheduler(),
275 self.inner.runtime.command_atom(),
276 schema,
277 Departures {
278 channel: name.to_owned(),
279 observer: Arc::clone(&self.inner.observer),
280 },
281 ));
282 self.spawn_actor_for(&core)?;
283 Ok(core)
284 }
285
286 /// Ensures `core` has a live actor process, spawning (or restarting) one if
287 /// its current pid is dead. Honours the restart budget: once exhausted, a
288 /// dead actor is not restarted and a [`LiminalError::DeliveryFailed`] is
289 /// returned. This is the one-for-one restart that leaves other channels
290 /// untouched (each `core` is supervised independently).
291 ///
292 /// # Errors
293 /// Returns [`LiminalError`] when restart is disabled/exhausted or the spawn
294 /// fails.
295 pub(crate) fn ensure_running(
296 &self,
297 core: &Arc<ChannelActorCore>,
298 restarts: &AtomicU32,
299 ) -> Result<u64, LiminalError> {
300 // Fast path: a live pid needs no lock. The slow (respawn) path below is
301 // serialised so two concurrent callers that both see a dead pid cannot
302 // both spawn a replacement (the restart TOCTOU).
303 if let Some(pid) = self.live_pid(core)? {
304 return Ok(pid);
305 }
306 // Hold the per-channel restart lock across the dead-check and respawn so
307 // exactly one thread restarts; any racing caller re-reads the now-live
308 // pid below. The lock lives on `core` so each channel is supervised
309 // independently (mirrors `conversation/actor/core.rs`'s `restart_lock`).
310 let guard = core
311 .restart_lock()
312 .lock()
313 .map_err(|error| LiminalError::DeliveryFailed {
314 message: format!("channel actor restart lock poisoned: {error}"),
315 })?;
316 // Double-checked liveness AFTER acquiring the lock: the thread that won
317 // the race has already respawned, so we must not spawn a second actor.
318 if let Some(pid) = self.live_pid(core)? {
319 return Ok(pid);
320 }
321 if !self.inner.policy.restart {
322 return Err(LiminalError::DeliveryFailed {
323 message: "channel actor died and restart is disabled".to_owned(),
324 });
325 }
326 let used = restarts.fetch_add(1, Ordering::Relaxed);
327 if used >= self.inner.policy.max_restarts {
328 return Err(LiminalError::DeliveryFailed {
329 message: format!(
330 "channel actor restart budget ({}) exhausted",
331 self.inner.policy.max_restarts
332 ),
333 });
334 }
335 if let Some(pid) = core.current_pid()? {
336 self.inner.runtime.retire(pid, core)?;
337 }
338 let pid = self.spawn_actor_for(core)?;
339 drop(guard);
340 Ok(pid)
341 }
342
343 /// The current pid if it is still live in the scheduler's process table,
344 /// otherwise `None` (the actor needs spawning/restarting).
345 fn live_pid(&self, core: &Arc<ChannelActorCore>) -> Result<Option<u64>, LiminalError> {
346 if let Some(pid) = core.current_pid()? {
347 if self.inner.scheduler.process_table().get(pid).is_some() {
348 return Ok(Some(pid));
349 }
350 }
351 Ok(None)
352 }
353
354 fn spawn_actor_for(&self, core: &Arc<ChannelActorCore>) -> Result<u64, LiminalError> {
355 let pid = self
356 .inner
357 .scheduler
358 .spawn_trap_exit(
359 self.inner.module_name,
360 self.inner.entry_function,
361 Vec::new(),
362 )
363 .map_err(|error| LiminalError::ConversationFailed {
364 message: format!("failed to spawn channel actor: {error:?}"),
365 })?;
366 self.inner.runtime.register(pid, Arc::downgrade(core))?;
367 core.set_current_pid(pid)?;
368 // Watch the actor's exit before any command can wait on it (LIM-009).
369 // An actor already gone is told at once as DOWN when beamr kept its
370 // exit; one that left no trace cannot be monitored, and is retired here.
371 if self
372 .inner
373 .scheduler
374 .monitor(self.inner.watcher, pid)
375 .is_err()
376 {
377 self.inner.runtime.retire(pid, core)?;
378 }
379 // Re-link the new process to every surviving subscriber so subscriber
380 // death (EXIT) detection works after a restart — exactly as the
381 // conversation actor's `spawn_actor_for` calls `core.boot(...)`. On the
382 // very first spawn the subscriber list is empty, so this is a no-op.
383 core.boot()?;
384 Ok(pid)
385 }
386
387 /// Stops the underlying scheduler.
388 pub fn shutdown(&self) {
389 self.inner.scheduler.shutdown();
390 }
391}
392
393/// The process-global default channel supervisor, lazily started on first use.
394static SHARED: OnceLock<ChannelSupervisor> = OnceLock::new();
395
396/// Returns the shared default supervisor, starting it on first use.
397///
398/// # Errors
399/// Returns [`LiminalError`] when the shared scheduler cannot start.
400pub fn shared_supervisor() -> Result<ChannelSupervisor, LiminalError> {
401 if let Some(existing) = SHARED.get() {
402 return Ok(existing.clone());
403 }
404 let supervisor = ChannelSupervisor::new()?;
405 Ok(SHARED.get_or_init(|| supervisor).clone())
406}