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    registered_nif_modules: Arc<dashmap::DashSet<String>>,
79    spawn_heaps: RetainedSpawnHeaps,
80    signal_delivery: SignalDeliveryConfig,
81    /// Flag gating the durable-outbox fan-out dispatch path; read by
82    /// `nif_collect.rs` to route fresh fan-out members and completions.
83    outbox_enabled: bool,
84    /// Bounded follow-up wakes for delivered mailbox markers, healing
85    /// beamr 0.4.9's lost-wakeup window (see [`super::wake_confirm`]).
86    pub(super) wake_confirmer: super::wake_confirm::WakeConfirmer,
87    /// Highest process identifier this runtime has spawned.
88    ///
89    /// beamr allocates pids from a monotonic counter, so any pid at or below
90    /// this watermark was spawned here and its exit outcome stays observable
91    /// through the scheduler's exit tombstones even after the process leaves
92    /// the live table. Monitor installation uses this to accept processes
93    /// that exited between spawn and monitor setup.
94    spawned_pid_watermark: AtomicU64,
95}
96
97impl RuntimeHandle {
98    /// Construct and start an embedded runtime from builder-supplied config.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`EngineError::Runtime`] when beamr cannot start its scheduler.
103    pub fn new(config: RuntimeConfig) -> Result<Self, EngineError> {
104        let atom_table = Arc::new(AtomTable::with_common_atoms());
105        let module_registry = Arc::new(ModuleRegistry::new());
106        // One NIF state per runtime instance, recovered by every native call
107        // through beamr's NIF private data — never process-wide globals.
108        let nif_state = Arc::new(super::nif_state::EngineNifState::default());
109        let scheduler_config = SchedulerConfig {
110            thread_count: config.thread_count,
111            nif_private_data: Some(Arc::clone(&nif_state) as _),
112            ..Default::default()
113        };
114        let native_registry = Arc::new(BifRegistryImpl::new());
115        register_all_bifs(&native_registry, &atom_table)?;
116        let scheduler = Scheduler::with_code_server(
117            scheduler_config,
118            Arc::clone(&module_registry),
119            Arc::clone(&atom_table),
120            Arc::clone(&native_registry),
121        )
122        .map_err(runtime_error_from_display)?;
123
124        Ok(Self {
125            scheduler,
126            atom_table,
127            module_registry,
128            native_registry,
129            nif_state,
130            activity_results: Arc::new(dashmap::DashMap::new()),
131            activity_errors: Arc::new(dashmap::DashMap::new()),
132            registered_nif_modules: Arc::new(dashmap::DashSet::new()),
133            spawn_heaps: Arc::new(dashmap::DashMap::new()),
134            signal_delivery: config.signal_delivery,
135            outbox_enabled: config.outbox_enabled,
136            wake_confirmer: super::wake_confirm::WakeConfirmer::new(config.signal_delivery)?,
137            spawned_pid_watermark: AtomicU64::new(0),
138        })
139    }
140
141    /// This runtime instance's engine-scoped NIF state.
142    pub(crate) fn nif_state(&self) -> &Arc<super::nif_state::EngineNifState> {
143        &self.nif_state
144    }
145
146    /// Builder-supplied delivery/readiness policy for spawn-window waits.
147    pub(crate) fn signal_delivery(&self) -> SignalDeliveryConfig {
148        self.signal_delivery
149    }
150
151    /// Whether the durable-outbox fan-out dispatch path is enabled.
152    ///
153    /// Read by `nif_collect.rs` to route fresh fan-out members through the
154    /// durable outbox and record completions via the dedup primitive.
155    pub(crate) fn outbox_enabled(&self) -> bool {
156        self.outbox_enabled
157    }
158
159    /// Install collected NIF entries into beamr's native registry.
160    ///
161    /// Consumes the registration collection so no caller can append more entries
162    /// after this installation step. Callers must invoke this before loading and
163    /// spawning workflow modules whose imports depend on these NIFs.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`EngineError::NifRegistration`] when beamr rejects an entry,
168    /// including duplicate module/function/arity registrations.
169    pub fn install_nifs(&self, registration: NifRegistration) -> Result<(), EngineError> {
170        for entry in registration.into_entries() {
171            let mfa = entry.mfa;
172            let module = self.atom_table.intern(&mfa.module);
173            let function = self.atom_table.intern(&mfa.function);
174            let capability = beamr::native::Capability::ExternalIo;
175            let result = if entry.is_dirty {
176                self.native_registry.register_dirty(
177                    module,
178                    function,
179                    mfa.arity,
180                    entry.function,
181                    beamr::scheduler::dirty::DirtySchedulerKind::Cpu,
182                    capability,
183                )
184            } else {
185                self.native_registry.register(
186                    module,
187                    function,
188                    mfa.arity,
189                    entry.function,
190                    capability,
191                )
192            };
193            result.map_err(|error| nif_registration_error(&mfa, error))?;
194            self.registered_nif_modules.insert(mfa.module);
195        }
196
197        Ok(())
198    }
199
200    /// Return module names that have registered NIFs and should not be
201    /// content-hash renamed during package loading.
202    #[must_use]
203    pub fn registered_nif_modules(&self) -> Vec<String> {
204        let mut module_names: Vec<_> = self
205            .registered_nif_modules
206            .iter()
207            .map(|module_name| module_name.key().clone())
208            .collect();
209        module_names.sort();
210        module_names
211    }
212
213    /// Spawn a top-level workflow process at a deployed module/function entrypoint.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
218    /// resolved or beamr rejects the spawn request.
219    pub fn spawn_workflow(
220        &self,
221        deployed_module: &str,
222        function: &str,
223        input: RuntimeInput,
224    ) -> Result<Pid, EngineError> {
225        self.spawn_process(deployed_module, function, input)
226    }
227
228    /// Spawn a top-level workflow process with trap-exit enabled before it runs.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
233    /// resolved or beamr rejects the spawn request.
234    pub fn spawn_workflow_trapping(
235        &self,
236        deployed_module: &str,
237        function: &str,
238        input: RuntimeInput,
239    ) -> Result<Pid, EngineError> {
240        self.release_dead_spawn_heaps();
241        let module = self.atom_table.intern(deployed_module);
242        let function = self.atom_table.intern(function);
243        let (terms, heaps) = input.into_spawn_parts();
244        let pid = self
245            .scheduler
246            .spawn_trap_exit(module, function, terms)
247            .map_err(runtime_error_from_display)?;
248        self.record_spawned_pid(pid);
249        self.retain_spawn_heaps(pid, heaps);
250        Ok(pid)
251    }
252
253    /// Spawn an activity child process linked to its workflow parent.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`EngineError::Runtime`] when the parent process is not live, the
258    /// module/function/arity cannot be resolved, or beamr rejects the linked
259    /// spawn request.
260    pub fn spawn_activity(
261        &self,
262        parent_pid: Pid,
263        deployed_module: &str,
264        function: &str,
265        input: RuntimeInput,
266    ) -> Result<Pid, EngineError> {
267        self.release_dead_spawn_heaps();
268        self.ensure_live_pid(parent_pid)?;
269        self.wait_for_process_ready(parent_pid)?;
270        let arity = input.arity();
271        let module = self.atom_table.intern(deployed_module);
272        let function_atom = self.atom_table.intern(function);
273        let (terms, heaps) = input.into_spawn_parts();
274        let pid = if self.is_dirty_with_arity(deployed_module, function, arity) {
275            self.scheduler
276                .spawn_link_dirty(parent_pid, module, function_atom, terms)
277                .map_err(runtime_error_from_display)?
278        } else {
279            self.scheduler
280                .spawn_link(parent_pid, module, function_atom, terms)
281                .map_err(runtime_error_from_display)?
282        };
283        self.record_spawned_pid(pid);
284        self.retain_spawn_heaps(pid, heaps);
285        Ok(pid)
286    }
287
288    /// Return whether the registered native activity entry is dirty for arity 1.
289    #[must_use]
290    pub fn is_dirty(&self, module: &str, function: &str) -> bool {
291        self.is_dirty_with_arity(module, function, 1)
292    }
293
294    /// Return whether the registered native entry is dirty for the supplied arity.
295    #[must_use]
296    pub fn is_dirty_with_arity(&self, module: &str, function: &str, arity: u8) -> bool {
297        let module = self.atom_table.intern(module);
298        let function = self.atom_table.intern(function);
299        self.native_registry
300            .lookup(module, function, arity)
301            .is_some_and(|entry| entry.dirty_kind.is_some())
302    }
303
304    /// Cancel a live process by PID.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`EngineError::Runtime`] when `pid` is not live.
309    pub fn cancel_pid(&self, pid: Pid) -> Result<(), EngineError> {
310        self.ensure_live_pid(pid)?;
311        self.scheduler.terminate_process(pid, ExitReason::Kill);
312        self.release_spawn_heaps(pid);
313        Ok(())
314    }
315
316    /// Set a live process' trap-exit flag, returning the previous value.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`EngineError::Runtime`] when `pid` is not live.
321    pub fn set_trap_exit(&self, pid: Pid, value: bool) -> Result<bool, EngineError> {
322        self.scheduler
323            .set_trap_exit(pid, value)
324            .map_err(runtime_error_from_display)
325    }
326
327    /// Return true when `pid` is currently live.
328    #[must_use]
329    pub fn is_live(&self, pid: Pid) -> bool {
330        self.scheduler.process_table().get(pid).is_some()
331    }
332
333    /// Return a live process' trap-exit flag.
334    ///
335    /// # Errors
336    ///
337    /// Returns [`EngineError::Runtime`] when `pid` is not live.
338    pub fn trap_exit(&self, pid: Pid) -> Result<bool, EngineError> {
339        self.scheduler
340            .trap_exit(pid)
341            .ok_or_else(|| runtime_error(format!("process {pid} is not live")))
342    }
343
344    /// Return true when two live processes have a bidirectional link.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`EngineError::Runtime`] when either process is not live.
349    pub fn is_linked(&self, left: Pid, right: Pid) -> Result<bool, EngineError> {
350        self.ensure_live_pid(left)?;
351        self.ensure_live_pid(right)?;
352        Ok(self.scheduler.is_linked(left, right))
353    }
354
355    /// Shut down the embedded scheduler and wait for worker threads to stop.
356    ///
357    /// # Errors
358    ///
359    /// Currently infallible; reserved for typed runtime shutdown failures.
360    pub fn shutdown(&self) -> Result<(), EngineError> {
361        // Stop the wake-confirmation worker first: its only job is healing
362        // lost wakeups for a running scheduler, and pending follow-ups are
363        // moot once the scheduler stops.
364        self.wake_confirmer.shutdown();
365        self.scheduler.shutdown();
366        self.spawn_heaps.clear();
367        Ok(())
368    }
369
370    fn spawn_process(
371        &self,
372        deployed_module: &str,
373        function: &str,
374        input: RuntimeInput,
375    ) -> Result<Pid, EngineError> {
376        self.release_dead_spawn_heaps();
377        let module = self.atom_table.intern(deployed_module);
378        let function = self.atom_table.intern(function);
379        let (terms, heaps) = input.into_spawn_parts();
380        let pid = self
381            .scheduler
382            .spawn(module, function, terms)
383            .map_err(runtime_error_from_display)?;
384        self.record_spawned_pid(pid);
385        self.retain_spawn_heaps(pid, heaps);
386        Ok(pid)
387    }
388
389    fn record_spawned_pid(&self, pid: Pid) {
390        self.spawned_pid_watermark.fetch_max(pid, Ordering::AcqRel);
391    }
392
393    fn retain_spawn_heaps(&self, pid: Pid, heaps: RetainedHeaps) {
394        if heaps.is_empty() {
395            return;
396        }
397        self.spawn_heaps.insert(pid, Mutex::new(heaps));
398    }
399
400    pub(super) fn release_spawn_heaps(&self, pid: Pid) {
401        self.spawn_heaps.remove(&pid);
402    }
403
404    fn release_dead_spawn_heaps(&self) {
405        let dead_pids: Vec<Pid> = self
406            .spawn_heaps
407            .iter()
408            .filter_map(|entry| {
409                let pid = *entry.key();
410                self.scheduler
411                    .process_table()
412                    .get(pid)
413                    .is_none()
414                    .then_some(pid)
415            })
416            .collect();
417        for pid in dead_pids {
418            self.release_spawn_heaps(pid);
419        }
420    }
421
422    pub(super) fn ensure_live_pid(&self, pid: Pid) -> Result<(), EngineError> {
423        if self.scheduler.process_table().get(pid).is_some() {
424            Ok(())
425        } else {
426            Err(runtime_error(format!("process {pid} is not live")))
427        }
428    }
429
430    /// Ensure `pid` has an observable exit outcome: it is either live now or
431    /// was spawned by this runtime and already exited.
432    ///
433    /// A workflow can run to completion on a scheduler thread between its
434    /// spawn and the caller's monitor installation. The exited process is no
435    /// longer in the live table, but beamr keeps its exit tombstone, so
436    /// `run_until_exit` still returns the recorded outcome immediately.
437    /// Rejecting such pids would spuriously fail the spawn of any workflow
438    /// that finishes faster than its monitor is installed.
439    pub(super) fn ensure_monitorable_pid(&self, pid: Pid) -> Result<(), EngineError> {
440        if self.scheduler.process_table().get(pid).is_some() {
441            return Ok(());
442        }
443        if pid > 0 && pid <= self.spawned_pid_watermark.load(Ordering::Acquire) {
444            return Ok(());
445        }
446        Err(runtime_error(format!(
447            "process {pid} was never spawned by this runtime"
448        )))
449    }
450
451    /// Register a test module whose exported function waits indefinitely.
452    ///
453    /// This keeps lifecycle tests at the runtime boundary while still exercising
454    /// real module lookup and trap-exit workflow spawning.
455    #[cfg(test)]
456    pub fn register_waiting_test_module(&self, deployed_name: &str, function: &str) {
457        use std::collections::HashMap;
458
459        use beamr::loader::Instruction;
460        use beamr::loader::decode::compact::Operand;
461        use beamr::module::Module;
462
463        let module = self.atom_table.intern(deployed_name);
464        let function = self.atom_table.intern(function);
465        let label = 10;
466        self.module_registry.insert(Module {
467            name: module,
468            generation: 0,
469            origin: beamr::module::ModuleOrigin::Preloaded,
470            exports: HashMap::from([((function, 1), label)]),
471            label_index: HashMap::from([(label, 0)]),
472            code: vec![
473                Instruction::Label { label },
474                Instruction::Wait {
475                    fail: Operand::Label(label),
476                },
477            ],
478            function_table: Vec::new(),
479            line_table: Vec::new(),
480            literals: Vec::new(),
481            constant_pool: beamr::constant_pool::ConstantPool::new(),
482            resolved_imports: Vec::new(),
483            lambdas: Vec::new(),
484            string_table: Vec::new(),
485            line_info: Vec::new(),
486        });
487    }
488
489    /// Spawn an inert test process without module code.
490    ///
491    /// # Errors
492    ///
493    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
494    #[cfg(test)]
495    pub fn spawn_test_process(&self) -> Result<Pid, EngineError> {
496        let pid = self.scheduler.spawn_test_process(false);
497        self.record_spawned_pid(pid);
498        Ok(pid)
499    }
500
501    /// Spawn an inert test process with explicit trap-exit state.
502    ///
503    /// # Errors
504    ///
505    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
506    #[cfg(test)]
507    pub fn spawn_test_process_with_trap_exit(&self, trap_exit: bool) -> Result<Pid, EngineError> {
508        let pid = self.scheduler.spawn_test_process(trap_exit);
509        self.record_spawned_pid(pid);
510        Ok(pid)
511    }
512
513    /// Spawn an inert linked test child without enabling trap-exit on the child.
514    ///
515    /// # Errors
516    ///
517    /// Returns [`EngineError::Runtime`] when the parent is not live or beamr
518    /// rejects the linked spawn.
519    #[cfg(test)]
520    pub fn spawn_linked_test_process(&self, parent_pid: Pid) -> Result<Pid, EngineError> {
521        self.ensure_live_pid(parent_pid)?;
522        let pid = self
523            .scheduler
524            .spawn_linked_test_process(parent_pid)
525            .map_err(runtime_error_from_display)?;
526        self.record_spawned_pid(pid);
527        Ok(pid)
528    }
529
530    /// Return true when a live process has a trapped EXIT message from `source_pid`.
531    ///
532    /// # Errors
533    ///
534    /// Returns [`EngineError::Runtime`] when `target_pid` is not live.
535    #[cfg(test)]
536    pub fn has_trapped_exit_message(
537        &self,
538        target_pid: Pid,
539        source_pid: Pid,
540    ) -> Result<bool, EngineError> {
541        self.ensure_live_pid(target_pid)?;
542        Ok(self
543            .scheduler
544            .has_trapped_exit_message(target_pid, source_pid)
545            .unwrap_or(false))
546    }
547
548    /// Poll until a trapped EXIT message from `source_pid` arrives at `target_pid`.
549    ///
550    /// beamr delivers exit signals asynchronously after process termination.
551    /// Tests that assert on trapped exit messages must wait for delivery.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`EngineError::Runtime`] if the message does not arrive within 50ms.
556    #[cfg(test)]
557    pub fn wait_for_trapped_exit(
558        &self,
559        target_pid: Pid,
560        source_pid: Pid,
561    ) -> Result<(), EngineError> {
562        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
563        while std::time::Instant::now() < deadline {
564            if self
565                .scheduler
566                .has_trapped_exit_message(target_pid, source_pid)
567                .unwrap_or(false)
568            {
569                return Ok(());
570            }
571            std::thread::sleep(std::time::Duration::from_millis(1));
572        }
573        Err(runtime_error(format!(
574            "trapped exit from {source_pid} to {target_pid} did not arrive"
575        )))
576    }
577
578    /// Terminate a test process with a trappable abnormal reason.
579    ///
580    /// # Errors
581    ///
582    /// Returns [`EngineError::Runtime`] when `pid` is not live.
583    #[cfg(test)]
584    pub fn terminate_test_process_with_error(&self, pid: Pid) -> Result<(), EngineError> {
585        self.ensure_live_pid(pid)?;
586        self.scheduler.terminate_process(pid, ExitReason::Error);
587        Ok(())
588    }
589
590    #[cfg(test)]
591    pub(crate) fn lookup_native_for_test(
592        &self,
593        module: &str,
594        function: &str,
595        arity: u8,
596    ) -> Option<beamr::native::NativeEntry> {
597        let module = self.atom_table.intern(module);
598        let function = self.atom_table.intern(function);
599        self.native_registry.lookup(module, function, arity)
600    }
601
602    #[cfg(test)]
603    pub(crate) fn run_until_exit_for_test(&self, pid: Pid) -> (ExitReason, Term) {
604        let (reason, owned_result) = self.scheduler.run_until_exit(pid);
605        self.release_spawn_heaps(pid);
606        (reason, owned_result.root())
607    }
608
609    #[cfg(test)]
610    pub(crate) fn retained_spawn_heap_count_for_test(&self) -> usize {
611        self.release_dead_spawn_heaps();
612        self.spawn_heaps.len()
613    }
614}
615
616fn runtime_error(reason: String) -> EngineError {
617    EngineError::Runtime { reason }
618}
619
620fn runtime_error_from_display(reason: impl std::fmt::Display) -> EngineError {
621    runtime_error(reason.to_string())
622}
623
624fn nif_registration_error(mfa: &Mfa, error: NativeRegistrationError) -> EngineError {
625    match error {
626        NativeRegistrationError::DuplicateMfa { .. } => EngineError::NifRegistration {
627            reason: format!("native function already registered for {}", mfa.display()),
628        },
629    }
630}
631
632fn register_all_bifs(
633    registry: &BifRegistryImpl,
634    atom_table: &AtomTable,
635) -> Result<(), EngineError> {
636    use beamr::native::{
637        bifs::register_gate1_bifs, gate3_bifs::register_gate3_bifs,
638        gleam_ffi::register_gleam_ffi_bifs, otp_stubs::init_otp_atoms,
639        otp_stubs::register_otp_stubs, process_bifs::register_gate2_bifs,
640        selector_ffi::register_selector_bifs, stdlib_stubs::register_stdlib_stubs,
641    };
642    register_gate1_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
643    register_gate2_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
644    register_gate3_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
645    register_stdlib_stubs(registry, atom_table).map_err(runtime_error_from_display)?;
646    register_selector_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
647    register_gleam_ffi_bifs(registry, atom_table).map_err(runtime_error_from_display)?;
648    init_otp_atoms(atom_table);
649    register_otp_stubs(registry, atom_table).map_err(runtime_error_from_display)?;
650    Ok(())
651}
652
653mod delivery;
654
655#[cfg(test)]
656#[path = "handle/test_support.rs"]
657mod test_support;
658
659#[cfg(test)]
660mod tests {
661    use aion_core::Payload;
662    use std::time::Duration;
663
664    use beamr::loader::Instruction;
665    use beamr::loader::decode::compact::Operand;
666    use beamr::module::{Module, ResolvedImport, ResolvedImportTarget};
667    use beamr::native::ProcessContext;
668    use beamr::term::Term;
669    use beamr::term::binary_ref::BinaryRef;
670
671    use super::{RuntimeHandle, RuntimeInput};
672    use crate::error::EngineError;
673    use crate::runtime::{Mfa, NifEntry, NifRegistration, RuntimeConfig, SignalDeliveryConfig};
674
675    fn forty_two(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
676        if args.len() > 255 {
677            return Err(Term::small_int(0));
678        }
679        Ok(Term::small_int(42))
680    }
681
682    fn thirteen(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
683        if args.len() > 255 {
684            return Err(Term::small_int(0));
685        }
686        Ok(Term::small_int(13))
687    }
688
689    fn binary_length(args: &[Term], _context: &mut ProcessContext) -> Result<Term, Term> {
690        match args {
691            [term] => BinaryRef::new(*term)
692                .and_then(|binary| i64::try_from(binary.as_bytes().len()).ok())
693                .map(Term::small_int)
694                .ok_or_else(|| Term::small_int(0)),
695            _ => Err(Term::small_int(0)),
696        }
697    }
698
699    fn native_call_module_for_test(
700        module: beamr::atom::Atom,
701        function: beamr::atom::Atom,
702        target_module: beamr::atom::Atom,
703        target_function: beamr::atom::Atom,
704        native_entry: Option<beamr::native::NativeEntry>,
705    ) -> Module {
706        native_call_module_with_arity_for_test(
707            module,
708            function,
709            target_module,
710            target_function,
711            0,
712            native_entry,
713        )
714    }
715
716    fn native_call_module_with_arity_for_test(
717        module: beamr::atom::Atom,
718        function: beamr::atom::Atom,
719        target_module: beamr::atom::Atom,
720        target_function: beamr::atom::Atom,
721        arity: u8,
722        native_entry: Option<beamr::native::NativeEntry>,
723    ) -> Module {
724        let label = 1;
725        let code = vec![
726            Instruction::Label { label },
727            Instruction::CallExt {
728                arity: Operand::Unsigned(arity.into()),
729                import: Operand::Unsigned(0),
730            },
731            Instruction::Return,
732        ];
733        let mut module_data = Module {
734            name: module,
735            generation: 0,
736            origin: beamr::module::ModuleOrigin::Preloaded,
737            exports: std::collections::HashMap::from([((function, arity), label)]),
738            label_index: std::collections::HashMap::from([(label, 0)]),
739            code,
740            function_table: Vec::new(),
741            line_table: Vec::new(),
742            literals: Vec::new(),
743            constant_pool: beamr::constant_pool::ConstantPool::new(),
744            resolved_imports: Vec::new(),
745            lambdas: Vec::new(),
746            string_table: Vec::new(),
747            line_info: Vec::new(),
748        };
749        if let Some(native_entry) = native_entry {
750            module_data.resolved_imports.push(ResolvedImport {
751                module: target_module,
752                function: target_function,
753                arity,
754                target: ResolvedImportTarget::Native(native_entry),
755            });
756        }
757        module_data
758    }
759
760    fn assert_send_sync<T: Send + Sync>() {}
761
762    fn fixture_workflow_beam() -> &'static [u8] {
763        include_bytes!("../../tests/fixtures/aion_fixture_workflow.beam")
764    }
765
766    #[test]
767    fn runtime_handle_is_send_sync() {
768        assert_send_sync::<RuntimeHandle>();
769    }
770
771    #[test]
772    fn registers_spawns_and_shuts_down() -> Result<(), Box<dyn std::error::Error>> {
773        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
774        runtime.register_module("aion_fixture_workflow", fixture_workflow_beam())?;
775
776        let pid =
777            runtime.spawn_workflow("aion_fixture_workflow", "wait", RuntimeInput::default())?;
778        assert!(runtime.cancel_pid(pid).is_ok());
779        runtime.shutdown()?;
780        Ok(())
781    }
782
783    #[test]
784    fn signal_delivery_to_dead_process_returns_typed_error()
785    -> Result<(), Box<dyn std::error::Error>> {
786        let signal_delivery =
787            SignalDeliveryConfig::new(Duration::ZERO, 1, Duration::ZERO, Duration::ZERO);
788        let runtime =
789            RuntimeHandle::new(RuntimeConfig::new(Some(1)).with_signal_delivery(signal_delivery))?;
790        let pid = runtime.spawn_test_process()?;
791        runtime.terminate_test_process_with_error(pid)?;
792
793        let error = runtime
794            .deliver_signal_received(pid)
795            .err()
796            .ok_or("dead process delivery unexpectedly succeeded")?;
797
798        assert!(matches!(error, EngineError::Runtime { .. }));
799        runtime.shutdown()?;
800        Ok(())
801    }
802
803    #[test]
804    fn duplicate_nif_mfa_returns_typed_error() -> Result<(), Box<dyn std::error::Error>> {
805        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
806        let mfa = Mfa::new("host", "answer", 0);
807        let mut registration = NifRegistration::new();
808        registration.add_host_nifs([
809            NifEntry::new(mfa.clone(), forty_two),
810            NifEntry::dirty(mfa, thirteen),
811        ]);
812
813        let error = runtime.install_nifs(registration).err();
814
815        assert!(matches!(
816            error,
817            Some(EngineError::NifRegistration { reason })
818                if reason.contains("host:answer/0")
819        ));
820        assert_eq!(runtime.registered_nif_modules(), vec!["host"]);
821        runtime.shutdown()?;
822        Ok(())
823    }
824
825    #[test]
826    fn payload_binary_remains_valid_through_spawn_and_is_released()
827    -> Result<(), Box<dyn std::error::Error>> {
828        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
829        let mfa = Mfa::new("host", "binary_length", 1);
830        let mut registration = NifRegistration::new();
831        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
832        runtime.install_nifs(registration)?;
833
834        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
835        let module = native_call_module_with_arity_for_test(
836            runtime.atom_table.intern("payload_echo"),
837            runtime.atom_table.intern("run"),
838            runtime.atom_table.intern("host"),
839            runtime.atom_table.intern("binary_length"),
840            1,
841            native_entry,
842        );
843        runtime.module_registry.insert(module);
844        let payload = Payload::new(
845            aion_core::ContentType::Json,
846            br#"{"hello":"world"}"#.to_vec(),
847        );
848
849        let pid =
850            runtime.spawn_workflow("payload_echo", "run", RuntimeInput::from_payload(&payload)?)?;
851        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 1);
852        let (reason, result) = runtime.run_until_exit_for_test(pid);
853
854        assert_eq!(reason, beamr::process::ExitReason::Normal);
855        assert_eq!(
856            result.as_small_int(),
857            Some(i64::try_from(payload.bytes().len()).unwrap_or(0))
858        );
859        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
860        runtime.shutdown()?;
861        Ok(())
862    }
863
864    #[test]
865    fn workflow_outcome_releases_payload_heaps() -> Result<(), Box<dyn std::error::Error>> {
866        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
867        let mfa = Mfa::new("host", "binary_length", 1);
868        let mut registration = NifRegistration::new();
869        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
870        runtime.install_nifs(registration)?;
871
872        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
873        let module = native_call_module_with_arity_for_test(
874            runtime.atom_table.intern("payload_workflow_outcome"),
875            runtime.atom_table.intern("run"),
876            runtime.atom_table.intern("host"),
877            runtime.atom_table.intern("binary_length"),
878            1,
879            native_entry,
880        );
881        runtime.module_registry.insert(module);
882        let payload = Payload::new(
883            aion_core::ContentType::Json,
884            br#"{"workflow":"outcome"}"#.to_vec(),
885        );
886
887        let pid = runtime.spawn_workflow(
888            "payload_workflow_outcome",
889            "run",
890            RuntimeInput::from_payload(&payload)?,
891        )?;
892        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 1);
893        let outcome = runtime.workflow_outcome(pid)?;
894
895        assert_eq!(
896            outcome?,
897            Payload::from_json(&serde_json::json!(payload.bytes().len()))?
898        );
899        assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
900        runtime.shutdown()?;
901        Ok(())
902    }
903
904    #[test]
905    fn repeated_completed_payload_spawns_do_not_accumulate_retained_heaps()
906    -> Result<(), Box<dyn std::error::Error>> {
907        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
908        let mfa = Mfa::new("host", "binary_length", 1);
909        let mut registration = NifRegistration::new();
910        registration.add_host_nifs([NifEntry::new(mfa, binary_length)]);
911        runtime.install_nifs(registration)?;
912
913        let native_entry = runtime.lookup_native_for_test("host", "binary_length", 1);
914        let module = native_call_module_with_arity_for_test(
915            runtime.atom_table.intern("payload_echo_many"),
916            runtime.atom_table.intern("run"),
917            runtime.atom_table.intern("host"),
918            runtime.atom_table.intern("binary_length"),
919            1,
920            native_entry,
921        );
922        runtime.module_registry.insert(module);
923        let payload = Payload::new(
924            aion_core::ContentType::Json,
925            br#"{"iteration":true}"#.to_vec(),
926        );
927
928        for _ in 0..1_000 {
929            let pid = runtime.spawn_workflow(
930                "payload_echo_many",
931                "run",
932                RuntimeInput::from_payload(&payload)?,
933            )?;
934            let (reason, result) = runtime.run_until_exit_for_test(pid);
935            assert_eq!(reason, beamr::process::ExitReason::Normal);
936            assert_eq!(
937                result.as_small_int(),
938                Some(i64::try_from(payload.bytes().len()).unwrap_or(0))
939            );
940            assert_eq!(runtime.retained_spawn_heap_count_for_test(), 0);
941        }
942
943        runtime.shutdown()?;
944        Ok(())
945    }
946
947    #[test]
948    fn distinct_nifs_are_registered_and_callable() -> Result<(), Box<dyn std::error::Error>> {
949        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
950        let mut registration = NifRegistration::new();
951        registration.add_engine_nifs().add_host_nifs([
952            NifEntry::new(Mfa::new("host", "answer", 0), forty_two),
953            NifEntry::dirty(Mfa::new("host", "thirteen", 0), thirteen),
954        ]);
955
956        runtime.install_nifs(registration)?;
957
958        assert_eq!(
959            runtime.registered_nif_modules(),
960            vec!["aion_flow_ffi", "host"]
961        );
962        let answer = runtime.lookup_native_for_test("host", "answer", 0);
963        assert!(answer.is_some());
964        assert!(
965            runtime
966                .lookup_native_for_test("host", "thirteen", 0)
967                .is_some_and(|entry| entry.dirty_kind.is_some())
968        );
969
970        let host_nif_call = native_call_module_for_test(
971            runtime.atom_table.intern("host_nif_call"),
972            runtime.atom_table.intern("answer"),
973            runtime.atom_table.intern("host"),
974            runtime.atom_table.intern("answer"),
975            answer,
976        );
977        runtime.module_registry.insert(host_nif_call);
978        let pid = runtime.spawn_workflow("host_nif_call", "answer", RuntimeInput::default())?;
979        let (reason, result) = runtime.run_until_exit_for_test(pid);
980
981        assert_eq!(reason, beamr::process::ExitReason::Normal);
982        assert_eq!(result, Term::small_int(42));
983        runtime.shutdown()?;
984        Ok(())
985    }
986}