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