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