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