Skip to main content

aion/runtime/handle/
delivery.rs

1//! Mailbox delivery surface of [`RuntimeHandle`]: wake markers, two-phase
2//! activity completion retention, and the retry-tolerant enqueue path.
3//!
4//! Markers are pure wakes — durable state lives in recorded history or the
5//! retained completion maps, never in the marker itself.
6
7use aion_core::{
8    ActivityError, ActivityErrorKind, ActivityId, ContentType, Payload, RunId, WorkflowId,
9};
10use beamr::atom::Atom;
11use beamr::process::ExitReason;
12
13use crate::error::EngineError;
14use crate::registry::Registry;
15
16use super::activity_delivery::{ActivityOutcomeKind, RetainedActivityDelivery};
17use super::{Pid, RuntimeHandle, runtime_error};
18use crate::runtime::payload::term_to_payload;
19
20impl RuntimeHandle {
21    /// Block until an activity exits, then surface its success or failure to the parent.
22    ///
23    /// Normal returns become typed payload results queued for the workflow and
24    /// abnormal exits become typed activity errors that can be read alongside the
25    /// trapped EXIT message delivered by the runtime link.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`EngineError::Runtime`] when the parent is not live, the result
30    /// term cannot be converted to a payload, or mailbox delivery fails.
31    pub fn propagate_activity_outcome(
32        &self,
33        parent_pid: Pid,
34        activity_pid: Pid,
35    ) -> Result<(), EngineError> {
36        self.ensure_live_pid(parent_pid)?;
37        let observed = self.activity_process_exit_outcome(activity_pid)?;
38        self.release_spawn_heaps(activity_pid);
39        if observed.reason == ExitReason::Normal {
40            let payload = term_to_payload(
41                observed.result.root(),
42                &self.atom_table,
43                observed.result.borrow_terms(),
44            )?;
45            self.deliver_activity_result(parent_pid, activity_pid, payload)
46        } else {
47            let error = self
48                .activity_errors
49                .get(&(parent_pid, activity_pid))
50                .map_or_else(
51                    || ActivityError {
52                        kind: ActivityErrorKind::Terminal,
53                        message: activity_exit_message(activity_pid, observed.reason),
54                        details: None,
55                    },
56                    |entry| entry.clone(),
57                );
58            self.deliver_activity_error(parent_pid, activity_pid, error)
59        }
60    }
61
62    /// Block until an in-VM activity child exits and decode its outcome.
63    ///
64    /// The child body is the SDK-composed runner thunk, whose Gleam `Result`
65    /// crosses the exit boundary verbatim: a `Normal` exit carrying
66    /// `{ok, JsonBin}` is a completion, `{error, ReasonBin}` is a failure
67    /// whose reason already uses the SDK's prefixed vocabulary
68    /// (`retryable:`/`terminal:`/...), and an abnormal exit (runner panic,
69    /// `let assert`, NIF badarg) synthesizes a `terminal:`-prefixed reason
70    /// mirroring [`Self::propagate_activity_outcome`]'s trapped-exit message.
71    /// A `Normal` exit with any other result shape is a defect surfaced as a
72    /// terminal failure, never a hang.
73    ///
74    /// Deliberately NOT keyed through the legacy `(parent, child_pid)` maps:
75    /// the caller delivers the decoded outcome by correlation id into the
76    /// ordinal-keyed two-phase maps, the same regime the remote wire uses.
77    pub(crate) fn in_vm_child_outcome(
78        &self,
79        child_pid: Pid,
80    ) -> Result<InVmChildOutcome, EngineError> {
81        let observed = self.activity_process_exit_outcome(child_pid)?;
82        self.release_spawn_heaps(child_pid);
83        if observed.reason == ExitReason::Normal {
84            match decode_in_vm_result(observed.result.root(), observed.result.borrow_terms()) {
85                Some(outcome) => Ok(outcome),
86                None => Ok(InVmChildOutcome::Failed(format!(
87                    "terminal:activity process {child_pid} returned an unexpected result shape"
88                ))),
89            }
90        } else {
91            Ok(InVmChildOutcome::Failed(format!(
92                "terminal:{}",
93                activity_exit_message(child_pid, observed.reason)
94            )))
95        }
96    }
97
98    /// Deliver a recorded signal wake marker to the workflow mailbox surface.
99    ///
100    /// The marker is a pure wake: the signal payload was already durably
101    /// recorded by the signal router before delivery, and the awaiting NIF
102    /// resolves it from recorded history. Nothing is retained here.
103    ///
104    /// Blocking variant for synchronous callers (engine-seam trait impls and
105    /// scheduler-thread paths); async tasks use
106    /// [`Self::deliver_signal_received_async`] so their executor threads are
107    /// never parked in `std::thread::sleep`.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
112    /// mailbox marker cannot be queued.
113    pub fn deliver_signal_received(&self, workflow_pid: Pid) -> Result<(), EngineError> {
114        self.ensure_live_pid(workflow_pid)?;
115        self.wait_for_process_ready(workflow_pid)?;
116        let marker = self.atom_table.intern("aion_signal_received");
117        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
118    }
119
120    /// Async variant of [`Self::deliver_signal_received`] for runtime tasks:
121    /// the readiness wait and the enqueue retry yield to the executor
122    /// instead of blocking its worker thread.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
127    /// mailbox marker cannot be queued.
128    pub(crate) async fn deliver_signal_received_async(
129        &self,
130        workflow_pid: Pid,
131    ) -> Result<(), EngineError> {
132        self.ensure_live_pid(workflow_pid)?;
133        self.wait_for_process_ready_async(workflow_pid).await?;
134        let marker = self.atom_table.intern("aion_signal_received");
135        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
136            .await
137    }
138
139    /// Deliver a pending-query wake marker to the workflow mailbox surface.
140    ///
141    /// The marker is a pure wake: the pending query (id and name) was already
142    /// queued in the engine NIF state by the query mailbox engine, and the
143    /// woken suspending await drains it through the query-pump entry check.
144    /// Nothing is retained here and nothing is recorded.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
149    /// mailbox marker cannot be queued.
150    pub(crate) fn deliver_query_request(&self, workflow_pid: Pid) -> Result<(), EngineError> {
151        self.ensure_live_pid(workflow_pid)?;
152        self.wait_for_process_ready(workflow_pid)?;
153        let marker = self.atom_table.intern("aion_query");
154        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
155    }
156
157    /// Deliver a recorded child-terminal wake marker to the parent workflow
158    /// mailbox surface.
159    ///
160    /// The marker is a pure wake: the child's terminal outcome was already
161    /// durably recorded into the parent's history (as
162    /// `ChildWorkflowCompleted`/`ChildWorkflowFailed`) by the child-terminal
163    /// watcher before delivery, and the awaiting NIF resolves it from
164    /// recorded history. Nothing is retained here.
165    ///
166    /// Async by contract: the only caller is the child-terminal watcher on
167    /// the single-worker child-task runtime, where a blocking readiness wait
168    /// would serialize every other watcher's delivery behind it (worst case
169    /// N × `ready_timeout` under fan-out).
170    ///
171    /// # Errors
172    ///
173    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
174    /// mailbox marker cannot be queued.
175    pub(crate) async fn deliver_child_terminal(
176        &self,
177        workflow_pid: Pid,
178    ) -> Result<(), EngineError> {
179        self.ensure_live_pid(workflow_pid)?;
180        self.wait_for_process_ready_async(workflow_pid).await?;
181        let marker = self.atom_table.intern("aion_child_terminal");
182        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
183            .await
184    }
185
186    /// Deliver a two-phase activity completion marker to the workflow mailbox.
187    ///
188    /// The structured `{activity_complete, CorrelationId, Result}` payload is
189    /// retained in the runtime boundary, and an atom marker wakes any suspended
190    /// selective receive. The await NIF resolves the retained payload by
191    /// correlation id after consuming the marker.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`EngineError::ActivityDeliveryPoisoned`] when this workflow's
196    /// scoped delivery gate was poisoned, or [`EngineError::Runtime`] when the
197    /// workflow is not live or the marker cannot be queued.
198    pub(crate) fn deliver_activity_completion_message(
199        &self,
200        workflow_pid: Pid,
201        correlation_id: &str,
202        result: String,
203    ) -> Result<(), EngineError> {
204        self.deliver_activity_completion_message_with_attempt(
205            workflow_pid,
206            correlation_id,
207            result,
208            None,
209        )
210    }
211
212    pub(crate) fn deliver_activity_completion_message_with_attempt(
213        &self,
214        workflow_pid: Pid,
215        correlation_id: &str,
216        result: String,
217        attempt: Option<u32>,
218    ) -> Result<(), EngineError> {
219        let activity_id = correlation_to_activity_pid(correlation_id)?;
220        let key = (workflow_pid, activity_id);
221        let marker = self.atom_table.intern("activity_complete");
222        self.retain_activity_outcome_and_deliver_marker(
223            workflow_pid,
224            &self.activity_results,
225            RetainedActivityDelivery {
226                key,
227                outcome: Payload::new(ContentType::Json, result.into_bytes()),
228                kind: ActivityOutcomeKind::Result,
229                attempt,
230            },
231            || self.enqueue_activity_marker(workflow_pid, marker, activity_id, correlation_id),
232        )
233    }
234
235    /// Deliver a two-phase activity failure marker to the workflow mailbox.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`EngineError::ActivityDeliveryPoisoned`] when this workflow's
240    /// scoped delivery gate was poisoned, or [`EngineError::Runtime`] when the
241    /// workflow is not live or the marker cannot be queued.
242    pub(crate) fn deliver_activity_failure_message(
243        &self,
244        workflow_pid: Pid,
245        correlation_id: &str,
246        reason: String,
247    ) -> Result<(), EngineError> {
248        self.deliver_activity_failure_message_with_attempt(
249            workflow_pid,
250            correlation_id,
251            reason,
252            None,
253        )
254    }
255
256    pub(crate) fn deliver_activity_failure_message_with_attempt(
257        &self,
258        workflow_pid: Pid,
259        correlation_id: &str,
260        reason: String,
261        attempt: Option<u32>,
262    ) -> Result<(), EngineError> {
263        let activity_id = correlation_to_activity_pid(correlation_id)?;
264        let key = (workflow_pid, activity_id);
265        let marker = self.atom_table.intern("activity_failed");
266        self.retain_activity_outcome_and_deliver_marker(
267            workflow_pid,
268            &self.activity_errors,
269            RetainedActivityDelivery {
270                key,
271                outcome: activity_failure(reason),
272                kind: ActivityOutcomeKind::Error,
273                attempt,
274            },
275            || self.enqueue_activity_marker(workflow_pid, marker, activity_id, correlation_id),
276        )
277    }
278
279    /// Route an unmatched durable-outbox activity completion into the live
280    /// workflow's mailbox.
281    ///
282    /// Resolves `workflow_id` to its live pid through `registry` (the
283    /// [`RuntimeHandle`] does not hold the registry) and delegates to
284    /// [`Self::deliver_activity_completion_message`], whose retained payload
285    /// the engine's `take_and_record` later records as the terminal.
286    ///
287    /// Returns `Ok(true)` when delivered to a live workflow and `Ok(false)`
288    /// when no run for the workflow is currently live — the expected
289    /// stale-completion case after a crash or eviction, which recovery
290    /// re-arms. A `false` is not an error: the caller logs it at debug.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`EngineError::RegistryPoisoned`] when the registry index lock
295    /// was poisoned, [`EngineError::ActivityDeliveryPoisoned`] when the
296    /// resolved workflow's scoped delivery gate was poisoned, or
297    /// [`EngineError::Runtime`] when the process is not live or the mailbox
298    /// marker cannot be queued.
299    pub fn deliver_outbox_completion(
300        &self,
301        registry: &Registry,
302        workflow_id: &WorkflowId,
303        activity_id: &ActivityId,
304        run_id: Option<&RunId>,
305        result: String,
306    ) -> Result<bool, EngineError> {
307        // Run-aware gate: a completion carrying a run_id is only delivered when
308        // that run is still the workflow's live run. After continue-as-new the
309        // prior run is superseded, and its late completion must NOT resolve the
310        // new run's reused ordinal (OBX-011). The recorder's
311        // `record_fan_out_completion` run check is the second enforcement layer.
312        let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
313            return Ok(false);
314        };
315        self.deliver_activity_completion_message(pid, &activity_id.to_string(), result)?;
316        Ok(true)
317    }
318
319    /// Route an unmatched durable-outbox activity failure into the live
320    /// workflow's mailbox.
321    ///
322    /// Failure twin of [`Self::deliver_outbox_completion`]: same registry
323    /// resolution and the same not-live `Ok(false)` outcome, delegating to
324    /// [`Self::deliver_activity_failure_message`].
325    ///
326    /// # Errors
327    ///
328    /// Returns [`EngineError::RegistryPoisoned`] when the registry index lock
329    /// was poisoned, [`EngineError::ActivityDeliveryPoisoned`] when the
330    /// resolved workflow's scoped delivery gate was poisoned, or
331    /// [`EngineError::Runtime`] when the process is not live or the mailbox
332    /// marker cannot be queued.
333    pub fn deliver_outbox_failure(
334        &self,
335        registry: &Registry,
336        workflow_id: &WorkflowId,
337        activity_id: &ActivityId,
338        run_id: Option<&RunId>,
339        reason: String,
340    ) -> Result<bool, EngineError> {
341        // Run-aware gate, identical to `deliver_outbox_completion`: a failure
342        // belonging to a superseded run (post continue-as-new) must not resolve
343        // the new run's reused ordinal (OBX-011).
344        let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
345            return Ok(false);
346        };
347        self.deliver_activity_failure_message(pid, &activity_id.to_string(), reason)?;
348        Ok(true)
349    }
350
351    /// Deliver a successful activity result payload to the workflow mailbox surface.
352    ///
353    /// # Errors
354    ///
355    /// Returns [`EngineError::ActivityDeliveryPoisoned`] when the parent's
356    /// scoped delivery gate was poisoned, or [`EngineError::Runtime`] when the
357    /// workflow is not live or the mailbox marker cannot be queued.
358    pub fn deliver_activity_result(
359        &self,
360        parent_pid: Pid,
361        activity_pid: Pid,
362        payload: Payload,
363    ) -> Result<(), EngineError> {
364        let key = (parent_pid, activity_pid);
365        let marker = self.atom_table.intern("aion_activity_result");
366        self.retain_activity_outcome_and_deliver_marker(
367            parent_pid,
368            &self.activity_results,
369            RetainedActivityDelivery {
370                key,
371                outcome: payload,
372                kind: ActivityOutcomeKind::Result,
373                attempt: None,
374            },
375            || {
376                self.enqueue_activity_marker(
377                    parent_pid,
378                    marker,
379                    activity_pid,
380                    &format!("activity process {activity_pid}"),
381                )
382            },
383        )
384    }
385
386    /// Wake a suspended workflow process so blocking awaits re-run their
387    /// two-phase resolution (a fired timer, an expired `with_timeout`
388    /// deadline, or any other recorded arrival).
389    ///
390    /// # Errors
391    ///
392    /// Returns [`EngineError::Runtime`] when the workflow process is not
393    /// live or the wake marker cannot be queued.
394    pub(crate) fn wake_workflow(&self, workflow_pid: Pid) -> Result<(), EngineError> {
395        self.ensure_live_pid(workflow_pid)?;
396        let marker = self.atom_table.intern("aion_timer_fired");
397        // Retry covers the transient just-spawned/executing windows where
398        // beamr's enqueue declines; a recovery-re-armed timer can fire
399        // before the recovered process slot is fully materialized.
400        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
401    }
402
403    /// Store a typed activity error for a trapped activity EXIT signal.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`EngineError::ActivityDeliveryPoisoned`] when the parent's
408    /// scoped delivery gate was poisoned, or [`EngineError::Runtime`] when the
409    /// workflow process is not live.
410    pub fn deliver_activity_error(
411        &self,
412        parent_pid: Pid,
413        activity_pid: Pid,
414        error: ActivityError,
415    ) -> Result<(), EngineError> {
416        self.with_activity_delivery(parent_pid, |state| {
417            self.ensure_activity_delivery_live(parent_pid, state)?;
418            self.activity_errors
419                .insert((parent_pid, activity_pid), error);
420            state.retain_outcome(activity_pid, ActivityOutcomeKind::Error);
421            Ok(())
422        })
423    }
424
425    /// Read a previously delivered activity result payload.
426    #[must_use]
427    pub fn activity_result(&self, parent_pid: Pid, activity_pid: Pid) -> Option<Payload> {
428        self.activity_results
429            .get(&(parent_pid, activity_pid))
430            .map(|entry| entry.clone())
431    }
432
433    /// Read a previously delivered activity error associated with a trapped exit.
434    #[must_use]
435    pub fn activity_error(&self, parent_pid: Pid, activity_pid: Pid) -> Option<ActivityError> {
436        self.activity_errors
437            .get(&(parent_pid, activity_pid))
438            .map(|entry| entry.clone())
439    }
440
441    pub(crate) fn take_activity_result(
442        &self,
443        parent_pid: Pid,
444        activity_sequence: Pid,
445    ) -> Result<Option<(Payload, Option<u32>)>, EngineError> {
446        self.take_activity_outcome(
447            parent_pid,
448            activity_sequence,
449            &self.activity_results,
450            ActivityOutcomeKind::Result,
451        )
452    }
453
454    pub(crate) fn take_activity_error(
455        &self,
456        parent_pid: Pid,
457        activity_sequence: Pid,
458    ) -> Result<Option<(ActivityError, Option<u32>)>, EngineError> {
459        self.take_activity_outcome(
460            parent_pid,
461            activity_sequence,
462            &self.activity_errors,
463            ActivityOutcomeKind::Error,
464        )
465    }
466
467    /// Number of retained two-phase activity completion entries (results
468    /// plus failures) across every workflow process.
469    ///
470    /// Diagnostic surface: after a workflow exits, the monitor drain must
471    /// leave nothing behind for its pid, so an engine with no live awaits
472    /// should report zero.
473    #[must_use]
474    pub fn retained_activity_completions(&self) -> usize {
475        self.activity_results.len() + self.activity_errors.len()
476    }
477
478    pub(crate) fn activity_complete_atom(&self) -> Atom {
479        self.atom_table.intern("activity_complete")
480    }
481
482    pub(crate) fn activity_failed_atom(&self) -> Atom {
483        self.atom_table.intern("activity_failed")
484    }
485
486    pub(crate) fn activity_result_atom(&self) -> Atom {
487        self.atom_table.intern("aion_activity_result")
488    }
489
490    pub(crate) fn signal_received_atom(&self) -> Atom {
491        self.atom_table.intern("aion_signal_received")
492    }
493
494    pub(crate) fn timer_fired_atom(&self) -> Atom {
495        self.atom_table.intern("aion_timer_fired")
496    }
497
498    pub(crate) fn query_marker_atom(&self) -> Atom {
499        self.atom_table.intern("aion_query")
500    }
501
502    pub(crate) fn child_terminal_atom(&self) -> Atom {
503        self.atom_table.intern("aion_child_terminal")
504    }
505
506    fn enqueue_signal_marker_with_retry(
507        &self,
508        workflow_pid: Pid,
509        marker: Atom,
510    ) -> Result<(), EngineError> {
511        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
512        let mut backoff = self.signal_delivery.initial_backoff;
513        for attempt in 1..=attempts {
514            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
515                self.confirm_marker_wake(workflow_pid);
516                return Ok(());
517            }
518
519            if self.scheduler.process_table().get(workflow_pid).is_none() {
520                return Err(runtime_error(format!(
521                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
522                )));
523            }
524
525            if attempt < attempts {
526                // beamr 0.3.15 normal spawn publishes the PID before a scheduler
527                // worker materializes the process body from its SpawnRequest. It
528                // also exposes an Executing slot while the process is running.
529                // enqueue_atom_message only accepts a Present slot, so an alive
530                // just-spawned or currently executing process can transiently
531                // return false even after the liveness/ready gate above.
532                sleep_signal_delivery_backoff(backoff);
533                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
534            }
535        }
536
537        Err(runtime_error(format!(
538            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
539        )))
540    }
541
542    /// Async twin of [`Self::enqueue_signal_marker_with_retry`]: identical
543    /// retry policy over the same just-spawned/executing windows, with the
544    /// backoff yielded to the executor instead of blocking its worker.
545    async fn enqueue_signal_marker_with_retry_async(
546        &self,
547        workflow_pid: Pid,
548        marker: Atom,
549    ) -> Result<(), EngineError> {
550        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
551        let mut backoff = self.signal_delivery.initial_backoff;
552        for attempt in 1..=attempts {
553            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
554                self.confirm_marker_wake(workflow_pid);
555                return Ok(());
556            }
557
558            if self.scheduler.process_table().get(workflow_pid).is_none() {
559                return Err(runtime_error(format!(
560                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
561                )));
562            }
563
564            if attempt < attempts {
565                // Same transient-window rationale as the blocking variant.
566                yield_signal_delivery_backoff(backoff).await;
567                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
568            }
569        }
570
571        Err(runtime_error(format!(
572            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
573        )))
574    }
575
576    /// Arm the consumption-gated wake ladder for a delivered marker.
577    ///
578    /// `enqueue_atom_message` stores the message and wakes the pid, but
579    /// beamr's `Wait`-arm gap can swallow that wake (the message is
580    /// stored after the parked process's mailbox re-check and the wake runs
581    /// before its wait-set insert), parking the process forever on a
582    /// one-shot delivery. Follow-up wakes land after the insert and drain
583    /// the already-stored message; the ladder stops once the target's
584    /// wake-observation epoch moves — a suspending-native entry or process
585    /// exit after this delivery — so it survives arbitrarily stretched gaps
586    /// (OS preemption) without waking healthy processes forever.
587    ///
588    /// NOTE: this workaround was written against beamr 0.4.9. The crate is now
589    /// pinned to beamr 0.6.4; the `Wait`-arm gap may have been fixed upstream,
590    /// so this ladder needs re-validation against 0.6.4 and may now be stale.
591    pub(super) fn confirm_marker_wake(&self, workflow_pid: Pid) {
592        let state = std::sync::Arc::clone(self.nif_state());
593        let snapshot = state.wake_observation_epoch(workflow_pid);
594        self.wake_confirmer
595            .confirm(self.scheduler.wake_notifier(workflow_pid), move || {
596                state.wake_ladder_done(workflow_pid, snapshot)
597            });
598    }
599}
600
601/// Resolve the pid an unmatched outbox completion/failure should be delivered
602/// to, enforcing run scoping when a `run_id` is supplied.
603///
604/// When `run_id` is `Some(r)`, delivery is gated on the workflow's live run
605/// still being `r`: a completion for a superseded/dead run (e.g. a prior run
606/// after continue-as-new) resolves to `Ok(None)` and is dropped, so it can
607/// never resolve the new run's reused ordinal space (OBX-011).
608///
609/// When `run_id` is `None` (legacy/pre-CAN callers), this preserves the
610/// original run-agnostic behaviour: deliver to whatever run is live.
611///
612/// `Ok(None)` is the not-live / wrong-run outcome, never an error.
613fn outbox_delivery_pid(
614    registry: &Registry,
615    workflow_id: &WorkflowId,
616    run_id: Option<&RunId>,
617) -> Result<Option<u64>, EngineError> {
618    match run_id {
619        None => registry.live_pid(workflow_id),
620        Some(expected) => {
621            let Some((live_run, pid)) = registry.live_run_pid(workflow_id)? else {
622                return Ok(None);
623            };
624            if live_run == *expected {
625                Ok(Some(pid))
626            } else {
627                tracing::debug!(
628                    %workflow_id,
629                    %expected,
630                    live_run = %live_run,
631                    "dropping outbox delivery for superseded run"
632                );
633                Ok(None)
634            }
635        }
636    }
637}
638
639fn activity_failure(message: String) -> ActivityError {
640    ActivityError {
641        kind: ActivityErrorKind::Terminal,
642        message,
643        details: None,
644    }
645}
646
647/// The one canonical message for an activity child that exited abnormally,
648/// shared by the trapped-exit propagation path and the in-VM outcome decode.
649fn activity_exit_message(activity_pid: Pid, reason: ExitReason) -> String {
650    format!("activity process {activity_pid} exited: {reason:?}")
651}
652
653/// Outcome of one in-VM activity child, decoded at its exit boundary.
654///
655/// Both variants carry the raw wire string the correlation-keyed delivery
656/// path expects: a completion carries the runner's output-codec JSON, a
657/// failure carries the SDK's prefixed reason vocabulary.
658#[derive(Debug, PartialEq, Eq)]
659pub(crate) enum InVmChildOutcome {
660    /// Normal exit with `{ok, JsonBin}`: the encoded activity output.
661    Completed(String),
662    /// Normal exit with `{error, ReasonBin}`, or a synthesized reason for an
663    /// abnormal exit / unexpected result shape.
664    Failed(String),
665}
666
667/// Decode the thunk child's exit result term (`{ok, Bin} | {error, Bin}`).
668///
669/// Returns `None` for any other shape — including non-UTF-8 payload bytes —
670/// so the caller synthesizes a terminal failure instead of guessing.
671fn decode_in_vm_result(
672    term: beamr::term::Term,
673    heap: beamr::term::heap_borrow::HeapBorrow<'_>,
674) -> Option<InVmChildOutcome> {
675    let tuple = beamr::term::boxed::Tuple::new(term)?;
676    if tuple.arity() != 2 {
677        return None;
678    }
679    let tag = tuple.get(0)?;
680    let value = tuple.get(1)?;
681    let bin = beamr::term::binary_ref::BinaryRef::new(value)?;
682    let text = String::from_utf8(bin.as_bytes(heap).to_vec()).ok()?;
683    if tag == beamr::term::Term::atom(Atom::OK) {
684        Some(InVmChildOutcome::Completed(text))
685    } else if tag == beamr::term::Term::atom(Atom::ERROR) {
686        Some(InVmChildOutcome::Failed(text))
687    } else {
688        None
689    }
690}
691
692fn correlation_to_activity_pid(correlation_id: &str) -> Result<Pid, EngineError> {
693    let Some(raw) = correlation_id.strip_prefix("activity:") else {
694        return Err(runtime_error(format!(
695            "invalid activity correlation id {correlation_id}"
696        )));
697    };
698    raw.parse::<Pid>().map_err(|error| {
699        runtime_error(format!(
700            "invalid activity correlation sequence {correlation_id}: {error}"
701        ))
702    })
703}
704
705pub(super) fn next_signal_delivery_backoff(
706    current: std::time::Duration,
707    max: std::time::Duration,
708) -> std::time::Duration {
709    let doubled = current.saturating_mul(2);
710    if doubled > max { max } else { doubled }
711}
712
713pub(super) fn sleep_signal_delivery_backoff(duration: std::time::Duration) {
714    if duration.is_zero() {
715        std::thread::yield_now();
716    } else {
717        std::thread::sleep(duration);
718    }
719}
720
721pub(super) async fn yield_signal_delivery_backoff(duration: std::time::Duration) {
722    if duration.is_zero() {
723        tokio::task::yield_now().await;
724    } else {
725        tokio::time::sleep(duration).await;
726    }
727}
728
729#[cfg(test)]
730#[path = "delivery_tests.rs"]
731mod tests;