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