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, SchedulerServices};
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        // Conversation actors are native and make no dirty call: the worker
324        // and no ancillary service.
325        let scheduler = Scheduler::with_services(
326            SchedulerConfig {
327                thread_count: Some(1),
328                nif_private_data: Some(private_data),
329                ..SchedulerConfig::default()
330            },
331            SchedulerServices::minimal(),
332            registry,
333            // No bytecode is loaded into this scheduler and no native BIFs are
334            // resolved -- see the fuller note at channel/supervisor.rs. The one
335            // module registered is `actor_module`, whose `function_table` is
336            // empty (conversation/actor/beam.rs:162) and whose instructions
337            // contain no arithmetic, comparison or type guard, which is the only
338            // place `NativeBifs::none()` can bite.
339            NativeBifs::none(),
340        )
341        .map_err(|message| LiminalError::ConversationFailed { message })?;
342        Ok(Self {
343            shutting_down: std::sync::atomic::AtomicBool::new(false),
344            scheduler: Arc::new(scheduler),
345            runtime,
346            participant_runtime,
347            participant_wakeup_atom,
348            module_name,
349            entry_function,
350            #[cfg(test)]
351            boot_barrier: Mutex::new(None),
352        })
353    }
354
355    /// Installs the one-shot arm→boot rendezvous consumed by the next spawn
356    /// attempt; see [`BootBarrier`].
357    #[cfg(test)]
358    fn install_boot_barrier(&self, barrier: BootBarrier) {
359        if let Ok(mut slot) = self.boot_barrier.lock() {
360            *slot = Some(barrier);
361        }
362    }
363
364    /// Spawns a real participant native process running `behaviour`, registers it
365    /// with the participant runtime, and returns the channel the conversation
366    /// actor forwards requests through plus the participant pid. The process is a
367    /// first-class beamr [`NativeHandler`]; the conversation actor links to it
368    /// during boot for structural crash detection.
369    fn spawn_participant(&self) -> Result<ParticipantChannel, LiminalError> {
370        let runtime = Arc::clone(&self.participant_runtime);
371        let wakeup_atom = self.participant_wakeup_atom;
372        let factory = Box::new(move || {
373            Box::new(ParticipantProcess::new(Arc::clone(&runtime), wakeup_atom))
374                as Box<dyn beamr::native::native_process::NativeHandler>
375        });
376        let pid = self.scheduler.spawn_native(factory).map_err(|error| {
377            LiminalError::ConversationFailed {
378                message: format!("failed to spawn conversation participant: {error}"),
379            }
380        })?;
381        let participant = ParticipantPid::new(pid);
382        let inbox = Arc::new(Mutex::new(VecDeque::new()));
383        Ok(ParticipantChannel::new(participant, inbox))
384    }
385
386    /// Spawns and boots one actor incarnation as a rollback-safe transaction:
387    /// on ANY failure after the actor process exists, every process this
388    /// attempt created is terminated, the registration is removed, and the
389    /// queued boot command is purged (inside the bounded `boot`), so a failed
390    /// spawn leaves nothing behind. Finalization is rechecked immediately
391    /// before publishing the actor and again after boot: a spawn that loses to
392    /// a concurrent close/finalize rolls itself back rather than returning a
393    /// fresh actor nobody will ever clean up.
394    fn spawn_actor_for(
395        self: &Arc<Self>,
396        core: &Arc<ActorCore>,
397    ) -> Result<ParticipantPid, LiminalError> {
398        if core.is_finalized() {
399            return Err(LiminalError::ConversationFailed {
400                message: "conversation is closed".to_owned(),
401            });
402        }
403        let pid = self
404            .scheduler
405            .spawn_trap_exit(self.module_name, self.entry_function, Vec::new())
406            .map_err(|error| LiminalError::ConversationFailed {
407                message: format!("failed to spawn conversation actor: {error}"),
408            })?;
409        let actor = ParticipantPid::new(pid);
410        if let Err(error) = self.runtime.register(actor, Arc::downgrade(core)) {
411            self.rollback_actor_attempt(core, actor, None);
412            return Err(error);
413        }
414        let watcher = match self.spawn_watcher(core, actor) {
415            Ok(watcher) => watcher,
416            // `spawn_watcher` already terminated its own watcher on failure.
417            Err(error) => {
418                self.rollback_actor_attempt(core, actor, None);
419                return Err(error);
420            }
421        };
422        if let Err(error) = core
423            .set_watcher_pid(watcher)
424            .and_then(|()| core.set_current_pid(actor))
425        {
426            self.rollback_actor_attempt(core, actor, Some(watcher));
427            return Err(error);
428        }
429        #[cfg(test)]
430        self.boot_barrier_rendezvous(actor);
431        if let Err(error) = core.boot(actor) {
432            self.rollback_actor_attempt(core, actor, Some(watcher));
433            return Err(error);
434        }
435        // A close/finalize that could not take the lifecycle gate (the actor's
436        // own Close slice) may have finalized the core while boot was in
437        // flight; a success return here would publish an actor nobody cleans.
438        if core.is_finalized() {
439            self.rollback_actor_attempt(core, actor, Some(watcher));
440            return Err(LiminalError::ConversationFailed {
441                message: "conversation is closed".to_owned(),
442            });
443        }
444        Ok(actor)
445    }
446
447    /// Aborts one spawn attempt: terminates every process the attempt created
448    /// and removes the actor registration. Idempotent — each step tolerates
449    /// already-dead pids and already-removed entries, so it composes with the
450    /// watcher's own cleanup and with a concurrent finalize.
451    fn rollback_actor_attempt(
452        &self,
453        core: &ActorCore,
454        actor: ParticipantPid,
455        watcher: Option<ParticipantPid>,
456    ) {
457        if let Some(watcher) = watcher {
458            self.scheduler
459                .terminate_process(watcher.get(), beamr::process::ExitReason::Normal);
460        }
461        self.scheduler
462            .terminate_process(actor.get(), beamr::process::ExitReason::Normal);
463        self.runtime.deregister_owned(actor, core);
464    }
465
466    /// Blocks the spawning thread at the arm→boot seam when a test installed a
467    /// rendezvous, handing it the actor pid and waiting for its proceed signal.
468    /// This is the held-gap injection point for the construction-ordering pins
469    /// (actor killed after watcher arm, before the boot enqueue); it does not
470    /// exist outside tests.
471    #[cfg(test)]
472    fn boot_barrier_rendezvous(&self, actor: ParticipantPid) {
473        let barrier = self
474            .boot_barrier
475            .lock()
476            .ok()
477            .and_then(|mut barrier| barrier.take());
478        if let Some((notify, proceed)) = barrier {
479            let _ = notify.send(actor);
480            let _ = proceed.recv();
481        }
482    }
483
484    /// Spawns the exit watcher for a freshly spawned actor process and waits
485    /// (bounded) for its first slice to arm trap-exit. The wait is what makes
486    /// the later boot-slice link race-free: a link created before the trap is
487    /// armed would let an abnormal actor exit cascade-kill the watcher
488    /// unobserved. Construction is already a blocking path (boot itself is a
489    /// command round trip); teardown paths never wait on the watcher.
490    fn spawn_watcher(
491        self: &Arc<Self>,
492        core: &Arc<ActorCore>,
493        actor: ParticipantPid,
494    ) -> Result<ParticipantPid, LiminalError> {
495        let (armed_tx, armed_rx) = mpsc::sync_channel::<()>(1);
496        let watcher_core = Arc::downgrade(core);
497        let watcher_supervisor = Arc::downgrade(self);
498        let factory = Box::new(move || {
499            Box::new(watcher::ActorExitWatcher::new(
500                watcher_core.clone(),
501                watcher_supervisor.clone(),
502                actor,
503                armed_tx.clone(),
504            )) as Box<dyn beamr::native::native_process::NativeHandler>
505        });
506        let watcher_pid = self.scheduler.spawn_native(factory).map_err(|error| {
507            LiminalError::ConversationFailed {
508                message: format!("failed to spawn conversation exit watcher: {error}"),
509            }
510        })?;
511        // One scheduler slice away; the generous bound only guards a wedged
512        // scheduler at construction time.
513        if armed_rx
514            .recv_timeout(std::time::Duration::from_secs(5))
515            .is_err()
516        {
517            self.scheduler
518                .terminate_process(watcher_pid, beamr::process::ExitReason::Normal);
519            return Err(LiminalError::ConversationFailed {
520                message: "conversation exit watcher failed to arm".to_owned(),
521            });
522        }
523        Ok(ParticipantPid::new(watcher_pid))
524    }
525}