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