Skip to main content

aion/runtime/
handle.rs

1//! `RuntimeHandle` spawn, register, cancel, and shutdown support.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5
6use aion_core::{ActivityError, Payload};
7use beamr::atom::AtomTable;
8use beamr::module::ModuleRegistry;
9use beamr::native::{BifRegistryImpl, NativeRegistrationError};
10use beamr::process::ExitReason;
11use beamr::scheduler::{Scheduler, SchedulerConfig};
12use beamr::term::Term;
13
14use crate::error::EngineError;
15
16use super::config::{RuntimeConfig, SignalDeliveryConfig};
17use super::nif::{Mfa, NifRegistration};
18use super::payload::payload_to_term;
19
20/// Local BEAM process identifier exposed by the runtime boundary.
21pub type Pid = u64;
22
23type RetainedHeap = Box<[u64]>;
24type RetainedHeaps = Vec<RetainedHeap>;
25type RetainedSpawnHeaps = Arc<dashmap::DashMap<Pid, Mutex<RetainedHeaps>>>;
26
27/// Runtime-owned workflow or activity input terms.
28///
29/// The wrapper keeps the beamr term representation inside the runtime module
30/// while later lifecycle and payload code decide how durable payloads become VM
31/// terms.
32#[derive(Debug, Default, Eq, PartialEq)]
33pub struct RuntimeInput {
34    terms: Vec<Term>,
35    heaps: RetainedHeaps,
36}
37
38impl RuntimeInput {
39    /// Convert one durable payload into the single BEAM argument used by
40    /// in-VM activity dispatch.
41    ///
42    /// The runtime boundary owns this representation. JSON payloads are passed
43    /// as BEAM binary terms and any boxed host heap backing those terms is
44    /// retained until the spawned process is observed exiting or cancelled.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`EngineError::Runtime`] when a JSON number does not fit in an
49    /// immediate small integer.
50    pub fn from_payload(payload: &Payload) -> Result<Self, EngineError> {
51        let (term, heaps) = payload_to_term(payload)?.into_parts();
52        Ok(Self {
53            terms: vec![term],
54            heaps,
55        })
56    }
57
58    /// Number of terms supplied to the BEAM entrypoint.
59    #[must_use]
60    pub fn arity(&self) -> u8 {
61        u8::try_from(self.terms.len()).unwrap_or(u8::MAX)
62    }
63
64    fn into_spawn_parts(self) -> (Vec<Term>, RetainedHeaps) {
65        (self.terms, self.heaps)
66    }
67}
68
69/// Handle to the embedded beamr scheduler and code-server state.
70pub struct RuntimeHandle {
71    pub(super) scheduler: Scheduler,
72    pub(super) atom_table: Arc<AtomTable>,
73    pub(super) module_registry: Arc<ModuleRegistry>,
74    pub(super) native_registry: Arc<BifRegistryImpl>,
75    nif_state: Arc<super::nif_state::EngineNifState>,
76    activity_results: Arc<dashmap::DashMap<(Pid, Pid), Payload>>,
77    activity_errors: Arc<dashmap::DashMap<(Pid, Pid), ActivityError>>,
78    /// One-based delivery attempt that produced a retained two-phase activity
79    /// outcome, keyed like [`Self::activity_results`] / [`Self::activity_errors`]
80    /// (#197). Noted by the completion task's retry loop when it delivers a
81    /// final outcome; taken by the awaiting NIF so recorded terminals carry
82    /// the genuine attempt. Absence means the first delivery (paths that never
83    /// retry — outbox re-delivery, in-VM — note nothing).
84    activity_delivery_attempts: Arc<dashmap::DashMap<(Pid, Pid), u32>>,
85    /// Live in-VM activity children per workflow pid.
86    ///
87    /// A BEAM link tears a child down when its workflow dies ABNORMALLY, but
88    /// a `Normal` exit never propagates through links (classic BEAM
89    /// semantics), so a workflow that completes while an in-VM runner is
90    /// still executing — e.g. after a `with_timeout` expiry abandoned the
91    /// await — would orphan the child forever, pinning its exit watcher's
92    /// blocking thread. The workflow process monitor kills any children
93    /// still registered here when the workflow exits (for any reason), and
94    /// [`Self::shutdown`] kills every remaining child so no watcher outlives
95    /// the scheduler.
96    in_vm_children: Arc<dashmap::DashMap<Pid, std::collections::HashSet<Pid>>>,
97    registered_nif_modules: Arc<dashmap::DashSet<String>>,
98    spawn_heaps: RetainedSpawnHeaps,
99    signal_delivery: SignalDeliveryConfig,
100    /// Flag gating the durable-outbox fan-out dispatch path; read by
101    /// `nif_collect.rs` to route fresh fan-out members and completions.
102    outbox_enabled: bool,
103    /// Bounded follow-up wakes for delivered mailbox markers, healing
104    /// beamr 0.4.9's lost-wakeup window (see [`super::wake_confirm`]).
105    pub(super) wake_confirmer: super::wake_confirm::WakeConfirmer,
106    /// Highest process identifier this runtime has spawned.
107    ///
108    /// beamr allocates pids from a monotonic counter, so any pid at or below
109    /// this watermark was spawned here and its exit outcome stays observable
110    /// through the scheduler's exit tombstones even after the process leaves
111    /// the live table. Monitor installation uses this to accept processes
112    /// that exited between spawn and monitor setup.
113    spawned_pid_watermark: AtomicU64,
114}
115
116impl RuntimeHandle {
117    /// Construct and start an embedded runtime from builder-supplied config.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`EngineError::Runtime`] when beamr cannot start its scheduler.
122    pub fn new(config: RuntimeConfig) -> Result<Self, EngineError> {
123        let atom_table = Arc::new(AtomTable::with_common_atoms());
124        let module_registry = Arc::new(ModuleRegistry::new());
125        // One NIF state per runtime instance, recovered by every native call
126        // through beamr's NIF private data — never process-wide globals.
127        let nif_state = Arc::new(super::nif_state::EngineNifState::default());
128        let scheduler_config = SchedulerConfig {
129            thread_count: config.thread_count,
130            nif_private_data: Some(Arc::clone(&nif_state) as _),
131            ..Default::default()
132        };
133        let native_registry = Arc::new(BifRegistryImpl::new());
134        register_all_bifs(&native_registry, &atom_table)?;
135        let scheduler = Scheduler::with_code_server(
136            scheduler_config,
137            Arc::clone(&module_registry),
138            Arc::clone(&atom_table),
139            Arc::clone(&native_registry),
140        )
141        .map_err(runtime_error_from_display)?;
142
143        Ok(Self {
144            scheduler,
145            atom_table,
146            module_registry,
147            native_registry,
148            nif_state,
149            activity_results: Arc::new(dashmap::DashMap::new()),
150            activity_errors: Arc::new(dashmap::DashMap::new()),
151            activity_delivery_attempts: Arc::new(dashmap::DashMap::new()),
152            in_vm_children: Arc::new(dashmap::DashMap::new()),
153            registered_nif_modules: Arc::new(dashmap::DashSet::new()),
154            spawn_heaps: Arc::new(dashmap::DashMap::new()),
155            signal_delivery: config.signal_delivery,
156            outbox_enabled: config.outbox_enabled,
157            wake_confirmer: super::wake_confirm::WakeConfirmer::new(config.signal_delivery)?,
158            spawned_pid_watermark: AtomicU64::new(0),
159        })
160    }
161
162    /// This runtime instance's engine-scoped NIF state.
163    pub(crate) fn nif_state(&self) -> &Arc<super::nif_state::EngineNifState> {
164        &self.nif_state
165    }
166
167    /// Builder-supplied delivery/readiness policy for spawn-window waits.
168    pub(crate) fn signal_delivery(&self) -> SignalDeliveryConfig {
169        self.signal_delivery
170    }
171
172    /// Whether the durable-outbox fan-out dispatch path is enabled.
173    ///
174    /// Read by `nif_collect.rs` to route fresh fan-out members through the
175    /// durable outbox and record completions via the dedup primitive.
176    pub(crate) fn outbox_enabled(&self) -> bool {
177        self.outbox_enabled
178    }
179
180    /// Install collected NIF entries into beamr's native registry.
181    ///
182    /// Consumes the registration collection so no caller can append more entries
183    /// after this installation step. Callers must invoke this before loading and
184    /// spawning workflow modules whose imports depend on these NIFs.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`EngineError::NifRegistration`] when beamr rejects an entry,
189    /// including duplicate module/function/arity registrations.
190    pub fn install_nifs(&self, registration: NifRegistration) -> Result<(), EngineError> {
191        for entry in registration.into_entries() {
192            let mfa = entry.mfa;
193            let module = self.atom_table.intern(&mfa.module);
194            let function = self.atom_table.intern(&mfa.function);
195            let capability = beamr::native::Capability::ExternalIo;
196            let result = if entry.is_dirty {
197                self.native_registry.register_dirty(
198                    module,
199                    function,
200                    mfa.arity,
201                    entry.function,
202                    beamr::scheduler::dirty::DirtySchedulerKind::Cpu,
203                    capability,
204                )
205            } else {
206                self.native_registry.register(
207                    module,
208                    function,
209                    mfa.arity,
210                    entry.function,
211                    capability,
212                )
213            };
214            result.map_err(|error| nif_registration_error(&mfa, error))?;
215            self.registered_nif_modules.insert(mfa.module);
216        }
217
218        Ok(())
219    }
220
221    /// Return module names that have registered NIFs and should not be
222    /// content-hash renamed during package loading.
223    #[must_use]
224    pub fn registered_nif_modules(&self) -> Vec<String> {
225        let mut module_names: Vec<_> = self
226            .registered_nif_modules
227            .iter()
228            .map(|module_name| module_name.key().clone())
229            .collect();
230        module_names.sort();
231        module_names
232    }
233
234    /// Spawn a top-level workflow process at a deployed module/function entrypoint.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
239    /// resolved or beamr rejects the spawn request.
240    pub fn spawn_workflow(
241        &self,
242        deployed_module: &str,
243        function: &str,
244        input: RuntimeInput,
245    ) -> Result<Pid, EngineError> {
246        self.spawn_process(deployed_module, function, input)
247    }
248
249    /// Spawn a top-level workflow process with trap-exit enabled before it runs.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
254    /// resolved or beamr rejects the spawn request.
255    pub fn spawn_workflow_trapping(
256        &self,
257        deployed_module: &str,
258        function: &str,
259        input: RuntimeInput,
260    ) -> Result<Pid, EngineError> {
261        self.release_dead_spawn_heaps();
262        let module = self.atom_table.intern(deployed_module);
263        let function = self.atom_table.intern(function);
264        let (terms, heaps) = input.into_spawn_parts();
265        let pid = self
266            .scheduler
267            .spawn_trap_exit(module, function, terms)
268            .map_err(runtime_error_from_display)?;
269        self.record_spawned_pid(pid);
270        self.retain_spawn_heaps(pid, heaps);
271        Ok(pid)
272    }
273
274    /// Spawn an activity child process linked to its workflow parent.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`EngineError::Runtime`] when the parent process is not live, the
279    /// module/function/arity cannot be resolved, or beamr rejects the linked
280    /// spawn request.
281    pub fn spawn_activity(
282        &self,
283        parent_pid: Pid,
284        deployed_module: &str,
285        function: &str,
286        input: RuntimeInput,
287    ) -> Result<Pid, EngineError> {
288        self.release_dead_spawn_heaps();
289        self.ensure_live_pid(parent_pid)?;
290        self.wait_for_process_ready(parent_pid)?;
291        let arity = input.arity();
292        let module = self.atom_table.intern(deployed_module);
293        let function_atom = self.atom_table.intern(function);
294        let (terms, heaps) = input.into_spawn_parts();
295        let pid = if self.is_dirty_with_arity(deployed_module, function, arity) {
296            self.scheduler
297                .spawn_link_dirty(parent_pid, module, function_atom, terms)
298                .map_err(runtime_error_from_display)?
299        } else {
300            self.scheduler
301                .spawn_link(parent_pid, module, function_atom, terms)
302                .map_err(runtime_error_from_display)?
303        };
304        self.record_spawned_pid(pid);
305        self.retain_spawn_heaps(pid, heaps);
306        Ok(pid)
307    }
308
309    /// Spawn an in-VM activity child process linked to its workflow parent,
310    /// running a zero-arity closure (the SDK-composed runner thunk).
311    ///
312    /// beamr deep-copies the closure's environment into the child's own heap
313    /// before the child becomes runnable (`Scheduler::spawn_link_closure`), so
314    /// no spawn heap is retained here and the caller's heap may move (GC) the
315    /// moment this returns. The child does not trap exits: workflow
316    /// cancellation propagates through the link, and an abnormal child exit is
317    /// observed by the in-VM completion watcher via [`Self::in_vm_child_outcome`].
318    ///
319    /// No parent readiness wait is performed: the only production caller is
320    /// the dispatch NIF executing ON the parent process, which is therefore
321    /// already materialized (a readiness poll on an `Executing` slot would be
322    /// pointless at best).
323    ///
324    /// # Errors
325    ///
326    /// Returns [`EngineError::Runtime`] when the parent is not live, the term
327    /// is not a zero-arity closure, or its module cannot be resolved.
328    pub fn spawn_activity_closure(
329        &self,
330        parent_pid: Pid,
331        closure_term: Term,
332    ) -> Result<Pid, EngineError> {
333        self.release_dead_spawn_heaps();
334        self.ensure_live_pid(parent_pid)?;
335        let pid = self
336            .scheduler
337            .spawn_link_closure(parent_pid, closure_term)
338            .map_err(runtime_error_from_display)?;
339        self.record_spawned_pid(pid);
340        self.in_vm_children
341            .entry(parent_pid)
342            .or_default()
343            .insert(pid);
344        // Close the external-kill registration race: if the workflow died
345        // between the liveness check above and this registration, the
346        // monitor's `kill_in_vm_children` sweep may already have run (and
347        // beamr's link may never have been established — a caller that died
348        // mid-spawn yields an UNLINKED child), which would leave a hanging
349        // runner alive until engine shutdown. Re-checking AFTER registration
350        // makes both orderings safe: a parent death after this point observes
351        // the registration and is swept by the monitor; a death before it is
352        // torn down here (both kill paths are idempotent — the sweep guards
353        // with `is_live`).
354        if !self.is_live(parent_pid) {
355            self.kill_in_vm_children(parent_pid);
356            return Err(EngineError::Runtime {
357                reason: format!(
358                    "in-vm activity child spawn: parent workflow process {parent_pid} exited during spawn"
359                ),
360            });
361        }
362        Ok(pid)
363    }
364
365    /// Drop a finished in-VM child from its workflow's teardown set (called
366    /// by the exit watcher once the child's outcome is decoded).
367    pub(crate) fn deregister_in_vm_child(&self, parent_pid: Pid, child_pid: Pid) {
368        if let Some(mut children) = self.in_vm_children.get_mut(&parent_pid) {
369            children.remove(&child_pid);
370        }
371        self.in_vm_children
372            .remove_if(&parent_pid, |_, children| children.is_empty());
373    }
374
375    /// Kill every in-VM activity child still registered for `workflow_pid`.
376    ///
377    /// Invoked by the workflow process monitor on workflow exit: a `Normal`
378    /// exit does not propagate through BEAM links, so a completed workflow
379    /// would otherwise orphan a still-running runner (and pin its exit
380    /// watcher's blocking thread forever). Killing writes the child's exit
381    /// tombstone, which unblocks the watcher; its delivery to the dead
382    /// workflow is refused and nothing is retained.
383    pub(crate) fn kill_in_vm_children(&self, workflow_pid: Pid) {
384        let Some((_, children)) = self.in_vm_children.remove(&workflow_pid) else {
385            return;
386        };
387        for child_pid in children {
388            if self.is_live(child_pid) {
389                tracing::debug!(
390                    workflow_pid,
391                    child_pid,
392                    "killing orphaned in-vm activity child on workflow exit"
393                );
394                self.scheduler
395                    .terminate_process(child_pid, ExitReason::Kill);
396            }
397            self.release_spawn_heaps(child_pid);
398        }
399    }
400
401    /// Return whether the registered native activity entry is dirty for arity 1.
402    #[must_use]
403    pub fn is_dirty(&self, module: &str, function: &str) -> bool {
404        self.is_dirty_with_arity(module, function, 1)
405    }
406
407    /// Return whether the registered native entry is dirty for the supplied arity.
408    #[must_use]
409    pub fn is_dirty_with_arity(&self, module: &str, function: &str, arity: u8) -> bool {
410        let module = self.atom_table.intern(module);
411        let function = self.atom_table.intern(function);
412        self.native_registry
413            .lookup(module, function, arity)
414            .is_some_and(|entry| entry.dirty_kind.is_some())
415    }
416
417    /// Cancel a live process by PID.
418    ///
419    /// # Errors
420    ///
421    /// Returns [`EngineError::Runtime`] when `pid` is not live.
422    pub fn cancel_pid(&self, pid: Pid) -> Result<(), EngineError> {
423        self.ensure_live_pid(pid)?;
424        self.scheduler.terminate_process(pid, ExitReason::Kill);
425        self.release_spawn_heaps(pid);
426        Ok(())
427    }
428
429    /// Set a live process' trap-exit flag, returning the previous value.
430    ///
431    /// # Errors
432    ///
433    /// Returns [`EngineError::Runtime`] when `pid` is not live.
434    pub fn set_trap_exit(&self, pid: Pid, value: bool) -> Result<bool, EngineError> {
435        self.scheduler
436            .set_trap_exit(pid, value)
437            .map_err(runtime_error_from_display)
438    }
439
440    /// Return true when `pid` is currently live.
441    #[must_use]
442    pub fn is_live(&self, pid: Pid) -> bool {
443        self.scheduler.process_table().get(pid).is_some()
444    }
445
446    /// Return a live process' trap-exit flag.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`EngineError::Runtime`] when `pid` is not live.
451    pub fn trap_exit(&self, pid: Pid) -> Result<bool, EngineError> {
452        self.scheduler
453            .trap_exit(pid)
454            .ok_or_else(|| runtime_error(format!("process {pid} is not live")))
455    }
456
457    /// Return true when two live processes have a bidirectional link.
458    ///
459    /// # Errors
460    ///
461    /// Returns [`EngineError::Runtime`] when either process is not live.
462    pub fn is_linked(&self, left: Pid, right: Pid) -> Result<bool, EngineError> {
463        self.ensure_live_pid(left)?;
464        self.ensure_live_pid(right)?;
465        Ok(self.scheduler.is_linked(left, right))
466    }
467
468    /// Shut down the embedded scheduler and wait for worker threads to stop.
469    ///
470    /// # Errors
471    ///
472    /// Currently infallible; reserved for typed runtime shutdown failures.
473    pub fn shutdown(&self) -> Result<(), EngineError> {
474        // Stop the wake-confirmation worker first: its only job is healing
475        // lost wakeups for a running scheduler, and pending follow-ups are
476        // moot once the scheduler stops.
477        self.wake_confirmer.shutdown();
478        // Kill every still-live in-VM activity child BEFORE stopping the
479        // scheduler: the kill writes each child's exit tombstone while the
480        // scheduler can still process it, releasing the exit watchers'
481        // blocking threads (`run_until_exit` polls tombstones forever and
482        // would otherwise pin its thread past this shutdown).
483        let workflow_pids: Vec<Pid> = self
484            .in_vm_children
485            .iter()
486            .map(|entry| *entry.key())
487            .collect();
488        for workflow_pid in workflow_pids {
489            self.kill_in_vm_children(workflow_pid);
490        }
491        self.scheduler.shutdown();
492        self.spawn_heaps.clear();
493        Ok(())
494    }
495
496    fn spawn_process(
497        &self,
498        deployed_module: &str,
499        function: &str,
500        input: RuntimeInput,
501    ) -> Result<Pid, EngineError> {
502        self.release_dead_spawn_heaps();
503        let module = self.atom_table.intern(deployed_module);
504        let function = self.atom_table.intern(function);
505        let (terms, heaps) = input.into_spawn_parts();
506        let pid = self
507            .scheduler
508            .spawn(module, function, terms)
509            .map_err(runtime_error_from_display)?;
510        self.record_spawned_pid(pid);
511        self.retain_spawn_heaps(pid, heaps);
512        Ok(pid)
513    }
514
515    fn record_spawned_pid(&self, pid: Pid) {
516        self.spawned_pid_watermark.fetch_max(pid, Ordering::AcqRel);
517    }
518
519    fn retain_spawn_heaps(&self, pid: Pid, heaps: RetainedHeaps) {
520        if heaps.is_empty() {
521            return;
522        }
523        self.spawn_heaps.insert(pid, Mutex::new(heaps));
524    }
525
526    pub(super) fn release_spawn_heaps(&self, pid: Pid) {
527        self.spawn_heaps.remove(&pid);
528    }
529
530    fn release_dead_spawn_heaps(&self) {
531        let dead_pids: Vec<Pid> = self
532            .spawn_heaps
533            .iter()
534            .filter_map(|entry| {
535                let pid = *entry.key();
536                self.scheduler
537                    .process_table()
538                    .get(pid)
539                    .is_none()
540                    .then_some(pid)
541            })
542            .collect();
543        for pid in dead_pids {
544            self.release_spawn_heaps(pid);
545        }
546    }
547
548    pub(super) fn ensure_live_pid(&self, pid: Pid) -> Result<(), EngineError> {
549        if self.scheduler.process_table().get(pid).is_some() {
550            Ok(())
551        } else {
552            Err(runtime_error(format!("process {pid} is not live")))
553        }
554    }
555
556    /// Ensure `pid` has an observable exit outcome: it is either live now or
557    /// was spawned by this runtime and already exited.
558    ///
559    /// A workflow can run to completion on a scheduler thread between its
560    /// spawn and the caller's monitor installation. The exited process is no
561    /// longer in the live table, but beamr keeps its exit tombstone, so
562    /// `run_until_exit` still returns the recorded outcome immediately.
563    /// Rejecting such pids would spuriously fail the spawn of any workflow
564    /// that finishes faster than its monitor is installed.
565    pub(super) fn ensure_monitorable_pid(&self, pid: Pid) -> Result<(), EngineError> {
566        if self.scheduler.process_table().get(pid).is_some() {
567            return Ok(());
568        }
569        if pid > 0 && pid <= self.spawned_pid_watermark.load(Ordering::Acquire) {
570            return Ok(());
571        }
572        Err(runtime_error(format!(
573            "process {pid} was never spawned by this runtime"
574        )))
575    }
576
577    /// Register a test module whose exported function waits indefinitely.
578    ///
579    /// This keeps lifecycle tests at the runtime boundary while still exercising
580    /// real module lookup and trap-exit workflow spawning.
581    #[cfg(test)]
582    pub fn register_waiting_test_module(&self, deployed_name: &str, function: &str) {
583        use std::collections::HashMap;
584
585        use beamr::loader::Instruction;
586        use beamr::loader::decode::compact::Operand;
587        use beamr::module::Module;
588
589        let module = self.atom_table.intern(deployed_name);
590        let function = self.atom_table.intern(function);
591        let label = 10;
592        self.module_registry.insert(Module {
593            name: module,
594            generation: 0,
595            origin: beamr::module::ModuleOrigin::Preloaded,
596            exports: HashMap::from([((function, 1), label)]),
597            label_index: HashMap::from([(label, 0)]),
598            code: vec![
599                Instruction::Label { label },
600                Instruction::Wait {
601                    fail: Operand::Label(label),
602                },
603            ],
604            function_table: Vec::new(),
605            line_table: Vec::new(),
606            literals: Vec::new(),
607            constant_pool: beamr::constant_pool::ConstantPool::new(),
608            resolved_imports: Vec::new(),
609            lambdas: Vec::new(),
610            string_table: Vec::new(),
611            line_info: Vec::new(),
612        });
613    }
614
615    /// Spawn an inert test process without module code.
616    ///
617    /// # Errors
618    ///
619    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
620    #[cfg(test)]
621    pub fn spawn_test_process(&self) -> Result<Pid, EngineError> {
622        let pid = self.scheduler.spawn_test_process(false);
623        self.record_spawned_pid(pid);
624        Ok(pid)
625    }
626
627    /// Spawn an inert test process with explicit trap-exit state.
628    ///
629    /// # Errors
630    ///
631    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
632    #[cfg(test)]
633    pub fn spawn_test_process_with_trap_exit(&self, trap_exit: bool) -> Result<Pid, EngineError> {
634        let pid = self.scheduler.spawn_test_process(trap_exit);
635        self.record_spawned_pid(pid);
636        Ok(pid)
637    }
638
639    /// Spawn an inert linked test child without enabling trap-exit on the child.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`EngineError::Runtime`] when the parent is not live or beamr
644    /// rejects the linked spawn.
645    #[cfg(test)]
646    pub fn spawn_linked_test_process(&self, parent_pid: Pid) -> Result<Pid, EngineError> {
647        self.ensure_live_pid(parent_pid)?;
648        let pid = self
649            .scheduler
650            .spawn_linked_test_process(parent_pid)
651            .map_err(runtime_error_from_display)?;
652        self.record_spawned_pid(pid);
653        Ok(pid)
654    }
655
656    /// Return true when a live process has a trapped EXIT message from `source_pid`.
657    ///
658    /// # Errors
659    ///
660    /// Returns [`EngineError::Runtime`] when `target_pid` is not live.
661    #[cfg(test)]
662    pub fn has_trapped_exit_message(
663        &self,
664        target_pid: Pid,
665        source_pid: Pid,
666    ) -> Result<bool, EngineError> {
667        self.ensure_live_pid(target_pid)?;
668        Ok(self
669            .scheduler
670            .has_trapped_exit_message(target_pid, source_pid)
671            .unwrap_or(false))
672    }
673
674    /// Poll until a trapped EXIT message from `source_pid` arrives at `target_pid`.
675    ///
676    /// beamr delivers exit signals asynchronously after process termination.
677    /// Tests that assert on trapped exit messages must wait for delivery.
678    ///
679    /// # Errors
680    ///
681    /// Returns [`EngineError::Runtime`] if the message does not arrive within 50ms.
682    #[cfg(test)]
683    pub fn wait_for_trapped_exit(
684        &self,
685        target_pid: Pid,
686        source_pid: Pid,
687    ) -> Result<(), EngineError> {
688        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
689        while std::time::Instant::now() < deadline {
690            if self
691                .scheduler
692                .has_trapped_exit_message(target_pid, source_pid)
693                .unwrap_or(false)
694            {
695                return Ok(());
696            }
697            std::thread::sleep(std::time::Duration::from_millis(1));
698        }
699        Err(runtime_error(format!(
700            "trapped exit from {source_pid} to {target_pid} did not arrive"
701        )))
702    }
703
704    /// Terminate a test process with a trappable abnormal reason.
705    ///
706    /// # Errors
707    ///
708    /// Returns [`EngineError::Runtime`] when `pid` is not live.
709    #[cfg(test)]
710    pub fn terminate_test_process_with_error(&self, pid: Pid) -> Result<(), EngineError> {
711        self.ensure_live_pid(pid)?;
712        self.scheduler.terminate_process(pid, ExitReason::Error);
713        Ok(())
714    }
715
716    #[cfg(test)]
717    pub(crate) fn lookup_native_for_test(
718        &self,
719        module: &str,
720        function: &str,
721        arity: u8,
722    ) -> Option<beamr::native::NativeEntry> {
723        let module = self.atom_table.intern(module);
724        let function = self.atom_table.intern(function);
725        self.native_registry.lookup(module, function, arity)
726    }
727
728    #[cfg(test)]
729    pub(crate) fn run_until_exit_for_test(&self, pid: Pid) -> (ExitReason, Term) {
730        let (reason, owned_result) = self.scheduler.run_until_exit(pid);
731        self.release_spawn_heaps(pid);
732        (reason, owned_result.root())
733    }
734
735    #[cfg(test)]
736    pub(crate) fn retained_spawn_heap_count_for_test(&self) -> usize {
737        self.release_dead_spawn_heaps();
738        self.spawn_heaps.len()
739    }
740}
741
742fn runtime_error(reason: String) -> EngineError {
743    EngineError::Runtime { reason }
744}
745
746fn runtime_error_from_display(reason: impl std::fmt::Display) -> EngineError {
747    runtime_error(reason.to_string())
748}
749
750fn nif_registration_error(mfa: &Mfa, error: NativeRegistrationError) -> EngineError {
751    match error {
752        NativeRegistrationError::DuplicateMfa { .. } => EngineError::NifRegistration {
753            reason: format!("native function already registered for {}", mfa.display()),
754        },
755    }
756}
757
758fn register_all_bifs(
759    registry: &BifRegistryImpl,
760    atom_table: &AtomTable,
761) -> Result<(), EngineError> {
762    use beamr::native::{
763        bifs::register_gate1_bifs, gate3_bifs::register_gate3_bifs,
764        gleam_ffi::register_gleam_ffi_bifs, otp_stubs::init_otp_atoms,
765        otp_stubs::register_otp_stubs, process_bifs::register_gate2_bifs,
766        selector_ffi::register_selector_bifs, stdlib_stubs::register_stdlib_stubs,
767    };
768    register_gate1_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
769    register_gate2_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
770    register_gate3_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
771    register_stdlib_stubs(registry, atom_table).map_err(runtime_error_from_display)?;
772    register_selector_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
773    register_gleam_ffi_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
774    init_otp_atoms(atom_table);
775    register_otp_stubs(registry, atom_table).map_err(runtime_error_from_display)?;
776    Ok(())
777}
778
779mod delivery;
780
781pub(crate) use delivery::InVmChildOutcome;
782
783#[cfg(test)]
784#[path = "handle/test_support.rs"]
785mod test_support;
786
787#[cfg(test)]
788mod tests {
789    use aion_core::Payload;
790    use std::time::Duration;
791
792    use beamr::loader::Instruction;
793    use beamr::loader::decode::compact::Operand;
794    use beamr::module::{Module, ResolvedImport, ResolvedImportTarget};
795    use beamr::native::ProcessContext;
796    use beamr::term::Term;
797    use beamr::term::binary_ref::BinaryRef;
798
799    use super::{RuntimeHandle, RuntimeInput};
800    use crate::error::EngineError;
801    use crate::runtime::{Mfa, NifEntry, NifRegistration, RuntimeConfig, SignalDeliveryConfig};
802
803    fn forty_two(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
804        if args.len() > 255 {
805            return Err(Term::small_int(0));
806        }
807        Ok(Term::small_int(42))
808    }
809
810    fn thirteen(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
811        if args.len() > 255 {
812            return Err(Term::small_int(0));
813        }
814        Ok(Term::small_int(13))
815    }
816
817    fn binary_length(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
818        match args {
819            [term] => BinaryRef::new(*term)
820                .and_then(|binary| i64::try_from(binary.as_bytes().len()).ok())
821                .map(Term::small_int)
822                .ok_or_else(|| Term::small_int(0)),
823            _ => Err(Term::small_int(0)),
824        }
825    }
826
827    fn native_call_module_for_test(
828        module: beamr::atom::Atom,
829        function: beamr::atom::Atom,
830        target_module: beamr::atom::Atom,
831        target_function: beamr::atom::Atom,
832        native_entry: Option<beamr::native::NativeEntry>,
833    ) -> Module {
834        native_call_module_with_arity_for_test(
835            module,
836            function,
837            target_module,
838            target_function,
839            0,
840            native_entry,
841        )
842    }
843
844    fn native_call_module_with_arity_for_test(
845        module: beamr::atom::Atom,
846        function: beamr::atom::Atom,
847        target_module: beamr::atom::Atom,
848        target_function: beamr::atom::Atom,
849        arity: u8,
850        native_entry: Option<beamr::native::NativeEntry>,
851    ) -> Module {
852        let label = 1;
853        let code = vec![
854            Instruction::Label { label },
855            Instruction::CallExt {
856                arity: Operand::Unsigned(arity.into()),
857                import: Operand::Unsigned(0),
858            },
859            Instruction::Return,
860        ];
861        let mut module_data = Module {
862            name: module,
863            generation: 0,
864            origin: beamr::module::ModuleOrigin::Preloaded,
865            exports: std::collections::HashMap::from([((function, arity), label)]),
866            label_index: std::collections::HashMap::from([(label, 0)]),
867            code,
868            function_table: Vec::new(),
869            line_table: Vec::new(),
870            literals: Vec::new(),
871            constant_pool: beamr::constant_pool::ConstantPool::new(),
872            resolved_imports: Vec::new(),
873            lambdas: Vec::new(),
874            string_table: Vec::new(),
875            line_info: Vec::new(),
876        };
877        if let Some(native_entry) = native_entry {
878            module_data.resolved_imports.push(ResolvedImport {
879                module: target_module,
880                function: target_function,
881                arity,
882                target: ResolvedImportTarget::Native(native_entry),
883            });
884        }
885        module_data
886    }
887
888    fn assert_send_sync<T: Send + Sync>() {}
889
890    fn fixture_workflow_beam() -> &'static [u8] {
891        include_bytes!("../../tests/fixtures/aion_fixture_workflow.beam")
892    }
893
894    #[test]
895    fn runtime_handle_is_send_sync() {
896        assert_send_sync::<RuntimeHandle>();
897    }
898
899    #[test]
900    fn registers_spawns_and_shuts_down() -> Result<(), Box<dyn std::error::Error>> {
901        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
902        runtime.register_module("aion_fixture_workflow", fixture_workflow_beam())?;
903
904        let pid =
905            runtime.spawn_workflow("aion_fixture_workflow", "wait", RuntimeInput::default())?;
906        assert!(runtime.cancel_pid(pid).is_ok());
907        runtime.shutdown()?;
908        Ok(())
909    }
910
911    #[test]
912    fn signal_delivery_to_dead_process_returns_typed_error()
913    -> Result<(), Box<dyn std::error::Error>> {
914        let signal_delivery =
915            SignalDeliveryConfig::new(Duration::ZERO, 1, Duration::ZERO, Duration::ZERO);
916        let runtime =
917            RuntimeHandle::new(RuntimeConfig::new(Some(1)).with_signal_delivery(signal_delivery))?;
918        let pid = runtime.spawn_test_process()?;
919        runtime.terminate_test_process_with_error(pid)?;
920
921        let error = runtime
922            .deliver_signal_received(pid)
923            .err()
924            .ok_or("dead process delivery unexpectedly succeeded")?;
925
926        assert!(matches!(error, EngineError::Runtime { .. }));
927        runtime.shutdown()?;
928        Ok(())
929    }
930
931    #[test]
932    fn duplicate_nif_mfa_returns_typed_error() -> Result<(), Box<dyn std::error::Error>> {
933        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
934        let mfa = Mfa::new("host", "answer", 0);
935        let mut registration = NifRegistration::new();
936        registration.add_host_nifs([
937            NifEntry::new(mfa.clone(), forty_two),
938            NifEntry::dirty(mfa, thirteen),
939        ]);
940
941        let error = runtime.install_nifs(registration).err();
942
943        assert!(matches!(
944            error,
945            Some(EngineError::NifRegistration { reason })
946                if reason.contains("host:answer/0")
947        ));
948        assert_eq!(runtime.registered_nif_modules(), vec!["host"]);
949        runtime.shutdown()?;
950        Ok(())
951    }
952
953    #[test]
954    fn payload_binary_remains_valid_through_spawn_and_is_released()
955    -> Result<(), Box<dyn std::error::Error>> {
956        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
957        let mfa = Mfa::new("host", "binary_length", 1);
958        let mut registration = NifRegistration::new();
959        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
960        runtime.install_nifs(registration)?;
961
962        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
963        let module = native_call_module_with_arity_for_test(
964            runtime.atom_table.intern("payload_echo"),
965            runtime.atom_table.intern("run"),
966            runtime.atom_table.intern("host"),
967            runtime.atom_table.intern("binary_length"),
968            1,
969            native_entry,
970        );
971        runtime.module_registry.insert(module);
972        let payload = Payload::new(
973            aion_core::ContentType::Json,
974            br#"{"hello":"world"}"#.to_vec(),
975        );
976
977        let pid =
978            runtime.spawn_workflow("payload_echo", "run", RuntimeInput::from_payload(&payload)?)?;
979        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 1);
980        let (reason, result) = runtime.run_until_exit_for_test(pid);
981
982        assert_eq!(reason, beamr::process::ExitReason::Normal);
983        assert_eq!(
984            result.as_small_int(),
985            Some(i64::try_from(payload.bytes().len()).unwrap_or(0))
986        );
987        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
988        runtime.shutdown()?;
989        Ok(())
990    }
991
992    #[test]
993    fn workflow_outcome_releases_payload_heaps() -> Result<(), Box<dyn std::error::Error>> {
994        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
995        let mfa = Mfa::new("host", "binary_length", 1);
996        let mut registration = NifRegistration::new();
997        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
998        runtime.install_nifs(registration)?;
999
1000        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
1001        let module = native_call_module_with_arity_for_test(
1002            runtime.atom_table.intern("payload_workflow_outcome"),
1003            runtime.atom_table.intern("run"),
1004            runtime.atom_table.intern("host"),
1005            runtime.atom_table.intern("binary_length"),
1006            1,
1007            native_entry,
1008        );
1009        runtime.module_registry.insert(module);
1010        let payload = Payload::new(
1011            aion_core::ContentType::Json,
1012            br#"{"workflow":"outcome"}"#.to_vec(),
1013        );
1014
1015        let pid = runtime.spawn_workflow(
1016            "payload_workflow_outcome",
1017            "run",
1018            RuntimeInput::from_payload(&payload)?,
1019        )?;
1020        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 1);
1021        let outcome = runtime.workflow_outcome(pid)?;
1022
1023        assert_eq!(
1024            outcome?,
1025            Payload::from_json(&serde_json::json!(payload.bytes().len()))?
1026        );
1027        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
1028        runtime.shutdown()?;
1029        Ok(())
1030    }
1031
1032    #[test]
1033    fn repeated_completed_payload_spawns_do_not_accumulate_retained_heaps()
1034    -> Result<(), Box<dyn std::error::Error>> {
1035        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
1036        let mfa = Mfa::new("host", "binary_length", 1);
1037        let mut registration = NifRegistration::new();
1038        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
1039        runtime.install_nifs(registration)?;
1040
1041        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
1042        let module = native_call_module_with_arity_for_test(
1043            runtime.atom_table.intern("payload_echo_many"),
1044            runtime.atom_table.intern("run"),
1045            runtime.atom_table.intern("host"),
1046            runtime.atom_table.intern("binary_length"),
1047            1,
1048            native_entry,
1049        );
1050        runtime.module_registry.insert(module);
1051        let payload = Payload::new(
1052            aion_core::ContentType::Json,
1053            br#"{"iteration":true}"#.to_vec(),
1054        );
1055
1056        for _ in 0..1_000 {
1057            let pid = runtime.spawn_workflow(
1058                "payload_echo_many",
1059                "run",
1060                RuntimeInput::from_payload(&payload)?,
1061            )?;
1062            let (reason, result) = runtime.run_until_exit_for_test(pid);
1063            assert_eq!(reason, beamr::process::ExitReason::Normal);
1064            assert_eq!(
1065                result.as_small_int(),
1066                Some(i64::try_from(payload.bytes().len()).unwrap_or(0))
1067            );
1068            assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
1069        }
1070
1071        runtime.shutdown()?;
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn distinct_nifs_are_registered_and_callable() -> Result<(), Box<dyn std::error::Error>> {
1077        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
1078        let mut registration = NifRegistration::new();
1079        registration.add_engine_nifs().add_host_nifs([
1080            NifEntry::new(Mfa::new("host", "answer", 0), forty_two),
1081            NifEntry::dirty(Mfa::new("host", "thirteen", 0), thirteen),
1082        ]);
1083
1084        runtime.install_nifs(registration)?;
1085
1086        assert_eq!(
1087            runtime.registered_nif_modules(),
1088            vec!["aion_flow_ffi", "host"]
1089        );
1090        let answer = runtime.lookup_native_for_test("host", "answer", 0);
1091        assert!(answer.is_some());
1092        assert!(
1093            runtime
1094                .lookup_native_for_test("host", "thirteen", 0)
1095                .is_some_and(|entry| entry.dirty_kind.is_some())
1096        );
1097
1098        let host_nif_call = native_call_module_for_test(
1099            runtime.atom_table.intern("host_nif_call"),
1100            runtime.atom_table.intern("answer"),
1101            runtime.atom_table.intern("host"),
1102            runtime.atom_table.intern("answer"),
1103            answer,
1104        );
1105        runtime.module_registry.insert(host_nif_call);
1106        let pid = runtime.spawn_workflow("host_nif_call", "answer", RuntimeInput::default())?;
1107        let (reason, result) = runtime.run_until_exit_for_test(pid);
1108
1109        assert_eq!(reason, beamr::process::ExitReason::Normal);
1110        assert_eq!(result, Term::small_int(42));
1111        runtime.shutdown()?;
1112        Ok(())
1113    }
1114}