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    /// Engine-owned executor for background tasks that append to durable
80    /// history: child-terminal watchers, spawn recovery, and process-exit
81    /// completion retries.
82    ///
83    /// Owned here rather than by any bridge because a bridge is optional and
84    /// these tasks are not. Its shutdown aborts AND awaits, which is what stops
85    /// a task outliving the epoch and becoming a second writer against a
86    /// successor engine over the same store.
87    engine_tasks: Arc<super::engine_tasks::EngineTaskRuntime>,
88    activity_results: Arc<dashmap::DashMap<(Pid, Pid), Payload>>,
89    activity_errors: Arc<dashmap::DashMap<(Pid, Pid), ActivityError>>,
90    /// Per-workflow synchronization for retained activity delivery and death draining.
91    ///
92    /// Each workflow has an independent gate, so an exited process that remains
93    /// in beamr's process table cannot block unrelated workflows. A
94    /// dead gate remains until process-table removal is observed, preventing
95    /// delivery from inserting behind that workflow's death sweep.
96    activity_delivery_gates: dashmap::DashMap<Pid, Arc<activity_delivery::ActivityDeliveryGate>>,
97    /// One-based delivery attempt that produced a retained two-phase activity
98    /// outcome, keyed like [`Self::activity_results`] / [`Self::activity_errors`]
99    /// (#197). Retained in the same gate transaction as the final outcome and
100    /// taken atomically with that outcome, so recorded terminals carry the
101    /// genuine attempt. Absence means the first delivery (paths that never
102    /// retry — outbox re-delivery and in-VM execution — retain nothing).
103    activity_delivery_attempts: Arc<dashmap::DashMap<(Pid, Pid), u32>>,
104    #[cfg(test)]
105    activity_delivery_test_seams: activity_delivery::ActivityDeliveryTestSeams,
106    /// Live in-VM activity children per workflow pid.
107    ///
108    /// A BEAM link tears a child down when its workflow dies ABNORMALLY, but
109    /// a `Normal` exit never propagates through links (classic BEAM
110    /// semantics), so a workflow that completes while an in-VM runner is
111    /// still executing — e.g. after a `with_timeout` expiry abandoned the
112    /// await — would orphan the child and its completion waiter forever. The
113    /// workflow process monitor kills children still registered here when the
114    /// workflow exits (for any reason), and [`Self::shutdown`] kills every
115    /// remaining child so no waiter outlives the scheduler.
116    in_vm_children: Arc<dashmap::DashMap<Pid, std::collections::HashSet<Pid>>>,
117    registered_nif_modules: Arc<dashmap::DashSet<String>>,
118    spawn_heaps: RetainedSpawnHeaps,
119    signal_delivery: SignalDeliveryConfig,
120    completion_retry: super::config::CompletionRetryConfig,
121    /// Flag gating the durable-outbox fan-out dispatch path; read by
122    /// `nif_collect.rs` to route fresh fan-out members and completions.
123    outbox_enabled: bool,
124    /// Bounded follow-up wakes for delivered mailbox markers, healing
125    /// beamr 0.4.9's lost-wakeup window (see [`super::wake_confirm`]).
126    pub(super) wake_confirmer: super::wake_confirm::WakeConfirmer,
127    /// Per-pid outcomes established before top-level process pids are published.
128    pub(super) process_exits: Arc<super::process_exit::ProcessExitRegistry>,
129    /// Runtime-provisioned owner for every process abort and shared cleanup.
130    pub(super) cleanup_executor: super::cleanup_executor::CleanupExecutor,
131    /// Identity-bearing abort jobs retained for deduplicated retries.
132    pub(super) abort_jobs: dashmap::DashMap<Pid, Arc<super::monitor::UnmonitoredProcessAbortJob>>,
133}
134
135impl RuntimeHandle {
136    /// Construct and start an embedded runtime from builder-supplied config.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`EngineError::Runtime`] when beamr cannot start its scheduler.
141    /// Returns [`EngineError::Gate3BifReplacementMissing`] if beamr's complete
142    /// Gate-3 table no longer contains a required tracked fun-spawn BIF.
143    pub fn new(config: RuntimeConfig) -> Result<Self, EngineError> {
144        let atom_table = Arc::new(AtomTable::with_common_atoms());
145        let module_registry = Arc::new(ModuleRegistry::new());
146        // One NIF state per runtime instance, recovered by every native call
147        // through beamr's NIF private data — never process-wide globals.
148        let nif_state = Arc::new(super::nif_state::EngineNifState::default());
149        let scheduler_config = SchedulerConfig {
150            thread_count: config.thread_count,
151            // `None` here is beamr's own default threshold, not a value aion
152            // chose. See `RuntimeConfig::jit_threshold` for what a large value
153            // does and — importantly — what it does not do.
154            jit_threshold: config.jit_threshold,
155            nif_private_data: Some(Arc::clone(&nif_state) as _),
156            ..Default::default()
157        };
158        let native_registry = Arc::new(BifRegistryImpl::new());
159        register_all_bifs(&native_registry, &atom_table, &nif_state)?;
160        let scheduler = Arc::new(
161            Scheduler::with_code_server(
162                scheduler_config,
163                Arc::clone(&module_registry),
164                Arc::clone(&atom_table),
165                Arc::clone(&native_registry),
166            )
167            .map_err(runtime_error_from_display)?,
168        );
169        let wake_confirmer = super::wake_confirm::WakeConfirmer::new(config.signal_delivery)?;
170        let shutdown_timeout = config.signal_delivery.cleanup_shutdown_timeout();
171        let cleanup_executor = super::cleanup_executor::CleanupExecutor::new(
172            config.signal_delivery.max_enqueue_attempts as usize,
173            shutdown_timeout,
174        )?;
175        // Claim beamr's singleton stream before this scheduler can publish a pid
176        // through any RuntimeHandle spawn API.
177        let process_exits = super::process_exit::ProcessExitRegistry::new(
178            Arc::clone(&scheduler),
179            shutdown_timeout,
180            config.signal_delivery.max_enqueue_attempts as usize,
181        )?;
182        nif_state.set_process_exit_registry(&process_exits)?;
183
184        Ok(Self {
185            scheduler,
186            atom_table,
187            module_registry,
188            native_registry,
189            nif_state,
190            engine_tasks: Arc::new(super::engine_tasks::EngineTaskRuntime::new()?),
191            activity_results: Arc::new(dashmap::DashMap::new()),
192            activity_errors: Arc::new(dashmap::DashMap::new()),
193            activity_delivery_gates: dashmap::DashMap::new(),
194            activity_delivery_attempts: Arc::new(dashmap::DashMap::new()),
195            #[cfg(test)]
196            activity_delivery_test_seams: activity_delivery::ActivityDeliveryTestSeams::default(),
197            in_vm_children: Arc::new(dashmap::DashMap::new()),
198            registered_nif_modules: Arc::new(dashmap::DashSet::new()),
199            spawn_heaps: Arc::new(dashmap::DashMap::new()),
200            signal_delivery: config.signal_delivery,
201            completion_retry: config.completion_retry,
202            outbox_enabled: config.outbox_enabled,
203            wake_confirmer,
204            process_exits,
205            cleanup_executor,
206            abort_jobs: dashmap::DashMap::new(),
207        })
208    }
209
210    /// This runtime instance's engine-scoped NIF state.
211    pub(crate) fn nif_state(&self) -> &Arc<super::nif_state::EngineNifState> {
212        &self.nif_state
213    }
214
215    /// Builder-supplied delivery/readiness policy for spawn-window waits.
216    pub(crate) fn signal_delivery(&self) -> SignalDeliveryConfig {
217        self.signal_delivery
218    }
219
220    /// Builder-supplied backoff ladder for durable completion retries.
221    ///
222    /// Deliberately not [`Self::signal_delivery`]: that policy bounds a
223    /// mailbox-enqueue wait measured in scheduler ticks, and a durable retry
224    /// against a failing store is a different question with a different answer.
225    pub(crate) fn completion_retry(&self) -> super::config::CompletionRetryConfig {
226        self.completion_retry
227    }
228
229    /// Whether the durable-outbox fan-out dispatch path is enabled.
230    ///
231    /// Read by `nif_collect.rs` to route fresh fan-out members through the
232    /// durable outbox and record completions via the dedup primitive.
233    pub(crate) fn outbox_enabled(&self) -> bool {
234        self.outbox_enabled
235    }
236
237    /// Install collected NIF entries into beamr's native registry.
238    ///
239    /// Consumes the registration collection so no caller can append more entries
240    /// after this installation step. Callers must invoke this before loading and
241    /// spawning workflow modules whose imports depend on these NIFs.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`EngineError::NifRegistration`] when beamr rejects an entry,
246    /// including duplicate module/function/arity registrations.
247    pub fn install_nifs(&self, registration: NifRegistration) -> Result<(), EngineError> {
248        for entry in registration.into_entries() {
249            let mfa = entry.mfa;
250            let module = self.atom_table.intern(&mfa.module);
251            let function = self.atom_table.intern(&mfa.function);
252            let capability = beamr::native::Capability::ExternalIo;
253            let result = if entry.is_dirty {
254                self.native_registry.register_dirty(
255                    module,
256                    function,
257                    mfa.arity,
258                    entry.function,
259                    beamr::scheduler::dirty::DirtySchedulerKind::Cpu,
260                    capability,
261                )
262            } else {
263                self.native_registry.register(
264                    module,
265                    function,
266                    mfa.arity,
267                    entry.function,
268                    capability,
269                )
270            };
271            result.map_err(|error| nif_registration_error(&mfa, error))?;
272            self.registered_nif_modules.insert(mfa.module);
273        }
274
275        Ok(())
276    }
277
278    /// Return module names that have registered NIFs and should not be
279    /// content-hash renamed during package loading.
280    #[must_use]
281    pub fn registered_nif_modules(&self) -> Vec<String> {
282        let mut module_names: Vec<_> = self
283            .registered_nif_modules
284            .iter()
285            .map(|module_name| module_name.key().clone())
286            .collect();
287        module_names.sort();
288        module_names
289    }
290
291    /// Spawn a top-level workflow process at a deployed module/function entrypoint.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
296    /// resolved or beamr rejects the spawn request.
297    pub fn spawn_workflow(
298        &self,
299        deployed_module: &str,
300        function: &str,
301        input: RuntimeInput,
302    ) -> Result<Pid, EngineError> {
303        self.spawn_process(deployed_module, function, input)
304    }
305
306    /// Spawn a top-level workflow process with trap-exit enabled before it runs.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`EngineError::Runtime`] when the module/function/arity cannot be
311    /// resolved or beamr rejects the spawn request.
312    pub fn spawn_workflow_trapping(
313        &self,
314        deployed_module: &str,
315        function: &str,
316        input: RuntimeInput,
317    ) -> Result<Pid, EngineError> {
318        self.release_dead_spawn_heaps();
319        let module = self.atom_table.intern(deployed_module);
320        let function = self.atom_table.intern(function);
321        let (terms, heaps) = input.into_spawn_parts();
322        let pid = self.spawn_with_exit_ownership(|| {
323            self.scheduler
324                .spawn_trap_exit(module, function, terms)
325                .map_err(runtime_error_from_display)
326        })?;
327        self.retain_spawn_heaps(pid, heaps);
328        Ok(pid)
329    }
330
331    /// Spawn an activity child process linked to its workflow parent.
332    ///
333    /// # Errors
334    ///
335    /// Returns [`EngineError::Runtime`] when the parent process is not live, the
336    /// module/function/arity cannot be resolved, or beamr rejects the linked
337    /// spawn request.
338    pub fn spawn_activity(
339        &self,
340        parent_pid: Pid,
341        deployed_module: &str,
342        function: &str,
343        input: RuntimeInput,
344    ) -> Result<Pid, EngineError> {
345        self.release_dead_spawn_heaps();
346        self.ensure_live_pid(parent_pid)?;
347        self.wait_for_process_ready(parent_pid)?;
348        let module = self.atom_table.intern(deployed_module);
349        let function_atom = self.atom_table.intern(function);
350        let (terms, heaps) = input.into_spawn_parts();
351        let pid = self.spawn_with_exit_ownership(|| {
352            self.scheduler
353                .spawn_link(parent_pid, module, function_atom, terms)
354                .map_err(runtime_error_from_display)
355        })?;
356        self.retain_spawn_heaps(pid, heaps);
357        Ok(pid)
358    }
359
360    /// Spawn an in-VM activity child process linked to its workflow parent,
361    /// running a zero-arity closure (the SDK-composed runner thunk).
362    ///
363    /// beamr deep-copies the closure's environment into the child's own heap
364    /// before the child becomes runnable (`Scheduler::spawn_link_closure`), so
365    /// no spawn heap is retained here and the caller's heap may move (GC) the
366    /// moment this returns. The child does not trap exits: workflow
367    /// cancellation propagates through the link, and an abnormal child exit is
368    /// observed by the in-VM completion watcher via [`Self::in_vm_child_outcome`].
369    ///
370    /// No parent readiness wait is performed: the only production caller is
371    /// the dispatch NIF executing ON the parent process, which is therefore
372    /// already materialized (a readiness poll on an `Executing` slot would be
373    /// pointless at best).
374    ///
375    /// # Errors
376    ///
377    /// Returns [`EngineError::Runtime`] when the parent is not live, the term
378    /// is not a zero-arity closure, or its module cannot be resolved.
379    pub fn spawn_activity_closure(
380        &self,
381        parent_pid: Pid,
382        closure_term: Term,
383    ) -> Result<Pid, EngineError> {
384        self.release_dead_spawn_heaps();
385        self.ensure_live_pid(parent_pid)?;
386        let pid = self.spawn_with_exit_ownership(|| {
387            self.scheduler
388                .spawn_link_closure(parent_pid, closure_term)
389                .map_err(runtime_error_from_display)
390        })?;
391        self.in_vm_children
392            .entry(parent_pid)
393            .or_default()
394            .insert(pid);
395        // Close the external-kill registration race: if the workflow died
396        // between the liveness check above and this registration, the
397        // monitor's `kill_in_vm_children` sweep may already have run (and
398        // beamr's link may never have been established — a caller that died
399        // mid-spawn yields an UNLINKED child), which would leave a hanging
400        // runner alive until engine shutdown. Re-checking AFTER registration
401        // makes both orderings safe: a parent death after this point observes
402        // the registration and is swept by the monitor; a death before it is
403        // torn down here (both kill paths are idempotent — the sweep guards
404        // with `is_live`).
405        if !self.is_live(parent_pid) {
406            self.kill_in_vm_children(parent_pid);
407            return Err(EngineError::Runtime {
408                reason: format!(
409                    "in-vm activity child spawn: parent workflow process {parent_pid} exited during spawn"
410                ),
411            });
412        }
413        Ok(pid)
414    }
415
416    /// Drop a finished in-VM child from its workflow's teardown set (called
417    /// by the completion waiter once the child's cached outcome is decoded).
418    pub(crate) fn deregister_in_vm_child(&self, parent_pid: Pid, child_pid: Pid) {
419        if let Some(mut children) = self.in_vm_children.get_mut(&parent_pid) {
420            children.remove(&child_pid);
421        }
422        self.in_vm_children
423            .remove_if(&parent_pid, |_, children| children.is_empty());
424    }
425
426    /// Kill every in-VM activity child still registered for `workflow_pid`.
427    ///
428    /// Invoked by the workflow process monitor on workflow exit: a `Normal`
429    /// exit does not propagate through BEAM links, so a completed workflow
430    /// would otherwise orphan a still-running runner and its completion waiter.
431    /// Killing publishes the child's durable outcome, which wakes that waiter;
432    /// delivery to the dead workflow is refused and nothing is retained.
433    pub(crate) fn kill_in_vm_children(&self, workflow_pid: Pid) {
434        let Some((_, children)) = self.in_vm_children.remove(&workflow_pid) else {
435            return;
436        };
437        for child_pid in children {
438            if self.is_live(child_pid) {
439                tracing::debug!(
440                    workflow_pid,
441                    child_pid,
442                    "killing orphaned in-vm activity child on workflow exit"
443                );
444                self.scheduler
445                    .terminate_process(child_pid, ExitReason::Kill);
446            }
447            self.release_spawn_heaps(child_pid);
448        }
449    }
450
451    /// Return whether the registered native activity entry is dirty for arity 1.
452    #[must_use]
453    pub fn is_dirty(&self, module: &str, function: &str) -> bool {
454        self.is_dirty_with_arity(module, function, 1)
455    }
456
457    /// Return whether the registered native entry is dirty for the supplied arity.
458    #[must_use]
459    pub fn is_dirty_with_arity(&self, module: &str, function: &str, arity: u8) -> bool {
460        let module = self.atom_table.intern(module);
461        let function = self.atom_table.intern(function);
462        self.native_registry
463            .lookup(module, function, arity)
464            .is_some_and(|entry| entry.dirty_kind.is_some())
465    }
466
467    /// Cancel a live process by PID.
468    ///
469    /// # Errors
470    ///
471    /// Returns [`EngineError::Runtime`] when `pid` is not live.
472    pub fn cancel_pid(&self, pid: Pid) -> Result<(), EngineError> {
473        self.ensure_live_pid(pid)?;
474        self.scheduler.terminate_process(pid, ExitReason::Kill);
475        self.release_spawn_heaps(pid);
476        Ok(())
477    }
478
479    /// Set a live process' trap-exit flag, returning the previous value.
480    ///
481    /// # Errors
482    ///
483    /// Returns [`EngineError::Runtime`] when `pid` is not live.
484    pub fn set_trap_exit(&self, pid: Pid, value: bool) -> Result<bool, EngineError> {
485        self.scheduler
486            .set_trap_exit(pid, value)
487            .map_err(runtime_error_from_display)
488    }
489
490    /// Return true when `pid` is currently live.
491    ///
492    /// This reports scheduler process-table residency, not whether Aion's
493    /// exit cleanup has started; the crate-internal `process_cleanup_started`
494    /// reports that.
495    #[must_use]
496    pub fn is_live(&self, pid: Pid) -> bool {
497        self.scheduler.process_table().get(pid).is_some()
498    }
499
500    /// Return a live process' trap-exit flag.
501    ///
502    /// # Errors
503    ///
504    /// Returns [`EngineError::Runtime`] when `pid` is not live.
505    pub fn trap_exit(&self, pid: Pid) -> Result<bool, EngineError> {
506        self.scheduler
507            .trap_exit(pid)
508            .ok_or_else(|| runtime_error(format!("process {pid} is not live")))
509    }
510
511    /// Return true when two live processes have a bidirectional link.
512    ///
513    /// # Errors
514    ///
515    /// Returns [`EngineError::Runtime`] when either process is not live.
516    pub fn is_linked(&self, left: Pid, right: Pid) -> Result<bool, EngineError> {
517        self.ensure_live_pid(left)?;
518        self.ensure_live_pid(right)?;
519        Ok(self.scheduler.is_linked(left, right))
520    }
521
522    /// The engine-owned executor for durable background tasks.
523    ///
524    /// Every caller shares this one instance: the child bridge, spawn recovery
525    /// and the process-exit completion retry. A second executor with the same
526    /// epoch-close discipline would be the same rule in two places.
527    pub(crate) fn engine_tasks(&self) -> Arc<super::engine_tasks::EngineTaskRuntime> {
528        Arc::clone(&self.engine_tasks)
529    }
530
531    /// Arm the injected process-exit drain failure, so a test can make
532    /// [`Self::shutdown`] fail deterministically.
533    ///
534    /// Exists because none of the drain failures can be produced on demand,
535    /// which is how the "every teardown step still runs" property stayed
536    /// unpinned.
537    ///
538    /// 🔴 THE SET IS OPEN, AND TWO EARLIER REVISIONS OF THIS COMMENT CLAIMED
539    /// OTHERWISE. The first said they are all timeout-shaped. The second
540    /// replaced that with an eleven-variant enumeration — three timeout-shaped,
541    /// five poison-shaped, three panic-shaped — and asserted "what they share is
542    /// a precondition: each needs a stalled, poisoned or dead worker thread".
543    /// **Both are false, and the second is the more dangerous because it looks
544    /// exhaustive.**
545    ///
546    /// `close_and_join_all` ends with
547    /// `handle.join().map_err(|_| ProcessExitDrainerPanicked)??` — and the
548    /// SECOND `?` propagates the drainer thread's own `Result<(), EngineError>`
549    /// verbatim (`runtime/process_exit.rs`). Whatever that thread can return
550    /// reaches this function's caller. That includes at least
551    /// `ProcessExitEventStreamDisconnected` (`runtime/process_exit_drainer.rs`
552    /// — beamr disconnected its publisher) and
553    /// `ProcessExitOutcomeMissingAfterEvent` (a beamr contract breach surfaced
554    /// through `registry.process_event`). Neither is timeout-, poison- or
555    /// panic-shaped, and neither needs a worker thread of ours to be stalled,
556    /// poisoned or dead.
557    ///
558    /// So: **do not enumerate this set, and do not reason from a shared shape or
559    /// a shared precondition.** The property that actually holds, and the only
560    /// one this seam needs, is that none of them can be arranged by a test
561    /// through this type's public surface. `process_exits` stays private; this
562    /// is the one named seam, and it is `#[cfg(test)]` so it cannot reach a
563    /// shipped binary.
564    ///
565    /// The injected variant is `ProcessExitRegistryPoisoned` because that is
566    /// what the injection point (`begin_shutdown` → `lock_lifecycle`) can
567    /// actually raise. A fault wearing a label its own injection site cannot
568    /// issue is a fixture modelling a machine that does not exist.
569    ///
570    /// Crate-visible rather than module-visible because the property it exists
571    /// to measure lives at [`crate::Engine::shutdown`], one module over — a seam
572    /// only reachable from its own module cannot test the caller that wraps it.
573    #[cfg(test)]
574    pub(crate) fn force_process_exit_drain_failure(&self) {
575        self.process_exits.force_shutdown_failure();
576    }
577
578    /// Shut down the embedded scheduler and wait for worker threads to stop.
579    ///
580    /// # Errors
581    ///
582    /// Returns the **first** typed failure raised by the process-exit drain
583    /// (`begin_shutdown`, `close_and_join_all`) or the cleanup-executor drain.
584    /// Those failures are an OPEN set — see
585    /// [`Self::force_process_exit_drain_failure`] for why they cannot be
586    /// enumerated — and the shape does not matter here, because none of them
587    /// short-circuits this function: the error is carried to the end and
588    /// returned only after the
589    /// engine-task epoch has been closed and the scheduler stopped. See the
590    /// comment on `first_error` in the body for why that ordering is a
591    /// correctness requirement rather than a tidiness preference.
592    pub fn shutdown(&self) -> Result<(), EngineError> {
593        // Kill every still-live in-VM activity child before stopping the
594        // scheduler so the singleton drainer can capture each durable outcome.
595        let workflow_pids: Vec<Pid> = self
596            .in_vm_children
597            .iter()
598            .map(|entry| *entry.key())
599            .collect();
600        for workflow_pid in workflow_pids {
601            self.kill_in_vm_children(workflow_pid);
602        }
603        // 🔴 Every fallible drain below RECORDS its failure instead of
604        // returning it, so that the epoch close at the end of this function is
605        // reached on every path.
606        //
607        // The epoch close aborts and awaits tasks that append terminal events.
608        // A drain timeout is not an unrelated inconvenience — it is precisely
609        // the condition under which those tasks are still armed, because one
610        // degraded store both stalls the drain and is what the completion
611        // retries are waiting on. If a `?` here returned early, the retries
612        // would keep running; the operator, seeing a shutdown error, restarts;
613        // and the successor engine recovers the same histories while this
614        // process is still appending to them — two writers for one workflow,
615        // which is the invariant-3 violation this whole mechanism exists to
616        // prevent.
617        //
618        // Nothing downstream would catch it on THIS path either. `Drop for
619        // Engine` exists, and what it does is enumerated in exactly one place —
620        // its own doc comment, which is the authority; this comment deliberately
621        // does not repeat the list, because it has already been stale once (it
622        // named two of the three things that drop does, and was written when
623        // there were two). What matters here is the shared PROPERTY of every
624        // item on that list: none of them await, and none of them run at all
625        // while the caller still holds the engine to read this function's `Err`.
626        //
627        // `EngineTaskRuntime::drop` is no help under the
628        // same conditions: an attempt in flight holds its own strong handle, so
629        // that backstop is pinned shut for exactly the span of the append it
630        // would need to stop. Closing the epoch here is the only mechanism that
631        // is guaranteed to run, which is why it does not sit behind a `?`.
632        let mut first_error: Option<EngineError> = None;
633
634        match self.process_exits.begin_shutdown() {
635            Ok(pids) => {
636                for pid in pids {
637                    if self.is_live(pid) {
638                        self.scheduler.terminate_process(pid, ExitReason::Kill);
639                    }
640                }
641            }
642            Err(error) => first_error = Some(error),
643        }
644        // 🔴 EVERY FAILURE IS REPORTED, EVEN THOUGH ONLY ONE CAN BE RETURNED.
645        // Three steps below can each fail independently and the signature can
646        // carry one `EngineError`, so a later failure that is not the first
647        // would otherwise vanish with no trace at all — a swallowed `Result` in
648        // the teardown path of a durable engine, which this codebase forbids
649        // outright. `keep_shutdown_error` returns the first and emits every
650        // subsequent one at `error` level with the step that produced it.
651        // Abort jobs may be blocked waiting for exactly the exits published
652        // above, so drain the bounded executor only after every registered pid
653        // has been force-unblocked.
654        keep_shutdown_error(
655            &mut first_error,
656            "cleanup_executor.shutdown",
657            self.cleanup_executor.shutdown(),
658        );
659        // Pending wake follow-ups are moot once process observation is closed.
660        self.wake_confirmer.shutdown();
661        keep_shutdown_error(
662            &mut first_error,
663            "process_exits.close_and_join_all",
664            self.process_exits.close_and_join_all(),
665        );
666        // Close the engine-task epoch here, after every process-exit callback
667        // has been drained (so an exit observed during teardown still gets its
668        // one attempt) and before the scheduler goes away.
669        //
670        // This must be on THIS path, not only on the child bridge's. The
671        // executor is owned here precisely because completion retries append
672        // terminal events on a core lifecycle path, and a core path's epoch
673        // close must not depend on whether an optional bridge was installed:
674        // a retry still running after this returns could append against a
675        // store a successor engine is also recovering (invariant 3).
676        //
677        // A second `shutdown` call is a no-op: it re-gates and re-sweeps the
678        // three already-empty maps `gate_and_abort` covers (`watches`,
679        // `spawn_retries`, `completion_retries`), finds the runtime slot empty,
680        // and returns without awaiting anything. That makes the bridge's own
681        // later call harmless
682        // because the two are strictly sequential (`Engine::shutdown` runs this
683        // one, then the bridge's). It does NOT mean "returning implies
684        // quiescence" for a hypothetical concurrent second caller — only the
685        // first call awaits.
686        self.engine_tasks.shutdown();
687        self.scheduler.shutdown();
688        self.spawn_heaps.clear();
689
690        match first_error {
691            Some(error) => Err(error),
692            None => Ok(()),
693        }
694    }
695
696    fn spawn_process(
697        &self,
698        deployed_module: &str,
699        function: &str,
700        input: RuntimeInput,
701    ) -> Result<Pid, EngineError> {
702        self.release_dead_spawn_heaps();
703        let module = self.atom_table.intern(deployed_module);
704        let function = self.atom_table.intern(function);
705        let (terms, heaps) = input.into_spawn_parts();
706        let pid = self.spawn_with_exit_ownership(|| {
707            self.scheduler
708                .spawn(module, function, terms)
709                .map_err(runtime_error_from_display)
710        })?;
711        self.retain_spawn_heaps(pid, heaps);
712        Ok(pid)
713    }
714
715    fn retain_spawn_heaps(&self, pid: Pid, heaps: RetainedHeaps) {
716        if heaps.is_empty() {
717            return;
718        }
719        self.spawn_heaps.insert(pid, Mutex::new(heaps));
720    }
721
722    pub(super) fn release_spawn_heaps(&self, pid: Pid) {
723        self.spawn_heaps.remove(&pid);
724    }
725
726    fn release_dead_spawn_heaps(&self) {
727        let dead_pids: Vec<Pid> = self
728            .spawn_heaps
729            .iter()
730            .filter_map(|entry| {
731                let pid = *entry.key();
732                self.scheduler
733                    .process_table()
734                    .get(pid)
735                    .is_none()
736                    .then_some(pid)
737            })
738            .collect();
739        for pid in dead_pids {
740            self.release_spawn_heaps(pid);
741        }
742    }
743
744    pub(super) fn ensure_live_pid(&self, pid: Pid) -> Result<(), EngineError> {
745        if self.scheduler.process_table().get(pid).is_some() {
746            Ok(())
747        } else {
748            Err(runtime_error(format!("process {pid} is not live")))
749        }
750    }
751
752    #[cfg(test)]
753    pub(crate) fn live_processes_for_test(&self) -> usize {
754        self.scheduler.process_table().len()
755    }
756
757    /// Spawn an inert test process without module code.
758    ///
759    /// # Errors
760    ///
761    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
762    #[cfg(test)]
763    pub fn spawn_test_process(&self) -> Result<Pid, EngineError> {
764        self.spawn_with_exit_ownership(|| Ok(self.scheduler.spawn_test_process(false)))
765    }
766
767    /// Spawn an inert test process with explicit trap-exit state.
768    ///
769    /// # Errors
770    ///
771    /// Returns [`EngineError::Runtime`] when beamr rejects the test spawn.
772    #[cfg(test)]
773    pub fn spawn_test_process_with_trap_exit(&self, trap_exit: bool) -> Result<Pid, EngineError> {
774        self.spawn_with_exit_ownership(|| Ok(self.scheduler.spawn_test_process(trap_exit)))
775    }
776
777    /// Spawn an inert linked test child without enabling trap-exit on the child.
778    ///
779    /// # Errors
780    ///
781    /// Returns [`EngineError::Runtime`] when the parent is not live or beamr
782    /// rejects the linked spawn.
783    #[cfg(test)]
784    pub fn spawn_linked_test_process(&self, parent_pid: Pid) -> Result<Pid, EngineError> {
785        self.ensure_live_pid(parent_pid)?;
786        self.spawn_with_exit_ownership(|| {
787            self.scheduler
788                .spawn_linked_test_process(parent_pid)
789                .map_err(runtime_error_from_display)
790        })
791    }
792
793    /// Return true when a live process has a trapped EXIT message from `source_pid`.
794    ///
795    /// # Errors
796    ///
797    /// Returns [`EngineError::Runtime`] when `target_pid` is not live.
798    #[cfg(test)]
799    pub fn has_trapped_exit_message(
800        &self,
801        target_pid: Pid,
802        source_pid: Pid,
803    ) -> Result<bool, EngineError> {
804        self.ensure_live_pid(target_pid)?;
805        Ok(self
806            .scheduler
807            .has_trapped_exit_message(target_pid, source_pid)
808            .unwrap_or(false))
809    }
810
811    /// Poll until a trapped EXIT message from `source_pid` arrives at `target_pid`.
812    ///
813    /// beamr delivers exit signals asynchronously after process termination.
814    /// Tests that assert on trapped exit messages must wait for delivery.
815    ///
816    /// # Errors
817    ///
818    /// Returns [`EngineError::Runtime`] if the message does not arrive within 50ms.
819    #[cfg(test)]
820    pub fn wait_for_trapped_exit(
821        &self,
822        target_pid: Pid,
823        source_pid: Pid,
824    ) -> Result<(), EngineError> {
825        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
826        while std::time::Instant::now() < deadline {
827            if self
828                .scheduler
829                .has_trapped_exit_message(target_pid, source_pid)
830                .unwrap_or(false)
831            {
832                return Ok(());
833            }
834            std::thread::sleep(std::time::Duration::from_millis(1));
835        }
836        Err(runtime_error(format!(
837            "trapped exit from {source_pid} to {target_pid} did not arrive"
838        )))
839    }
840
841    /// Terminate a test process with a trappable abnormal reason.
842    ///
843    /// # Errors
844    ///
845    /// Returns [`EngineError::Runtime`] when `pid` is not live.
846    #[cfg(test)]
847    pub fn terminate_test_process_with_error(&self, pid: Pid) -> Result<(), EngineError> {
848        self.ensure_live_pid(pid)?;
849        self.scheduler.terminate_process(pid, ExitReason::Error);
850        Ok(())
851    }
852
853    #[cfg(test)]
854    pub(crate) fn lookup_native_for_test(
855        &self,
856        module: &str,
857        function: &str,
858        arity: u8,
859    ) -> Option<beamr::native::NativeEntry> {
860        let module = self.atom_table.intern(module);
861        let function = self.atom_table.intern(function);
862        self.native_registry.lookup(module, function, arity)
863    }
864
865    #[cfg(test)]
866    pub(crate) fn retained_spawn_heap_count_for_test(&self) -> usize {
867        self.release_dead_spawn_heaps();
868        self.spawn_heaps.len()
869    }
870}
871
872/// Keep the FIRST teardown failure for the caller, and report every subsequent
873/// one rather than dropping it.
874///
875/// Teardown accumulates instead of failing fast — every step must run, because
876/// a skipped one leaves a durable writer armed. The cost of that choice is that
877/// more than one step can fail while only one `EngineError` can be returned.
878/// Reporting the others here is what keeps "accumulate and continue" from
879/// becoming "swallow and continue": the return value carries the first, the log
880/// carries the rest, and no failure is lost.
881fn keep_shutdown_error(
882    first_error: &mut Option<EngineError>,
883    step: &'static str,
884    result: Result<(), EngineError>,
885) {
886    let Err(error) = result else {
887        return;
888    };
889    if first_error.is_none() {
890        *first_error = Some(error);
891        return;
892    }
893    tracing::error!(
894        step,
895        error = %error,
896        "a further runtime-shutdown step failed after an earlier one; only the first failure \
897         can be returned, so this one is reported here"
898    );
899}
900
901fn runtime_error(reason: String) -> EngineError {
902    EngineError::Runtime { reason }
903}
904
905fn runtime_error_from_display(reason: impl std::fmt::Display) -> EngineError {
906    runtime_error(reason.to_string())
907}
908
909mod activity_delivery;
910mod delivery;
911mod process_ownership;
912mod readiness;
913mod registration;
914mod spawn_bifs;
915
916pub(crate) use delivery::InVmChildOutcome;
917
918#[cfg(test)]
919#[path = "handle/test_support.rs"]
920mod test_support;
921
922#[cfg(test)]
923#[path = "handle/tests.rs"]
924mod tests;