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 reader stopped with no error of its own: its stream was dropped,
113    /// or the link ended without one.
114    Stopped,
115}
116
117/// An item larger than the mailbox could ever hold.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub struct MailboxOverflow {
120    /// The refused item's payload bytes.
121    pub payload_bytes: usize,
122    /// The mailbox's byte bound.
123    pub max_bytes: usize,
124}
125
126impl core::fmt::Display for MailboxOverflow {
127    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
128        write!(
129            formatter,
130            "mailbox overflow: a {}-byte item exceeds the {}-byte mailbox",
131            self.payload_bytes, self.max_bytes
132        )
133    }
134}
135
136impl From<MailboxOverflow> for SdkError {
137    fn from(overflow: MailboxOverflow) -> Self {
138        Self::Backpressure {
139            reason: format!("{overflow}"),
140        }
141    }
142}
143
144#[derive(Debug)]
145struct State<T> {
146    queue: VecDeque<T>,
147    stats: MailboxStats,
148    end: Option<ReaderEnd>,
149    abandoned: bool,
150}
151
152/// The reader's view of one offer.
153#[derive(Debug, PartialEq, Eq)]
154pub enum Offer {
155    /// Held for the consumer, or discarded because the stream was dropped.
156    Accepted,
157    /// The item could never fit; the reader must end with this refusal.
158    Refused(MailboxOverflow),
159}
160
161/// The shared mailbox. One lock, one condition variable: the reader waits on
162/// it when full, the consumer when empty, teardown until the reader ends.
163#[derive(Debug)]
164pub struct Mailbox<T> {
165    limits: MailboxLimits,
166    words: MailboxWords,
167    state: Mutex<State<T>>,
168    changed: Condvar,
169}
170
171fn poisoned() -> SdkError {
172    SdkError::Connection {
173        description: "stream mailbox lock poisoned: a thread panicked while holding it".to_string(),
174    }
175}
176
177impl<T: MailboxItem> Mailbox<T> {
178    pub fn new(limits: MailboxLimits, words: MailboxWords) -> Self {
179        Self {
180            limits,
181            words,
182            state: Mutex::new(State {
183                queue: VecDeque::new(),
184                stats: MailboxStats::default(),
185                end: None,
186                abandoned: false,
187            }),
188            changed: Condvar::new(),
189        }
190    }
191
192    fn lock(&self) -> Result<MutexGuard<'_, State<T>>, SdkError> {
193        self.state.lock().map_err(|_| poisoned())
194    }
195
196    /// Offers one item from the reader, waiting while the mailbox is full.
197    ///
198    /// # Errors
199    /// Returns [`SdkError::Connection`] when the lock is poisoned.
200    pub fn offer(&self, item: T) -> Result<Offer, SdkError> {
201        let size = item.payload_len();
202        if size > self.limits.bytes {
203            let mut state = self.lock()?;
204            state.stats.refused_frames = state.stats.refused_frames.saturating_add(1);
205            drop(state);
206            return Ok(Offer::Refused(MailboxOverflow {
207                payload_bytes: size,
208                max_bytes: self.limits.bytes,
209            }));
210        }
211        let mut state = self.lock()?;
212        let mut counted = false;
213        while !state.abandoned
214            && (state.queue.len() >= self.limits.frames
215                || state.stats.held_bytes.saturating_add(size) > self.limits.bytes)
216        {
217            if !counted {
218                state.stats.backpressure_waits = state.stats.backpressure_waits.saturating_add(1);
219                counted = true;
220                self.changed.notify_all();
221            }
222            state = self.changed.wait(state).map_err(|_| poisoned())?;
223        }
224        if state.abandoned {
225            drop(state);
226            return Ok(Offer::Accepted);
227        }
228        state.queue.push_back(item);
229        let tally = &mut state.stats;
230        tally.held_frames = tally.held_frames.saturating_add(1);
231        tally.held_bytes = tally.held_bytes.saturating_add(size);
232        tally.peak_frames = tally.peak_frames.max(tally.held_frames);
233        tally.peak_bytes = tally.peak_bytes.max(tally.held_bytes);
234        drop(state);
235        self.changed.notify_all();
236        Ok(Offer::Accepted)
237    }
238
239    /// Records why the reader stopped and wakes every waiter. Called exactly
240    /// once, as the reader's last act. An error with no consumer left to
241    /// receive it is counted.
242    pub fn close(&self, end: ReaderEnd) {
243        if let Ok(mut state) = self.state.lock() {
244            if state.abandoned && matches!(end, ReaderEnd::Failed(_)) {
245                state.stats.undelivered_errors = state.stats.undelivered_errors.saturating_add(1);
246            }
247            state.end = Some(end);
248        }
249        // A poisoned lock leaves `end` unset; waiters still wake here and
250        // observe the poison themselves on their next lock.
251        self.changed.notify_all();
252    }
253
254    /// Takes the next item, waiting up to `timeout` for one.
255    ///
256    /// # Errors
257    /// Returns the reader's end once every held item has been taken, a
258    /// [`SdkError::Connection`] naming the timeout, or the poisoned lock.
259    pub fn take(&self, timeout: Duration) -> Result<T, SdkError> {
260        let deadline = Instant::now().checked_add(timeout);
261        let mut state = self.lock()?;
262        loop {
263            if let Some(item) = state.queue.pop_front() {
264                let tally = &mut state.stats;
265                tally.held_frames = tally.held_frames.saturating_sub(1);
266                tally.held_bytes = tally.held_bytes.saturating_sub(item.payload_len());
267                drop(state);
268                self.changed.notify_all();
269                return Ok(item);
270            }
271            match &state.end {
272                Some(ReaderEnd::Failed(error)) => return Err(error.clone()),
273                Some(ReaderEnd::Stopped) => {
274                    return Err(SdkError::Connection {
275                        description: format!(
276                            "{}: the {} stopped before a {} arrived",
277                            self.words.receive, self.words.reader, self.words.item
278                        ),
279                    });
280                }
281                None => {}
282            }
283            let remaining = deadline.map_or(timeout, |deadline| {
284                deadline.saturating_duration_since(Instant::now())
285            });
286            if remaining.is_zero() {
287                return Err(SdkError::Connection {
288                    description: format!(
289                        "{}: no {} arrived within the timeout",
290                        self.words.receive, self.words.item
291                    ),
292                });
293            }
294            state = self
295                .changed
296                .wait_timeout(state, remaining)
297                .map_err(|_| poisoned())?
298                .0;
299        }
300    }
301
302    /// Discards everything held and every later item, and releases a reader
303    /// waiting on a full mailbox: the consumer is going away.
304    ///
305    /// # Errors
306    /// Returns [`SdkError::Connection`] when the lock is poisoned.
307    pub fn abandon(&self) -> Result<(), SdkError> {
308        let mut state = self.lock()?;
309        state.abandoned = true;
310        state.queue.clear();
311        state.stats.held_frames = 0;
312        state.stats.held_bytes = 0;
313        drop(state);
314        self.changed.notify_all();
315        Ok(())
316    }
317
318    /// [`Self::abandon`] for teardown paths with no caller to report to. On a
319    /// poisoned lock the waiters are still woken, and a reader waiting to
320    /// offer then observes the poison itself and ends.
321    pub fn release(&self) {
322        if let Ok(mut state) = self.state.lock() {
323            state.abandoned = true;
324            state.queue.clear();
325            state.stats.held_frames = 0;
326            state.stats.held_bytes = 0;
327        }
328        self.changed.notify_all();
329    }
330
331    /// Waits until `done` holds for the counters and reader end, or `budget`
332    /// passes; reports whether it held.
333    ///
334    /// # Errors
335    /// Returns [`SdkError::Connection`] when the lock is poisoned.
336    pub fn wait_until(
337        &self,
338        budget: Duration,
339        done: impl Fn(&MailboxStats, Option<&ReaderEnd>) -> bool,
340    ) -> Result<bool, SdkError> {
341        let deadline = Instant::now().checked_add(budget);
342        let mut state = self.lock()?;
343        loop {
344            if done(&state.stats, state.end.as_ref()) {
345                return Ok(true);
346            }
347            let remaining = deadline.map_or(budget, |deadline| {
348                deadline.saturating_duration_since(Instant::now())
349            });
350            if remaining.is_zero() {
351                return Ok(false);
352            }
353            state = self
354                .changed
355                .wait_timeout(state, remaining)
356                .map_err(|_| poisoned())?
357                .0;
358        }
359    }
360
361    /// Counts one frame the reader read and set aside.
362    ///
363    /// # Errors
364    /// Returns [`SdkError::Connection`] when the lock is poisoned.
365    pub fn record_ignored_frame(&self) -> Result<(), SdkError> {
366        let mut state = self.lock()?;
367        state.stats.ignored_frames = state.stats.ignored_frames.saturating_add(1);
368        drop(state);
369        Ok(())
370    }
371
372    /// The counters as they stand.
373    ///
374    /// # Errors
375    /// Returns [`SdkError::Connection`] when the lock is poisoned.
376    pub fn stats(&self) -> Result<MailboxStats, SdkError> {
377        Ok(self.lock()?.stats)
378    }
379}
380
381/// Offers `item`, mapping the outcome onto the reader's loop: `None` to keep
382/// reading, or the end the reader must stop with.
383pub fn offer_or_end<T: MailboxItem>(mailbox: &Mailbox<T>, item: T) -> Option<ReaderEnd> {
384    match mailbox.offer(item) {
385        Ok(Offer::Accepted) => None,
386        Ok(Offer::Refused(overflow)) => Some(ReaderEnd::Failed(SdkError::from(overflow))),
387        Err(error) => Some(ReaderEnd::Failed(error)),
388    }
389}