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::{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        )
297        .map_err(|message| LiminalError::ConversationFailed { message })?;
298        Ok(Self {
299            scheduler: Arc::new(scheduler),
300            runtime,
301            participant_runtime,
302            participant_wakeup_atom,
303            module_name,
304            entry_function,
305            #[cfg(test)]
306            boot_barrier: Mutex::new(None),
307        })
308    }
309
310    /// Installs the one-shot arm→boot rendezvous consumed by the next spawn
311    /// attempt; see [`BootBarrier`].
312    #[cfg(test)]
313    fn install_boot_barrier(&self, barrier: BootBarrier) {
314        if let Ok(mut slot) = self.boot_barrier.lock() {
315            *slot = Some(barrier);
316        }
317    }
318
319    /// Spawns a real participant native process running `behaviour`, registers it
320    /// with the participant runtime, and returns the channel the conversation
321    /// actor forwards requests through plus the participant pid. The process is a
322    /// first-class beamr [`NativeHandler`]; the conversation actor links to it
323    /// during boot for structural crash detection.
324    fn spawn_participant(&self) -> Result<ParticipantChannel, LiminalError> {
325        let runtime = Arc::clone(&self.participant_runtime);
326        let wakeup_atom = self.participant_wakeup_atom;
327        let factory = Box::new(move || {
328            Box::new(ParticipantProcess::new(Arc::clone(&runtime), wakeup_atom))
329                as Box<dyn beamr::native::native_process::NativeHandler>
330        });
331        let pid = self.scheduler.spawn_native(factory).map_err(|error| {
332            LiminalError::ConversationFailed {
333                message: format!("failed to spawn conversation participant: {error}"),
334            }
335        })?;
336        let participant = ParticipantPid::new(pid);
337        let inbox = Arc::new(Mutex::new(VecDeque::new()));
338        Ok(ParticipantChannel::new(participant, inbox))
339    }
340
341    /// Spawns and boots one actor incarnation as a rollback-safe transaction:
342    /// on ANY failure after the actor process exists, every process this
343    /// attempt created is terminated, the registration is removed, and the
344    /// queued boot command is purged (inside the bounded `boot`), so a failed
345    /// spawn leaves nothing behind. Finalization is rechecked immediately
346    /// before publishing the actor and again after boot: a spawn that loses to
347    /// a concurrent close/finalize rolls itself back rather than returning a
348    /// fresh actor nobody will ever clean up.
349    fn spawn_actor_for(
350        self: &Arc<Self>,
351        core: &Arc<ActorCore>,
352    ) -> Result<ParticipantPid, LiminalError> {
353        if core.is_finalized() {
354            return Err(LiminalError::ConversationFailed {
355                message: "conversation is closed".to_owned(),
356            });
357        }
358        let pid = self
359            .scheduler
360            .spawn_trap_exit(self.module_name, self.entry_function, Vec::new())
361            .map_err(|error| LiminalError::ConversationFailed {
362                message: format!("failed to spawn conversation actor: {error}"),
363            })?;
364        let actor = ParticipantPid::new(pid);
365        if let Err(error) = self.runtime.register(actor, Arc::downgrade(core)) {
366            self.rollback_actor_attempt(core, actor, None);
367            return Err(error);
368        }
369        let watcher = match self.spawn_watcher(core, actor) {
370            Ok(watcher) => watcher,
371            // `spawn_watcher` already terminated its own watcher on failure.
372            Err(error) => {
373                self.rollback_actor_attempt(core, actor, None);
374                return Err(error);
375            }
376        };
377        if let Err(error) = core
378            .set_watcher_pid(watcher)
379            .and_then(|()| core.set_current_pid(actor))
380        {
381            self.rollback_actor_attempt(core, actor, Some(watcher));
382            return Err(error);
383        }
384        #[cfg(test)]
385        self.boot_barrier_rendezvous(actor);
386        if let Err(error) = core.boot(actor) {
387            self.rollback_actor_attempt(core, actor, Some(watcher));
388            return Err(error);
389        }
390        // A close/finalize that could not take the lifecycle gate (the actor's
391        // own Close slice) may have finalized the core while boot was in
392        // flight; a success return here would publish an actor nobody cleans.
393        if core.is_finalized() {
394            self.rollback_actor_attempt(core, actor, Some(watcher));
395            return Err(LiminalError::ConversationFailed {
396                message: "conversation is closed".to_owned(),
397            });
398        }
399        Ok(actor)
400    }
401
402    /// Aborts one spawn attempt: terminates every process the attempt created
403    /// and removes the actor registration. Idempotent — each step tolerates
404    /// already-dead pids and already-removed entries, so it composes with the
405    /// watcher's own cleanup and with a concurrent finalize.
406    fn rollback_actor_attempt(
407        &self,
408        core: &ActorCore,
409        actor: ParticipantPid,
410        watcher: Option<ParticipantPid>,
411    ) {
412        if let Some(watcher) = watcher {
413            self.scheduler
414                .terminate_process(watcher.get(), beamr::process::ExitReason::Normal);
415        }
416        self.scheduler
417            .terminate_process(actor.get(), beamr::process::ExitReason::Normal);
418        self.runtime.deregister_owned(actor, core);
419    }
420
421    /// Blocks the spawning thread at the arm→boot seam when a test installed a
422    /// rendezvous, handing it the actor pid and waiting for its proceed signal.
423    /// This is the held-gap injection point for the construction-ordering pins
424    /// (actor killed after watcher arm, before the boot enqueue); it does not
425    /// exist outside tests.
426    #[cfg(test)]
427    fn boot_barrier_rendezvous(&self, actor: ParticipantPid) {
428        let barrier = self
429            .boot_barrier
430            .lock()
431            .ok()
432            .and_then(|mut barrier| barrier.take());
433        if let Some((notify, proceed)) = barrier {
434            let _ = notify.send(actor);
435            let _ = proceed.recv();
436        }
437    }
438
439    /// Spawns the exit watcher for a freshly spawned actor process and waits
440    /// (bounded) for its first slice to arm trap-exit. The wait is what makes
441    /// the later boot-slice link race-free: a link created before the trap is
442    /// armed would let an abnormal actor exit cascade-kill the watcher
443    /// unobserved. Construction is already a blocking path (boot itself is a
444    /// command round trip); teardown paths never wait on the watcher.
445    fn spawn_watcher(
446        self: &Arc<Self>,
447        core: &Arc<ActorCore>,
448        actor: ParticipantPid,
449    ) -> Result<ParticipantPid, LiminalError> {
450        let (armed_tx, armed_rx) = mpsc::sync_channel::<()>(1);
451        let watcher_core = Arc::downgrade(core);
452        let watcher_supervisor = Arc::downgrade(self);
453        let factory = Box::new(move || {
454            Box::new(watcher::ActorExitWatcher::new(
455                watcher_core.clone(),
456                watcher_supervisor.clone(),
457                actor,
458                armed_tx.clone(),
459            )) as Box<dyn beamr::native::native_process::NativeHandler>
460        });
461        let watcher_pid = self.scheduler.spawn_native(factory).map_err(|error| {
462            LiminalError::ConversationFailed {
463                message: format!("failed to spawn conversation exit watcher: {error}"),
464            }
465        })?;
466        // One scheduler slice away; the generous bound only guards a wedged
467        // scheduler at construction time.
468        if armed_rx
469            .recv_timeout(std::time::Duration::from_secs(5))
470            .is_err()
471        {
472            self.scheduler
473                .terminate_process(watcher_pid, beamr::process::ExitReason::Normal);
474            return Err(LiminalError::ConversationFailed {
475                message: "conversation exit watcher failed to arm".to_owned(),
476            });
477        }
478        Ok(ParticipantPid::new(watcher_pid))
479    }
480}