Skip to main content

meerkat_runtime/handles/
peer_interaction.rs

1//! Runtime impl of [`meerkat_core::handles::PeerInteractionHandle`] (W1-A).
2//!
3//! Routes peer request/response lifecycle events into the session's
4//! MeerkatMachine DSL (`pending_peer_requests` / `inbound_peer_requests`
5//! substate maps) and fans the emitted `PeerInteractionCleanup` effects
6//! out to the installed shell-side observer, so the subscriber / stream
7//! registries update causally — the map is a strict projection of DSL
8//! truth, not shadow state that happens to be updated lexically near each
9//! terminal transition.
10
11use std::sync::{Arc, RwLock, Weak};
12
13use meerkat_core::handles::{
14    DslTransitionError, PeerInteractionCleanupObserver, PeerInteractionHandle,
15    PeerTerminalDisposition as CorePeerDisposition,
16};
17use meerkat_core::interaction::{TerminalDisposition, TerminalityClass};
18use meerkat_core::peer_correlation::{
19    InboundPeerRequestState as CoreInboundState, OutboundPeerRequestState as CoreOutboundState,
20    PeerCorrelationId,
21};
22use meerkat_core::types::HandlingMode;
23
24use super::HandleDslAuthority;
25use crate::meerkat_machine::dsl as mm_dsl;
26
27/// Runtime-backed [`PeerInteractionHandle`] impl.
28///
29/// Every trait method routes to the corresponding DSL input on the session's
30/// shared MeerkatMachine authority. After the transition lands, emitted
31/// effects are scanned for `PeerInteractionCleanup` and dispatched to the
32/// installed [`PeerInteractionCleanupObserver`] (if any) — closing the
33/// "terminal transition → effect → shell projection cleanup" loop.
34///
35/// The cleanup observer is held as a `Weak` reference. In production the
36/// observer is the session's `CommsRuntime`, which in turn holds a strong
37/// `Arc<dyn PeerInteractionHandle>` to this struct; storing the observer
38/// strongly would create a cycle that prevents `CommsRuntime::drop` from
39/// firing on session teardown (dropped listeners, leaked session-identity
40/// claims, zombie `InprocRegistry` entries). `Weak` breaks the cycle —
41/// once the runtime drops, `upgrade()` returns `None` and subsequent
42/// effect dispatches become no-ops, which is the desired semantics
43/// post-teardown.
44pub struct RuntimePeerInteractionHandle {
45    dsl: Arc<HandleDslAuthority>,
46    cleanup_observer: RwLock<Option<Weak<dyn PeerInteractionCleanupObserver>>>,
47}
48
49impl std::fmt::Debug for RuntimePeerInteractionHandle {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        let observer_tag = self
52            .cleanup_observer
53            .read()
54            .ok()
55            .as_deref()
56            .and_then(|o| o.as_ref().map(|_| "<observer>"));
57        f.debug_struct("RuntimePeerInteractionHandle")
58            .field("dsl", &self.dsl)
59            .field("cleanup_observer", &observer_tag)
60            .finish()
61    }
62}
63
64impl RuntimePeerInteractionHandle {
65    /// Construct a handle backed by the session's shared DSL authority.
66    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
67        Self {
68            dsl,
69            cleanup_observer: RwLock::new(None),
70        }
71    }
72
73    /// Construct a handle backed by an ephemeral DSL authority (tests /
74    /// legacy recovery paths).
75    pub fn ephemeral() -> Self {
76        Self::new(Arc::new(HandleDslAuthority::ephemeral()))
77    }
78
79    fn apply_input_and_dispatch_cleanup(
80        &self,
81        input: mm_dsl::MeerkatMachineInput,
82        context: &'static str,
83    ) -> Result<(), DslTransitionError> {
84        // Sample the cleanup observer slot UNDER the DSL lock so any
85        // concurrent `install_cleanup_observer` is totally ordered vs
86        // this transition (same pattern as `session_context.rs` closes
87        // for PR #286's race). The observer callback runs OUTSIDE the
88        // lock to avoid reentrancy with any shell-side state that calls
89        // back into the handle. Each cleanup target is carried as an
90        // `Ok(core_id)` / `Err(raw)` pair so the invalid-UUID diagnostic
91        // still fires with the raw DSL string when dispatch happens
92        // post-lock.
93        type CleanupTarget = Result<PeerCorrelationId, String>;
94        let dispatch: Option<(Arc<dyn PeerInteractionCleanupObserver>, Vec<CleanupTarget>)> = self
95            .dsl
96            .apply_input_with_effects_and_sample(input, context, |effects| {
97                let observer_opt = self
98                    .cleanup_observer
99                    .read()
100                    .unwrap_or_else(std::sync::PoisonError::into_inner)
101                    .as_ref()
102                    .and_then(Weak::upgrade);
103                let observer = observer_opt?;
104                let targets: Vec<CleanupTarget> = effects
105                    .iter()
106                    .filter_map(|effect| match effect {
107                        mm_dsl::MeerkatMachineEffect::PeerInteractionCleanup { corr_id } => {
108                            Some(match dsl_corr_id_to_core(corr_id.clone()) {
109                                Some(core_id) => Ok(core_id),
110                                None => Err(corr_id.0.clone()),
111                            })
112                        }
113                        _ => None,
114                    })
115                    .collect();
116                Some((observer, targets))
117            })?;
118        if let Some((observer, targets)) = dispatch {
119            for target in targets {
120                match target {
121                    Ok(core_id) => observer.on_peer_interaction_cleanup(core_id),
122                    Err(raw) => tracing::error!(
123                        raw = %raw,
124                        context = context,
125                        "PeerInteractionCleanup: DSL emitted a corr_id that is not a valid UUID — broken invariant; skipping observer dispatch"
126                    ),
127                }
128            }
129        }
130        Ok(())
131    }
132}
133
134fn dsl_corr_id_to_core(dsl_id: mm_dsl::PeerCorrelationId) -> Option<PeerCorrelationId> {
135    // The DSL key is always produced by `From<PeerCorrelationId> for
136    // mm_dsl::PeerCorrelationId`, which stringifies a UUID. Parse must
137    // succeed on every canonical path; a parse failure here is a broken
138    // invariant, not a recoverable condition. Return `None` so the caller
139    // skips observer dispatch and logs — silently substituting nil would
140    // cross-contaminate any real `corr_id 0` event.
141    uuid::Uuid::parse_str(&dsl_id.0)
142        .ok()
143        .map(PeerCorrelationId::from_uuid)
144}
145
146fn response_status_to_dsl(
147    status: meerkat_core::ResponseStatus,
148) -> mm_dsl::PeerIngressResponseStatus {
149    match status {
150        meerkat_core::ResponseStatus::Accepted => mm_dsl::PeerIngressResponseStatus::Accepted,
151        meerkat_core::ResponseStatus::Completed => mm_dsl::PeerIngressResponseStatus::Completed,
152        meerkat_core::ResponseStatus::Failed => mm_dsl::PeerIngressResponseStatus::Failed,
153    }
154}
155
156fn terminality_from_dsl(terminality: mm_dsl::PeerIngressResponseTerminality) -> TerminalityClass {
157    match terminality {
158        mm_dsl::PeerIngressResponseTerminality::Progress => TerminalityClass::Progress,
159        mm_dsl::PeerIngressResponseTerminality::TerminalCompleted => TerminalityClass::Terminal {
160            disposition: TerminalDisposition::Completed,
161        },
162        mm_dsl::PeerIngressResponseTerminality::TerminalFailed => TerminalityClass::Terminal {
163            disposition: TerminalDisposition::Failed,
164        },
165    }
166}
167
168fn handling_mode_from_dsl(lane: mm_dsl::InputLane) -> HandlingMode {
169    match lane {
170        mm_dsl::InputLane::Queue => HandlingMode::Queue,
171        mm_dsl::InputLane::Steer => HandlingMode::Steer,
172    }
173}
174
175fn peer_reply_classified_effect(
176    effects: Vec<mm_dsl::MeerkatMachineEffect>,
177    context: &'static str,
178) -> Result<TerminalityClass, DslTransitionError> {
179    effects
180        .into_iter()
181        .find_map(|effect| match effect {
182            mm_dsl::MeerkatMachineEffect::PeerResponseReplyClassified {
183                response_terminality,
184            } => Some(terminality_from_dsl(response_terminality)),
185            _ => None,
186        })
187        .ok_or_else(|| {
188            DslTransitionError::guard_rejected(
189                context,
190                "machine transition did not emit PeerResponseReplyClassified",
191            )
192        })
193}
194
195impl PeerInteractionHandle for RuntimePeerInteractionHandle {
196    fn request_sent(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
197        self.apply_input_and_dispatch_cleanup(
198            mm_dsl::MeerkatMachineInput::PeerRequestSent {
199                corr_id: corr_id.into(),
200            },
201            "PeerInteractionHandle::request_sent",
202        )
203    }
204
205    fn response_progress(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
206        self.apply_input_and_dispatch_cleanup(
207            mm_dsl::MeerkatMachineInput::PeerResponseProgressArrived {
208                corr_id: corr_id.into(),
209            },
210            "PeerInteractionHandle::response_progress",
211        )
212    }
213
214    fn response_terminal(
215        &self,
216        corr_id: PeerCorrelationId,
217        disposition: CorePeerDisposition,
218    ) -> Result<(), DslTransitionError> {
219        self.apply_input_and_dispatch_cleanup(
220            mm_dsl::MeerkatMachineInput::PeerResponseTerminalArrived {
221                corr_id: corr_id.into(),
222                disposition: disposition.into(),
223            },
224            "PeerInteractionHandle::response_terminal",
225        )
226    }
227
228    fn response_rejected(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
229        self.apply_input_and_dispatch_cleanup(
230            mm_dsl::MeerkatMachineInput::PeerResponseRejected {
231                corr_id: corr_id.into(),
232            },
233            "PeerInteractionHandle::response_rejected",
234        )
235    }
236
237    fn request_timed_out(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
238        self.apply_input_and_dispatch_cleanup(
239            mm_dsl::MeerkatMachineInput::PeerRequestTimedOut {
240                corr_id: corr_id.into(),
241            },
242            "PeerInteractionHandle::request_timed_out",
243        )
244    }
245
246    fn request_send_failed(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
247        self.apply_input_and_dispatch_cleanup(
248            mm_dsl::MeerkatMachineInput::PeerRequestSendFailed {
249                corr_id: corr_id.into(),
250            },
251            "PeerInteractionHandle::request_send_failed",
252        )
253    }
254
255    fn request_received(
256        &self,
257        corr_id: PeerCorrelationId,
258        handling_mode: HandlingMode,
259    ) -> Result<(), DslTransitionError> {
260        self.apply_input_and_dispatch_cleanup(
261            mm_dsl::MeerkatMachineInput::PeerRequestReceived {
262                corr_id: corr_id.into(),
263                handling_mode: mm_dsl::InputLane::from(handling_mode),
264            },
265            "PeerInteractionHandle::request_received",
266        )
267    }
268
269    fn classify_response_reply(
270        &self,
271        status: meerkat_core::ResponseStatus,
272    ) -> Result<TerminalityClass, DslTransitionError> {
273        let context = "PeerInteractionHandle::classify_response_reply";
274        let effects = self.dsl.apply_signal_with_effects(
275            mm_dsl::MeerkatMachineSignal::ClassifyPeerResponseReply {
276                status: response_status_to_dsl(status),
277            },
278            context,
279        )?;
280        peer_reply_classified_effect(effects, context)
281    }
282
283    fn response_replied(&self, corr_id: PeerCorrelationId) -> Result<(), DslTransitionError> {
284        self.apply_input_and_dispatch_cleanup(
285            mm_dsl::MeerkatMachineInput::PeerResponseReplied {
286                corr_id: corr_id.into(),
287            },
288            "PeerInteractionHandle::response_replied",
289        )
290    }
291
292    fn outbound_state(&self, corr_id: PeerCorrelationId) -> Option<CoreOutboundState> {
293        let dsl_key: mm_dsl::PeerCorrelationId = corr_id.into();
294        self.dsl
295            .snapshot_state()
296            .pending_peer_requests
297            .get(&dsl_key)
298            .copied()
299            .map(Into::into)
300    }
301
302    fn inbound_state(&self, corr_id: PeerCorrelationId) -> Option<CoreInboundState> {
303        let dsl_key: mm_dsl::PeerCorrelationId = corr_id.into();
304        self.dsl
305            .snapshot_state()
306            .inbound_peer_requests
307            .get(&dsl_key)
308            .copied()
309            .map(Into::into)
310    }
311
312    fn inbound_handling_mode(&self, corr_id: PeerCorrelationId) -> Option<HandlingMode> {
313        let dsl_key: mm_dsl::PeerCorrelationId = corr_id.into();
314        self.dsl
315            .snapshot_state()
316            .inbound_peer_request_lanes
317            .get(&dsl_key)
318            .copied()
319            .map(handling_mode_from_dsl)
320    }
321
322    fn install_cleanup_observer(&self, observer: Arc<dyn PeerInteractionCleanupObserver>) {
323        // Downgrade to a `Weak` so this handle does not keep the observer
324        // (typically the session's `CommsRuntime`) alive. The caller retains
325        // the canonical strong `Arc` via its own field; when the runtime is
326        // dropped, the weak here fails to upgrade and cleanup dispatch
327        // becomes a no-op — matching the "post-teardown, no more work"
328        // semantics the shell-side projection expects.
329        *self
330            .cleanup_observer
331            .write()
332            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::downgrade(&observer));
333    }
334}