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;