Skip to main content

liminal_server/server/connection/
conversation.rs

1//! Connection-owned conversation resources.
2//!
3//! A connection process owns a [`ConnectionConversation`] per open conversation.
4//! The default implementation ([`LiminalConversationResource`]) wraps a real
5//! beamr-backed supervised conversation actor: messages are forwarded over its
6//! handle, and a participant crash is surfaced structurally through the actor's
7//! trapped linked-EXIT notifier — never by polling, sleeping, or a heartbeat.
8
9use std::sync::{Mutex, mpsc};
10use std::time::{Duration, Instant};
11
12use liminal::channel::SchemaId;
13use liminal::conversation::{ConversationActor, ConversationPhase, ParticipantPid};
14use liminal::envelope::{Envelope, PublisherId};
15use liminal::protocol::{
16    CausalContext as ProtocolCausalContext, MessageEnvelope, SchemaId as ProtocolSchemaId,
17};
18
19use crate::ServerError;
20
21/// Marker for library conversation state owned by a single connection process.
22pub trait ConversationResource: std::fmt::Debug + Send {
23    /// Delegates one conversation message to the library resource.
24    ///
25    /// # Errors
26    /// Returns [`ServerError`] when the liminal library rejects the conversation message.
27    fn message(&self, envelope: &MessageEnvelope) -> Result<(), ServerError>;
28
29    /// Returns the participant PIDs linked to the supervised conversation, if any.
30    ///
31    /// A trace-only conversation has no participant process and returns an empty
32    /// slice; a real supervised conversation returns the linked participant PIDs.
33    fn participant_pids(&self) -> Vec<u64>;
34
35    /// Returns true if the conversation has structurally detected a participant
36    /// crash via the trapped linked-EXIT path (never by polling/sleeping).
37    ///
38    /// This is non-blocking: it observes whether the actor's exit notifier has
39    /// already fired (the link-EXIT event landed) and falls back to the actor's
40    /// structurally-set `Failed` phase. It does not sample liveness.
41    fn has_detected_crash(&self) -> bool;
42
43    /// Blocks up to `timeout` waiting for a structural linked-EXIT crash signal,
44    /// returning the [`Instant`] the EXIT was observed inside the actor's link
45    /// handler, or `None` if no crash is detected within the bound.
46    ///
47    /// The wait is event-driven (parks on the exit notifier and is woken by the
48    /// EXIT handler), not a poll loop. Used by tests to prove real detection.
49    fn await_crash(&self, timeout: Duration) -> Option<Instant>;
50
51    /// Receives the next reply the participant produced for this conversation,
52    /// bounded by `timeout`.
53    ///
54    /// A real participant processes each forwarded message and delivers a reply
55    /// back through the conversation; this drains that reply. A trace-only or
56    /// non-replying resource times out.
57    ///
58    /// # Errors
59    /// Returns [`ServerError`] when no reply arrives within `timeout`, the
60    /// participant crashed, or the conversation is unavailable.
61    fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError>;
62
63    /// R1(vi)(a): non-blocking drain of one buffered participant reply, if any.
64    ///
65    /// Replaces the removed in-slice BLOCKING `receive_reply` on the request-reply
66    /// path: the connection polls this on its own slice and correlates the reply
67    /// through its pending-reply table. Defaulted to `None` for resources with no
68    /// live reply queue (trace-only / test stand-ins).
69    fn try_receive_reply(&self) -> Option<MessageEnvelope> {
70        None
71    }
72
73    /// Non-consuming reply availability query for the post-arm race barrier.
74    fn has_pending_reply(&self) -> bool {
75        false
76    }
77
78    /// R1(vi)(a): installs the reply-availability notifier (fired on the reply
79    /// queue's empty→non-empty transition and on terminal actor error), captured at
80    /// conversation open. Defaulted to a no-op for resources with no reply queue.
81    fn register_reply_notifier(&self, _notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {}
82
83    /// Releases or finishes the library conversation resource.
84    ///
85    /// # Errors
86    /// Returns [`ServerError`] when the liminal library reports a close failure.
87    fn close(self: Box<Self>) -> Result<(), ServerError>;
88
89    /// Releases the resource without requiring its backing actor to run:
90    /// bounded, non-blocking, and idempotent. Connection teardown paths (and
91    /// the teardown `Drop` backstop) MUST use this instead of
92    /// [`Self::close`] — `close` is a request/reply round trip into the
93    /// conversation scheduler, and a teardown that waits on another scheduler
94    /// being live re-creates the wedged-worker failure this repair removes.
95    /// Deliberately required, not defaulted: every resource author must decide
96    /// what teardown-safe release means for their live state (a defaulted
97    /// no-op would let a resource that needs real cleanup leak silently). A
98    /// resource with no live process behind it implements this as a plain
99    /// `drop(self)`.
100    fn finalize(self: Box<Self>);
101}
102
103/// Library conversation resource owned by a single connection process.
104#[derive(Debug)]
105pub struct ConnectionConversation {
106    resource: Box<dyn ConversationResource>,
107}
108
109impl ConnectionConversation {
110    /// Creates an owned conversation resource for one connection process.
111    #[must_use]
112    pub fn new(resource: Box<dyn ConversationResource>) -> Self {
113        Self { resource }
114    }
115
116    pub(super) fn message(&self, envelope: &MessageEnvelope) -> Result<(), ServerError> {
117        self.resource.message(envelope)
118    }
119
120    /// Returns the participant PIDs linked to the supervised conversation.
121    #[must_use]
122    pub fn participant_pids(&self) -> Vec<u64> {
123        self.resource.participant_pids()
124    }
125
126    /// Returns true once a participant crash has been structurally detected
127    /// through the linked-EXIT mechanism.
128    #[must_use]
129    pub fn has_detected_crash(&self) -> bool {
130        self.resource.has_detected_crash()
131    }
132
133    /// Blocks (event-driven) up to `timeout` for a structural crash signal.
134    #[must_use]
135    pub fn await_crash(&self, timeout: Duration) -> Option<Instant> {
136        self.resource.await_crash(timeout)
137    }
138
139    /// Receives the next participant reply for this conversation, bounded by
140    /// `timeout`.
141    ///
142    /// # Errors
143    /// Returns [`ServerError`] when no reply arrives in time or the conversation
144    /// is unavailable.
145    pub fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
146        self.resource.receive_reply(timeout)
147    }
148
149    /// R1(vi)(a): non-blocking drain of one buffered participant reply.
150    pub(super) fn try_receive_reply(&self) -> Option<MessageEnvelope> {
151        self.resource.try_receive_reply()
152    }
153
154    pub(super) fn has_pending_reply(&self) -> bool {
155        self.resource.has_pending_reply()
156    }
157
158    /// R1(vi)(a): installs the reply-availability notifier at conversation open.
159    pub(super) fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
160        self.resource.register_reply_notifier(notifier);
161    }
162
163    pub(super) fn close(self) -> Result<(), ServerError> {
164        self.resource.close()
165    }
166
167    /// Non-blocking teardown release; see [`ConversationResource::finalize`].
168    pub(super) fn finalize(self) {
169        self.resource.finalize();
170    }
171}
172
173/// A real supervised conversation owned by one connection process.
174///
175/// Wraps a beamr-backed [`ConversationActor`] (a genuine supervised process that
176/// traps its participants' EXITs) rather than a trace-only span. Messages are
177/// forwarded to the actor over its handle, and a participant crash is surfaced
178/// structurally through the link-EXIT notifier — never by polling.
179#[derive(Debug)]
180pub(super) struct LiminalConversationResource {
181    actor: ConversationActor,
182    participant: ParticipantPid,
183    /// Receives the link-EXIT instant from the actor's trapped-EXIT handler. The
184    /// single observed instant is cached in `crash_observed` once drained so the
185    /// (one-shot) signal is not lost across repeated observations.
186    exit_rx: Mutex<mpsc::Receiver<Instant>>,
187    crash_observed: Mutex<Option<Instant>>,
188}
189
190impl LiminalConversationResource {
191    /// Creates a resource around a booted, crash-armed supervised actor.
192    pub(super) const fn new(
193        actor: ConversationActor,
194        participant: ParticipantPid,
195        exit_rx: mpsc::Receiver<Instant>,
196    ) -> Self {
197        Self {
198            actor,
199            participant,
200            exit_rx: Mutex::new(exit_rx),
201            crash_observed: Mutex::new(None),
202        }
203    }
204
205    /// Returns the cached crash instant or, non-blocking, the one already sent by
206    /// the EXIT handler. This reads an already-fired structural event; it never
207    /// sleeps or samples participant liveness.
208    fn poll_exit_signal(&self) -> Option<Instant> {
209        if let Ok(cached) = self.crash_observed.lock() {
210            if let Some(instant) = *cached {
211                return Some(instant);
212            }
213        }
214        let received = self.exit_rx.lock().map_or(None, |rx| rx.try_recv().ok());
215        self.cache(received);
216        received
217    }
218
219    /// Caches an observed crash instant so the one-shot signal is replayable.
220    fn cache(&self, instant: Option<Instant>) {
221        if let Some(instant) = instant {
222            if let Ok(mut cached) = self.crash_observed.lock() {
223                *cached = Some(instant);
224            }
225        }
226    }
227
228    /// True when the actor's structurally-tracked phase is `Failed`, which the
229    /// trapped-EXIT handler sets under `CrashPolicy::Fail`. This is a structural
230    /// state read, not a liveness sample.
231    fn actor_phase_failed(&self) -> bool {
232        matches!(
233            self.actor.state().map(|state| state.current_phase),
234            Ok(ConversationPhase::Failed)
235        )
236    }
237}
238
239impl ConversationResource for LiminalConversationResource {
240    fn message(&self, envelope: &MessageEnvelope) -> Result<(), ServerError> {
241        // If the participant has already crashed (structural EXIT observed),
242        // refuse the message rather than forwarding into a failed conversation.
243        if self.poll_exit_signal().is_some() || self.actor_phase_failed() {
244            return Err(ServerError::ListenerAccept {
245                message: format!(
246                    "conversation participant {} crashed; message rejected",
247                    self.participant.get()
248                ),
249            });
250        }
251        let payload = envelope.payload.clone();
252        let message = Envelope::new(payload, None, SchemaId::new(), PublisherId::default());
253        self.actor
254            .handle()
255            .send(message)
256            .map_err(|error| ServerError::ListenerAccept {
257                message: format!("conversation message delivery failed: {error}"),
258            })
259    }
260
261    fn participant_pids(&self) -> Vec<u64> {
262        vec![self.participant.get()]
263    }
264
265    fn has_detected_crash(&self) -> bool {
266        self.poll_exit_signal().is_some() || self.actor_phase_failed()
267    }
268
269    fn await_crash(&self, timeout: Duration) -> Option<Instant> {
270        if let Some(instant) = self.poll_exit_signal() {
271            return Some(instant);
272        }
273        // Event-driven: park on the exit notifier; the actor's trapped-EXIT
274        // handler wakes us the instant the participant's link fires. No polling.
275        let received = self
276            .exit_rx
277            .lock()
278            .map_or(None, |rx| rx.recv_timeout(timeout).ok());
279        self.cache(received);
280        received
281    }
282
283    fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
284        // The participant produced a reply that the conversation actor delivered
285        // back into the conversation; drain it (bounded). This is the reply leg
286        // of the request-reply path — proof the participant genuinely processed
287        // the forwarded message, not just that it was linked.
288        let reply =
289            self.actor
290                .receive_timeout(timeout)
291                .map_err(|error| ServerError::ListenerAccept {
292                    message: format!("conversation reply receive failed: {error}"),
293                })?;
294        Ok(MessageEnvelope::new(
295            ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
296            ProtocolCausalContext::independent(),
297            reply.payload,
298        ))
299    }
300
301    fn try_receive_reply(&self) -> Option<MessageEnvelope> {
302        // Non-blocking host-side drain of one buffered participant reply, framed
303        // as the wire reply envelope (schema/causal metadata are not bridged in
304        // v1, matching the removed blocking `receive_reply`).
305        let reply = self.actor.try_take_reply()?;
306        Some(MessageEnvelope::new(
307            ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
308            ProtocolCausalContext::independent(),
309            reply.payload,
310        ))
311    }
312
313    fn has_pending_reply(&self) -> bool {
314        self.actor.has_pending_reply()
315    }
316
317    fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
318        self.actor.register_reply_notifier(notifier);
319    }
320
321    fn close(self: Box<Self>) -> Result<(), ServerError> {
322        let Self { actor, .. } = *self;
323        // A crashed (Failed) conversation cannot transition to Closed; tearing
324        // down its handle is sufficient and is not an error.
325        if matches!(
326            actor.state().map(|state| state.current_phase),
327            Ok(ConversationPhase::Failed)
328        ) {
329            actor.handle().close().ok();
330            return Ok(());
331        }
332        actor
333            .handle()
334            .close()
335            .map_err(|error| ServerError::ListenerAccept {
336                message: format!("conversation close failed: {error}"),
337            })
338    }
339
340    fn finalize(self: Box<Self>) {
341        self.actor.finalize();
342    }
343}