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, SchedulerServices};
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        // Channel actors are native and make no dirty call, so the scheduler
159        // runs its workers and no ancillary service (no dirty pool, no IO
160        // ring); distribution only when the cluster asks for it.
161        let services = distribution.map_or_else(SchedulerServices::minimal, |distribution| {
162            SchedulerServices::minimal().owned_distribution(distribution)
163        });
164        let scheduler = Scheduler::with_services(
165            SchedulerConfig {
166                thread_count: Some(CHANNEL_SCHEDULER_THREADS),
167                nif_private_data: Some(private_data(Arc::clone(&runtime))),
168                node_name,
169                creation,
170                ..SchedulerConfig::default()
171            },
172            services,
173            registry,
174            // This scheduler runs ONLY native actors and loads no bytecode, so
175            // it resolves no native BIFs. beamr 0.19 removed the ability to
176            // inherit this answer (`NativeBifs` has no `Default` and no `From`),
177            // so it is written down here deliberately. The declaration is safe
178            // by construction, not by hope: the only module in `registry` is
179            // `actor_module` above, whose entire instruction set is Label /
180            // LoopRec / RemoveMessage / CallExt / CallOnly / Wait, with an empty
181            // `function_table` (channel/actor/beam.rs:141). `NativeBifs::none()`
182            // only bites at guard-BIF execution -- arithmetic, comparison or
183            // type guards -- and this module emits none of those, so
184            // `ExecError::GuardBifUnavailable` is unreachable here. The single
185            // `CallExt` resolves through module-level native resolution
186            // (`ResolvedImportTarget::Native`), which is a different mechanism
187            // from the BIF registry and is unaffected by this declaration.
188            NativeBifs::none(),
189        )
190        .map_err(|message| LiminalError::ConversationFailed { message })?;
191        let watched = Arc::downgrade(&runtime);
192        let exit_watcher = scheduler
193            .spawn_native(Box::new(move || {
194                Box::new(ChannelExitWatcher::new(watched.clone()))
195                    as Box<dyn beamr::native::native_process::NativeHandler>
196            }))
197            .map_err(|error| LiminalError::ConversationFailed {
198                message: format!("failed to spawn the channel exit watcher: {error}"),
199            })?;
200        Ok(Self {
201            inner: Arc::new(SupervisorInner {
202                scheduler: Arc::new(scheduler),
203                runtime,
204                policy,
205                module_name,
206                entry_function,
207                watcher: exit_watcher,
208                observer: Arc::new(OnceLock::new()),
209            }),
210        })
211    }
212
213    /// The scheduler channel actors and their subscribers run on.
214    #[must_use]
215    pub fn scheduler(&self) -> Arc<Scheduler> {
216        Arc::clone(&self.inner.scheduler)
217    }
218
219    /// The test-only hold at the channel actors' command NIF entry.
220    #[cfg(test)]
221    pub(crate) fn entry_hold(&self) -> &crate::channel::actor::beam::EntryHold {
222        self.inner.runtime.entry_hold()
223    }
224
225    /// Test-only: a receiver told the pid of every channel actor retirement from now on.
226    #[cfg(test)]
227    pub(crate) fn retirements(&self) -> Result<std::sync::mpsc::Receiver<u64>, LiminalError> {
228        self.inner.runtime.retirements()
229    }
230
231    /// Test-only: the exit watcher's slices so far (LIM-009 R3).
232    #[cfg(test)]
233    pub(crate) fn watcher_wakes(&self) -> u64 {
234        self.inner.runtime.watcher_wakes()
235    }
236
237    /// Test-only: retires `pid` again through the one retirement path.
238    #[cfg(test)]
239    pub(crate) fn retire_again(
240        &self,
241        pid: u64,
242        core: &ChannelActorCore,
243    ) -> Result<(), LiminalError> {
244        self.inner.runtime.retire(pid, core)
245    }
246
247    /// Installs the cluster observer (SRV-005). Idempotent: the first install
248    /// wins and later attempts are ignored, so the observer can be wired exactly
249    /// once after the supervisor (and its scheduler) exist.
250    pub fn install_observer(&self, observer: Arc<dyn ClusterObserver>) {
251        if self.inner.observer.set(observer).is_err() {
252            tracing::debug!(
253                "a cluster observer is already installed; this later install is ignored"
254            );
255        }
256    }
257
258    /// The installed cluster observer, if any.
259    #[must_use]
260    pub(crate) fn observer(&self) -> Option<&Arc<dyn ClusterObserver>> {
261        self.inner.observer.get()
262    }
263
264    /// The configured restart policy.
265    #[must_use]
266    pub fn policy(&self) -> &ChannelRestartPolicy {
267        &self.inner.policy
268    }
269
270    /// Spawns a fresh channel actor for `schema` and returns its shared core.
271    ///
272    /// # Errors
273    /// Returns [`LiminalError`] when the actor process cannot be spawned.
274    pub(crate) fn spawn_channel(
275        &self,
276        name: &str,
277        schema: Schema,
278    ) -> Result<Arc<ChannelActorCore>, LiminalError> {
279        let core = Arc::new(ChannelActorCore::new(
280            self.scheduler(),
281            self.inner.runtime.command_atom(),
282            schema,
283            Departures {
284                channel: name.to_owned(),
285                observer: Arc::clone(&self.inner.observer),
286            },
287        ));
288        self.spawn_actor_for(&core)?;
289        Ok(core)
290    }
291
292    /// Ensures `core` has a live actor process, spawning (or restarting) one if
293    /// its current pid is dead. Honours the restart budget: once exhausted, a
294    /// dead actor is not restarted and a [`LiminalError::DeliveryFailed`] is
295    /// returned. This is the one-for-one restart that leaves other channels
296    /// untouched (each `core` is supervised independently).
297    ///
298    /// # Errors
299    /// Returns [`LiminalError`] when restart is disabled/exhausted or the spawn
300    /// fails.
301    pub(crate) fn ensure_running(
302        &self,
303        core: &Arc<ChannelActorCore>,
304        restarts: &AtomicU32,
305    ) -> Result<u64, LiminalError> {
306        // Fast path: a live pid needs no lock. The slow (respawn) path below is
307        // serialised so two concurrent callers that both see a dead pid cannot
308        // both spawn a replacement (the restart TOCTOU).
309        if let Some(pid) = self.live_pid(core)? {
310            return Ok(pid);
311        }
312        // Hold the per-channel restart lock across the dead-check and respawn so
313        // exactly one thread restarts; any racing caller re-reads the now-live
314        // pid below. The lock lives on `core` so each channel is supervised
315        // independently (mirrors `conversation/actor/core.rs`'s `restart_lock`).
316        let guard = core
317            .restart_lock()
318            .lock()
319            .map_err(|error| LiminalError::DeliveryFailed {
320                message: format!("channel actor restart lock poisoned: {error}"),
321            })?;
322        // Double-checked liveness AFTER acquiring the lock: the thread that won
323        // the race has already respawned, so we must not spawn a second actor.
324        if let Some(pid) = self.live_pid(core)? {
325            return Ok(pid);
326        }
327        if !self.inner.policy.restart {
328            return Err(LiminalError::DeliveryFailed {
329                message: "channel actor died and restart is disabled".to_owned(),
330            });
331        }
332        let used = restarts.fetch_add(1, Ordering::Relaxed);
333        if used >= self.inner.policy.max_restarts {
334            return Err(LiminalError::DeliveryFailed {
335                message: format!(
336                    "channel actor restart budget ({}) exhausted",
337                    self.inner.policy.max_restarts
338                ),
339            });
340        }
341        if let Some(pid) = core.current_pid()? {
342            self.inner.runtime.retire(pid, core)?;
343        }
344        let pid = self.spawn_actor_for(core)?;
345        drop(guard);
346        Ok(pid)
347    }
348
349    /// The current pid if it is still live in the scheduler's process table,
350    /// otherwise `None` (the actor needs spawning/restarting).
351    fn live_pid(&self, core: &Arc<ChannelActorCore>) -> Result<Option<u64>, LiminalError> {
352        if let Some(pid) = core.current_pid()? {
353            if self.inner.scheduler.process_table().get(pid).is_some() {
354                return Ok(Some(pid));
355            }
356        }
357        Ok(None)
358    }
359
360    fn spawn_actor_for(&self, core: &Arc<ChannelActorCore>) -> Result<u64, LiminalError> {
361        let pid = self
362            .inner
363            .scheduler
364            .spawn_trap_exit(
365                self.inner.module_name,
366                self.inner.entry_function,
367                Vec::new(),
368            )
369            .map_err(|error| LiminalError::ConversationFailed {
370                message: format!("failed to spawn channel actor: {error:?}"),
371            })?;
372        self.inner.runtime.register(pid, Arc::downgrade(core))?;
373        core.set_current_pid(pid)?;
374        // Watch the actor's exit before any command can wait on it (LIM-009).
375        // An actor already gone is told at once as DOWN when beamr kept its
376        // exit; one that left no trace cannot be monitored, and is retired here.
377        if self
378            .inner
379            .scheduler
380            .monitor(self.inner.watcher, pid)
381            .is_err()
382        {
383            self.inner.runtime.retire(pid, core)?;
384        }
385        // Re-link the new process to every surviving subscriber so subscriber
386        // death (EXIT) detection works after a restart — exactly as the
387        // conversation actor's `spawn_actor_for` calls `core.boot(...)`. On the
388        // very first spawn the subscriber list is empty, so this is a no-op.
389        core.boot()?;
390        Ok(pid)
391    }
392
393    /// Stops the underlying scheduler.
394    pub fn shutdown(&self) {
395        self.inner.scheduler.shutdown();
396    }
397}
398
399/// The process-global default channel supervisor, lazily started on first use.
400static SHARED: OnceLock<ChannelSupervisor> = OnceLock::new();
401
402/// Returns the shared default supervisor, starting it on first use.
403///
404/// # Errors
405/// Returns [`LiminalError`] when the shared scheduler cannot start.
406pub fn shared_supervisor() -> Result<ChannelSupervisor, LiminalError> {
407    if let Some(existing) = SHARED.get() {
408        return Ok(existing.clone());
409    }
410    let supervisor = ChannelSupervisor::new()?;
411    Ok(SHARED.get_or_init(|| supervisor).clone())
412}