Skip to main content

aion/runtime/
handle.rs

1//! `RuntimeHandle` spawn, register, cancel, and shutdown support.
2
3use std::sync::{Arc, Mutex};
4
5use aion_core::{ActivityError, Payload};
6use beamr::atom::AtomTable;
7use beamr::module::ModuleRegistry;
8use beamr::native::BifRegistryImpl;
9use beamr::process::ExitReason;
10use beamr::scheduler::{Scheduler, SchedulerConfig};
11use beamr::term::Term;
12
13use crate::error::EngineError;
14
15use super::config::{RuntimeConfig, SignalDeliveryConfig};
16#[cfg(test)]
17use super::nif::Mfa;
18use super::nif::NifRegistration;
19use super::payload::payload_to_term;
20
21use self::registration::{nif_registration_error, register_all_bifs};
22
23/// Local BEAM process identifier exposed by the runtime boundary.
24pub type Pid = u64;
25
26type RetainedHeap = Box<[u64]>;
27type RetainedHeaps = Vec<RetainedHeap>;
28type RetainedSpawnHeaps = Arc<dashmap::DashMap<Pid, Mutex<RetainedHeaps>>>;
29
30/// Runtime-owned workflow or activity input terms.
31///
32/// The wrapper keeps the beamr term representation inside the runtime module
33/// while later lifecycle and payload code decide how durable payloads become VM
34/// terms.
35#[derive(Debug, Default, Eq, PartialEq)]
36pub struct RuntimeInput {
37    terms: Vec<Term>,
38    heaps: RetainedHeaps,
39}
40
41impl RuntimeInput {
42    /// Convert one durable payload into the single BEAM argument used by
43    /// in-VM activity dispatch.
44    ///
45    /// The runtime boundary owns this representation. JSON payloads are passed
46    /// as BEAM binary terms and any boxed host heap backing those terms is
47    /// retained until the spawned process is observed exiting or cancelled.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`EngineError::Runtime`] when a JSON number does not fit in an
52    /// immediate small integer.
53    pub fn from_payload(payload: &Payload) -> Result<Self, EngineError> {
54        let (term, heaps) = payload_to_term(payload)?.into_parts();
55        Ok(Self {
56            terms: vec![term],
57            heaps,
58        })
59    }
60
61    /// Number of terms supplied to the BEAM entrypoint.
62    #[must_use]
63    pub fn arity(&self) -> u8 {
64        u8::try_from(self.terms.len()).unwrap_or(u8::MAX)
65    }
66
67    fn into_spawn_parts(self) -> (Vec<Term>, RetainedHeaps) {
68        (self.terms, self.heaps)
69    }
70}
71
72/// Handle to the embedded beamr scheduler and code-server state.
73pub struct RuntimeHandle {
74    pub(super) scheduler: Arc<Scheduler>,
75    pub(super) atom_table: Arc<AtomTable>,
76    pub(super) module_registry: Arc<ModuleRegistry>,
77    pub(super) native_registry: Arc<BifRegistryImpl>,
78    nif_state: Arc<super::nif_state::EngineNifState>,
79    activity_results: Arc<dashmap::DashMap<(Pid, Pid), Payload>>,
80    activity_errors: Arc<dashmap::DashMap<(Pid, Pid), ActivityError>>,
81    /// Per-workflow synchronization for retained activity delivery and death draining.
82    ///
83    /// Each workflow has an independent gate, so an exited process that remains
84    /// in beamr's process table cannot block unrelated workflows. A
85    /// dead gate remains until process-table removal is observed, preventing
86    /// delivery from inserting behind that workflow's death sweep.
87    activity_delivery_gates: dashmap::DashMap<Pid, Arc<activity_delivery::ActivityDeliveryGate>>,
88    /// One-based delivery attempt that produced a retained two-phase activity
89    /// outcome, keyed like [`Self::activity_results`] / [`Self::activity_errors`]
90    /// (#197). Retained in the same gate transaction as the final outcome and
91    /// taken atomically with that outcome, so recorded terminals carry the
92    /// genuine attempt. Absence means the first delivery (paths that never
93    /// retry — outbox re-delivery and in-VM execution — retain nothing).
94    activity_delivery_attempts: Arc<dashmap::DashMap<(Pid, Pid), u32>>,
95    #[cfg(test)]
96    activity_delivery_test_seams: activity_delivery::ActivityDeliveryTestSeams,
97    /// Live in-VM activity children per workflow pid.
98    ///
99    /// A BEAM link tears a child down when its workflow dies ABNORMALLY, but
100    /// a `Normal` exit never propagates through links (classic BEAM
101    /// semantics), so a workflow that completes while an in-VM runner is
102    /// still executing — e.g. after a `with_timeout` expiry abandoned the
103    /// await — would orphan the child and its completion waiter forever. The
104    /// workflow process monitor kills children still registered here when the
105    /// workflow exits (for any reason), and [`Self::shutdown`] kills every
106    /// remaining child so no waiter outlives the scheduler.
107    in_vm_children: Arc<dashmap::DashMap<Pid, std::collections::HashSet<Pid>>>,
108    registered_nif_modules: Arc<dashmap::DashSet<String>>,
109    spawn_heaps: RetainedSpawnHeaps,
110    signal_delivery: SignalDeliveryConfig,
111    /// Flag gating the durable-outbox fan-out dispatch path; read by
112    /// `nif_collect.rs` to route fresh fan-out members and completions.
113    outbox_enabled: bool,
114    /// Bounded follow-up wakes for delivered mailbox markers, healing
115    /// beamr 0.4.9's lost-wakeup window (see [`super::wake_confirm`]).
116    pub(super) wake_confirmer: super::wake_confirm::WakeConfirmer,
117    /// Per-pid outcomes established before top-level process pids are published.
118    pub(super) process_exits: Arc<super::process_exit::ProcessExitRegistry>,
119    /// Runtime-provisioned owner for every process abort and shared cleanup.
120    pub(super) cleanup_executor: super::cleanup_executor::CleanupExecutor,
121    /// Identity-bearing abort jobs retained for deduplicated retries.
122    pub(super) abort_jobs: dashmap::DashMap<Pid, Arc<super::monitor::UnmonitoredProcessAbortJob>>,
123}
124
125impl RuntimeHandle {
126    /// Construct and start an embedded runtime from builder-supplied config.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`EngineError::Runtime`] when beamr cannot start its scheduler.
131    /// Returns [`EngineError::Gate3BifReplacementMissing`] if beamr's complete
132    /// Gate-3 table no longer contains a required tracked fun-spawn BIF.
133    pub fn new(config: RuntimeConfig) -> Result<Self, EngineError> {
134        let atom_table = Arc::new(AtomTable::with_common_atoms());
135        let module_registry = Arc::new(ModuleRegistry::new());
136        // One NIF state per runtime instance, recovered by every native call
137        // through beamr's NIF private data — never process-wide globals.
138        let nif_state = Arc::new(super::nif_state::EngineNifState::default());
139        let scheduler_config = SchedulerConfig {
140            thread_count: config.thread_count,
141            nif_private_data: Some(Arc::clone(&nif_state) as _),
142            ..Default::default()
143        };
144        let native_registry = Arc::new(BifRegistryImpl::new());
145        register_all_bifs(&native_registry, &atom_table, &nif_state)?;
146        let scheduler = Arc::new(
147            Scheduler::with_code_server(
148                scheduler_config,
149                Arc::clone(&module_registry),
150                Arc::clone(&atom_table),
151                Arc::clone(&native_registry),
152            )
153            .map_err(runtime_error_from_display)?,
154        );
155        let wake_confirmer = super::wake_confirm::WakeConfirmer::new(config.signal_delivery)?;
156        let shutdown_timeout = config.signal_delivery.cleanup_shutdown_timeout();
157        let cleanup_executor = super::cleanup_executor::CleanupExecutor::new(
158            config.signal_delivery.max_enqueue_attempts as usize,
159            shutdown_timeout,
160        )?;
161        // Claim beamr's singleton stream before this scheduler can publish a pid
162        // through any RuntimeHandle spawn API.
163        let process_exits = super::process_exit::ProcessExitRegistry::new(
164            Arc::clone(&scheduler),
165            shutdown_timeout,
166            config.signal_delivery.max_enqueue_attempts as usize,
167        )?;
168        nif_state.set_process_exit_registry(&process_exits)?;
169
170        Ok(Self {
171            scheduler,
172            atom_table,
173            module_registry,
174            native_registry,
175            nif_state,
176            activity_results: Arc::new(dashmap::DashMap::new()),
177            activity_errors: Arc::new(dashmap::DashMap::new()),
178            activity_delivery_gates: dashmap::DashMap::new(),
179            activity_delivery_attempts: Arc::new(dashmap::DashMap::new()),
180            #[cfg(test)]
181            activity_delivery_test_seams: activity_delivery::ActivityDeliveryTestSeams::default(),
182            in_vm_children: Arc::new(dashmap::DashMap::new()),
183            registered_nif_modules: Arc::new(dashmap::DashSet::new()),
184            spawn_heaps: Arc::new(dashmap::DashMap::new()),
185            signal_delivery: config.signal_delivery,
186            outbox_enabled: config.outbox_enabled,
187            wake_confirmer,
188            process_exits,
189            cleanup_executor,
190            abort_jobs: dashmap::DashMap::new(),
191        })
192    }
193
194    /// This runtime instance's engine-scoped NIF state.
195    pub(crate) fn nif_state(&self) -> &Arc<super::nif_state::EngineNifState> {
196        &self.nif_state
197    }
198
199    /// Builder-supplied delivery/readiness policy for spawn-window waits.
200    pub(crate) fn signal_delivery(&self) -> SignalDeliveryConfig {
201        self.signal_delivery
202    }
203
204    /// Whether the durable-outbox fan-out dispatch path is enabled.
205    ///
206    /// Read by `nif_collect.rs` to route fresh fan-out members through the
207    /// durable outbox and record completions via the dedup primitive.
208    pub(crate) fn outbox_enabled(&self) -> bool {
209        self.outbox_enabled
210    }
211
212    /// Install collected NIF entries into beamr's native registry.
213    ///
214    /// Consumes the registration collection so no caller can append more entries
215    /// after this installation step. Callers must invoke this before loading and
216    /// spawning workflow modules whose imports depend on these NIFs.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`EngineError::NifRegistration`] when beamr rejects an entry,
221    /// including duplicate module/function/arity registrations.
222    pub fn install_nifs(&self, registration: NifRegistration) -> Result<(), EngineError> {
223        for entry in registration.into_entries() {
224            let mfa = entry.mfa;
225            let module = self.atom_table.intern(&mfa.module);
226            let function = self.atom_table.intern(&mfa.function);
227            let capability = beamr::native::Capability::ExternalIo;
228            let result = if entry.is_dirty {
229                self.native_registry.register_dirty(
230                    module,
231                    function,
232                    mfa.arity,
233                    entry.function,
234                    beamr::scheduler::dirty::DirtySchedulerKind::Cpu,
235                    capability,
236                )
237            } else {
238                self.native_registry.register(
239                    module,
240                    function,
241                    mfa.arity,
242                    entry.function,
243                    capability,
244                )
245            };
246            result.map_err(|error| nif_registration_error(&mfa, error))?;
247            self.registered_nif_modules.insert(mfa.module);
248        }
249
250        Ok(())
251    }
252
253    /// Return module names that have registered NIFs and should not be
254    /// content-hash renamed during package loading.
255    #[must_use]
256    pub fn registered_nif_modules(&self) -> Vec<String> {
257        let mut module_names: Vec<_> = self
258            .registered_nif_modules
259            .iter()
260            .map(|module_name| module_name.key().clone())
261            .collect();
262        module_names.sort();
263        module_names
264    }
265
266    /// Spawn a top-level workflow process at a deployed module/function entrypoint.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
271    /// resolved or beamr rejects the spawn request.
272    pub fn spawn_workflow(
273        &self,
274        deployed_module: &str,
275        function: &str,
276        input: RuntimeInput,
277    ) -> Result<Pid, EngineError> {
278        self.spawn_process(deployed_module, function, input)
279    }
280
281    /// Spawn a top-level workflow process with trap-exit enabled before it runs.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
286    /// resolved or beamr rejects the spawn request.
287    pub fn spawn_workflow_trapping(
288        &self,
289        deployed_module: &str,
290        function: &str,
291        input: RuntimeInput,
292    ) -> Result<Pid, EngineError> {
293        self.release_dead_spawn_heaps();
294        let module = self.atom_table.intern(deployed_module);
295        let function = self.atom_table.intern(function);
296        let (terms, heaps) = input.into_spawn_parts();
297        let pid = self.spawn_with_exit_ownership(|| {
298            self.scheduler
299                .spawn_trap_exit(module, function, terms)
300                .map_err(runtime_error_from_display)
301        })?;
302        self.retain_spawn_heaps(pid, heaps);
303        Ok(pid)
304    }
305
306    /// Spawn an activity child process linked to its workflow parent.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`EngineError::Runtime`] when the parent process is not live, the
311    /// module/function/arity cannot be resolved, or beamr rejects the linked
312    /// spawn request.
313    pub fn spawn_activity(
314        &self,
315        parent_pid: Pid,
316        deployed_module: &str,
317        function: &str,
318        input: RuntimeInput,
319    ) -> Result<Pid, EngineError> {
320        self.release_dead_spawn_heaps();
321        self.ensure_live_pid(parent_pid)?;
322        self.wait_for_process_ready(parent_pid)?;
323        let module = self.atom_table.intern(deployed_module);
324        let function_atom = self.atom_table.intern(function);
325        let (terms, heaps) = input.into_spawn_parts();
326        let pid = self.spawn_with_exit_ownership(|| {
327            self.scheduler
328                .spawn_link(parent_pid, module, function_atom, terms)
329                .map_err(runtime_error_from_display)
330        })?;
331        self.retain_spawn_heaps(pid, heaps);
332        Ok(pid)
333    }
334
335    /// Spawn an in-VM activity child process linked to its workflow parent,
336    /// running a zero-arity closure (the SDK-composed runner thunk).
337    ///
338    /// beamr deep-copies the closure's environment into the child's own heap
339    /// before the child becomes runnable (`Scheduler::spawn_link_closure`), so
340    /// no spawn heap is retained here and the caller's heap may move (GC) the
341    /// moment this returns. The child does not trap exits: workflow
342    /// cancellation propagates through the link, and an abnormal child exit is
343    /// observed by the in-VM completion watcher via [`Self::in_vm_child_outcome`].
344    ///
345    /// No parent readiness wait is performed: the only production caller is
346    /// the dispatch NIF executing ON the parent process, which is therefore
347    /// already materialized (a readiness poll on an `Executing` slot would be
348    /// pointless at best).
349    ///
350    /// # Errors
351    ///
352    /// Returns [`EngineError::Runtime`] when the parent is not live, the term
353    /// is not a zero-arity closure, or its module cannot be resolved.
354    pub fn spawn_activity_closure(
355        &self,
356        parent_pid: Pid,
357        closure_term: Term,
358    ) -> Result<Pid, EngineError> {
359        self.release_dead_spawn_heaps();
360        self.ensure_live_pid(parent_pid)?;
361        let pid = self.spawn_with_exit_ownership(|| {
362            self.scheduler
363                .spawn_link_closure(parent_pid, closure_term)
364                .map_err(runtime_error_from_display)
365        })?;
366        self.in_vm_children
367            .entry(parent_pid)
368            .or_default()
369            .insert(pid);
370        // Close the external-kill registration race: if the workflow died
371        // between the liveness check above and this registration, the
372        // monitor's `kill_in_vm_children` sweep may already have run (and
373        // beamr's link may never have been established — a caller that died
374        // mid-spawn yields an UNLINKED child), which would leave a hanging
375        // runner alive until engine shutdown. Re-checking AFTER registration
376        // makes both orderings safe: a parent death after this point observes
377        // the registration and is swept by the monitor; a death before it is
378        // torn down here (both kill paths are idempotent — the sweep guards
379        // with `is_live`).
380        if !self.is_live(parent_pid) {
381            self.kill_in_vm_children(parent_pid);
382            return Err(EngineError::Runtime {
383                reason: format!(
384                    "in-vm activity child spawn: parent workflow process {parent_pid} exited during spawn"
385                ),
386            });
387        }
388        Ok(pid)
389    }
390
391    /// Drop a finished in-VM child from its workflow's teardown set (called
392    /// by the completion waiter once the child's cached outcome is decoded).
393    pub(crate) fn deregister_in_vm_child(&self, parent_pid: Pid, child_pid: Pid) {
394        if let Some(mut children) = self.in_vm_children.get_mut(&parent_pid) {
395            children.remove(&child_pid);
396        }
397        self.in_vm_children
398            .remove_if(&parent_pid, |_, children| children.is_empty());
399    }
400
401    /// Kill every in-VM activity child still registered for `workflow_pid`.
402    ///
403    /// Invoked by the workflow process monitor on workflow exit: a `Normal`
404    /// exit does not propagate through BEAM links, so a completed workflow
405    /// would otherwise orphan a still-running runner and its completion waiter.
406    /// Killing publishes the child's durable outcome, which wakes that waiter;
407    /// delivery to the dead workflow is refused and nothing is retained.
408    pub(crate) fn kill_in_vm_children(&self, workflow_pid: Pid) {
409        let Some((_, children)) = self.in_vm_children.remove(&workflow_pid) else {
410            return;
411        };
412        for child_pid in children {
413            if self.is_live(child_pid) {
414                tracing::debug!(
415                    workflow_pid,
416                    child_pid,
417                    "killing orphaned in-vm activity child on workflow exit"
418                );
419                self.scheduler
420                    .terminate_process(child_pid, ExitReason::Kill);
421            }
422            self.release_spawn_heaps(child_pid);
423        }
424    }
425
426    /// Return whether the registered native activity entry is dirty for arity 1.
427    #[must_use]
428    pub fn is_dirty(&self, module: &str, function: &str) -> bool {
429        self.is_dirty_with_arity(module, function, 1)
430    }
431
432    /// Return whether the registered native entry is dirty for the supplied arity.
433    #[must_use]
434    pub fn is_dirty_with_arity(&self, module: &str, function: &str, arity: u8) -> bool {
435        let module = self.atom_table.intern(module);
436        let function = self.atom_table.intern(function);
437        self.native_registry
438            .lookup(module, function, arity)
439            .is_some_and(|entry| entry.dirty_kind.is_some())
440    }
441
442    /// Cancel a live process by PID.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`EngineError::Runtime`] when `pid` is not live.
447    pub fn cancel_pid(&self, pid: Pid) -> Result<(), EngineError> {
448        self.ensure_live_pid(pid)?;
449        self.scheduler.terminate_process(pid, ExitReason::Kill);
450        self.release_spawn_heaps(pid);
451        Ok(())
452    }
453
454    /// Set a live process' trap-exit flag, returning the previous value.
455    ///
456    /// # Errors
457    ///
458    /// Returns [`EngineError::Runtime`] when `pid` is not live.
459    pub fn set_trap_exit(&self, pid: Pid, value: bool) -> Result<bool, EngineError> {
460        self.scheduler
461            .set_trap_exit(pid, value)
462            .map_err(runtime_error_from_display)
463    }
464
465    /// Return true when `pid` is currently live.
466    #[must_use]
467    pub fn is_live(&self, pid: Pid) -> bool {
468        self.scheduler.process_table().get(pid).is_some()
469    }
470
471    /// Return a live process' trap-exit flag.
472    ///
473    /// # Errors
474    ///
475    /// Returns [`EngineError::Runtime`] when `pid` is not live.
476    pub fn trap_exit(&self, pid: Pid) -> Result<bool, EngineError> {
477        self.scheduler
478            .trap_exit(pid)
479            .ok_or_else(|| runtime_error(format!("process {pid} is not live")))
480    }
481
482    /// Return true when two live processes have a bidirectional link.
483    ///
484    /// # Errors
485    ///
486    /// Returns [`EngineError::Runtime`] when either process is not live.
487    pub fn is_linked(&self, left: Pid, right: Pid) -> Result<bool, EngineError> {
488        self.ensure_live_pid(left)?;
489        self.ensure_live_pid(right)?;
490        Ok(self.scheduler.is_linked(left, right))
491    }
492
493    /// Shut down the embedded scheduler and wait for worker threads to stop.
494    ///
495    /// # Errors
496    ///
497    /// Currently infallible; reserved for typed runtime shutdown failures.
498    pub fn shutdown(&self) -> Result<(), EngineError> {
499        // Kill every still-live in-VM activity child before stopping the
500        // scheduler so the singleton drainer can capture each durable outcome.
501        let workflow_pids: Vec<Pid> = self
502            .in_vm_children
503            .iter()
504            .map(|entry| *entry.key())
505            .collect();
506        for workflow_pid in workflow_pids {
507            self.kill_in_vm_children(workflow_pid);
508        }
509        for pid in self.process_exits.begin_shutdown()? {
510            if self.is_live(pid) {
511                self.scheduler.terminate_process(pid, ExitReason::Kill);
512            }
513        }
514        // Abort jobs may be blocked waiting for exactly the exits published
515        // above, so drain the bounded executor only after every registered pid
516        // has been force-unblocked.
517        self.cleanup_executor.shutdown()?;
518        // Pending wake follow-ups are moot once process observation is closed.
519        self.wake_confirmer.shutdown();
520        self.process_exits.close_and_join_all()?;
521        self.scheduler.shutdown();
522        self.spawn_heaps.clear();
523        Ok(())
524    }
525
526    fn spawn_process(
527        &self,
528        deployed_module: &str,
529        function: &str,
530        input: RuntimeInput,
531    ) -> Result<Pid, EngineError> {
532        self.release_dead_spawn_heaps();
533        let module = self.atom_table.intern(deployed_module);
534        let function = self.atom_table.intern(function);
535        let (terms, heaps) = input.into_spawn_parts();
536        let pid = self.spawn_with_exit_ownership(|| {
537            self.scheduler
538                .spawn(module, function, terms)
539                .map_err(runtime_error_from_display)
540        })?;
541        self.retain_spawn_heaps(pid, heaps);
542        Ok(pid)
543    }
544
545    fn retain_spawn_heaps(&self, pid: Pid, heaps: RetainedHeaps) {
546        if heaps.is_empty() {
547            return;
548        }
549        self.spawn_heaps.insert(pid, Mutex::new(heaps));
550    }
551
552    pub(super) fn release_spawn_heaps(&self, pid: Pid) {
553        self.spawn_heaps.remove(&pid);
554    }
555
556    fn release_dead_spawn_heaps(&self) {
557        let dead_pids: Vec<Pid> = self
558            .spawn_heaps
559            .iter()
560            .filter_map(|entry| {
561                let pid = *entry.key();
562                self.scheduler
563                    .process_table()
564                    .get(pid)
565                    .is_none()
566                    .then_some(pid)
567            })
568            .collect();
569        for pid in dead_pids {
570            self.release_spawn_heaps(pid);
571        }
572    }
573
574    pub(super) fn ensure_live_pid(&self, pid: Pid) -> Result<(), EngineError> {
575        if self.scheduler.process_table().get(pid).is_some() {
576            Ok(())
577        } else {
578            Err(runtime_error(format!("process {pid} is not live")))
579        }
580    }
581
582    #[cfg(test)]
583    pub(crate) fn live_processes_for_test(&self) -> usize {
584        self.scheduler.process_table().len()
585    }
586
587    /// Spawn an inert test process without module code.
588    ///
589    /// # Errors
590    ///
591    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
592    #[cfg(test)]
593    pub fn spawn_test_process(&self) -> Result<Pid, EngineError> {
594        self.spawn_with_exit_ownership(|| Ok(self.scheduler.spawn_test_process(false)))
595    }
596
597    /// Spawn an inert test process with explicit trap-exit state.
598    ///
599    /// # Errors
600    ///
601    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
602    #[cfg(test)]
603    pub fn spawn_test_process_with_trap_exit(&self, trap_exit: bool) -> Result<Pid, EngineError> {
604        self.spawn_with_exit_ownership(|| Ok(self.scheduler.spawn_test_process(trap_exit)))
605    }
606
607    /// Spawn an inert linked test child without enabling trap-exit on the child.
608    ///
609    /// # Errors
610    ///
611    /// Returns [`EngineError::Runtime`] when the parent is not live or beamr
612    /// rejects the linked spawn.
613    #[cfg(test)]
614    pub fn spawn_linked_test_process(&self, parent_pid: Pid) -> Result<Pid, EngineError> {
615        self.ensure_live_pid(parent_pid)?;
616        self.spawn_with_exit_ownership(|| {
617            self.scheduler
618                .spawn_linked_test_process(parent_pid)
619                .map_err(runtime_error_from_display)
620        })
621    }
622
623    /// Return true when a live process has a trapped EXIT message from `source_pid`.
624    ///
625    /// # Errors
626    ///
627    /// Returns [`EngineError::Runtime`] when `target_pid` is not live.
628    #[cfg(test)]
629    pub fn has_trapped_exit_message(
630        &self,
631        target_pid: Pid,
632        source_pid: Pid,
633    ) -> Result<bool, EngineError> {
634        self.ensure_live_pid(target_pid)?;
635        Ok(self
636            .scheduler
637            .has_trapped_exit_message(target_pid, source_pid)
638            .unwrap_or(false))
639    }
640
641    /// Poll until a trapped EXIT message from `source_pid` arrives at `target_pid`.
642    ///
643    /// beamr delivers exit signals asynchronously after process termination.
644    /// Tests that assert on trapped exit messages must wait for delivery.
645    ///
646    /// # Errors
647    ///
648    /// Returns [`EngineError::Runtime`] if the message does not arrive within 50ms.
649    #[cfg(test)]
650    pub fn wait_for_trapped_exit(
651        &self,
652        target_pid: Pid,
653        source_pid: Pid,
654    ) -> Result<(), EngineError> {
655        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
656        while std::time::Instant::now() < deadline {
657            if self
658                .scheduler
659                .has_trapped_exit_message(target_pid, source_pid)
660                .unwrap_or(false)
661            {
662                return Ok(());
663            }
664            std::thread::sleep(std::time::Duration::from_millis(1));
665        }
666        Err(runtime_error(format!(
667            "trapped exit from {source_pid} to {target_pid} did not arrive"
668        )))
669    }
670
671    /// Terminate a test process with a trappable abnormal reason.
672    ///
673    /// # Errors
674    ///
675    /// Returns [`EngineError::Runtime`] when `pid` is not live.
676    #[cfg(test)]
677    pub fn terminate_test_process_with_error(&self, pid: Pid) -> Result<(), EngineError> {
678        self.ensure_live_pid(pid)?;
679        self.scheduler.terminate_process(pid, ExitReason::Error);
680        Ok(())
681    }
682
683    #[cfg(test)]
684    pub(crate) fn lookup_native_for_test(
685        &self,
686        module: &str,
687        function: &str,
688        arity: u8,
689    ) -> Option<beamr::native::NativeEntry> {
690        let module = self.atom_table.intern(module);
691        let function = self.atom_table.intern(function);
692        self.native_registry.lookup(module, function, arity)
693    }
694
695    #[cfg(test)]
696    pub(crate) fn retained_spawn_heap_count_for_test(&self) -> usize {
697        self.release_dead_spawn_heaps();
698        self.spawn_heaps.len()
699    }
700}
701
702fn runtime_error(reason: String) -> EngineError {
703    EngineError::Runtime { reason }
704}
705
706fn runtime_error_from_display(reason: impl std::fmt::Display) -> EngineError {
707    runtime_error(reason.to_string())
708}
709
710mod activity_delivery;
711mod delivery;
712mod process_ownership;
713mod readiness;
714mod registration;
715mod spawn_bifs;
716
717pub(crate) use delivery::InVmChildOutcome;
718
719#[cfg(test)]
720#[path = "handle/test_support.rs"]
721mod test_support;
722
723#[cfg(test)]
724#[path = "handle/tests.rs"]
725mod tests;