Skip to main content

byteflow/scheduler/mailbox/
mod.rs

1//! Per-flow inbox: bounded hop queue + parked waiter (anti lost-wakeup).
2//!
3//! # Memory contract
4//!
5//! Unbounded `VecDeque` growth is not a capacity API — it is an OOM path
6//! when many flows share few workers. Every mailbox is constructed with a
7//! [`MailboxConfig`]: a validated [`MailboxCapacity`], a [`MailboxBytes`]
8//! budget, and an [`OverflowPolicy`]. Logical bound ≠ physical allocation;
9//! the queue grows geometrically up to the limit (see [`queue`]).
10//!
11//! Both bounds are load-bearing. A hop count alone stopped being a memory
12//! bound once hops could carry `Str` / `Bytes`: at the default capacity,
13//! 256 hops is ~12 KiB of scalars or ~256 MiB of 1 MiB blobs. Whichever
14//! bound is reached first refuses the hop, and [`MailboxFull::reason`] says
15//! which one it was.
16//!
17//! There is **no** `Block` policy. Blocking an OS worker on a full inbox
18//! would stall every other flow on that thread. Overflow is Reject /
19//! DropNewest / DropOldest; scheduler-level `WAITING_SEND` is a later
20//! phase.
21//!
22//! # Wake
23//!
24//! `park` and `push` share one mutex (lost-wakeup invariant — keep this
25//! comment and the race diagram). A hop that does not match a selective
26//! waiter is queued (if the bound allows) and the waiter stays parked.
27//! Overflow that **drops** a hop never produces [`Delivery::Handoff`].
28//!
29//! See `docs/mailbox.md`.
30
31mod capacity;
32mod metrics;
33mod policy;
34mod queue;
35
36use std::sync::Mutex;
37
38use crate::bytecode::Value;
39
40use super::error::RuntimeError;
41use super::process::Flow;
42use super::sync_lock;
43
44pub use capacity::{MailboxBytes, MailboxCapacity};
45pub use metrics::MailboxStats;
46pub use policy::{MailboxConfig, OverflowPolicy};
47
48use queue::{EnqueueEffect, MailboxQueue};
49
50/// Selective wait criterion installed while a flow is parked in its mailbox.
51///
52/// Used by classic `Receive`, `ReceiveMatch`, and `Ask`. Matching always
53/// **skips** (never drops) non-matching hops already in the queue.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub(crate) enum WaitFilter {
56    /// `Receive`: consume the oldest value regardless of contents.
57    Any,
58    /// `ReceiveMatch`: oldest `Message` whose application `tag` matches.
59    Tag(u16),
60    /// `Ask`: reply belonging to one specific request.
61    ///
62    /// `expect_request_id` is the RPC correlation key.
63    /// `expect_sender`, when present, additionally constrains reply origin.
64    /// Ask uses `Some(resolved_target_FlowId)` so a hop with the same
65    /// `request_id` from an unrelated flow cannot complete the RPC.
66    /// Compare against FlowId, never CapId (S2 after FlowCap).
67    Correlation {
68        expect_request_id: u64,
69        expect_sender: Option<u64>,
70    },
71}
72
73impl WaitFilter {
74    #[inline]
75    pub(crate) fn matches(&self, value: &Value) -> bool {
76        match *self {
77            Self::Any => true,
78            Self::Tag(expected_tag) => match value.as_message() {
79                Some(m) => m.tag == expected_tag,
80                None => false,
81            },
82            Self::Correlation {
83                expect_request_id,
84                expect_sender,
85            } => match value.as_message() {
86                Some(m) => {
87                    let id_ok = m.request_id == expect_request_id;
88                    let sender_ok = match expect_sender {
89                        Some(s) => m.sender == s,
90                        None => true,
91                    };
92                    id_ok && sender_ok
93                }
94                None => false,
95            },
96        }
97    }
98}
99
100/// A flow's inbox, plus (when the owning flow is blocked on
101/// `Receive` / `ReceiveMatch` / `Ask` with nothing to read) the parked flow itself.
102///
103/// # Why the flow lives *inside* its own mailbox while waiting
104///
105/// Internally, a flow is reachable through its [`FlowId`](crate::FlowId), which
106/// resolves (via the runtime's flow directory) to this `Mailbox`. Bytecode
107/// does **not** address by Pid anymore (FlowCap): `Send` / `Ask` resolve a
108/// Cap to a FlowId first, then look up here. Host [`crate::Runtime::send`]
109/// still uses FlowId directly (trusted).
110///
111/// Storing the blocked `Box<Flow>` directly in `MailboxInner::parked`, behind
112/// the same mutex that guards the message queue, turns "deliver a message and
113/// wake the receiver if it was waiting" into a single critical section — which
114/// is what actually prevents the classic lost-wakeup race:
115///
116/// ```text
117/// racing without a shared lock:
118///   receiver: queue.pop() -> None
119///   sender:   queue.push(msg); wake(receiver)   // receiver isn't parked yet!
120///   receiver: park()                            // ...and now sleeps forever
121///
122/// with both steps under one mutex (what this type does):
123///   receiver: lock; queue.pop() -> None; store self in `parked`; unlock
124///   sender:   lock; parked.take() -> Some(receiver); unlock; wake(receiver)
125/// ```
126/// Because "check the queue" and "become parked" happen atomically with
127/// respect to "push and check for a parked receiver", there is no window
128/// where a message can be pushed without either landing in the queue for a
129/// later `Receive` or immediately waking an already-parked one.
130///
131/// Wake only happens when a hop is **accepted and matches** the waiter.
132/// A flow that is already runnable (message queued, nobody parked) does
133/// not generate extra scheduler work — no wake storm on every `Send`.
134///
135/// # Selective wait (`ReceiveMatch` / `Ask`)
136///
137/// When parked with a selective (non-`Any`) filter, only a hop that
138/// satisfies the filter wakes the flow. Other hops are appended to the
139/// queue (subject to the bound) and the waiter stays parked (FIFO skip,
140/// never drop matching semantics).
141///
142/// # Bound
143///
144/// `push` may return [`MailboxFull`] when the policy is Reject and the
145/// logical capacity is already occupied **and** nobody matching is parked.
146/// A parked waiter that matches the hop still takes a **handoff** — that
147/// hop never occupies a queue slot.
148pub struct Mailbox {
149    inner: Mutex<MailboxInner>,
150    config: MailboxConfig,
151}
152
153struct MailboxInner {
154    queue: MailboxQueue,
155    parked: Option<Box<Flow>>,
156    /// Active while `parked` is `Some`. Ignored when nobody is waiting.
157    parked_filter: WaitFilter,
158    /// Bumped on every park install, so a deadline armed for an earlier
159    /// wait can be told apart from the current one. See [`WaitEpoch`].
160    wait_epoch: u64,
161    stats: MailboxStats,
162}
163
164/// Outcome of pushing a message.
165///
166/// `Queued*` means the hop (or a replacement under DropOldest) lives in
167/// the inbox for a later `Receive`. [`Handoff`](Delivery::Handoff) means a parked flow was
168/// waiting for **this** hop — the caller (`worker::deliver` /
169/// [`crate::Runtime::send`]) must `resume_with` and re-enqueue the flow.
170/// Dropped variants never wake a waiter.
171pub enum Delivery {
172    Queued,
173    QueuedDropOldest,
174    DroppedNewest,
175    Handoff(Box<Flow>),
176}
177
178/// Which of a mailbox's two bounds refused a hop.
179///
180/// Reported so an operator can tell "this flow is not draining its inbox"
181/// ([`Self::MessageLimit`]) from "this flow is being sent payloads too
182/// large for its budget" ([`Self::ByteLimit`]) without instrumenting the
183/// sender. Those have different fixes: raise capacity / speed up the
184/// receiver versus raise the byte budget / shrink the payload.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum MailboxFullReason {
187    /// [`MailboxCapacity`] hops are already queued.
188    MessageLimit,
189    /// Accepting the hop would exceed the [`MailboxBytes`] budget. Also
190    /// reported when a single hop is larger than the entire budget, which
191    /// no eviction policy can make room for.
192    ByteLimit,
193}
194
195impl std::fmt::Display for MailboxFullReason {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            MailboxFullReason::MessageLimit => write!(f, "hop count limit"),
199            MailboxFullReason::ByteLimit => write!(f, "byte budget"),
200        }
201    }
202}
203
204/// Inbox at one of its logical bounds under [`OverflowPolicy::Reject`].
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct MailboxFull {
207    reason: MailboxFullReason,
208}
209
210impl MailboxFull {
211    #[inline]
212    pub const fn reason(self) -> MailboxFullReason {
213        self.reason
214    }
215}
216
217impl std::fmt::Display for MailboxFull {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "mailbox full ({})", self.reason)
220    }
221}
222
223impl std::error::Error for MailboxFull {}
224
225/// Identifies **one specific park** of a flow in its mailbox.
226///
227/// # Why a timeout needs a token
228///
229/// Cancellation of a `ReceiveTimeout` deadline is lazy: the timer thread
230/// does not remove its entry when a hop wakes the receiver early, it just
231/// finds nobody parked when it eventually fires. That reasoning only holds
232/// if the flow never parks *again* before the old deadline — and in a
233/// receive loop it always does:
234///
235/// ```text
236///   t=0    ReceiveTimeout(r5, 100ms)  -> park A, timer(100ms) armed
237///   t=20   hop arrives                -> handoff, park A over, flow runs
238///   t=30   Receive(r7)                -> park B  (no timeout)
239///   t=100  timer for park A fires     -> takes park B!
240///                                        writes Unit into r5, not r7
241/// ```
242///
243/// That is a spurious wake *and* a write to the previous wait's register.
244/// So [`Mailbox::park`] returns the epoch of the park it installed, the
245/// timer carries it, and [`Mailbox::take_parked_at`] only hands the flow
246/// over while that epoch is still current.
247///
248/// There is no public constructor: an epoch can only come from parking,
249/// so a timeout cannot present one for a wait that never happened.
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
251pub struct WaitEpoch(u64);
252
253impl WaitEpoch {
254    /// Raw counter value, for logs and metrics only. Never compare epochs
255    /// from two different mailboxes: the counter is per-inbox.
256    #[inline]
257    pub const fn get(self) -> u64 {
258        self.0
259    }
260}
261
262impl std::fmt::Display for WaitEpoch {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        write!(f, "wait#{}", self.0)
265    }
266}
267
268impl Mailbox {
269    pub fn new() -> Self {
270        Self::with_config(MailboxConfig::DEFAULT)
271    }
272
273    pub fn with_config(config: MailboxConfig) -> Self {
274        Mailbox {
275            inner: Mutex::new(MailboxInner {
276                queue: MailboxQueue::new(config.capacity().get(), config.bytes().get()),
277                parked: None,
278                parked_filter: WaitFilter::Any,
279                wait_epoch: 0,
280                stats: MailboxStats::default(),
281            }),
282            config,
283        }
284    }
285
286    #[inline]
287    pub fn config(&self) -> MailboxConfig {
288        self.config
289    }
290
291    /// Snapshot of enqueue/dequeue/drop counters plus current occupancy
292    /// (taken under the mailbox lock, so the depth and the counters
293    /// describe the same instant).
294    pub fn stats(&self) -> Result<MailboxStats, RuntimeError> {
295        let inner = sync_lock::lock(&self.inner, "Mailbox::stats")?;
296        let mut stats = inner.stats;
297        stats.queued_messages = inner.queue.len();
298        stats.queued_bytes = inner.queue.bytes();
299        Ok(stats)
300    }
301
302    /// Push `value` under the mailbox config.
303    ///
304    /// 1. If a matching waiter is parked → [`Delivery::Handoff`] (does not
305    ///    consume a queue slot).
306    /// 2. Else enqueue / overflow according to [`OverflowPolicy`].
307    /// 3. [`MailboxFull`] only for Reject when the queue is already at one
308    ///    of its logical bounds (see [`MailboxFullReason`]) — plus the one
309    ///    case no policy can absorb: a hop larger than the whole byte
310    ///    budget.
311    ///
312    /// Mutex poison → [`RuntimeError`] (fail-closed).
313    pub fn push(&self, value: Value) -> Result<Result<Delivery, MailboxFull>, RuntimeError> {
314        let mut inner = sync_lock::lock(&self.inner, "Mailbox::push")?;
315        if let Some(flow) = inner.parked.take() {
316            if inner.parked_filter.matches(&value) {
317                inner.parked_filter = WaitFilter::Any;
318                inner.stats.dequeued = inner.stats.dequeued.saturating_add(1);
319                inner.stats.enqueued = inner.stats.enqueued.saturating_add(1);
320                return Ok(Ok(Delivery::Handoff(flow)));
321            }
322            inner.parked = Some(flow);
323            return Ok(enqueue_locked(
324                &mut inner,
325                value,
326                self.config.overflow(),
327            ));
328        }
329        Ok(enqueue_locked(
330            &mut inner,
331            value,
332            self.config.overflow(),
333        ))
334    }
335
336    /// Non-blocking pop of the front hop (classic `Receive`).
337    pub fn try_pop(&self) -> Result<Option<Value>, RuntimeError> {
338        self.try_pop_filter(WaitFilter::Any)
339    }
340
341    /// Non-blocking selective pop by application `tag` (`ReceiveMatch`).
342    pub fn try_pop_match(&self, tag: u16) -> Result<Option<Value>, RuntimeError> {
343        self.try_pop_filter(WaitFilter::Tag(tag))
344    }
345
346    /// Non-blocking pop under an arbitrary [`WaitFilter`] (FIFO skip).
347    pub(crate) fn try_pop_filter(
348        &self,
349        filter: WaitFilter,
350    ) -> Result<Option<Value>, RuntimeError> {
351        let mut inner = sync_lock::lock(&self.inner, "Mailbox::try_pop_filter")?;
352        let got = inner.queue.take(filter);
353        if got.is_some() {
354            inner.stats.dequeued = inner.stats.dequeued.saturating_add(1);
355        }
356        Ok(got)
357    }
358
359    /// Atomically re-check the queue and, if still empty, store `flow`
360    /// as parked (classic `Receive` — any hop wakes).
361    ///
362    /// Outer `Result` is infrastructure (mutex poison). Inner `Result` is
363    /// the lost-wakeup race: `Err(flow)` means a message arrived between
364    /// the worker's `try_pop` and this call — resume immediately with the
365    /// stashed pending message rather than parking forever.
366    ///
367    /// `Ok(Ok(epoch))` identifies the park that was installed. A caller
368    /// arming a `ReceiveTimeout` deadline must carry that [`WaitEpoch`] to
369    /// [`Mailbox::take_parked_at`], or a late deadline will wake whatever
370    /// wait happens to be current instead.
371    pub fn park(&self, flow: Box<Flow>) -> Result<Result<WaitEpoch, Box<Flow>>, RuntimeError> {
372        self.park_filter(flow, WaitFilter::Any)
373    }
374
375    /// Like [`park`](Self::park), but only a hop with `Message.tag == tag`
376    /// ends the wait. Non-matching hops already in the queue are left
377    /// untouched (FIFO skip).
378    pub fn park_match(
379        &self,
380        flow: Box<Flow>,
381        tag: u16,
382    ) -> Result<Result<WaitEpoch, Box<Flow>>, RuntimeError> {
383        self.park_filter(flow, WaitFilter::Tag(tag))
384    }
385
386    /// Park under an arbitrary filter. Re-checks the queue under the same
387    /// mutex before installing the waiter (anti lost-wakeup).
388    pub(crate) fn park_filter(
389        &self,
390        flow: Box<Flow>,
391        filter: WaitFilter,
392    ) -> Result<Result<WaitEpoch, Box<Flow>>, RuntimeError> {
393        let mut inner = sync_lock::lock(&self.inner, "Mailbox::park_filter")?;
394        if let Some(value) = inner.queue.take(filter) {
395            inner.stats.dequeued = inner.stats.dequeued.saturating_add(1);
396            drop(inner);
397            return Ok(Err(with_pending(flow, value)));
398        }
399        // Wrapping, not saturating: a saturated counter would make every
400        // later epoch compare equal, silently restoring the stale-deadline
401        // bug this exists to prevent. Reuse needs 2^64 parks on one inbox.
402        inner.wait_epoch = inner.wait_epoch.wrapping_add(1);
403        let epoch = WaitEpoch(inner.wait_epoch);
404        inner.parked_filter = filter;
405        inner.parked = Some(flow);
406        Ok(Ok(epoch))
407    }
408
409    /// Take the parked flow back out **only if** `epoch` is still the
410    /// current wait, used by the timer when a `ReceiveTimeout` deadline
411    /// fires.
412    ///
413    /// `None` means the deadline lost the race and must do nothing: either
414    /// a hop already woke that wait, or the flow has since parked on a
415    /// *different* `Receive` that this deadline does not own (see
416    /// [`WaitEpoch`]).
417    ///
418    /// The selective filter is reset only when the flow is actually taken.
419    /// Clearing it on a stale call would downgrade a live `ReceiveMatch` /
420    /// `Ask` waiter to "any hop wakes me".
421    pub fn take_parked_at(&self, epoch: WaitEpoch) -> Result<Option<Box<Flow>>, RuntimeError> {
422        let mut inner = sync_lock::lock(&self.inner, "Mailbox::take_parked_at")?;
423        if inner.wait_epoch != epoch.0 {
424            return Ok(None);
425        }
426        match inner.parked.take() {
427            Some(flow) => {
428                inner.parked_filter = WaitFilter::Any;
429                Ok(Some(flow))
430            }
431            None => Ok(None),
432        }
433    }
434}
435
436impl Default for Mailbox {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442fn enqueue_locked(
443    inner: &mut MailboxInner,
444    value: Value,
445    policy: OverflowPolicy,
446) -> Result<Delivery, MailboxFull> {
447    match inner.queue.enqueue(value, policy) {
448        Ok(EnqueueEffect::Enqueued) => {
449            inner.stats.enqueued = inner.stats.enqueued.saturating_add(1);
450            Ok(Delivery::Queued)
451        }
452        Ok(EnqueueEffect::DroppedOldest) => {
453            inner.stats.dropped_oldest = inner.stats.dropped_oldest.saturating_add(1);
454            inner.stats.enqueued = inner.stats.enqueued.saturating_add(1);
455            Ok(Delivery::QueuedDropOldest)
456        }
457        Ok(EnqueueEffect::DroppedNewest) => {
458            inner.stats.dropped_newest = inner.stats.dropped_newest.saturating_add(1);
459            Ok(Delivery::DroppedNewest)
460        }
461        Err(reason) => {
462            inner.stats.rejected = inner.stats.rejected.saturating_add(1);
463            if reason == MailboxFullReason::ByteLimit {
464                inner.stats.rejected_byte_limit =
465                    inner.stats.rejected_byte_limit.saturating_add(1);
466            }
467            Err(MailboxFull { reason })
468        }
469    }
470}
471
472/// Stashes a message that arrived just as we were about to park, so the
473/// worker loop can resume the flow with it on the very next step
474/// without re-entering the mailbox. See [`Mailbox::park`].
475fn with_pending(mut flow: Box<Flow>, value: Value) -> Box<Flow> {
476    flow.pending_message = Some(value);
477    flow
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::bytecode::Message;
484    use crate::scheduler::oneshot;
485    use crate::scheduler::process::{next_flow_id, RestartPolicy};
486    use crate::vm::{NativeTable, Vm};
487    use crate::ChunkBuilder;
488    use std::sync::Arc;
489
490    type TestResult = Result<(), Box<dyn std::error::Error>>;
491
492    fn dummy_flow() -> Result<Box<Flow>, Box<dyn std::error::Error>> {
493        let mut b = ChunkBuilder::new("mb");
494        b.begin_function("main", 0, 1);
495        b.emit_return(0);
496        let chunk = b.finish();
497        let vm = Vm::new(Arc::new(chunk), NativeTable::empty(), 0, &[])?;
498        let (tx, _rx) = oneshot::channel();
499        Ok(Box::new(Flow::new(
500            next_flow_id(),
501            vm,
502            Arc::new(Mailbox::new()),
503            RestartPolicy::Never,
504            tx,
505        )))
506    }
507
508    fn hop(sender: u64, request_id: u64, tag: u16, payload: u64) -> Value {
509        Value::Message(Message::new(sender, request_id, tag, payload))
510    }
511
512    fn msg(tag: u16, payload: u64) -> Value {
513        hop(1, 1, tag, payload)
514    }
515
516    fn tiny_reject(n: u32) -> Result<Mailbox, Box<dyn std::error::Error>> {
517        let cap = MailboxCapacity::new(n).ok_or("invalid mailbox capacity")?;
518        Ok(Mailbox::with_config(MailboxConfig::new(
519            cap,
520            OverflowPolicy::Reject,
521        )))
522    }
523
524    fn hop_msg(value: &Value) -> Result<Message, Box<dyn std::error::Error>> {
525        value.as_message().ok_or("expected Message hop".into())
526    }
527
528    #[test]
529    fn try_pop_match_skips_non_matching_fifo() -> TestResult {
530        let mb = Mailbox::new();
531        mb.push(msg(9, 1))??;
532        mb.push(msg(1, 42))??;
533        mb.push(msg(9, 2))??;
534        let got = mb.try_pop_match(1)?.ok_or("match")?;
535        assert_eq!(hop_msg(&got)?.payload, 42);
536        assert_eq!(
537            hop_msg(&mb.try_pop()?.ok_or("first leftover")?)?.tag,
538            9
539        );
540        assert_eq!(
541            hop_msg(&mb.try_pop()?.ok_or("second leftover")?)?.payload,
542            2
543        );
544        Ok(())
545    }
546
547    #[test]
548    fn push_while_park_match_queues_junk_keeps_waiter() -> TestResult {
549        let mb = Mailbox::new();
550        let flow = dummy_flow()?;
551        assert!(mb.park_match(flow, 1)?.is_ok());
552        assert!(matches!(mb.push(msg(9, 0))??, Delivery::Queued));
553        assert!(matches!(mb.push(msg(1, 7))??, Delivery::Handoff(_)));
554        assert_eq!(hop_msg(&mb.try_pop()?.ok_or("queued junk")?)?.tag, 9);
555        Ok(())
556    }
557
558    #[test]
559    fn ask_does_not_consume_reply_for_another_request() -> TestResult {
560        let mb = Mailbox::new();
561        mb.push(hop(10, 2, 2, 99))??;
562        mb.push(hop(10, 1, 2, 42))??;
563        let filter = WaitFilter::Correlation {
564            expect_request_id: 1,
565            expect_sender: Some(10),
566        };
567        let got = mb.try_pop_filter(filter)?.ok_or("id=1")?;
568        assert_eq!(hop_msg(&got)?.payload, 42);
569        let left = mb.try_pop()?.ok_or("leftover")?;
570        assert_eq!(hop_msg(&left)?.request_id, 2);
571        Ok(())
572    }
573
574    #[test]
575    fn ask_requires_reply_from_target() -> TestResult {
576        let mb = Mailbox::new();
577        let flow = dummy_flow()?;
578        let filter = WaitFilter::Correlation {
579            expect_request_id: 1,
580            expect_sender: Some(10),
581        };
582        assert!(mb.park_filter(flow, filter)?.is_ok());
583        assert!(matches!(mb.push(hop(99, 1, 2, 0))??, Delivery::Queued));
584        assert!(matches!(mb.push(hop(10, 1, 2, 42))??, Delivery::Handoff(_)));
585        assert_eq!(
586            hop_msg(&mb.try_pop()?.ok_or("non-matching queued")?)?.sender,
587            99
588        );
589        Ok(())
590    }
591
592    #[test]
593    fn reject_when_full_without_waiter() -> TestResult {
594        let mb = tiny_reject(1)?;
595        assert!(matches!(mb.push(msg(1, 1))??, Delivery::Queued));
596        // Bind the error: `Err(MailboxFull)` would introduce a *variable*
597        // named MailboxFull now that the type carries a reason, matching
598        // every error and asserting nothing.
599        match mb.push(msg(1, 2))? {
600            Err(full) => assert_eq!(full.reason(), MailboxFullReason::MessageLimit),
601            Ok(_) => return Err("expected the hop count bound to refuse".into()),
602        }
603        let s = mb.stats()?;
604        assert_eq!(s.enqueued, 1);
605        assert_eq!(s.rejected, 1);
606        assert_eq!(s.rejected_byte_limit, 0);
607        assert_eq!(s.queued_messages, 1);
608        Ok(())
609    }
610
611    /// Park `flow`, requiring that it actually parked, and hand back the
612    /// epoch. Collapses the two-level `Result` the tests do not care about.
613    fn park_now(mb: &Mailbox, flow: Box<Flow>) -> Result<WaitEpoch, Box<dyn std::error::Error>> {
614        match mb.park(flow)? {
615            Ok(epoch) => Ok(epoch),
616            Err(_) => Err("an empty mailbox should have parked the flow".into()),
617        }
618    }
619
620    fn handoff(mb: &Mailbox, value: Value) -> Result<Box<Flow>, Box<dyn std::error::Error>> {
621        match mb.push(value)?? {
622            Delivery::Handoff(flow) => Ok(flow),
623            other => Err(format!("expected a handoff, got {}", delivery_name(&other)).into()),
624        }
625    }
626
627    fn delivery_name(d: &Delivery) -> &'static str {
628        match d {
629            Delivery::Queued => "Queued",
630            Delivery::QueuedDropOldest => "QueuedDropOldest",
631            Delivery::DroppedNewest => "DroppedNewest",
632            Delivery::Handoff(_) => "Handoff",
633        }
634    }
635
636    #[test]
637    fn a_stale_deadline_cannot_steal_a_later_wait() -> TestResult {
638        let mb = Mailbox::new();
639        let first = park_now(&mb, dummy_flow()?)?;
640        // A hop beats the deadline: the handoff ends *this* wait.
641        let woken = handoff(&mb, msg(1, 1))?;
642        // The same flow parks again on a fresh `Receive`.
643        let second = park_now(&mb, woken)?;
644        assert_ne!(first, second, "each park must get its own epoch");
645
646        // The deadline armed for the first wait fires late. Before the
647        // epoch check it took this second wait, resumed the flow, and
648        // wrote Unit into the *first* wait's register.
649        assert!(mb.take_parked_at(first)?.is_none());
650        // The live wait is untouched, so its own deadline still works.
651        assert!(mb.take_parked_at(second)?.is_some());
652        Ok(())
653    }
654
655    #[test]
656    fn a_stale_deadline_does_not_downgrade_a_selective_waiter() -> TestResult {
657        let mb = Mailbox::new();
658        let first = park_now(&mb, dummy_flow()?)?;
659        let woken = handoff(&mb, msg(1, 1))?;
660        // Second wait is selective: only tag 7 may wake it.
661        let second = match mb.park_match(woken, 7)? {
662            Ok(epoch) => epoch,
663            Err(_) => return Err("empty mailbox should have parked the flow".into()),
664        };
665        assert_ne!(first, second);
666
667        assert!(mb.take_parked_at(first)?.is_none());
668        // The filter must survive the stale call: a tag-9 hop is queued,
669        // not handed off.
670        assert!(matches!(mb.push(msg(9, 0))??, Delivery::Queued));
671        // ...and tag 7 still wakes it.
672        assert!(matches!(mb.push(msg(7, 0))??, Delivery::Handoff(_)));
673        Ok(())
674    }
675
676    #[test]
677    fn a_deadline_for_a_wait_that_a_hop_ended_does_nothing() -> TestResult {
678        let mb = Mailbox::new();
679        let epoch = park_now(&mb, dummy_flow()?)?;
680        let _woken = handoff(&mb, msg(1, 1))?;
681        // Nobody is parked now; the deadline must be a no-op rather than
682        // reporting a flow it does not have.
683        assert!(mb.take_parked_at(epoch)?.is_none());
684        Ok(())
685    }
686
687    #[test]
688    fn byte_budget_refuses_before_the_hop_count_and_says_so() -> TestResult {
689        // 64 hop slots but a 1 KiB budget: blobs exhaust bytes first.
690        let cap = MailboxCapacity::new(64).ok_or("cap")?;
691        let budget = MailboxBytes::new(MailboxBytes::MIN).ok_or("bytes")?;
692        let mb = Mailbox::with_config(
693            MailboxConfig::new(cap, OverflowPolicy::Reject).with_bytes(budget),
694        );
695        assert!(matches!(mb.push(Value::bytes(vec![0u8; 900]))??, Delivery::Queued));
696        match mb.push(Value::bytes(vec![0u8; 900]))? {
697            Err(full) => assert_eq!(full.reason(), MailboxFullReason::ByteLimit),
698            Ok(_) => return Err("expected the byte budget to refuse".into()),
699        }
700        let s = mb.stats()?;
701        assert_eq!(s.queued_messages, 1);
702        assert!(s.queued_bytes >= 900);
703        assert_eq!(s.rejected, 1);
704        assert_eq!(s.rejected_byte_limit, 1);
705        Ok(())
706    }
707
708    #[test]
709    fn draining_a_hop_frees_its_byte_charge() -> TestResult {
710        let cap = MailboxCapacity::new(64).ok_or("cap")?;
711        let budget = MailboxBytes::new(MailboxBytes::MIN).ok_or("bytes")?;
712        let mb = Mailbox::with_config(
713            MailboxConfig::new(cap, OverflowPolicy::Reject).with_bytes(budget),
714        );
715        mb.push(Value::bytes(vec![0u8; 900]))??;
716        mb.try_pop()?.ok_or("queued blob")?;
717        assert_eq!(mb.stats()?.queued_bytes, 0);
718        // A receiver that keeps up must not be permanently throttled by a
719        // charge that was never refunded.
720        assert!(matches!(mb.push(Value::bytes(vec![0u8; 900]))??, Delivery::Queued));
721        Ok(())
722    }
723
724    #[test]
725    fn matching_handoff_does_not_count_as_full() -> TestResult {
726        let mb = tiny_reject(1)?;
727        mb.push(msg(9, 0))??;
728        let flow = dummy_flow()?;
729        assert!(mb.park_match(flow, 1)?.is_ok());
730        assert!(matches!(mb.push(msg(1, 7))??, Delivery::Handoff(_)));
731        assert_eq!(hop_msg(&mb.try_pop()?.ok_or("queued")?)?.tag, 9);
732        Ok(())
733    }
734
735    #[test]
736    fn drop_oldest_still_wakes_on_match() -> TestResult {
737        let cap = MailboxCapacity::new(1).ok_or("cap")?;
738        let mb = Mailbox::with_config(MailboxConfig::new(cap, OverflowPolicy::DropOldest));
739        let flow = dummy_flow()?;
740        assert!(mb.park_match(flow, 1)?.is_ok());
741        mb.push(msg(9, 1))??;
742        assert!(matches!(mb.push(msg(1, 2))??, Delivery::Handoff(_)));
743        Ok(())
744    }
745}