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, op_id: Option<u64>) -> 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<(u64, 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(
117 &self,
118 envelope: &MessageEnvelope,
119 op_id: Option<u64>,
120 ) -> Result<(), ServerError> {
121 self.resource.message(envelope, op_id)
122 }
123
124 /// Returns the participant PIDs linked to the supervised conversation.
125 #[must_use]
126 pub fn participant_pids(&self) -> Vec<u64> {
127 self.resource.participant_pids()
128 }
129
130 /// Returns true once a participant crash has been structurally detected
131 /// through the linked-EXIT mechanism.
132 #[must_use]
133 pub fn has_detected_crash(&self) -> bool {
134 self.resource.has_detected_crash()
135 }
136
137 /// Blocks (event-driven) up to `timeout` for a structural crash signal.
138 #[must_use]
139 pub fn await_crash(&self, timeout: Duration) -> Option<Instant> {
140 self.resource.await_crash(timeout)
141 }
142
143 /// Receives the next participant reply for this conversation, bounded by
144 /// `timeout`.
145 ///
146 /// # Errors
147 /// Returns [`ServerError`] when no reply arrives in time or the conversation
148 /// is unavailable.
149 pub fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
150 self.resource.receive_reply(timeout)
151 }
152
153 /// R1(vi)(a): non-blocking drain of one buffered participant reply.
154 pub(super) fn try_receive_reply(&self) -> Option<(u64, MessageEnvelope)> {
155 self.resource.try_receive_reply()
156 }
157
158 pub(super) fn has_pending_reply(&self) -> bool {
159 self.resource.has_pending_reply()
160 }
161
162 /// R1(vi)(a): installs the reply-availability notifier at conversation open.
163 pub(super) fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
164 self.resource.register_reply_notifier(notifier);
165 }
166
167 pub(super) fn close(self) -> Result<(), ServerError> {
168 self.resource.close()
169 }
170
171 /// Non-blocking teardown release; see [`ConversationResource::finalize`].
172 pub(super) fn finalize(self) {
173 self.resource.finalize();
174 }
175}
176
177/// A real supervised conversation owned by one connection process.
178///
179/// Wraps a beamr-backed [`ConversationActor`] (a genuine supervised process that
180/// traps its participants' EXITs) rather than a trace-only span. Messages are
181/// forwarded to the actor over its handle, and a participant crash is surfaced
182/// structurally through the link-EXIT notifier — never by polling.
183#[derive(Debug)]
184pub(super) struct LiminalConversationResource {
185 actor: ConversationActor,
186 participant: ParticipantPid,
187 /// Receives the link-EXIT instant from the actor's trapped-EXIT handler. The
188 /// single observed instant is cached in `crash_observed` once drained so the
189 /// (one-shot) signal is not lost across repeated observations.
190 exit_rx: Mutex<mpsc::Receiver<Instant>>,
191 crash_observed: Mutex<Option<Instant>>,
192}
193
194impl LiminalConversationResource {
195 /// Creates a resource around a booted, crash-armed supervised actor.
196 pub(super) const fn new(
197 actor: ConversationActor,
198 participant: ParticipantPid,
199 exit_rx: mpsc::Receiver<Instant>,
200 ) -> Self {
201 Self {
202 actor,
203 participant,
204 exit_rx: Mutex::new(exit_rx),
205 crash_observed: Mutex::new(None),
206 }
207 }
208
209 /// Returns the cached crash instant or, non-blocking, the one already sent by
210 /// the EXIT handler. This reads an already-fired structural event; it never
211 /// sleeps or samples participant liveness.
212 fn poll_exit_signal(&self) -> Option<Instant> {
213 if let Ok(cached) = self.crash_observed.lock() {
214 if let Some(instant) = *cached {
215 return Some(instant);
216 }
217 }
218 let received = self.exit_rx.lock().map_or(None, |rx| rx.try_recv().ok());
219 self.cache(received);
220 received
221 }
222
223 /// Caches an observed crash instant so the one-shot signal is replayable.
224 fn cache(&self, instant: Option<Instant>) {
225 if let Some(instant) = instant {
226 if let Ok(mut cached) = self.crash_observed.lock() {
227 *cached = Some(instant);
228 }
229 }
230 }
231
232 /// True when the actor's structurally-tracked phase is `Failed`, which the
233 /// trapped-EXIT handler sets under `CrashPolicy::Fail`. This is a structural
234 /// state read, not a liveness sample.
235 fn actor_phase_failed(&self) -> bool {
236 matches!(
237 self.actor.state().map(|state| state.current_phase),
238 Ok(ConversationPhase::Failed)
239 )
240 }
241}
242
243impl ConversationResource for LiminalConversationResource {
244 fn message(&self, envelope: &MessageEnvelope, op_id: Option<u64>) -> Result<(), ServerError> {
245 // If the participant has already crashed (structural EXIT observed),
246 // refuse the message rather than forwarding into a failed conversation.
247 if self.poll_exit_signal().is_some() || self.actor_phase_failed() {
248 return Err(ServerError::ListenerAccept {
249 message: format!(
250 "conversation participant {} crashed; message rejected",
251 self.participant.get()
252 ),
253 });
254 }
255 let payload = envelope.payload.clone();
256 let message = Envelope::new(payload, None, SchemaId::new(), PublisherId::default());
257 self.actor
258 .handle()
259 .send_with_op_id(message, op_id)
260 .map_err(|error| ServerError::ListenerAccept {
261 message: format!("conversation message delivery failed: {error}"),
262 })
263 }
264
265 fn participant_pids(&self) -> Vec<u64> {
266 vec![self.participant.get()]
267 }
268
269 fn has_detected_crash(&self) -> bool {
270 self.poll_exit_signal().is_some() || self.actor_phase_failed()
271 }
272
273 fn await_crash(&self, timeout: Duration) -> Option<Instant> {
274 if let Some(instant) = self.poll_exit_signal() {
275 return Some(instant);
276 }
277 // Event-driven: park on the exit notifier; the actor's trapped-EXIT
278 // handler wakes us the instant the participant's link fires. No polling.
279 let received = self
280 .exit_rx
281 .lock()
282 .map_or(None, |rx| rx.recv_timeout(timeout).ok());
283 self.cache(received);
284 received
285 }
286
287 fn receive_reply(&self, timeout: Duration) -> Result<MessageEnvelope, ServerError> {
288 // The participant produced a reply that the conversation actor delivered
289 // back into the conversation; drain it (bounded). This is the reply leg
290 // of the request-reply path — proof the participant genuinely processed
291 // the forwarded message, not just that it was linked.
292 let reply =
293 self.actor
294 .receive_timeout(timeout)
295 .map_err(|error| ServerError::ListenerAccept {
296 message: format!("conversation reply receive failed: {error}"),
297 })?;
298 Ok(MessageEnvelope::new(
299 ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
300 ProtocolCausalContext::independent(),
301 reply.payload,
302 ))
303 }
304
305 fn try_receive_reply(&self) -> Option<(u64, MessageEnvelope)> {
306 // Non-blocking host-side drain of one buffered participant reply, framed
307 // as the wire reply envelope (schema/causal metadata are not bridged in
308 // v1, matching the removed blocking `receive_reply`).
309 let (op_id, reply) = self.actor.try_take_reply()?;
310 Some((
311 op_id,
312 MessageEnvelope::new(
313 ProtocolSchemaId::new([0; ProtocolSchemaId::WIRE_LEN]),
314 ProtocolCausalContext::independent(),
315 reply.payload,
316 ),
317 ))
318 }
319
320 fn has_pending_reply(&self) -> bool {
321 self.actor.has_pending_reply()
322 }
323
324 fn register_reply_notifier(&self, notifier: std::sync::Arc<dyn Fn() + Send + Sync>) {
325 self.actor.register_reply_notifier(notifier);
326 }
327
328 fn close(self: Box<Self>) -> Result<(), ServerError> {
329 let Self { actor, .. } = *self;
330 // A crashed (Failed) conversation cannot transition to Closed; tearing
331 // down its handle is sufficient and is not an error.
332 if matches!(
333 actor.state().map(|state| state.current_phase),
334 Ok(ConversationPhase::Failed)
335 ) {
336 actor.handle().close().ok();
337 return Ok(());
338 }
339 actor
340 .handle()
341 .close()
342 .map_err(|error| ServerError::ListenerAccept {
343 message: format!("conversation close failed: {error}"),
344 })
345 }
346
347 fn finalize(self: Box<Self>) {
348 self.actor.finalize();
349 }
350}