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    /// Returns the scheduler used by this supervisor.
125    #[must_use]
126    pub fn scheduler(&self) -> Arc<Scheduler> {
127        Arc::clone(&self.inner.scheduler)
128    }
129
130    /// Number of actor registrations currently held by this supervisor's
131    /// runtime. Lifecycle observability for the leak/churn gates: every closed
132    /// or torn-down conversation must have removed its entry, so this count is
133    /// pinned bounded across open/close cycles.
134    #[must_use]
135    pub fn registered_actor_count(&self) -> usize {
136        self.inner.runtime.registration_count()
137    }
138
139    /// Number of participant registrations currently held by this supervisor's
140    /// runtime. Same lifecycle-gate role as
141    /// [`Self::registered_actor_count`].
142    #[must_use]
143    pub fn registered_participant_count(&self) -> usize {
144        self.inner.participant_runtime.registration_count()
145    }
146
147    /// Stops the underlying scheduler.
148    pub fn shutdown(&self) {
149        self.inner.scheduler.shutdown();
150    }
151}
152
153#[derive(Clone, Debug)]
154pub struct ConversationActor {
155    core: Arc<ActorCore>,
156    handle: ConversationHandle,
157}
158
159impl ConversationActor {
160    /// Returns a cloneable command handle.
161    #[must_use]
162    pub fn handle(&self) -> ConversationHandle {
163        self.handle.clone()
164    }
165
166    /// Returns the current actor PID, restarting after crash when needed.
167    ///
168    /// # Errors
169    /// Returns [`LiminalError`] when the actor is closed or cannot restart.
170    pub fn pid(&self) -> Result<ParticipantPid, LiminalError> {
171        self.core.ensure_running()
172    }
173
174    /// Queries actor state.
175    ///
176    /// # Errors
177    /// Returns [`LiminalError`] when the actor cannot service the query.
178    pub fn state(&self) -> Result<ConversationState, LiminalError> {
179        self.handle.query_state()
180    }
181
182    /// Receives the next reply from the conversation, bounded by `timeout`.
183    ///
184    /// Returns [`LiminalError::ConversationTimeout`] if no reply arrives in time,
185    /// or [`LiminalError::ParticipantCrashed`] if a linked participant crashes
186    /// while waiting (the crash drains the pending receive immediately).
187    ///
188    /// # Errors
189    /// Returns [`LiminalError`] on timeout, participant crash, or actor failure.
190    pub fn receive_timeout(&self, timeout: std::time::Duration) -> Result<Envelope, LiminalError> {
191        self.core.submit_receive_timeout(timeout)
192    }
193
194    /// R1(vi)(a): non-blocking drain of one buffered participant reply, if any.
195    /// The connection polls this on its own slice (woken by the reply-availability
196    /// notifier) instead of blocking the slice on `receive_timeout`.
197    #[must_use]
198    pub fn try_take_reply(&self) -> Option<Envelope> {
199        self.core.try_take_reply()
200    }
201
202    /// Whether a participant reply is buffered without consuming it.
203    #[must_use]
204    pub fn has_pending_reply(&self) -> bool {
205        self.core.has_pending_reply()
206    }
207
208    /// R1(vi)(a): installs the reply-availability notifier, fired on the reply
209    /// queue's empty→non-empty transition and on terminal actor error. Installed
210    /// permanently at conversation open; cleared at close/finalize.
211    pub fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
212        self.core.register_reply_notifier(notifier);
213    }
214
215    /// Finalizes the conversation without requiring its actor process to run:
216    /// bounded, non-blocking, and idempotent. Terminates the actor and every
217    /// participant directly (scheduler tombstone writes, not requests into the
218    /// actor's command loop), fails pending receives and queued commands with
219    /// the typed closed error, and removes both runtime registrations. This is
220    /// the teardown-path counterpart to [`ConversationHandle::close`]: a caller
221    /// releasing a conversation during ITS OWN teardown must never block on the
222    /// conversation scheduler being live or responsive.
223    ///
224    /// [`ConversationHandle::close`]: crate::conversation::ConversationHandle::close
225    pub fn finalize(&self) {
226        self.core.finalize();
227    }
228
229    /// Registers a one-shot notifier fired the instant `participant`'s trapped
230    /// EXIT is processed (carrying the observed [`Instant`] — a structural link
231    /// wakeup, not a poll). If `participant` is already dead at registration
232    /// (it crashed before this call), the recorded EXIT instant is replayed
233    /// immediately, so a crash-before-register is never lost. See
234    /// [`ActorCore::register_exit_notifier`].
235    ///
236    /// # Errors
237    /// Returns [`LiminalError`] when a state or registry lock is poisoned.
238    pub fn notify_on_participant_exit(
239        &self,
240        participant: ParticipantPid,
241        notifier: mpsc::SyncSender<Instant>,
242    ) -> Result<(), LiminalError> {
243        self.core.register_exit_notifier(participant, notifier)
244    }
245}
246
247/// Test-only rendezvous installed at the arm→boot seam of one spawn attempt:
248/// the spawner sends the fresh actor pid and blocks until the test signals it
249/// to proceed, letting construction-ordering tests inject events (e.g. an
250/// actor kill) at an exact point instead of sleeping.
251#[cfg(test)]
252type BootBarrier = (mpsc::Sender<ParticipantPid>, mpsc::Receiver<()>);
253
254struct SupervisorInner {
255    scheduler: Arc<Scheduler>,
256    runtime: Arc<ActorRuntime>,
257    participant_runtime: Arc<ParticipantRuntime>,
258    participant_wakeup_atom: Atom,
259    module_name: Atom,
260    entry_function: Atom,
261    #[cfg(test)]
262    boot_barrier: Mutex<Option<BootBarrier>>,
263}
264
265impl std::fmt::Debug for SupervisorInner {
266    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        formatter
268            .debug_struct("SupervisorInner")
269            .field("runtime", &self.runtime)
270            .field("module_name", &self.module_name)
271            .field("entry_function", &self.entry_function)
272            .finish_non_exhaustive()
273    }
274}
275
276impl SupervisorInner {
277    fn new() -> Result<Self, LiminalError> {
278        let atoms = AtomTable::with_common_atoms();
279        let module_name = atoms.intern("liminal_conversation_actor");
280        let entry_function = atoms.intern("main");
281        let command_function = atoms.intern("process_command");
282        let command_atom = atoms.intern("liminal_conversation_command");
283        let participant_wakeup_atom = atoms.intern("liminal_conversation_participant_wakeup");
284        let runtime = Arc::new(ActorRuntime::new(command_atom));
285        let participant_runtime = Arc::new(ParticipantRuntime::default());
286        let registry = Arc::new(ModuleRegistry::new());
287        registry.insert(actor_module(module_name, entry_function, command_function));
288        let private_data: Arc<dyn Any + Send + Sync> = runtime.clone();
289        let scheduler = Scheduler::new(
290            SchedulerConfig {
291                thread_count: Some(1),
292                nif_private_data: Some(private_data),
293                ..SchedulerConfig::default()
294            },
295            registry,
296            // No bytecode is loaded into this scheduler and no native BIFs are
297            // resolved -- see the fuller note at channel/supervisor.rs. The one
298            // module registered is `actor_module`, whose `function_table` is
299            // empty (conversation/actor/beam.rs:162) and whose instructions
300            // contain no arithmetic, comparison or type guard, which is the only
301            // place `NativeBifs::none()` can bite.
302            NativeBifs::none(),
303        )
304        .map_err(|message| LiminalError::ConversationFailed { message })?;
305        Ok(Self {
306            scheduler: Arc::new(scheduler),
307            runtime,
308            participant_runtime,
309            participant_wakeup_atom,
310            module_name,
311            entry_function,
312            #[cfg(test)]
313            boot_barrier: Mutex::new(None),
314        })
315    }
316
317    /// Installs the one-shot arm→boot rendezvous consumed by the next spawn
318    /// attempt; see [`BootBarrier`].
319    #[cfg(test)]
320    fn install_boot_barrier(&self, barrier: BootBarrier) {
321        if let Ok(mut slot) = self.boot_barrier.lock() {
322            *slot = Some(barrier);
323        }
324    }
325
326    /// Spawns a real participant native process running `behaviour`, registers it
327    /// with the participant runtime, and returns the channel the conversation
328    /// actor forwards requests through plus the participant pid. The process is a
329    /// first-class beamr [`NativeHandler`]; the conversation actor links to it
330    /// during boot for structural crash detection.
331    fn spawn_participant(&self) -> Result<ParticipantChannel, LiminalError> {
332        let runtime = Arc::clone(&self.participant_runtime);
333        let wakeup_atom = self.participant_wakeup_atom;
334        let factory = Box::new(move || {
335            Box::new(ParticipantProcess::new(Arc::clone(&runtime), wakeup_atom))
336                as Box<dyn beamr::native::native_process::NativeHandler>
337        });
338        let pid = self.scheduler.spawn_native(factory).map_err(|error| {
339            LiminalError::ConversationFailed {
340                message: format!("failed to spawn conversation participant: {error}"),
341            }
342        })?;
343        let participant = ParticipantPid::new(pid);
344        let inbox = Arc::new(Mutex::new(VecDeque::new()));
345        Ok(ParticipantChannel::new(participant, inbox))
346    }
347
348    /// Spawns and boots one actor incarnation as a rollback-safe transaction:
349    /// on ANY failure after the actor process exists, every process this
350    /// attempt created is terminated, the registration is removed, and the
351    /// queued boot command is purged (inside the bounded `boot`), so a failed
352    /// spawn leaves nothing behind. Finalization is rechecked immediately
353    /// before publishing the actor and again after boot: a spawn that loses to
354    /// a concurrent close/finalize rolls itself back rather than returning a
355    /// fresh actor nobody will ever clean up.
356    fn spawn_actor_for(
357        self: &Arc<Self>,
358        core: &Arc<ActorCore>,
359    ) -> Result<ParticipantPid, LiminalError> {
360        if core.is_finalized() {
361            return Err(LiminalError::ConversationFailed {
362                message: "conversation is closed".to_owned(),
363            });
364        }
365        let pid = self
366            .scheduler
367            .spawn_trap_exit(self.module_name, self.entry_function, Vec::new())
368            .map_err(|error| LiminalError::ConversationFailed {
369                message: format!("failed to spawn conversation actor: {error}"),
370            })?;
371        let actor = ParticipantPid::new(pid);
372        if let Err(error) = self.runtime.register(actor, Arc::downgrade(core)) {
373            self.rollback_actor_attempt(core, actor, None);
374            return Err(error);
375        }
376        let watcher = match self.spawn_watcher(core, actor) {
377            Ok(watcher) => watcher,
378            // `spawn_watcher` already terminated its own watcher on failure.
379            Err(error) => {
380                self.rollback_actor_attempt(core, actor, None);
381                return Err(error);
382            }
383        };
384        if let Err(error) = core
385            .set_watcher_pid(watcher)
386            .and_then(|()| core.set_current_pid(actor))
387        {
388            self.rollback_actor_attempt(core, actor, Some(watcher));
389            return Err(error);
390        }
391        #[cfg(test)]
392        self.boot_barrier_rendezvous(actor);
393        if let Err(error) = core.boot(actor) {
394            self.rollback_actor_attempt(core, actor, Some(watcher));
395            return Err(error);
396        }
397        // A close/finalize that could not take the lifecycle gate (the actor's
398        // own Close slice) may have finalized the core while boot was in
399        // flight; a success return here would publish an actor nobody cleans.
400        if core.is_finalized() {
401            self.rollback_actor_attempt(core, actor, Some(watcher));
402            return Err(LiminalError::ConversationFailed {
403                message: "conversation is closed".to_owned(),
404            });
405        }
406        Ok(actor)
407    }
408
409    /// Aborts one spawn attempt: terminates every process the attempt created
410    /// and removes the actor registration. Idempotent — each step tolerates
411    /// already-dead pids and already-removed entries, so it composes with the
412    /// watcher's own cleanup and with a concurrent finalize.
413    fn rollback_actor_attempt(
414        &self,
415        core: &ActorCore,
416        actor: ParticipantPid,
417        watcher: Option<ParticipantPid>,
418    ) {
419        if let Some(watcher) = watcher {
420            self.scheduler
421                .terminate_process(watcher.get(), beamr::process::ExitReason::Normal);
422        }
423        self.scheduler
424            .terminate_process(actor.get(), beamr::process::ExitReason::Normal);
425        self.runtime.deregister_owned(actor, core);
426    }
427
428    /// Blocks the spawning thread at the arm→boot seam when a test installed a
429    /// rendezvous, handing it the actor pid and waiting for its proceed signal.
430    /// This is the held-gap injection point for the construction-ordering pins
431    /// (actor killed after watcher arm, before the boot enqueue); it does not
432    /// exist outside tests.
433    #[cfg(test)]
434    fn boot_barrier_rendezvous(&self, actor: ParticipantPid) {
435        let barrier = self
436            .boot_barrier
437            .lock()
438            .ok()
439            .and_then(|mut barrier| barrier.take());
440        if let Some((notify, proceed)) = barrier {
441            let _ = notify.send(actor);
442            let _ = proceed.recv();
443        }
444    }
445
446    /// Spawns the exit watcher for a freshly spawned actor process and waits
447    /// (bounded) for its first slice to arm trap-exit. The wait is what makes
448    /// the later boot-slice link race-free: a link created before the trap is
449    /// armed would let an abnormal actor exit cascade-kill the watcher
450    /// unobserved. Construction is already a blocking path (boot itself is a
451    /// command round trip); teardown paths never wait on the watcher.
452    fn spawn_watcher(
453        self: &Arc<Self>,
454        core: &Arc<ActorCore>,
455        actor: ParticipantPid,
456    ) -> Result<ParticipantPid, LiminalError> {
457        let (armed_tx, armed_rx) = mpsc::sync_channel::<()>(1);
458        let watcher_core = Arc::downgrade(core);
459        let watcher_supervisor = Arc::downgrade(self);
460        let factory = Box::new(move || {
461            Box::new(watcher::ActorExitWatcher::new(
462                watcher_core.clone(),
463                watcher_supervisor.clone(),
464                actor,
465                armed_tx.clone(),
466            )) as Box<dyn beamr::native::native_process::NativeHandler>
467        });
468        let watcher_pid = self.scheduler.spawn_native(factory).map_err(|error| {
469            LiminalError::ConversationFailed {
470                message: format!("failed to spawn conversation exit watcher: {error}"),
471            }
472        })?;
473        // One scheduler slice away; the generous bound only guards a wedged
474        // scheduler at construction time.
475        if armed_rx
476            .recv_timeout(std::time::Duration::from_secs(5))
477            .is_err()
478        {
479            self.scheduler
480                .terminate_process(watcher_pid, beamr::process::ExitReason::Normal);
481            return Err(LiminalError::ConversationFailed {
482                message: "conversation exit watcher failed to arm".to_owned(),
483            });
484        }
485        Ok(ParticipantPid::new(watcher_pid))
486    }
487}