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}