Skip to main content

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