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;