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::LiminalError;
13use liminal::channel::SchemaId;
14use liminal::conversation::{
15    ConversationActor, ConversationPhase, ConversationState, ParticipantPid,
16};
17use liminal::envelope::{Envelope, PublisherId};
18use liminal::protocol::{
19    CausalContext as ProtocolCausalContext, MessageEnvelope, SchemaId as ProtocolSchemaId,
20};
21
22use crate::ServerError;
23
24/// Marker for library conversation state owned by a single connection process.
25pub trait ConversationResource: std::fmt::Debug + Send {
26    /// Delegates one conversation message to the library resource.
27    ///
28    /// # Errors
29    /// Returns [`ServerError`] when the liminal library rejects the conversation message.
30    fn message(&self, envelope: &MessageEnvelope, op_id: Option<u64>) -> Result<(), ServerError>;
31
32    /// Returns the participant PIDs linked to the supervised conversation, if any.
33    ///
34    /// A trace-only conversation has no participant process and returns an empty
35    /// slice; a real supervised conversation returns the linked participant PIDs.
36    fn participant_pids(&self) -> Vec<u64>;
37
38    /// Returns true if the conversation has structurally detected a participant
39    /// crash via the trapped linked-EXIT path (never by polling/sleeping).
40    ///
41    /// This is non-blocking: it observes whether the actor's exit notifier has
42    /// already fired (the link-EXIT event landed) and falls back to the actor's
43    /// structurally-set `Failed` phase. It does not sample liveness.
44    fn has_detected_crash(&self) -> bool;
45
46    /// Blocks up to `timeout` waiting for a structural linked-EXIT crash signal,
47    /// returning the [`Instant`] the EXIT was observed inside the actor's link
48    /// handler, or `None` if no crash is detected within the bound.
49    ///
50    /// The wait is event-driven (parks on the exit notifier and is woken by the
51    /// EXIT handler), not a poll loop. Used by tests to prove real detection.
52    fn await_crash(&self, timeout: Duration) -> Option<Instant>;
53
54    /// Receives the next reply the participant produced for this conversation,
55    /// bounded by `timeout`.
56    ///
57    /// A real participant processes each forwarded message and delivers a reply
58    /// back through the conversation; this drains that reply. A trace-only or
59    /// non-replying resource times out.
60    ///
61    /// # Errors
62    /// Returns [`ServerError`] when no reply arrives within `timeout`, the
63    /// participant crashed, or the conversation is unavailable.
64    fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError>;
65
66    /// R1(vi)(a): non-blocking drain of one buffered participant reply, if any.
67    ///
68    /// Replaces the removed in-slice BLOCKING `receive_reply` on the request-reply
69    /// path: the connection polls this on its own slice and correlates the reply
70    /// through its pending-reply table. Defaulted to `None` for resources with no
71    /// live reply queue (trace-only / test stand-ins).
72    fn try_receive_reply(&self) -> Option<(u64, MessageEnvelope)> {
73        None
74    }
75
76    /// Non-consuming reply availability query for the post-arm race barrier.
77    fn has_pending_reply(&self) -> bool {
78        false
79    }
80
81    /// R1(vi)(a): installs the reply-availability notifier (fired on the reply
82    /// queue's empty→non-empty transition and on terminal actor error), captured at
83    /// conversation open. Defaulted to a no-op for resources with no reply queue.
84    fn register_reply_notifier(&self, _notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {}
85
86    /// Releases or finishes the library conversation resource.
87    ///
88    /// # Errors
89    /// Returns [`ServerError`] when the liminal library reports a close failure.
90    fn close(self: Box<Self>) -> Result<(), ServerError>;
91
92    /// Releases the resource without requiring its backing actor to run:
93    /// bounded, non-blocking, and idempotent. Connection teardown paths (and
94    /// the teardown `Drop` backstop) MUST use this instead of
95    /// [`Self::close`] — `close` is a request/reply round trip into the
96    /// conversation scheduler, and a teardown that waits on another scheduler
97    /// being live re-creates the wedged-worker failure this repair removes.
98    /// Deliberately required, not defaulted: every resource author must decide
99    /// what teardown-safe release means for their live state (a defaulted
100    /// no-op would let a resource that needs real cleanup leak silently). A
101    /// resource with no live process behind it implements this as a plain
102    /// `drop(self)`.
103    fn finalize(self: Box<Self>);
104}
105
106/// Library conversation resource owned by a single connection process.
107#[derive(Debug)]
108pub struct ConnectionConversation {
109    resource: Box<dyn ConversationResource>,
110}
111
112impl ConnectionConversation {
113    /// Creates an owned conversation resource for one connection process.
114    #[must_use]
115    pub fn new(resource: Box<dyn ConversationResource>) -> Self {
116        Self { resource }
117    }
118
119    pub(super) fn message(
120        &self,
121        envelope: &MessageEnvelope,
122        op_id: Option<u64>,
123    ) -> Result<(), ServerError> {
124        self.resource.message(envelope, op_id)
125    }
126
127    /// Returns the participant PIDs linked to the supervised conversation.
128    #[must_use]
129    pub fn participant_pids(&self) -> Vec<u64> {
130        self.resource.participant_pids()
131    }
132
133    /// Returns true once a participant crash has been structurally detected
134    /// through the linked-EXIT mechanism.
135    #[must_use]
136    pub fn has_detected_crash(&self) -> bool {
137        self.resource.has_detected_crash()
138    }
139
140    /// Blocks (event-driven) up to `timeout` for a structural crash signal.
141    #[must_use]
142    pub fn await_crash(&self, timeout: Duration) -> Option<Instant> {
143        self.resource.await_crash(timeout)
144    }
145
146    /// Receives the next participant reply for this conversation, bounded by
147    /// `timeout`.
148    ///
149    /// # Errors
150    /// Returns [`ServerError`] when no reply arrives in time or the conversation
151    /// is unavailable.
152    pub fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
153        self.resource.receive_reply(timeout)
154    }
155
156    /// R1(vi)(a): non-blocking drain of one buffered participant reply.
157    pub(super) fn try_receive_reply(&self) -> Option<(u64, MessageEnvelope)> {
158        self.resource.try_receive_reply()
159    }
160
161    pub(super) fn has_pending_reply(&self) -> bool {
162        self.resource.has_pending_reply()
163    }
164
165    /// R1(vi)(a): installs the reply-availability notifier at conversation open.
166    pub(super) fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
167        self.resource.register_reply_notifier(notifier);
168    }
169
170    pub(super) fn close(self) -> Result<(), ServerError> {
171        self.resource.close()
172    }
173
174    /// Non-blocking teardown release; see [`ConversationResource::finalize`].
175    pub(super) fn finalize(self) {
176        self.resource.finalize();
177    }
178}
179
180/// A real supervised conversation owned by one connection process.
181///
182/// Wraps a beamr-backed [`ConversationActor`] (a genuine supervised process that
183/// traps its participants' EXITs) rather than a trace-only span. Messages are
184/// forwarded to the actor over its handle, and a participant crash is surfaced
185/// structurally through the link-EXIT notifier — never by polling.
186#[derive(Debug)]
187pub(super) struct LiminalConversationResource {
188    actor: ConversationActor,
189    participant: ParticipantPid,
190    /// Receives the link-EXIT instant from the actor's trapped-EXIT handler. The
191    /// single observed instant is cached in `crash_observed` once drained so the
192    /// (one-shot) signal is not lost across repeated observations.
193    exit_rx: Mutex<mpsc::Receiver<Instant>>,
194    crash_observed: Mutex<Option<Instant>>,
195}
196
197impl LiminalConversationResource {
198    /// Creates a resource around a booted, crash-armed supervised actor.
199    pub(super) const fn new(
200        actor: ConversationActor,
201        participant: ParticipantPid,
202        exit_rx: mpsc::Receiver<Instant>,
203    ) -> Self {
204        Self {
205            actor,
206            participant,
207            exit_rx: Mutex::new(exit_rx),
208            crash_observed: Mutex::new(None),
209        }
210    }
211
212    /// Returns the cached crash instant or, non-blocking, the one already sent by
213    /// the EXIT handler. This reads an already-fired structural event; it never
214    /// sleeps or samples participant liveness.
215    fn poll_exit_signal(&self) -> Option<Instant> {
216        observe_exit(&self.crash_observed, &self.exit_rx, None)
217    }
218
219    /// True when the actor's structurally-tracked phase is `Failed`, which the
220    /// trapped-EXIT handler sets under `CrashPolicy::Fail`. This is a structural
221    /// state read, not a liveness sample.
222    fn actor_phase_failed(&self) -> bool {
223        state_reads_failed(&self.actor.state())
224    }
225}
226
227/// Reads the one-shot participant-EXIT signal, caching the observed instant so
228/// repeated observations replay it. `wait` bounds a blocking receive; `None`
229/// polls without blocking. The single seam behind both [`poll_exit_signal`] and
230/// [`await_crash`].
231///
232/// [`poll_exit_signal`]: LiminalConversationResource::poll_exit_signal
233/// [`await_crash`]: LiminalConversationResource::await_crash
234/// A DISCONNECTED channel is a crash OBSERVATION, not the absence of one: the
235/// sender lives inside the actor's trapped-EXIT handler, so its disconnection
236/// means the participant actor is gone. That branch used to collapse to "no
237/// crash", which is how a connection kept forwarding conversation messages into
238/// a vanished participant.
239fn observe_exit(
240    crash_observed: &Mutex<Option<Instant>>,
241    exit_rx: &Mutex<mpsc::Receiver<Instant>>,
242    wait: Option<Duration>,
243) -> Option<Instant> {
244    if let Ok(cached) = crash_observed.lock()
245        && let Some(instant) = *cached
246    {
247        return Some(instant);
248    }
249    let received = exit_rx.lock().map_or(None, |rx| {
250        wait.map_or_else(
251            || match rx.try_recv() {
252                Ok(instant) => Some(instant),
253                Err(mpsc::TryRecvError::Empty) => None,
254                Err(mpsc::TryRecvError::Disconnected) => Some(vanished_actor_instant()),
255            },
256            |timeout| match rx.recv_timeout(timeout) {
257                Ok(instant) => Some(instant),
258                Err(mpsc::RecvTimeoutError::Timeout) => None,
259                Err(mpsc::RecvTimeoutError::Disconnected) => Some(vanished_actor_instant()),
260            },
261        )
262    });
263    if let Some(instant) = received
264        && let Ok(mut cached) = crash_observed.lock()
265    {
266        *cached = Some(instant);
267    }
268    received
269}
270
271/// The observation time for a crash inferred from a disconnected EXIT channel.
272/// The handler never got to send its own instant, so this is the earliest moment
273/// the host can honestly claim to have seen the crash. Warned once — the cache
274/// replays every later observation without re-entering this path.
275fn vanished_actor_instant() -> Instant {
276    tracing::warn!(
277        "conversation exit channel disconnected; the participant actor is gone \
278         and is reported as crashed"
279    );
280    Instant::now()
281}
282
283/// Maps one actor state query to "this conversation has structurally failed".
284///
285/// An `Err` is treated as FAILED, not as healthy. A cleanly closed or finalized
286/// conversation still answers `Ok` from its host-side snapshot, and the query
287/// blocks on an unbounded `wait_for` rather than a timeout, so an `Err` here is
288/// never a clean close and never a transient slow-actor sample — it is
289/// `ensure_running` failing, the command queue refusing, or the response channel
290/// closing under the query. All three mean the actor is unreachable.
291fn state_reads_failed(state: &Result<ConversationState, LiminalError>) -> bool {
292    match state {
293        Ok(state) => matches!(state.current_phase, ConversationPhase::Failed),
294        Err(error) => {
295            tracing::warn!(
296                %error,
297                "conversation actor could not service a state query; treating the \
298                 unreachable actor as failed"
299            );
300            true
301        }
302    }
303}
304
305impl ConversationResource for LiminalConversationResource {
306    fn message(&self, envelope: &MessageEnvelope, op_id: Option<u64>) -> Result<(), ServerError> {
307        // If the participant has already crashed (structural EXIT observed),
308        // refuse the message rather than forwarding into a failed conversation.
309        if self.poll_exit_signal().is_some() || self.actor_phase_failed() {
310            return Err(ServerError::ListenerAccept {
311                message: format!(
312                    "conversation participant {} crashed; message rejected",
313                    self.participant.get()
314                ),
315            });
316        }
317        let payload = envelope.payload.clone();
318        let message = Envelope::new(payload, None, SchemaId::new(), PublisherId::default());
319        self.actor
320            .handle()
321            .send_with_op_id(message, op_id)
322            .map_err(|error| ServerError::ListenerAccept {
323                message: format!("conversation message delivery failed: {error}"),
324            })
325    }
326
327    fn participant_pids(&self) -> Vec<u64> {
328        vec![self.participant.get()]
329    }
330
331    fn has_detected_crash(&self) -> bool {
332        self.poll_exit_signal().is_some() || self.actor_phase_failed()
333    }
334
335    fn await_crash(&self, timeout: Duration) -> Option<Instant> {
336        // Event-driven: park on the exit notifier; the actor's trapped-EXIT
337        // handler wakes us the instant the participant's link fires. No polling.
338        observe_exit(&self.crash_observed, &self.exit_rx, Some(timeout))
339    }
340
341    fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
342        // The participant produced a reply that the conversation actor delivered
343        // back into the conversation; drain it (bounded). This is the reply leg
344        // of the request-reply path — proof the participant genuinely processed
345        // the forwarded message, not just that it was linked.
346        let reply =
347            self.actor
348                .receive_timeout(timeout)
349                .map_err(|error| ServerError::ListenerAccept {
350                    message: format!("conversation reply receive failed: {error}"),
351                })?;
352        Ok(MessageEnvelope::new(
353            ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
354            ProtocolCausalContext::independent(),
355            reply.payload,
356        ))
357    }
358
359    fn try_receive_reply(&self) -> Option<(u64, MessageEnvelope)> {
360        // Non-blocking host-side drain of one buffered participant reply, framed
361        // as the wire reply envelope (schema/causal metadata are not bridged in
362        // v1, matching the removed blocking `receive_reply`).
363        let (op_id, reply) = self.actor.try_take_reply()?;
364        Some((
365            op_id,
366            MessageEnvelope::new(
367                ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
368                ProtocolCausalContext::independent(),
369                reply.payload,
370            ),
371        ))
372    }
373
374    fn has_pending_reply(&self) -> bool {
375        self.actor.has_pending_reply()
376    }
377
378    fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
379        self.actor.register_reply_notifier(notifier);
380    }
381
382    fn close(self: Box<Self>) -> Result<(), ServerError> {
383        let Self { actor, .. } = *self;
384        // A crashed (Failed) conversation cannot transition to Closed; tearing
385        // down its handle is sufficient and is not an error.
386        if matches!(
387            actor.state().map(|state| state.current_phase),
388            Ok(ConversationPhase::Failed)
389        ) {
390            actor.handle().close().ok();
391            return Ok(());
392        }
393        actor
394            .handle()
395            .close()
396            .map_err(|error| ServerError::ListenerAccept {
397                message: format!("conversation close failed: {error}"),
398            })
399    }
400
401    fn finalize(self: Box<Self>) {
402        self.actor.finalize();
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use std::sync::{Mutex, mpsc};
409    use std::time::{Duration, Instant};
410
411    use liminal::LiminalError;
412    use liminal::channel::ChannelMode;
413    use liminal::conversation::{ConversationPhase, ConversationState};
414
415    use super::{observe_exit, state_reads_failed};
416
417    /// A crash-detection fixture: the two mutexes `observe_exit` reads, plus the
418    /// live sender the actor's trapped-EXIT handler would own.
419    fn exit_channel() -> (
420        mpsc::SyncSender<Instant>,
421        Mutex<Option<Instant>>,
422        Mutex<mpsc::Receiver<Instant>>,
423    ) {
424        let (tx, rx) = mpsc::sync_channel::<Instant>(1);
425        (tx, Mutex::new(None), Mutex::new(rx))
426    }
427
428    fn state_with(current_phase: ConversationPhase) -> ConversationState {
429        ConversationState {
430            current_phase,
431            context: Vec::new(),
432            deadline: None,
433            participants: Vec::new(),
434            mode: ChannelMode::Ephemeral,
435        }
436    }
437
438    /// THE site-2 headline. The exit sender lives inside the actor's trapped-EXIT
439    /// handler, so a DISCONNECTED channel means that actor is gone — which is a
440    /// crash observation, not the absence of one. Reading `Disconnected` as "no
441    /// crash" is what let a connection keep forwarding conversation messages into
442    /// a vanished participant.
443    #[test]
444    fn a_dropped_exit_sender_is_a_crash_observation() {
445        let (tx, crash_observed, exit_rx) = exit_channel();
446        drop(tx);
447
448        let observed = observe_exit(&crash_observed, &exit_rx, None);
449
450        assert!(
451            observed.is_some(),
452            "a vanished participant actor must read as a crash, not as no-crash"
453        );
454    }
455
456    /// The same claim on the blocking leg, which additionally must not burn the
457    /// caller's whole timeout before reporting a crash that has already happened.
458    #[test]
459    fn a_dropped_exit_sender_ends_the_crash_wait_immediately() {
460        let (tx, crash_observed, exit_rx) = exit_channel();
461        drop(tx);
462
463        let started = Instant::now();
464        let observed = observe_exit(&crash_observed, &exit_rx, Some(Duration::from_secs(30)));
465        let elapsed = started.elapsed();
466
467        assert!(
468            observed.is_some(),
469            "a vanished participant actor must read as a crash on the blocking leg too"
470        );
471        assert!(
472            elapsed < Duration::from_secs(5),
473            "the crash is already observable; the wait must not run to its bound (took {elapsed:?})"
474        );
475    }
476
477    /// A state query the actor cannot service means the actor is unreachable —
478    /// it has no live process to answer, and a cleanly closed or finalized
479    /// conversation still answers `Ok` from its host-side snapshot. Reading that
480    /// `Err` as "not failed" reports a conversation whose actor is gone as
481    /// healthy.
482    #[test]
483    fn an_unserviceable_state_query_reads_as_failed() {
484        let unreachable = Err(LiminalError::ConversationFailed {
485            message: "state query response channel closed".to_owned(),
486        });
487
488        assert!(
489            state_reads_failed(&unreachable),
490            "an actor that cannot answer a state query must not read as healthy"
491        );
492    }
493
494    /// The healthy path is untouched: a live phase is not a failure, and the
495    /// structurally-set `Failed` phase still is.
496    #[test]
497    fn live_phases_still_read_healthy_and_failed_still_reads_failed() {
498        assert!(!state_reads_failed(&Ok(state_with(
499            ConversationPhase::Active
500        ))));
501        assert!(state_reads_failed(&Ok(state_with(
502            ConversationPhase::Failed
503        ))));
504    }
505}