Skip to main content

liminal_server/server/connection/supervisor/
handle.rs

1use super::{
2    Arc, Atom, ConnectionIncarnation, ConnectionRuntime, Duration, Frame, Instant,
3    PushSlotDisposition, PushSlotResolution, Receiver, RecvTimeoutError, ServerError, SocketAddr,
4    SupervisorInner, TryRecvError, Weak, WorkerRefusal,
5};
6
7/// Handle for one supervised connection process.
8#[derive(Clone, Debug)]
9pub struct ConnectionHandle {
10    pub(super) pid: u64,
11    pub(super) peer_addr: Option<SocketAddr>,
12    pub(super) connection_incarnation: Option<ConnectionIncarnation>,
13    pub(super) supervisor: Arc<SupervisorInner>,
14}
15
16impl ConnectionHandle {
17    /// Every later report of the frames this connection writes to its
18    /// transport. Test-only (R3b F8, ruling 2a).
19    #[cfg(test)]
20    pub(crate) fn observe_frames_written(&self) -> Receiver<Vec<liminal::protocol::FrameType>> {
21        self.supervisor.runtime.observe_frames_written(self.pid)
22    }
23
24    /// Returns the beamr process id for this connection.
25    #[must_use]
26    pub const fn pid(&self) -> u64 {
27        self.pid
28    }
29
30    /// Returns the peer address if it was available from the accepted stream.
31    #[must_use]
32    pub const fn peer_addr(&self) -> Option<SocketAddr> {
33        self.peer_addr
34    }
35
36    /// Returns the durable participant connection incarnation, when this
37    /// supervisor has a complete participant service installed.
38    ///
39    /// `None` identifies a services adapter that does not advertise participant
40    /// lifecycle semantics.
41    #[must_use]
42    pub const fn connection_incarnation(&self) -> Option<ConnectionIncarnation> {
43        self.connection_incarnation
44    }
45
46    /// Returns whether the beamr process is still live.
47    #[must_use]
48    pub fn is_live(&self) -> bool {
49        self.supervisor
50            .scheduler
51            .process_table()
52            .get(self.pid)
53            .is_some()
54    }
55
56    /// Requests an error exit for tests and supervisor control paths.
57    ///
58    /// # Errors
59    /// Returns [`ServerError`] when the process is no longer live.
60    pub fn request_crash(&self) -> Result<(), ServerError> {
61        if self
62            .supervisor
63            .scheduler
64            .enqueue_atom_message(self.pid, Atom::ERROR)
65        {
66            Ok(())
67        } else {
68            Err(ServerError::ListenerAccept {
69                message: format!("connection process {} is not live", self.pid),
70            })
71        }
72    }
73}
74
75/// Awaits the correlated reply to a single server-initiated push.
76///
77/// Returned by [`ConnectionSupervisor::push_to_connection`]. The reply slot is
78/// resolved when the originating connection process receives a `PushReply` frame
79/// carrying the same correlation id, so [`PushReplyAwaiter::receive`] blocks
80/// (bounded) for that one correlated answer.
81#[derive(Debug)]
82pub struct PushReplyAwaiter {
83    pub(super) correlation_id: u64,
84    pub(super) receiver: Receiver<PushSlotResolution>,
85    /// This push's absolute reply deadline, mirrored from its slot. `None` (the
86    /// default push) selects the no-deadline receive path, which NEVER touches
87    /// the runtime — byte-compatible with 0.2.3, no shared-lock exposure.
88    /// `Some` lets `receive` wait `min(caller quantum, time until deadline)` and
89    /// resolve expiry promptly, so the caller's quantum can never select a
90    /// deadlined push's terminal outcome.
91    pub(super) deadline: Option<Instant>,
92    /// Weak handle to the owning runtime, used ONLY by the explicit-deadline
93    /// path to resolve expiry host-side at [`receive`](Self::receive). A
94    /// no-deadline push never upgrades it. `Weak` so the awaiter never keeps the
95    /// runtime alive; if it is already gone, the slot (and its sender) is gone
96    /// with it — the connection side is torn down.
97    pub(super) runtime: Weak<ConnectionRuntime>,
98}
99
100impl PushReplyAwaiter {
101    /// Returns the correlation id this awaiter is matched on.
102    #[must_use]
103    pub const fn correlation_id(&self) -> u64 {
104        self.correlation_id
105    }
106
107    /// Blocks up to `timeout` for the client's correlated reply payload.
108    ///
109    /// `timeout` is a WAIT QUANTUM ONLY — a MAXIMUM wait, not a promise to
110    /// block: an elapsed poll is a benign re-arm, never a failure; the reply's
111    /// lifetime belongs to the push. A caller may re-invoke `receive`
112    /// indefinitely after a [`ServerError::PushReplyTimeout`]: the reserved slot
113    /// is untouched and a later reply is still delivered byte-exact. The poll
114    /// quantum never changes the protocol outcome — for a deadlined push the
115    /// call waits no longer than the EARLIER of the caller's quantum and the
116    /// push's deadline, so the terminal expiry is returned promptly once due,
117    /// never held until the quantum ends and never deferred past it.
118    ///
119    /// A push with no explicit deadline never touches shared supervisor state
120    /// here: the elapsed quantum returns straight from the channel wait
121    /// (behaviour-compatible with 0.2.3 — no registry lock, no contention, no
122    /// poison exposure on the unchanged API).
123    ///
124    /// # Errors
125    /// Returns [`ServerError::PushReplyTimeout`] when no reply arrived within this
126    /// `timeout` quantum and the push's deadline (if any) is not yet due (a
127    /// benign re-arm — call again to keep waiting);
128    /// [`ServerError::PushReplyExpired`] when the push carried an explicit reply
129    /// deadline (via
130    /// [`push_to_connection_with_deadline`](ConnectionSupervisor::push_to_connection_with_deadline))
131    /// and that deadline is due (terminal: the slot is removed and its §5 cap
132    /// admission released; returned as soon as the deadline passes, even
133    /// mid-quantum — but evaluated at observation points, not against the wall
134    /// clock: a reply already delivered when this call observes the slot wins
135    /// over expiry, even if it arrived after the deadline instant); or
136    /// [`ServerError::PushReplyDisconnected`] when the connection process
137    /// dropped the reply slot (the connection closed — the prompt worker-death
138    /// signal). The variants are distinct so callers classify by type, not
139    /// message.
140    pub fn receive(&self, timeout: Duration) -> Result<Vec<u8>, ServerError> {
141        self.deadline.map_or_else(
142            || self.receive_no_deadline(timeout),
143            |deadline| self.receive_deadlined(timeout, deadline),
144        )
145    }
146
147    /// The default-push receive: exactly the 0.2.3 shape. One bounded channel
148    /// wait; an elapsed quantum is a benign timeout straight from the channel —
149    /// no runtime upgrade, no registry lock, EVER (unrelated registry work can
150    /// never stretch this call past its quantum, and registry poison cannot
151    /// reach it).
152    fn receive_no_deadline(&self, timeout: Duration) -> Result<Vec<u8>, ServerError> {
153        match self.receiver.recv_timeout(timeout) {
154            Ok(resolution) => self.resolution_into_result(resolution),
155            Err(RecvTimeoutError::Timeout) => Err(ServerError::PushReplyTimeout {
156                correlation_id: self.correlation_id,
157            }),
158            Err(RecvTimeoutError::Disconnected) => Err(ServerError::PushReplyDisconnected {
159                correlation_id: self.correlation_id,
160            }),
161        }
162    }
163
164    /// The deadlined receive: waits `min(caller quantum, time until deadline)`
165    /// and re-evaluates reply-first-then-expiry on every wake, so the caller's
166    /// quantum can never select the terminal outcome (S1). Order per iteration:
167    ///
168    /// 1. Deliver a reply already in hand — an answer that is here must never be
169    ///    reported as a timeout OR an expiry (the observation-point rule).
170    /// 2. If the deadline is due, resolve expiry atomically against the registry
171    ///    (`expire_slot`) and return the terminal outcome promptly — even when
172    ///    the caller's quantum has time left (the quantum is a max wait).
173    /// 3. Otherwise wait for the earlier of quantum-remaining and deadline; a
174    ///    wake re-runs 1-2, and an exhausted quantum before the deadline is the
175    ///    benign `PushReplyTimeout` re-arm with the slot untouched.
176    fn receive_deadlined(
177        &self,
178        timeout: Duration,
179        deadline: Instant,
180    ) -> Result<Vec<u8>, ServerError> {
181        let started = Instant::now();
182        loop {
183            if let Some(result) = self.try_take_reply() {
184                return result;
185            }
186            let now = Instant::now();
187            if now >= deadline {
188                return self.expire_slot();
189            }
190            let quantum_left = timeout.saturating_sub(now.duration_since(started));
191            if quantum_left.is_zero() {
192                return Err(ServerError::PushReplyTimeout {
193                    correlation_id: self.correlation_id,
194                });
195            }
196            match self
197                .receiver
198                .recv_timeout(quantum_left.min(deadline.duration_since(now)))
199            {
200                Ok(resolution) => return self.resolution_into_result(resolution),
201                Err(RecvTimeoutError::Disconnected) => {
202                    return Err(ServerError::PushReplyDisconnected {
203                        correlation_id: self.correlation_id,
204                    });
205                }
206                // Re-loop: deliver a reply that raced the wake, expire a
207                // now-due deadline, or report the exhausted quantum benignly.
208                Err(RecvTimeoutError::Timeout) => {}
209            }
210        }
211    }
212
213    /// Resolves a due deadline against the registry's atomic removal transition.
214    fn expire_slot(&self) -> Result<Vec<u8>, ServerError> {
215        let timeout_error = || ServerError::PushReplyTimeout {
216            correlation_id: self.correlation_id,
217        };
218        let Some(runtime) = self.runtime.upgrade() else {
219            // The runtime is gone, and the slot map (with every sender) with it:
220            // the connection side is torn down. Re-check the channel so the
221            // dropped sender reads as the established DISCONNECTED outcome — a
222            // dead runtime must not be misreported as a benign healthy-but-slow
223            // timeout (S4).
224            return self
225                .try_take_reply()
226                .unwrap_or(Err(ServerError::PushReplyDisconnected {
227                    correlation_id: self.correlation_id,
228                }));
229        };
230        match runtime.expire_push_if_due(self.correlation_id) {
231            PushSlotDisposition::Expired => Err(ServerError::PushReplyExpired {
232                correlation_id: self.correlation_id,
233            }),
234            // Unreachable by construction (this is only called with the deadline
235            // due, and the registry re-reads a monotonic clock); honest benign
236            // fallback rather than a panic.
237            PushSlotDisposition::Live => Err(timeout_error()),
238            // Another path (a concurrent `resolve_push`, or connection close)
239            // removed the slot under the registry lock while we waited on it. Its
240            // send, if any, happens under that same lock, so re-check the channel:
241            // a delivered reply is present now; a dropped sender is disconnected.
242            PushSlotDisposition::Absent => self
243                .try_take_reply()
244                .unwrap_or_else(|| Err(timeout_error())),
245        }
246    }
247
248    /// SRV-027 R2: the outcome if the slot has resolved, read without waiting
249    /// by the requester's slice after its wake: `Some(Ok(payload))` for the
250    /// worker's reply, `Some(Err(..))` for a refusal, a frame the worker's
251    /// connection could not carry, or the worker gone (the slot swept at its
252    /// close), `None` while nothing has arrived.
253    pub(in crate::server::connection) fn try_receive(
254        &self,
255    ) -> Option<Result<Vec<u8>, PushReplyFailure>> {
256        match self.receiver.try_recv() {
257            Ok(PushSlotResolution::WorkerRefused(refusal)) => {
258                Some(Err(PushReplyFailure::Worker(refusal)))
259            }
260            Ok(resolution) => Some(
261                self.resolution_into_result(resolution)
262                    .map_err(PushReplyFailure::Server),
263            ),
264            Err(TryRecvError::Empty) => None,
265            Err(TryRecvError::Disconnected) => Some(Err(PushReplyFailure::Server(
266                ServerError::PushReplyDisconnected {
267                    correlation_id: self.correlation_id,
268                },
269            ))),
270        }
271    }
272
273    /// Non-blocking check for a reply already sitting in the channel. `Some` with
274    /// the payload or a disconnected error; `None` when the channel is still empty
275    /// (no reply yet — the caller re-arms).
276    pub(super) fn try_take_reply(&self) -> Option<Result<Vec<u8>, ServerError>> {
277        match self.receiver.try_recv() {
278            Ok(resolution) => Some(self.resolution_into_result(resolution)),
279            Err(TryRecvError::Disconnected) => Some(Err(ServerError::PushReplyDisconnected {
280                correlation_id: self.correlation_id,
281            })),
282            Err(TryRecvError::Empty) => None,
283        }
284    }
285
286    /// Turns one delivered [`PushSlotResolution`] into this awaiter's result, so
287    /// all three receive paths read a resolution the same way and none of them
288    /// can grow its own interpretation of one.
289    ///
290    /// Note what is NOT here: a dropped `Sender` never reaches this function. It
291    /// is a channel disconnect, and so stays the retryable
292    /// [`ServerError::PushReplyDisconnected`] on every path that kills a slot
293    /// without naming a reason — connection close included.
294    fn resolution_into_result(
295        &self,
296        resolution: PushSlotResolution,
297    ) -> Result<Vec<u8>, ServerError> {
298        match resolution {
299            PushSlotResolution::Reply(payload) => Ok(payload),
300            PushSlotResolution::WorkerRefused(refusal) => Err(ServerError::PushReplyRefused {
301                correlation_id: self.correlation_id,
302                reason: refusal.code().caller_name().to_owned(),
303            }),
304            PushSlotResolution::ReplyRefused { reason } => Err(ServerError::PushReplyRefused {
305                correlation_id: self.correlation_id,
306                reason,
307            }),
308            PushSlotResolution::OutboundFrameTooLarge {
309                needed,
310                capacity,
311                queued,
312            } => Err(ServerError::PushFrameExceedsOutboundCapacity {
313                correlation_id: self.correlation_id,
314                needed,
315                capacity,
316                queued,
317            }),
318        }
319    }
320}
321
322/// Routed calls retain words only inside the validated, redacted record. Host
323/// push APIs predate worker refusals and return the stable code alone, keeping
324/// worker data out of their generic error's derived debug representation.
325#[derive(Debug)]
326pub(in crate::server::connection) enum PushReplyFailure {
327    Worker(WorkerRefusal),
328    Server(ServerError),
329}
330
331impl PushReplyFailure {
332    /// Settles the pending operation and its tap exactly once. Worker words
333    /// are released only after the caller's current membership permits the
334    /// read, and only the stable refusal code reaches the tap.
335    pub(in crate::server::connection) fn into_frame(
336        self,
337        state: &mut super::state::ConnectionProcessState,
338        op_id: u64,
339        stream_id: u32,
340        conversation_id: u64,
341        subject: &str,
342        route_words: impl FnOnce(&str, &ServerError) -> String,
343    ) -> Frame {
344        let (reason_code, name, words) = match self {
345            Self::Worker(refusal) => match state
346                .membership
347                .release(subject, crate::auth_membership::MembershipAction::Read)
348            {
349                Ok(_) => (
350                    refusal.code().reason_code(),
351                    refusal.code().caller_name().to_owned(),
352                    refusal.into_words(),
353                ),
354                Err(error) => (0xffff, error.name().to_owned(), error.to_string()),
355            },
356            Self::Server(error) => {
357                let words = route_words(subject, &error);
358                (0xffff, words.clone(), words)
359            }
360        };
361        state.pending_replies.refuse(op_id, &name);
362        Frame::ConversationError {
363            flags: 0,
364            stream_id,
365            conversation_id,
366            reason_code,
367            message: Some(words),
368        }
369    }
370}