Skip to main content

liminal/conversation/
actor.rs

1use std::any::Any;
2use std::collections::VecDeque;
3use std::sync::{Arc, Mutex, mpsc};
4use std::time::Instant;
5
6use beamr::atom::{Atom, AtomTable};
7use beamr::module::ModuleRegistry;
8use beamr::scheduler::{NativeBifs, Scheduler, SchedulerConfig};
9
10mod backend;
11mod beam;
12mod core;
13mod exit;
14mod queue;
15mod sync;
16mod watcher;
17
18use crate::conversation::participant::{
19    ParticipantBehaviour, ParticipantChannel, ParticipantProcess, ParticipantRuntime,
20};
21use crate::conversation::types::{
22    ConversationConfig, ConversationHandle, ConversationState, CrashPolicy, ParticipantPid,
23};
24use crate::envelope::Envelope;
25use crate::error::LiminalError;
26use backend::ActorBackend;
27use beam::{ActorRuntime, actor_module};
28pub(crate) use core::ActorCore;
29
30#[cfg(test)]
31mod tests;
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum ConversationCommand {
35    Send(Envelope),
36    Receive,
37    Close,
38    QueryState,
39}
40
41#[derive(Clone, Debug)]
42pub struct ConversationSupervisor {
43    inner: Arc<SupervisorInner>,
44}
45
46impl ConversationSupervisor {
47    /// # Errors
48    /// Returns [`LiminalError`] when the beamr scheduler cannot start.
49    pub fn new() -> Result<Self, LiminalError> {
50        SupervisorInner::new().map(|inner| Self {
51            inner: Arc::new(inner),
52        })
53    }
54
55    /// Spawns one supervised conversation actor over the given participant pids.
56    ///
57    /// The participants are linked for crash detection but are NOT forwarded
58    /// requests (they are inert from the conversation's perspective). Use
59    /// [`ConversationSupervisor::spawn_with_participant`] to attach a real
60    /// participant that processes forwarded messages.
61    ///
62    /// # Errors
63    /// Returns [`LiminalError`] when spawn, boot, or participant linking fails.
64    pub fn spawn(&self, config: ConversationConfig) -> Result<ConversationActor, LiminalError> {
65        let core = Arc::new(ActorCore::new(Arc::clone(&self.inner), config, Vec::new()));
66        self.inner.spawn_actor_for(&core)?;
67        let handle = ConversationHandle::new(Arc::new(ActorBackend {
68            core: Arc::clone(&core),
69        }));
70        Ok(ConversationActor { core, handle })
71    }
72
73    /// Spawns a real participant native process running `behaviour`, then a
74    /// supervised conversation actor linked to it. Requests sent through the
75    /// returned actor's handle are FORWARDED to the participant process, which
76    /// genuinely processes them and delivers any reply back into the
77    /// conversation — the request-reply path from LIM-005.
78    ///
79    /// Returns the actor and the spawned participant's pid (for crash injection
80    /// and linkage assertions).
81    ///
82    /// # Errors
83    /// Returns [`LiminalError`] when participant spawn, actor spawn, boot, or
84    /// linking fails.
85    pub fn spawn_with_participant(
86        &self,
87        behaviour: Arc<dyn ParticipantBehaviour>,
88        timeout: Option<std::time::Duration>,
89        mode: crate::channel::ChannelMode,
90        on_crash: CrashPolicy,
91    ) -> Result<(ConversationActor, ParticipantPid), LiminalError> {
92        let channel = self.inner.spawn_participant()?;
93        let participant = channel.pid();
94        let config = ConversationConfig::new(vec![participant], timeout, mode, on_crash);
95        let core = Arc::new(ActorCore::new(
96            Arc::clone(&self.inner),
97            config,
98            vec![channel.clone()],
99        ));
100        // Register the participant with its inbox, behaviour, and a weak handle to
101        // the core so produced replies route back into this conversation.
102        self.inner.participant_runtime.register(
103            participant,
104            channel.inbox_arc(),
105            behaviour,
106            Arc::downgrade(&core),
107        )?;
108        // The participant belongs to this construction attempt: a failed actor
109        // spawn rolls it back too (terminate + deregister), so no path out of a
110        // failed open leaves a parked participant behind.
111        if let Err(error) = self.inner.spawn_actor_for(&core) {
112            self.inner
113                .scheduler
114                .terminate_process(participant.get(), beamr::process::ExitReason::Normal);
115            self.inner.participant_runtime.deregister(participant);
116            return Err(error);
117        }
118        let handle = ConversationHandle::new(Arc::new(ActorBackend {
119            core: Arc::clone(&core),
120        }));
121        Ok((ConversationActor { core, handle }, participant))
122    }
123
124    #[cfg(test)]
125    pub(crate) fn hold_participant_consumption(
126        &self,
127        participant: ParticipantPid,
128    ) -> Result<(super::participant::ConsumptionPermit, mpsc::Receiver<()>), LiminalError> {
129        self.inner.participant_runtime.hold_consumption(
130            participant,
131            Arc::clone(&self.inner.scheduler),
132            self.inner.participant_wakeup_atom,
133        )
134    }
135
136    /// Returns the scheduler used by this supervisor.
137    #[must_use]
138    pub fn scheduler(&self) -> Arc<Scheduler> {
139        Arc::clone(&self.inner.scheduler)
140    }
141
142    /// Number of actor registrations currently held by this supervisor's
143    /// runtime. Lifecycle observability for the leak/churn gates: every closed
144    /// or torn-down conversation must have removed its entry, so this count is
145    /// pinned bounded across open/close cycles.
146    #[must_use]
147    pub fn registered_actor_count(&self) -> usize {
148        self.inner.runtime.registration_count()
149    }
150
151    /// Number of participant registrations currently held by this supervisor's
152    /// runtime. Same lifecycle-gate role as
153    /// [`Self::registered_actor_count`].
154    #[must_use]
155    pub fn registered_participant_count(&self) -> usize {
156        self.inner.participant_runtime.registration_count()
157    }
158
159    /// Stops the underlying scheduler.
160    pub fn shutdown(&self) {
161        self.inner
162            .shutting_down
163            .store(true, std::sync::atomic::Ordering::Release);
164        if let Err(error) = self.inner.runtime.close_dispatch_waiters() {
165            tracing::error!(%error, "closing dispatch waiters during supervisor shutdown failed");
166        }
167        self.inner.scheduler.shutdown();
168    }
169}
170
171#[derive(Clone, Debug)]
172pub struct ConversationActor {
173    core: Arc<ActorCore>,
174    handle: ConversationHandle,
175}
176
177impl ConversationActor {
178    pub(crate) fn register_dispatch_receipt(
179        &self,
180        receipt: Arc<super::handoff::ConsumptionReceipt>,
181    ) -> Result<super::handoff::DispatchRegistration, LiminalError> {
182        self.core.register_dispatch_receipt(receipt)
183    }
184
185    pub(crate) fn send_dispatch(
186        &self,
187        message: Envelope,
188        receipt: Arc<super::handoff::ConsumptionReceipt>,
189    ) -> Result<(), LiminalError> {
190        self.core.submit_dispatch(message, receipt)
191    }
192
193    /// Returns a cloneable command handle.
194    #[must_use]
195    pub fn handle(&self) -> ConversationHandle {
196        self.handle.clone()
197    }
198
199    /// Returns the current actor PID, restarting after crash when needed.
200    ///
201    /// # Errors
202    /// Returns [`LiminalError`] when the actor is closed or cannot restart.
203    pub fn pid(&self) -> Result<ParticipantPid, LiminalError> {
204        self.core.ensure_running()
205    }
206
207    /// Queries actor state.
208    ///
209    /// # Errors
210    /// Returns [`LiminalError`] when the actor cannot service the query.
211    pub fn state(&self) -> Result<ConversationState, LiminalError> {
212        self.handle.query_state()
213    }
214
215    /// Receives the next reply from the conversation, bounded by `timeout`.
216    ///
217    /// Returns [`LiminalError::ConversationTimeout`] if no reply arrives in time,
218    /// or [`LiminalError::ParticipantCrashed`] if a linked participant crashes
219    /// while waiting (the crash drains the pending receive immediately).
220    ///
221    /// # Errors
222    /// Returns [`LiminalError`] on timeout, participant crash, or actor failure.
223    pub fn receive_timeout(&self, timeout: std::time::Duration) -> Result<Envelope, LiminalError> {
224        self.core.submit_receive_timeout(timeout)
225    }
226
227    /// R1(vi)(a): non-blocking drain of one buffered participant reply, if any.
228    /// The connection polls this on its own slice (woken by the reply-availability
229    /// notifier) instead of blocking the slice on `receive_timeout`.
230    #[must_use]
231    pub fn try_take_reply(&self) -> Option<(u64, Envelope)> {
232        self.core.try_take_reply()
233    }
234
235    /// Whether a participant reply is buffered without consuming it.
236    #[must_use]
237    pub fn has_pending_reply(&self) -> bool {
238        self.core.has_pending_reply()
239    }
240
241    /// R1(vi)(a): installs the reply-availability notifier, fired on the reply
242    /// queue's empty→non-empty transition and on terminal actor error. Installed
243    /// permanently at conversation open; cleared at close/finalize.
244    pub fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
245        self.core.register_reply_notifier(notifier);
246    }
247
248    /// Finalizes the conversation without requiring its actor process to run:
249    /// bounded, non-blocking, and idempotent. Terminates the actor and every
250    /// participant directly (scheduler tombstone writes, not requests into the
251    /// actor's command loop), fails pending receives and queued commands with
252    /// the typed closed error, and removes both runtime registrations. This is
253    /// the teardown-path counterpart to [`ConversationHandle::close`]: a caller
254    /// releasing a conversation during ITS OWN teardown must never block on the
255    /// conversation scheduler being live or responsive.
256    ///
257    /// [`ConversationHandle::close`]: crate::conversation::ConversationHandle::close
258    pub fn finalize(&self) {
259        self.core.finalize();
260    }
261
262    /// Registers a one-shot notifier fired the instant `participant`'s trapped
263    /// EXIT is processed (carrying the observed [`Instant`] — a structural link
264    /// wakeup, not a poll). If `participant` is already dead at registration
265    /// (it crashed before this call), the recorded EXIT instant is replayed
266    /// immediately, so a crash-before-register is never lost. See
267    /// [`ActorCore::register_exit_notifier`].
268    ///
269    /// # Errors
270    /// Returns [`LiminalError`] when a state or registry lock is poisoned.
271    pub fn notify_on_participant_exit(
272        &self,
273        participant: ParticipantPid,
274        notifier: mpsc::SyncSender<Instant>,
275    ) -> Result<(), LiminalError> {
276        self.core.register_exit_notifier(participant, notifier)
277    }
278}
279
280/// Test-only rendezvous installed at the arm→boot seam of one spawn attempt:
281/// the spawner sends the fresh actor pid and blocks until the test signals it
282/// to proceed, letting construction-ordering tests inject events (e.g. an
283/// actor kill) at an exact point instead of sleeping.
284#[cfg(test)]
285type BootBarrier = (mpsc::Sender<ParticipantPid>, mpsc::Receiver<()>);
286
287struct SupervisorInner {
288    shutting_down: std::sync::atomic::AtomicBool,
289    scheduler: Arc<Scheduler>,
290    runtime: Arc<ActorRuntime>,
291    participant_runtime: Arc<ParticipantRuntime>,
292    participant_wakeup_atom: Atom,
293    module_name: Atom,
294    entry_function: Atom,
295    #[cfg(test)]
296    boot_barrier: Mutex<Option<BootBarrier>>,
297}
298
299impl std::fmt::Debug for SupervisorInner {
300    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        formatter
302            .debug_struct("SupervisorInner")
303            .field("runtime", &self.runtime)
304            .field("module_name", &self.module_name)
305            .field("entry_function", &self.entry_function)
306            .finish_non_exhaustive()
307    }
308}
309
310impl SupervisorInner {
311    fn new() -> Result<Self, LiminalError> {
312        let atoms = AtomTable::with_common_atoms();
313        let module_name = atoms.intern("liminal_conversation_actor");
314        let entry_function = atoms.intern("main");
315        let command_function = atoms.intern("process_command");
316        let command_atom = atoms.intern("liminal_conversation_command");
317        let participant_wakeup_atom = atoms.intern("liminal_conversation_participant_wakeup");
318        let runtime = Arc::new(ActorRuntime::new(command_atom));
319        let participant_runtime = Arc::new(ParticipantRuntime::default());
320        let registry = Arc::new(ModuleRegistry::new());
321        registry.insert(actor_module(module_name, entry_function, command_function));
322        let private_data: Arc<dyn Any + Send + Sync> = runtime.clone();
323        let scheduler = Scheduler::new(
324            SchedulerConfig {
325                thread_count: Some(1),
326                nif_private_data: Some(private_data),
327                ..SchedulerConfig::default()
328            },
329            registry,
330            // No bytecode is loaded into this scheduler and no native BIFs are
331            // resolved -- see the fuller note at channel/supervisor.rs. The one
332            // module registered is `actor_module`, whose `function_table` is
333            // empty (conversation/actor/beam.rs:162) and whose instructions
334            // contain no arithmetic, comparison or type guard, which is the only
335            // place `NativeBifs::none()` can bite.
336            NativeBifs::none(),
337        )
338        .map_err(|message| LiminalError::ConversationFailed { message })?;
339        Ok(Self {
340            shutting_down: std::sync::atomic::AtomicBool::new(false),
341            scheduler: Arc::new(scheduler),
342            runtime,
343            participant_runtime,
344            participant_wakeup_atom,
345            module_name,
346            entry_function,
347            #[cfg(test)]
348            boot_barrier: Mutex::new(None),
349        })
350    }
351
352    /// Installs the one-shot arm→boot rendezvous consumed by the next spawn
353    /// attempt; see [`BootBarrier`].
354    #[cfg(test)]
355    fn install_boot_barrier(&self, barrier: BootBarrier) {
356        if let Ok(mut slot) = self.boot_barrier.lock() {
357            *slot = Some(barrier);
358        }
359    }
360
361    /// Spawns a real participant native process running `behaviour`, registers it
362    /// with the participant runtime, and returns the channel the conversation
363    /// actor forwards requests through plus the participant pid. The process is a
364    /// first-class beamr [`NativeHandler`]; the conversation actor links to it
365    /// during boot for structural crash detection.
366    fn spawn_participant(&self) -> Result<ParticipantChannel, LiminalError> {
367        let runtime = Arc::clone(&self.participant_runtime);
368        let wakeup_atom = self.participant_wakeup_atom;
369        let factory = Box::new(move || {
370            Box::new(ParticipantProcess::new(Arc::clone(&runtime), wakeup_atom))
371                as Box<dyn beamr::native::native_process::NativeHandler>
372        });
373        let pid = self.scheduler.spawn_native(factory).map_err(|error| {
374            LiminalError::ConversationFailed {
375                message: format!("failed to spawn conversation participant: {error}"),
376            }
377        })?;
378        let participant = ParticipantPid::new(pid);
379        let inbox = Arc::new(Mutex::new(VecDeque::new()));
380        Ok(ParticipantChannel::new(participant, inbox))
381    }
382
383    /// Spawns and boots one actor incarnation as a rollback-safe transaction:
384    /// on ANY failure after the actor process exists, every process this
385    /// attempt created is terminated, the registration is removed, and the
386    /// queued boot command is purged (inside the bounded `boot`), so a failed
387    /// spawn leaves nothing behind. Finalization is rechecked immediately
388    /// before publishing the actor and again after boot: a spawn that loses to
389    /// a concurrent close/finalize rolls itself back rather than returning a
390    /// fresh actor nobody will ever clean up.
391    fn spawn_actor_for(
392        self: &Arc<Self>,
393        core: &Arc<ActorCore>,
394    ) -> Result<ParticipantPid, LiminalError> {
395        if core.is_finalized() {
396            return Err(LiminalError::ConversationFailed {
397                message: "conversation is closed".to_owned(),
398            });
399        }
400        let pid = self
401            .scheduler
402            .spawn_trap_exit(self.module_name, self.entry_function, Vec::new())
403            .map_err(|error| LiminalError::ConversationFailed {
404                message: format!("failed to spawn conversation actor: {error}"),
405            })?;
406        let actor = ParticipantPid::new(pid);
407        if let Err(error) = self.runtime.register(actor, Arc::downgrade(core)) {
408            self.rollback_actor_attempt(core, actor, None);
409            return Err(error);
410        }
411        let watcher = match self.spawn_watcher(core, actor) {
412            Ok(watcher) => watcher,
413            // `spawn_watcher` already terminated its own watcher on failure.
414            Err(error) => {
415                self.rollback_actor_attempt(core, actor, None);
416                return Err(error);
417            }
418        };
419        if let Err(error) = core
420            .set_watcher_pid(watcher)
421            .and_then(|()| core.set_current_pid(actor))
422        {
423            self.rollback_actor_attempt(core, actor, Some(watcher));
424            return Err(error);
425        }
426        #[cfg(test)]
427        self.boot_barrier_rendezvous(actor);
428        if let Err(error) = core.boot(actor) {
429            self.rollback_actor_attempt(core, actor, Some(watcher));
430            return Err(error);
431        }
432        // A close/finalize that could not take the lifecycle gate (the actor's
433        // own Close slice) may have finalized the core while boot was in
434        // flight; a success return here would publish an actor nobody cleans.
435        if core.is_finalized() {
436            self.rollback_actor_attempt(core, actor, Some(watcher));
437            return Err(LiminalError::ConversationFailed {
438                message: "conversation is closed".to_owned(),
439            });
440        }
441        Ok(actor)
442    }
443
444    /// Aborts one spawn attempt: terminates every process the attempt created
445    /// and removes the actor registration. Idempotent — each step tolerates
446    /// already-dead pids and already-removed entries, so it composes with the
447    /// watcher's own cleanup and with a concurrent finalize.
448    fn rollback_actor_attempt(
449        &self,
450        core: &ActorCore,
451        actor: ParticipantPid,
452        watcher: Option<ParticipantPid>,
453    ) {
454        if let Some(watcher) = watcher {
455            self.scheduler
456                .terminate_process(watcher.get(), beamr::process::ExitReason::Normal);
457        }
458        self.scheduler
459            .terminate_process(actor.get(), beamr::process::ExitReason::Normal);
460        self.runtime.deregister_owned(actor, core);
461    }
462
463    /// Blocks the spawning thread at the arm→boot seam when a test installed a
464    /// rendezvous, handing it the actor pid and waiting for its proceed signal.
465    /// This is the held-gap injection point for the construction-ordering pins
466    /// (actor killed after watcher arm, before the boot enqueue); it does not
467    /// exist outside tests.
468    #[cfg(test)]
469    fn boot_barrier_rendezvous(&self, actor: ParticipantPid) {
470        let barrier = self
471            .boot_barrier
472            .lock()
473            .ok()
474            .and_then(|mut barrier| barrier.take());
475        if let Some((notify, proceed)) = barrier {
476            let _ = notify.send(actor);
477            let _ = proceed.recv();
478        }
479    }
480
481    /// Spawns the exit watcher for a freshly spawned actor process and waits
482    /// (bounded) for its first slice to arm trap-exit. The wait is what makes
483    /// the later boot-slice link race-free: a link created before the trap is
484    /// armed would let an abnormal actor exit cascade-kill the watcher
485    /// unobserved. Construction is already a blocking path (boot itself is a
486    /// command round trip); teardown paths never wait on the watcher.
487    fn spawn_watcher(
488        self: &Arc<Self>,
489        core: &Arc<ActorCore>,
490        actor: ParticipantPid,
491    ) -> Result<ParticipantPid, LiminalError> {
492        let (armed_tx, armed_rx) = mpsc::sync_channel::<()>(1);
493        let watcher_core = Arc::downgrade(core);
494        let watcher_supervisor = Arc::downgrade(self);
495        let factory = Box::new(move || {
496            Box::new(watcher::ActorExitWatcher::new(
497                watcher_core.clone(),
498                watcher_supervisor.clone(),
499                actor,
500                armed_tx.clone(),
501            )) as Box<dyn beamr::native::native_process::NativeHandler>
502        });
503        let watcher_pid = self.scheduler.spawn_native(factory).map_err(|error| {
504            LiminalError::ConversationFailed {
505                message: format!("failed to spawn conversation exit watcher: {error}"),
506            }
507        })?;
508        // One scheduler slice away; the generous bound only guards a wedged
509        // scheduler at construction time.
510        if armed_rx
511            .recv_timeout(std::time::Duration::from_secs(5))
512            .is_err()
513        {
514            self.scheduler
515                .terminate_process(watcher_pid, beamr::process::ExitReason::Normal);
516            return Err(LiminalError::ConversationFailed {
517                message: "conversation exit watcher failed to arm".to_owned(),
518            });
519        }
520        Ok(ParticipantPid::new(watcher_pid))
521    }
522}