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}