Skip to main content

liminal_sdk/remote/
mailbox.rs

1//! The bounded mailbox between a background socket reader and its consumer.
2//!
3//! Every SDK stream with a reader thread (the push client and both
4//! subscription streams) drains its socket into one of these and the consumer
5//! takes from it. A consumer that stops receiving must not let the reader keep
6//! pulling the server's bytes into memory, so a mailbox holds at most a frame
7//! count and a payload byte count. When either bound is reached the reader
8//! waits on the mailbox and stops reading the socket, so TCP flow control
9//! carries the pressure back to the server; nothing already read is dropped.
10//! A single item that could never fit is refused by name and ends the reader.
11//! Why the reader stopped is recorded here as its last act, and the consumer
12//! receives it once every item before it has been taken.
13
14use alloc::collections::VecDeque;
15use alloc::format;
16use alloc::string::ToString;
17use core::time::Duration;
18
19use std::sync::{Condvar, Mutex, MutexGuard};
20use std::time::Instant;
21
22use crate::SdkError;
23
24/// Default cap on frames a push client holds for a consumer that has not
25/// received them.
26pub const DEFAULT_PUSH_MAILBOX_FRAMES: usize = 1024;
27
28/// Default cap on payload bytes a push client holds. Equal to the push
29/// reader's single-frame buffering bound, so every frame it can decode fits
30/// an empty mailbox.
31pub const DEFAULT_PUSH_MAILBOX_BYTES: usize = 16 * 1024 * 1024;
32
33/// Default cap on deliveries a subscription stream holds for a consumer that
34/// has not received them.
35pub const DEFAULT_SUBSCRIPTION_MAILBOX_FRAMES: usize = 1024;
36
37/// Default cap on delivered payload bytes a subscription stream holds.
38///
39/// Equal to the TCP subscription reader's single-frame buffering bound, so
40/// every delivery it can decode fits an empty mailbox; the WebSocket stream
41/// shares it so both transports hold the same.
42pub const DEFAULT_SUBSCRIPTION_MAILBOX_BYTES: usize = 64 * 1024 * 1024;
43
44/// One item a mailbox can hold: it reports the payload bytes it keeps alive.
45pub trait MailboxItem {
46    fn payload_len(&self) -> usize;
47}
48
49/// The bounds one mailbox enforces.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct MailboxLimits {
52    pub frames: usize,
53    pub bytes: usize,
54}
55
56impl MailboxLimits {
57    pub const PUSH: Self = Self {
58        frames: DEFAULT_PUSH_MAILBOX_FRAMES,
59        bytes: DEFAULT_PUSH_MAILBOX_BYTES,
60    };
61    pub const SUBSCRIPTION: Self = Self {
62        frames: DEFAULT_SUBSCRIPTION_MAILBOX_FRAMES,
63        bytes: DEFAULT_SUBSCRIPTION_MAILBOX_BYTES,
64    };
65}
66
67/// The words a mailbox's consumer-facing errors use, so each stream keeps
68/// its own wording.
69#[derive(Clone, Copy, Debug)]
70pub struct MailboxWords {
71    /// Prefix of every receive failure, e.g. "push receive failed".
72    pub receive: &'static str,
73    /// What the consumer waits for, e.g. "server push" or "delivery".
74    pub item: &'static str,
75    /// The reader's name, e.g. "push reader".
76    pub reader: &'static str,
77}
78
79/// Counters describing one stream's mailbox, readable at any time from the
80/// stream that owns it.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct MailboxStats {
84    /// Items held now.
85    pub held_frames: usize,
86    /// Payload bytes held now.
87    pub held_bytes: usize,
88    /// Most items ever held at once.
89    pub peak_frames: usize,
90    /// Most payload bytes ever held at once.
91    pub peak_bytes: usize,
92    /// Times the reader found the mailbox full and stopped reading the socket
93    /// until the consumer made room.
94    pub backpressure_waits: u64,
95    /// Items refused because they could never fit the mailbox.
96    pub refused_frames: u64,
97    /// Frames of a kind this stream does not carry, read and counted rather
98    /// than surfaced, so a stray frame cannot stop the frames behind it.
99    pub ignored_frames: u64,
100    /// Reader errors (a server-ended subscription, a read or decode failure)
101    /// that no consumer was left to receive.
102    pub undelivered_errors: u64,
103}
104
105/// Why the reader stopped; surfaced to the consumer after every held item.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum ReaderEnd {
108    /// The reader ended with this error: the connection closed, a read
109    /// failed, a frame did not decode, the server ended the stream, or an item
110    /// was refused.
111    Failed(SdkError),
112    /// The peer closed a complete stream; the error preserves timed reception.
113    PeerClosed(SdkError),
114    /// The reader stopped with no error of its own: its stream was dropped,
115    /// or the link ended without one.
116    Stopped,
117}
118
119/// An item larger than the mailbox could ever hold.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub struct MailboxOverflow {
122    /// The refused item's payload bytes.
123    pub payload_bytes: usize,
124    /// The mailbox's byte bound.
125    pub max_bytes: usize,
126}
127
128impl core::fmt::Display for MailboxOverflow {
129    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        write!(
131            formatter,
132            "mailbox overflow: a {}-byte item exceeds the {}-byte mailbox",
133            self.payload_bytes, self.max_bytes
134        )
135    }
136}
137
138impl From<MailboxOverflow> for SdkError {
139    fn from(overflow: MailboxOverflow) -> Self {
140        Self::Backpressure {
141            reason: format!("{overflow}"),
142        }
143    }
144}
145
146#[derive(Debug)]
147struct State<T> {
148    queue: VecDeque<T>,
149    stats: MailboxStats,
150    end: Option<ReaderEnd>,
151    abandoned: bool,
152    receiver_claimed: bool,
153    direct_receives: usize,
154    #[cfg(test)]
155    blocking_wait_observer: Option<std::sync::mpsc::Sender<()>>,
156    #[cfg(test)]
157    receive_wakes: u64,
158    #[cfg(test)]
159    receive_timers: u64,
160}
161
162/// The reader's view of one offer.
163#[derive(Debug, PartialEq, Eq)]
164pub enum Offer {
165    /// Held for the consumer, or discarded because the stream was dropped.
166    Accepted,
167    /// The item could never fit; the reader must end with this refusal.
168    Refused(MailboxOverflow),
169}
170
171/// One blocking receive result, retaining the terminal's typed provenance.
172#[derive(Debug)]
173pub enum BlockingTake<T> {
174    /// The next queued item, moved rather than cloned.
175    Item(T),
176    /// The owning stream stopped accepting reception.
177    Abandoned,
178    /// The reader's retained terminal after its queue drained.
179    Ended(ReaderEnd),
180}
181
182/// The shared mailbox. One lock, one condition variable: the reader waits on
183/// it when full, the consumer when empty, teardown until the reader ends.
184#[derive(Debug)]
185pub struct Mailbox<T> {
186    limits: MailboxLimits,
187    words: MailboxWords,
188    state: Mutex<State<T>>,
189    changed: Condvar,
190}
191
192fn poisoned() -> SdkError {
193    SdkError::Connection {
194        description: "stream mailbox lock poisoned: a thread panicked while holding it".to_string(),
195    }
196}
197
198impl<T: MailboxItem> Mailbox<T> {
199    pub fn new(limits: MailboxLimits, words: MailboxWords) -> Self {
200        Self {
201            limits,
202            words,
203            state: Mutex::new(State {
204                queue: VecDeque::new(),
205                stats: MailboxStats::default(),
206                end: None,
207                abandoned: false,
208                receiver_claimed: false,
209                direct_receives: 0,
210                #[cfg(test)]
211                blocking_wait_observer: None,
212                #[cfg(test)]
213                receive_wakes: 0,
214                #[cfg(test)]
215                receive_timers: 0,
216            }),
217            changed: Condvar::new(),
218        }
219    }
220
221    fn lock(&self) -> Result<MutexGuard<'_, State<T>>, SdkError> {
222        self.state.lock().map_err(|_| poisoned())
223    }
224
225    /// Offers one item from the reader, waiting while the mailbox is full.
226    ///
227    /// # Errors
228    /// Returns [`SdkError::Connection`] when the lock is poisoned.
229    pub fn offer(&self, item: T) -> Result<Offer, SdkError> {
230        let size = item.payload_len();
231        if size > self.limits.bytes {
232            let mut state = self.lock()?;
233            state.stats.refused_frames = state.stats.refused_frames.saturating_add(1);
234            drop(state);
235            return Ok(Offer::Refused(MailboxOverflow {
236                payload_bytes: size,
237                max_bytes: self.limits.bytes,
238            }));
239        }
240        let mut state = self.lock()?;
241        let mut counted = false;
242        while !state.abandoned
243            && (state.queue.len() >= self.limits.frames
244                || state.stats.held_bytes.saturating_add(size) > self.limits.bytes)
245        {
246            if !counted {
247                state.stats.backpressure_waits = state.stats.backpressure_waits.saturating_add(1);
248                counted = true;
249                self.changed.notify_all();
250            }
251            state = self.changed.wait(state).map_err(|_| poisoned())?;
252        }
253        if state.abandoned {
254            drop(state);
255            return Ok(Offer::Accepted);
256        }
257        state.queue.push_back(item);
258        let tally = &mut state.stats;
259        tally.held_frames = tally.held_frames.saturating_add(1);
260        tally.held_bytes = tally.held_bytes.saturating_add(size);
261        tally.peak_frames = tally.peak_frames.max(tally.held_frames);
262        tally.peak_bytes = tally.peak_bytes.max(tally.held_bytes);
263        drop(state);
264        self.changed.notify_all();
265        Ok(Offer::Accepted)
266    }
267
268    /// Records why the reader stopped and wakes every waiter. Called exactly
269    /// once, as the reader's last act. An error with no consumer left to
270    /// receive it is counted.
271    pub fn close(&self, end: ReaderEnd) {
272        if let Ok(mut state) = self.state.lock() {
273            if state.abandoned && matches!(end, ReaderEnd::Failed(_) | ReaderEnd::PeerClosed(_)) {
274                state.stats.undelivered_errors = state.stats.undelivered_errors.saturating_add(1);
275            }
276            state.end = Some(end);
277        }
278        // A poisoned lock leaves `end` unset; waiters still wake here and
279        // observe the poison themselves on their next lock.
280        self.changed.notify_all();
281    }
282
283    /// Takes the next item, waiting up to `timeout` for one.
284    ///
285    /// # Errors
286    /// Returns the reader's end once every held item has been taken, a
287    /// [`SdkError::Connection`] naming the timeout, or the poisoned lock.
288    pub fn take(&self, timeout: Duration) -> Result<T, SdkError> {
289        let deadline = Instant::now().checked_add(timeout);
290        let mut state = self.lock()?;
291        loop {
292            if let Some(item) = state.queue.pop_front() {
293                let tally = &mut state.stats;
294                tally.held_frames = tally.held_frames.saturating_sub(1);
295                tally.held_bytes = tally.held_bytes.saturating_sub(item.payload_len());
296                drop(state);
297                self.changed.notify_all();
298                return Ok(item);
299            }
300            match &state.end {
301                Some(ReaderEnd::Failed(error) | ReaderEnd::PeerClosed(error)) => {
302                    return Err(error.clone());
303                }
304                Some(ReaderEnd::Stopped) => {
305                    return Err(SdkError::Connection {
306                        description: format!(
307                            "{}: the {} stopped before a {} arrived",
308                            self.words.receive, self.words.reader, self.words.item
309                        ),
310                    });
311                }
312                None => {}
313            }
314            let remaining = deadline.map_or(timeout, |deadline| {
315                deadline.saturating_duration_since(Instant::now())
316            });
317            if remaining.is_zero() {
318                return Err(SdkError::Connection {
319                    description: format!(
320                        "{}: no {} arrived within the timeout",
321                        self.words.receive, self.words.item
322                    ),
323                });
324            }
325            #[cfg(test)]
326            {
327                state.receive_timers = state.receive_timers.saturating_add(1);
328            }
329            state = self
330                .changed
331                .wait_timeout(state, remaining)
332                .map_err(|_| poisoned())?
333                .0;
334        }
335    }
336
337    /// Claims the single push receiver while no direct receive is active.
338    ///
339    /// # Errors
340    /// Returns [`SdkError::ReceiverAlreadyClaimed`] when the receiver is
341    /// claimed or a direct receive is active, and
342    /// [`SdkError::Connection`] when the lock is poisoned.
343    pub fn claim_receiver(&self) -> Result<(), SdkError> {
344        let mut state = self.lock()?;
345        let claimable = !state.receiver_claimed && state.direct_receives == 0;
346        if claimable {
347            state.receiver_claimed = true;
348        }
349        drop(state);
350        if claimable {
351            Ok(())
352        } else {
353            Err(SdkError::ReceiverAlreadyClaimed)
354        }
355    }
356
357    fn begin_direct(state: &mut State<T>) -> Result<(), SdkError> {
358        if state.receiver_claimed {
359            return Err(SdkError::ReceiverAlreadyClaimed);
360        }
361        state.direct_receives =
362            state
363                .direct_receives
364                .checked_add(1)
365                .ok_or_else(|| SdkError::Protocol {
366                    description: "push direct receive count exhausted".to_string(),
367                })?;
368        Ok(())
369    }
370
371    /// Preserves timed reception while fencing receiver-handle ownership.
372    pub fn take_push_timeout(&self, timeout: Duration) -> Result<T, SdkError> {
373        let mut state = self.lock()?;
374        Self::begin_direct(&mut state)?;
375        drop(state);
376        let result = self.take(timeout);
377        let mut state = self.lock()?;
378        state.direct_receives -= 1;
379        result
380    }
381
382    /// Waits on the locked arrival or terminal predicate without a timer.
383    pub fn take_blocking(&self, direct: bool) -> Result<BlockingTake<T>, SdkError> {
384        let mut state = self.lock()?;
385        if direct {
386            Self::begin_direct(&mut state)?;
387        }
388        let result = loop {
389            if state.abandoned {
390                break Ok(BlockingTake::Abandoned);
391            }
392            if let Some(item) = state.queue.pop_front() {
393                state.stats.held_frames -= 1;
394                state.stats.held_bytes -= item.payload_len();
395                break Ok(BlockingTake::Item(item));
396            }
397            if let Some(end) = &state.end {
398                break Ok(BlockingTake::Ended(end.clone()));
399            }
400            #[cfg(test)]
401            if let Some(observer) = &state.blocking_wait_observer {
402                if let Err(error) = observer.send(()) {
403                    break Err(SdkError::Connection {
404                        description: format!("blocking receive observer failed: {error}"),
405                    });
406                }
407            }
408            state = self.changed.wait(state).map_err(|_| poisoned())?;
409            #[cfg(test)]
410            {
411                state.receive_wakes = state.receive_wakes.saturating_add(1);
412            }
413        };
414        if direct {
415            state.direct_receives -= 1;
416        }
417        let made_room = matches!(&result, Ok(BlockingTake::Item(_)));
418        drop(state);
419        if made_room {
420            self.changed.notify_all();
421        }
422        result
423    }
424
425    #[cfg(test)]
426    pub fn observe_blocking_wait(
427        &self,
428        observer: std::sync::mpsc::Sender<()>,
429    ) -> Result<(), SdkError> {
430        self.lock()?.blocking_wait_observer = Some(observer);
431        Ok(())
432    }
433
434    #[cfg(test)]
435    pub fn receive_wait_counts(&self) -> Result<(u64, u64), SdkError> {
436        let state = self.lock()?;
437        Ok((state.receive_wakes, state.receive_timers))
438    }
439
440    /// Discards everything held and every later item, and releases a reader
441    /// waiting on a full mailbox: the consumer is going away.
442    ///
443    /// # Errors
444    /// Returns [`SdkError::Connection`] when the lock is poisoned.
445    pub fn abandon(&self) -> Result<(), SdkError> {
446        let mut state = self.lock()?;
447        state.abandoned = true;
448        state.queue.clear();
449        state.stats.held_frames = 0;
450        state.stats.held_bytes = 0;
451        drop(state);
452        self.changed.notify_all();
453        Ok(())
454    }
455
456    /// [`Self::abandon`] for teardown paths with no caller to report to. On a
457    /// poisoned lock the waiters are still woken, and a reader waiting to
458    /// offer then observes the poison itself and ends.
459    pub fn release(&self) {
460        if let Ok(mut state) = self.state.lock() {
461            state.abandoned = true;
462            state.queue.clear();
463            state.stats.held_frames = 0;
464            state.stats.held_bytes = 0;
465        }
466        self.changed.notify_all();
467    }
468
469    /// Waits until `done` holds for the counters and reader end, or `budget`
470    /// passes; reports whether it held.
471    ///
472    /// # Errors
473    /// Returns [`SdkError::Connection`] when the lock is poisoned.
474    pub fn wait_until(
475        &self,
476        budget: Duration,
477        done: impl Fn(&MailboxStats, Option<&ReaderEnd>) -> bool,
478    ) -> Result<bool, SdkError> {
479        let deadline = Instant::now().checked_add(budget);
480        let mut state = self.lock()?;
481        loop {
482            if done(&state.stats, state.end.as_ref()) {
483                return Ok(true);
484            }
485            let remaining = deadline.map_or(budget, |deadline| {
486                deadline.saturating_duration_since(Instant::now())
487            });
488            if remaining.is_zero() {
489                return Ok(false);
490            }
491            state = self
492                .changed
493                .wait_timeout(state, remaining)
494                .map_err(|_| poisoned())?
495                .0;
496        }
497    }
498
499    /// Counts one frame the reader read and set aside.
500    ///
501    /// # Errors
502    /// Returns [`SdkError::Connection`] when the lock is poisoned.
503    pub fn record_ignored_frame(&self) -> Result<(), SdkError> {
504        let mut state = self.lock()?;
505        state.stats.ignored_frames = state.stats.ignored_frames.saturating_add(1);
506        drop(state);
507        Ok(())
508    }
509
510    /// The counters as they stand.
511    ///
512    /// # Errors
513    /// Returns [`SdkError::Connection`] when the lock is poisoned.
514    pub fn stats(&self) -> Result<MailboxStats, SdkError> {
515        Ok(self.lock()?.stats)
516    }
517}
518
519/// Offers `item`, mapping the outcome onto the reader's loop: `None` to keep
520/// reading, or the end the reader must stop with.
521pub fn offer_or_end<T: MailboxItem>(mailbox: &Mailbox<T>, item: T) -> Option<ReaderEnd> {
522    match mailbox.offer(item) {
523        Ok(Offer::Accepted) => None,
524        Ok(Offer::Refused(overflow)) => Some(ReaderEnd::Failed(SdkError::from(overflow))),
525        Err(error) => Some(ReaderEnd::Failed(error)),
526    }
527}