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