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}