Skip to main content

liminal/channel/
subscription.rs

1//! LIM-002 R2/R3: subscriptions backed by real beamr processes.
2//!
3//! Each subscription owns a real, scheduler-supervised beamr native process
4//! (a [`SubscriberProcess`]) plus the in-memory inbox the channel actor delivers
5//! matching envelopes into. The channel actor LINKS to this process's pid on
6//! `Subscribe`; when the [`SubscriptionHandle`] is dropped (or the caller
7//! unsubscribes) the process is terminated, the link fires an `{EXIT, pid, _}`
8//! signal, and the trapping channel actor removes the dead subscriber from its
9//! fan-out list. There is NO weak-Arc polling: liveness is observed structurally
10//! through the beamr link/EXIT path, exactly as the conversation actor observes
11//! its participants (`conversation/actor/beam.rs`).
12//!
13//! R3 predicates live INSIDE the channel actor process: a [`SubscriptionPredicate`]
14//! is a boxed `Fn(&Envelope) -> bool` owned by the actor's subscriber
15//! registration and evaluated at delivery time. This mirrors the participant
16//! `behaviour` pattern (a boxed trait object the process owns); for an in-memory
17//! ephemeral channel there is no need for a serialisable predicate, so a closure
18//! the actor holds is the simplest faithful design.
19
20use std::collections::VecDeque;
21use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
22use std::sync::{Arc, Mutex};
23
24use beamr::native::native_process::{NativeContext, NativeHandler, NativeOutcome};
25use beamr::process::ExitReason;
26use beamr::scheduler::Scheduler;
27use beamr::term::binary_ref::BinaryRef;
28
29use crate::channel::wire::{decode_envelope, encode_envelope};
30use crate::envelope::Envelope;
31use crate::error::LiminalError;
32
33/// A shared, cloneable in-memory inbox a subscriber receives delivered envelopes
34/// on. See [`SubscriptionInbox`].
35pub(crate) type SubscriberInbox = Arc<SubscriptionInbox>;
36
37/// A wake callback fired on EVERY envelope admitted to the inbox (R3, §1.2(2)).
38///
39/// The server installs one that fires the CONNECTION scheduler's `READY` marker,
40/// so a publish into a parked connection's inbox wakes it. It is called from the
41/// PUBLISHING actor's slice (the channel actor for local delivery, the subscriber
42/// process for a remote frame), so it must be cheap and non-blocking — a single
43/// `enqueue_atom_message`. `None` (no notifier installed) is the standalone
44/// library / test case: nothing to wake, delivery still lands in the inbox.
45pub type InboxNotifier = Arc<dyn Fn() + Send + Sync>;
46
47/// One shared inbox-byte budget per connection (§5).
48///
49/// Spent across ALL that connection's subscription inboxes. The accounting unit is
50/// serialized envelope bytes AS ADMITTED — charged at enqueue, released at dequeue
51/// — so the signed 4 MiB product is exact and envelope-size-independent, not a
52/// per-inbox count bounding a variable the design does not control.
53#[derive(Debug)]
54pub struct ConnectionInboxBudget {
55    used: AtomicUsize,
56    cap: usize,
57}
58
59impl ConnectionInboxBudget {
60    /// Creates a shared budget with `cap` bytes of headroom across all the
61    /// connection's inboxes.
62    #[must_use]
63    pub fn new(cap: usize) -> Arc<Self> {
64        Arc::new(Self {
65            used: AtomicUsize::new(0),
66            cap,
67        })
68    }
69
70    /// Attempts to charge `bytes`. Returns `true` and reserves the bytes when they
71    /// fit within the remaining budget, `false` (reserving nothing) on overflow. A
72    /// CAS loop keeps the reservation exact under concurrent charges from several
73    /// inboxes — no transient over-charge is ever observable.
74    fn try_charge(&self, bytes: usize) -> bool {
75        let mut current = self.used.load(Ordering::Acquire);
76        loop {
77            let Some(projected) = current.checked_add(bytes) else {
78                return false;
79            };
80            if projected > self.cap {
81                return false;
82            }
83            match self.used.compare_exchange_weak(
84                current,
85                projected,
86                Ordering::AcqRel,
87                Ordering::Acquire,
88            ) {
89                Ok(_) => return true,
90                Err(observed) => current = observed,
91            }
92        }
93    }
94
95    /// Releases `bytes` previously charged (at dequeue). Saturating so a double
96    /// release can never wrap the counter below zero.
97    fn release(&self, bytes: usize) {
98        let mut current = self.used.load(Ordering::Acquire);
99        loop {
100            let next = current.saturating_sub(bytes);
101            match self.used.compare_exchange_weak(
102                current,
103                next,
104                Ordering::AcqRel,
105                Ordering::Acquire,
106            ) {
107                Ok(_) => return,
108                Err(observed) => current = observed,
109            }
110        }
111    }
112
113    /// Bytes currently reserved across the connection's inboxes.
114    #[cfg(test)]
115    pub(crate) fn used(&self) -> usize {
116        self.used.load(Ordering::Acquire)
117    }
118}
119
120/// Everything a server connection installs onto a subscription's inbox.
121///
122/// Carries the shared §5 byte budget, the per-inbox fairness cap, and the R3
123/// wake notifier. Passed INTO the subscribe call so the installation happens at
124/// inbox construction — strictly BEFORE the registration is published to the
125/// channel actor — closing the pre-install window in which envelopes could be
126/// admitted uncharged or without a wake.
127pub struct InboxInstall {
128    /// Shared per-connection byte budget (§5).
129    pub budget: Arc<ConnectionInboxBudget>,
130    /// Per-inbox envelope-count fairness trip (§5).
131    pub depth_cap: usize,
132    /// R3 wake notifier fired on every envelope admitted to the inbox. `None`
133    /// when the caller has no waker (scheduler-free unit tests).
134    pub notifier: Option<InboxNotifier>,
135}
136
137impl std::fmt::Debug for InboxInstall {
138    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        formatter
140            .debug_struct("InboxInstall")
141            .field("depth_cap", &self.depth_cap)
142            .field("has_notifier", &self.notifier.is_some())
143            .finish_non_exhaustive()
144    }
145}
146
147/// Mutable inbox state guarded by one lock: the queued envelopes (each carrying
148/// the exact bytes CHARGED for it, so release is symmetric with charge), the
149/// installed shared budget and per-inbox fairness cap, the wake notifier, and
150/// the closed marker.
151struct InboxState {
152    /// Each entry is `(envelope, charged_bytes)` — the amount actually charged to
153    /// the shared budget at enqueue (0 when no budget was installed at admit
154    /// time), released verbatim at dequeue/close. Storing the CHARGE, not the
155    /// size, makes release byte-identical to charge on every entry even across a
156    /// budget install, so the budget can never under- or over-release.
157    queue: VecDeque<(Envelope, usize)>,
158    /// Shared per-connection byte budget (§5). `None` = unbounded (standalone
159    /// library use / tests), preserving the pre-bounding behaviour exactly.
160    budget: Option<Arc<ConnectionInboxBudget>>,
161    /// Per-inbox envelope-count secondary fairness trip (§5). `usize::MAX` = off;
162    /// stops one subscription starving its siblings inside the shared byte budget.
163    depth_cap: usize,
164    /// Wake callback (R3). `None` until the connection installs one.
165    notifier: Option<InboxNotifier>,
166    /// Terminal marker set by [`SubscriptionInbox::close`]: admissions are refused
167    /// WITHOUT charging, and all queued charges have been released. Closing is the
168    /// release-by-construction seam — every teardown path (explicit unsubscribe,
169    /// overflow shed, connection teardown, and the `Drop` backstop) funnels
170    /// through it, so queued bytes can never be stranded on the connection-lifetime
171    /// budget.
172    closed: bool,
173}
174
175/// The shared subscription inbox (R3 + §5). Replaces the bare
176/// `Arc<Mutex<VecDeque<Envelope>>>`: it fires a wake notifier on every admitted
177/// envelope and enforces the connection-scoped byte budget plus the per-inbox
178/// fairness trip, shedding the offending subscription on overflow.
179pub(crate) struct SubscriptionInbox {
180    state: Mutex<InboxState>,
181    /// Sticky overflow marker: set when an admission is refused by the byte budget
182    /// or the fairness trip. The server-side delivery pump observes it and sheds
183    /// this subscription with a typed error frame, mirroring the outbound overflow
184    /// policy (a slow consumer sheds its own subscription; it cannot grow server
185    /// memory without bound). Sticky (never cleared) because a shed is terminal.
186    overflowed: AtomicBool,
187}
188
189impl std::fmt::Debug for SubscriptionInbox {
190    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        formatter
192            .debug_struct("SubscriptionInbox")
193            .field("overflowed", &self.overflowed.load(Ordering::Acquire))
194            .finish_non_exhaustive()
195    }
196}
197
198/// Why an inbox admission was refused (§5). The budget/fairness refusals set the
199/// sticky overflow marker and drop the envelope rather than growing memory; a
200/// closed inbox refuses without charging and without marking.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub(crate) enum InboxAdmission {
203    /// The envelope was admitted (and, when it made the inbox non-empty, the wake
204    /// notifier was fired).
205    Admitted,
206    /// The shared connection byte budget (§5) had no room; the subscription is
207    /// shed.
208    BudgetExceeded,
209    /// The per-inbox fairness trip (§5) is full; the subscription is shed.
210    FairnessTripped,
211    /// The inbox was closed (unsubscribe/shed/teardown): the envelope is dropped
212    /// without charging the budget — a closed inbox can never re-accumulate cost.
213    Closed,
214}
215
216impl SubscriptionInbox {
217    /// Creates an unbounded, notifier-less inbox — the standalone/default shape,
218    /// byte-identical to the pre-bounding behaviour. A server connection passes an
219    /// [`InboxInstall`] through subscribe so budget/cap/notifier are installed at
220    /// construction instead.
221    pub(crate) fn new() -> Arc<Self> {
222        Arc::new(Self {
223            state: Mutex::new(InboxState {
224                queue: VecDeque::new(),
225                budget: None,
226                depth_cap: usize::MAX,
227                notifier: None,
228                closed: false,
229            }),
230            overflowed: AtomicBool::new(false),
231        })
232    }
233
234    /// Installs the connection's shared byte budget and per-inbox fairness cap
235    /// (§5). Runs at inbox construction (via [`InboxInstall`]) — before the
236    /// registration is published to the channel actor — so no envelope can be
237    /// admitted uncharged.
238    pub(crate) fn install_budget(&self, budget: Arc<ConnectionInboxBudget>, depth_cap: usize) {
239        if let Ok(mut state) = self.state.lock() {
240            state.budget = Some(budget);
241            state.depth_cap = depth_cap;
242        }
243    }
244
245    /// Installs the wake notifier (R3), fired on every admitted envelope,
246    /// capturing the connection scheduler's enqueue handle (§1.2(2)).
247    ///
248    /// Defensive invariant: the install RECHECKS non-emptiness under the lock and
249    /// fires the notifier (outside the lock) when envelopes are already queued —
250    /// those envelopes were admitted while there was no notifier to fire, so
251    /// without this recheck their wake would be lost to install ordering. On the
252    /// normal construction path the queue is empty and this is a no-op.
253    pub(crate) fn install_notifier(&self, notifier: InboxNotifier) {
254        let fire = {
255            let Ok(mut state) = self.state.lock() else {
256                return;
257            };
258            let pending = !state.queue.is_empty();
259            let handle = notifier.clone();
260            state.notifier = Some(notifier);
261            pending.then_some(handle)
262        };
263        if let Some(notifier) = fire {
264            notifier();
265        }
266    }
267
268    /// Admits `envelope` under the byte budget and fairness trip, charging the
269    /// serialized bytes as admitted and firing the wake notifier for EVERY
270    /// admitted envelope (level-triggered — see the fire site below for why the
271    /// edge-triggered form starved a subscriber that fell more than one delivery
272    /// slice behind). On budget/fairness refusal the sticky overflow marker is set
273    /// and the envelope dropped (memory never grows past the bound); a closed
274    /// inbox refuses without charging or marking.
275    ///
276    /// The notifier fires OUTSIDE the state lock so the publishing actor's slice
277    /// never holds the inbox lock across the scheduler enqueue.
278    pub(crate) fn admit(&self, envelope: Envelope) -> InboxAdmission {
279        // Serialize once, before the lock: the admitted byte count is the wire
280        // size (§5 denomination). The entry stores the amount actually CHARGED
281        // (0 when no budget is installed), so dequeue/close releases exactly
282        // what enqueue charged.
283        let bytes = encode_envelope(&envelope).len();
284        let notifier = {
285            let Ok(mut state) = self.state.lock() else {
286                // A poisoned inbox lock is terminal for this subscription; treat it
287                // as a shed rather than silently dropping into a dead inbox.
288                self.overflowed.store(true, Ordering::Release);
289                return InboxAdmission::BudgetExceeded;
290            };
291            if state.closed {
292                return InboxAdmission::Closed;
293            }
294            if state.queue.len() >= state.depth_cap {
295                self.overflowed.store(true, Ordering::Release);
296                let notifier = state.notifier.clone();
297                drop(state);
298                if let Some(notifier) = notifier {
299                    notifier();
300                }
301                return InboxAdmission::FairnessTripped;
302            }
303            let charged = match state.budget.as_ref() {
304                Some(budget) => {
305                    if !budget.try_charge(bytes) {
306                        self.overflowed.store(true, Ordering::Release);
307                        let notifier = state.notifier.clone();
308                        drop(state);
309                        if let Some(notifier) = notifier {
310                            notifier();
311                        }
312                        return InboxAdmission::BudgetExceeded;
313                    }
314                    bytes
315                }
316                None => 0,
317            };
318            state.queue.push_back((envelope, charged));
319            // LEVEL-TRIGGERED, not edge-triggered: EVERY successful enqueue fires.
320            //
321            // The consumer drains a BOUNDED slice (the server's delivery pump: 32
322            // envelopes per connection slice), so an inbox more than a slice deep
323            // does NOT empty when it is serviced. Under the edge rule a subscriber
324            // in exactly that state — the normal state of anyone who has fallen
325            // behind — earned one wake for an entire burst and none afterwards,
326            // and every later envelope arrived with no wake attached to it at all.
327            // That is a lost-wake hazard on its face, and it is removed here.
328            //
329            // HONEST SCOPE, measured — this is NOT what starves a subscriber at
330            // today's bytes, and it must not be cited as if it were. A/B over 120
331            // fresh-boot iterations per arm (gate-logs/p0-55/) found the edge and
332            // level forms indistinguishable: 51.7% vs 53.3% of boots lost a
333            // subscriber to the depth-cap shed. The reason is R6 coalescing
334            // itself. N fires collapse into one mailbox drain, so turning one wake
335            // into N cannot buy the connection a single extra SLICE, and slices —
336            // not wakes — are what drain the queue. The variable that does move it
337            // is the pump's per-slice budget (32 -> 256 took the same harness to
338            // 0/120), which is a cross-connection fairness knob and not this
339            // file's to turn. See the report accompanying this lane.
340            //
341            // What firing every time costs: one non-blocking `enqueue_atom_message`
342            // per admitted envelope, which R6 coalescing collapses to one slice.
343            // An idle inbox admits nothing and so still fires nothing — the
344            // zero-cost-at-rest property is unchanged.
345            state.notifier.clone()
346        };
347        if let Some(notifier) = notifier {
348            notifier();
349        }
350        InboxAdmission::Admitted
351    }
352
353    /// Removes and returns the next envelope, releasing its CHARGED bytes back to
354    /// the shared budget (exact charge/release symmetry).
355    pub(crate) fn pop(&self) -> Option<Envelope> {
356        let (envelope, charged, budget) = {
357            let mut state = self.state.lock().ok()?;
358            let (envelope, charged) = state.queue.pop_front()?;
359            (envelope, charged, state.budget.clone())
360        };
361        // Release the charged bytes AFTER dropping the state lock so the shared
362        // budget's atomic is never touched while the inbox lock is held.
363        if let Some(budget) = budget {
364            budget.release(charged);
365        }
366        Some(envelope)
367    }
368
369    /// Non-consuming race-barrier query used after a connection arms readiness.
370    pub(crate) fn has_pending(&self) -> bool {
371        self.state.lock().is_ok_and(|state| !state.queue.is_empty())
372    }
373
374    /// Atomically closes the inbox, releasing every queued charge back to the
375    /// shared budget: under the lock it marks the inbox closed, drains all
376    /// entries, and detaches the notifier and budget; the summed release happens
377    /// outside the lock. Idempotent. Admissions after close are refused without
378    /// charging ([`InboxAdmission::Closed`]).
379    ///
380    /// This is the release-by-construction seam: explicit unsubscribe, overflow
381    /// shed, and connection teardown ALL reach it through the subscription
382    /// handle's drop (see [`SubscriptionInner::drop`]), and the inbox's own `Drop`
383    /// is the final backstop — no teardown path can strand queued bytes on the
384    /// connection-lifetime budget.
385    pub(crate) fn close(&self) {
386        let (released, budget) = {
387            let Ok(mut state) = self.state.lock() else {
388                return;
389            };
390            if state.closed {
391                return;
392            }
393            state.closed = true;
394            let released: usize = state
395                .queue
396                .drain(..)
397                .map(|(_envelope, charged)| charged)
398                .sum();
399            state.notifier = None;
400            (released, state.budget.take())
401        };
402        if let Some(budget) = budget {
403            budget.release(released);
404        }
405    }
406
407    /// Whether this subscription has been marked for shedding by an overflow.
408    pub(crate) fn is_overflowed(&self) -> bool {
409        self.overflowed.load(Ordering::Acquire)
410    }
411
412    /// Number of queued envelopes (test observability).
413    #[cfg(test)]
414    pub(crate) fn len(&self) -> usize {
415        self.state.lock().map_or(0, |state| state.queue.len())
416    }
417}
418
419impl Drop for SubscriptionInbox {
420    fn drop(&mut self) {
421        // Backstop: if no teardown path ever called `close`, release the queued
422        // charges here so the last Arc dropping can never strand budget bytes.
423        // Idempotent against an earlier close (the closed marker short-circuits).
424        self.close();
425    }
426}
427
428/// A delivery predicate evaluated by the channel actor against each published
429/// envelope. `None` (no predicate) means deliver everything.
430pub(crate) type SubscriptionPredicate = Arc<dyn Fn(&Envelope) -> bool + Send + Sync>;
431
432/// Real beamr native process backing one subscription.
433///
434/// For LOCAL delivery it is an idle handler (mirroring
435/// `aion::worker::link::IdleWorkerProcess`): local envelopes travel through the
436/// shared [`SubscriberInbox`] the channel actor writes and
437/// [`SubscriptionHandle::try_next`] reads. Its other job is to BE a first-class
438/// linkable, killable process whose lifetime equals the subscription's, so the
439/// channel actor detects the subscription dying via a real EXIT signal rather
440/// than by polling a weak pointer.
441///
442/// For CROSS-NODE delivery (SRV-005) it is also the landing point for a remote
443/// publish: a remote node sends a published envelope, encoded by
444/// [`crate::channel::wire::encode_envelope`], as a single beamr binary directly
445/// to this process's pid (the pid the cluster registered in the channel's
446/// distributed process group). The binary lands in this process's mailbox; the
447/// handler decodes it back into an [`Envelope`] and pushes it onto the SAME
448/// inbox a local publish would, so a subscriber observes local and remote
449/// messages identically. Non-binary wakeups (trapped `{EXIT, _, _}` signals) are
450/// drained and ignored.
451struct SubscriberProcess {
452    inbox: SubscriberInbox,
453}
454
455impl NativeHandler for SubscriberProcess {
456    fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
457        // Trapping is set authoritatively at spawn (see `SubscriptionHandle::spawn`)
458        // so it holds before the actor ever links — re-assert it here defensively
459        // for any future restart of this handler.
460        ctx.set_trap_exit(true);
461        // Drain every queued wakeup. A binary message is a remote envelope frame
462        // (SRV-005) to decode and enqueue; everything else (e.g. a trapped
463        // `{EXIT, _, _}` tuple from a crashed actor this subscriber outlives) is
464        // ignored. Death is driven only by an explicit `terminate_process` on
465        // unsubscribe/handle drop.
466        while let Some(message) = ctx.recv() {
467            if BinaryRef::new(message).is_some() {
468                // beamr 0.20.0 ties byte access to a `HeapBorrow` witness, and
469                // `NativeContext` exposes no borrow of the process heap, so the
470                // frame is deep-copied into ETS-owned storage and read under
471                // the copy's own witness — the one borrow-correct path a
472                // native handler has (beamr#36 tracks a direct accessor). A
473                // frame whose copy fails is dropped, same as one that fails to
474                // decode: a corrupt cross-node payload must never crash the
475                // subscriber or stall delivery of well-formed messages.
476                if let Ok(owned) = beamr::ets::copy_term_to_ets(message)
477                    && let Some(binary) = BinaryRef::new(owned.root())
478                {
479                    self.accept_remote_frame(binary.as_bytes(owned.borrow_terms()));
480                }
481            }
482        }
483        NativeOutcome::Wait
484    }
485}
486
487impl SubscriberProcess {
488    /// Decode a remote envelope frame and push it onto the inbox. A frame that
489    /// fails to decode is dropped: a corrupt cross-node payload must never crash
490    /// the subscriber or stall delivery of well-formed messages.
491    fn accept_remote_frame(&self, bytes: &[u8]) {
492        let Ok(envelope) = decode_envelope(bytes) else {
493            return;
494        };
495        // R3: the remote-delivery leg fires the same wake notifier and obeys the
496        // same §5 byte budget as the local leg. An overflow marks the subscription
497        // for shedding (inside `admit`); the frame is dropped rather than growing
498        // server memory.
499        self.inbox.admit(envelope);
500    }
501}
502
503/// The actor-side record of one subscriber: the inbox to deliver into and the
504/// optional predicate to gate delivery. Held by the channel actor INSIDE its
505/// process, keyed by the subscriber process's pid.
506pub(crate) struct SubscriberRegistration {
507    pid: u64,
508    inbox: SubscriberInbox,
509    predicate: Option<SubscriptionPredicate>,
510}
511
512impl SubscriberRegistration {
513    pub(crate) const fn pid(&self) -> u64 {
514        self.pid
515    }
516
517    /// Delivers `envelope` to this subscriber when its predicate accepts it (or
518    /// it has no predicate). Returns `true` when the envelope was pushed onto
519    /// the inbox, `false` when a predicate filtered it out, the inbox refused it
520    /// (§5 overflow — the subscription is then marked for shedding), or the inbox
521    /// is closed. The boolean lets the channel actor count genuine deliveries for
522    /// the delivery-ack signal.
523    pub(crate) fn deliver(&self, envelope: &Envelope) -> bool {
524        if let Some(predicate) = self.predicate.as_ref() {
525            if !predicate(envelope) {
526                return false;
527            }
528        }
529        // R3 + §5: admission charges the connection byte budget, fires the wake
530        // notifier for every admitted envelope, and — on overflow — marks the
531        // subscription for shedding (the server pump sheds it with a typed error
532        // frame). An overflowed envelope is NOT counted as a genuine delivery, so
533        // the delivery-ack signal reflects only envelopes that entered the inbox.
534        matches!(self.inbox.admit(envelope.clone()), InboxAdmission::Admitted)
535    }
536}
537
538impl std::fmt::Debug for SubscriberRegistration {
539    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540        formatter
541            .debug_struct("SubscriberRegistration")
542            .field("pid", &self.pid)
543            .field("has_predicate", &self.predicate.is_some())
544            .finish_non_exhaustive()
545    }
546}
547
548/// Handle returned by channel subscriptions for receiving validated envelopes.
549///
550/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
551/// scheduler so the process can be terminated when the subscription ends. The
552/// handle is the subscription's lifetime: dropping the last clone terminates the
553/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
554#[derive(Clone)]
555pub struct SubscriptionHandle {
556    inner: Arc<SubscriptionInner>,
557}
558
559struct SubscriptionInner {
560    pid: u64,
561    inbox: SubscriberInbox,
562    scheduler: Arc<Scheduler>,
563}
564
565impl SubscriptionHandle {
566    /// Spawns a real subscriber process on `scheduler` and returns the handle
567    /// plus its actor-side registration record (carrying any predicate).
568    ///
569    /// # Errors
570    /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
571    /// spawn the subscriber process.
572    pub(crate) fn spawn(
573        scheduler: &Arc<Scheduler>,
574        predicate: Option<SubscriptionPredicate>,
575        install: Option<InboxInstall>,
576    ) -> Result<(Self, SubscriberRegistration), LiminalError> {
577        let inbox: SubscriberInbox = SubscriptionInbox::new();
578        // Install the §5 budget/fairness cap and the R3 wake notifier AT
579        // CONSTRUCTION — strictly before the registration is handed to the
580        // channel actor — so there is no window in which a publish can be
581        // admitted uncharged, past the depth cap, or without a wake.
582        if let Some(install) = install {
583            inbox.install_budget(install.budget, install.depth_cap);
584            if let Some(notifier) = install.notifier {
585                inbox.install_notifier(notifier);
586            }
587        }
588        let process_inbox = Arc::clone(&inbox);
589        let factory = Box::new(move || {
590            Box::new(SubscriberProcess {
591                inbox: Arc::clone(&process_inbox),
592            }) as Box<dyn NativeHandler>
593        });
594        // trap_exit is set on the process BEFORE it is published as runnable
595        // (0.16.1 `spawn_native_trap_exit` — pre-runnable by construction), so
596        // an abnormal channel-actor crash is trapped (delivered as a message
597        // the subscriber drains) instead of cascading across the link and
598        // killing the subscriber. This makes the subscriber outlive a
599        // channel-actor crash so the restarted actor can re-link to it on boot
600        // (R2/R4): the flag is in place before the process's first slice, with
601        // no post-spawn window for `set_trap_exit` to race (or return
602        // `NoCaller` against a mid-first-slice process).
603        let pid = scheduler.spawn_native_trap_exit(factory).map_err(|error| {
604            LiminalError::SubscriptionFailed {
605                message: format!("failed to spawn subscriber process: {error:?}"),
606            }
607        })?;
608        let handle = Self {
609            inner: Arc::new(SubscriptionInner {
610                pid,
611                inbox: Arc::clone(&inbox),
612                scheduler: Arc::clone(scheduler),
613            }),
614        };
615        let registration = SubscriberRegistration {
616            pid,
617            inbox,
618            predicate,
619        };
620        Ok((handle, registration))
621    }
622
623    /// Returns the beamr pid of the subscriber process this handle owns.
624    #[must_use]
625    pub(crate) fn pid(&self) -> u64 {
626        self.inner.pid
627    }
628
629    /// Attempts to receive the next delivered envelope without blocking.
630    ///
631    /// # Errors
632    ///
633    /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
634    pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
635        // Dequeue releases the envelope's admitted bytes back to the shared
636        // connection budget (§5 charge/release symmetry).
637        Ok(self.inner.inbox.pop())
638    }
639
640    /// Whether an envelope is available without consuming it.
641    #[must_use]
642    pub fn has_pending(&self) -> bool {
643        self.inner.inbox.has_pending()
644    }
645
646    /// Whether an overflow has marked this subscription for shedding (§5).
647    #[must_use]
648    pub fn is_overflowed(&self) -> bool {
649        self.inner.inbox.is_overflowed()
650    }
651}
652
653impl std::fmt::Debug for SubscriptionHandle {
654    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655        formatter
656            .debug_struct("SubscriptionHandle")
657            .field("pid", &self.inner.pid)
658            .finish_non_exhaustive()
659    }
660}
661
662impl Drop for SubscriptionInner {
663    fn drop(&mut self) {
664        // Close the inbox FIRST: atomically mark it closed, drain queued entries,
665        // and release every charged byte back to the shared connection budget
666        // (§5 — queued bytes must never be stranded on the connection-lifetime
667        // budget by unsubscribe, shed, or teardown; all of them funnel through
668        // this drop). Post-close deliveries from the channel actor (whose EXIT
669        // prune below is asynchronous) are refused without charging.
670        self.inbox.close();
671        // Terminating the subscriber process fires the bidirectional link to the
672        // channel actor, which traps the EXIT and removes this subscriber from
673        // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
674        self.scheduler
675            .terminate_process(self.pid, ExitReason::Normal);
676    }
677}
678
679/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
680/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
681///
682/// This proves the production subscriber handler — the same `NativeHandler` the
683/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
684/// cooperative scheduler that a browser host drives. There is no toy stand-in:
685/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
686/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
687/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
688/// handler's own `accept_remote_frame` path and lands in the shared inbox a
689/// [`SubscriptionHandle::try_next`] would read.
690///
691/// The handler runs cooperatively AS-IS: its `handle` only touches
692/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
693/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
694/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
695/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
696/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
697/// native.
698#[cfg(test)]
699#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
700mod cooperative_smoke {
701    use std::cell::RefCell;
702    use std::rc::Rc;
703    use std::sync::Arc;
704
705    use beamr::atom::AtomTable;
706    use beamr::ets::copy_term_to_ets;
707    use beamr::module::ModuleRegistry;
708    use beamr::native::BifRegistryImpl;
709    use beamr::process::heap::Heap;
710    use beamr::scheduler::WasmScheduler;
711    use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
712
713    use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
714    use crate::channel::SchemaId;
715    use crate::channel::wire::encode_envelope;
716    use crate::envelope::{Envelope, PublisherId};
717
718    /// Build a cooperative scheduler the way a wasm host holds it (single
719    /// `Rc<RefCell<…>>` on one thread).
720    fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
721        let atom_table = Arc::new(AtomTable::with_common_atoms());
722        let modules = Arc::new(ModuleRegistry::new());
723        let bifs = Arc::new(BifRegistryImpl::new());
724        Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
725    }
726
727    /// Encode `envelope` into the production wire frame and wrap it as a
728    /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
729    /// remote node hands a published frame to a subscriber pid (SRV-005).
730    fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
731        let bytes = encode_envelope(envelope);
732        let shared = SharedBinary::new(bytes);
733        // A ProcBin reference needs three heap words; copy it into ETS-owned
734        // memory so the scratch heap can be dropped before delivery.
735        let mut scratch = Heap::new(8);
736        let words = scratch
737            .alloc_slice(3)
738            .expect("scratch heap holds a proc-bin reference");
739        let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
740        copy_term_to_ets(term).expect("frame copies into an owned binary")
741    }
742
743    fn sample_envelope() -> Envelope {
744        // A whole-millisecond timestamp so the round-trip through the wire codec
745        // (which carries millisecond resolution, see `channel::wire`) is exact;
746        // `Utc::now()` sub-millisecond precision would otherwise be truncated on
747        // decode and is irrelevant to what this smoke proves.
748        let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
749            .single()
750            .expect("valid fixed millisecond timestamp");
751        Envelope::with_timestamp(
752            b"{\"value\":42}".to_vec(),
753            None,
754            SchemaId::new(),
755            PublisherId::from("publisher-cooperative"),
756            timestamp,
757        )
758    }
759
760    #[test]
761    fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
762        let scheduler = cooperative_scheduler();
763
764        // The shared inbox the subscriber pushes decoded envelopes onto — the
765        // exact channel the threaded `SubscriptionHandle::try_next` reads.
766        let inbox: SubscriberInbox = SubscriptionInbox::new();
767        let process_inbox = Arc::clone(&inbox);
768
769        // Spawn the GENUINE production subscriber handler as a first-class native
770        // process on the cooperative scheduler.
771        let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
772            Box::new(SubscriberProcess {
773                inbox: Arc::clone(&process_inbox),
774            }) as Box<dyn beamr::native::native_process::NativeHandler>
775        }));
776
777        // First turn: the handler runs once, asserts trap_exit, finds an empty
778        // mailbox, and parks (`Wait`). No envelope has been delivered yet.
779        scheduler.borrow_mut().run_until_idle();
780        assert_eq!(
781            inbox.len(),
782            0,
783            "no envelope is delivered before one is published"
784        );
785
786        // Publish: deliver a real encoded frame as a beamr binary straight to the
787        // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
788        // the parked process.
789        let published = sample_envelope();
790        let frame = frame_as_owned_binary(&published);
791        scheduler
792            .borrow_mut()
793            .send_owned(pid, &frame)
794            .expect("frame is delivered to the live subscriber pid");
795
796        // Pump turns: the woken handler drains the binary, decodes it through its
797        // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
798        let mut delivered = None;
799        for _ in 0..8 {
800            scheduler.borrow_mut().run_until_idle();
801            let next = inbox.pop();
802            if let Some(envelope) = next {
803                delivered = Some(envelope);
804                break;
805            }
806        }
807
808        assert_eq!(
809            delivered.as_ref(),
810            Some(&published),
811            "the real subscriber decoded and delivered the published envelope"
812        );
813    }
814}
815
816/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
817/// admitted envelope; the shared byte budget is spent across ALL a
818/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
819/// fairness trip stops one inbox starving its siblings; and charge/release is
820/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
821/// with no scheduler — the server-side wake wiring and shed are tested there.
822#[cfg(test)]
823#[allow(clippy::expect_used)]
824mod inbox_bounding {
825    use std::sync::Arc;
826    use std::sync::atomic::{AtomicUsize, Ordering};
827
828    use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
829    use crate::channel::SchemaId;
830    use crate::channel::wire::encode_envelope;
831    use crate::envelope::{Envelope, PublisherId};
832
833    fn envelope(payload: &[u8]) -> Envelope {
834        Envelope::new(
835            payload.to_vec(),
836            None,
837            SchemaId::new(),
838            PublisherId::from("inbox-bounding-test"),
839        )
840    }
841
842    fn admitted_bytes(env: &Envelope) -> usize {
843        encode_envelope(env).len()
844    }
845
846    /// The level-triggered wake contract (P0 #55). This test previously asserted
847    /// the edge-triggered form — that a second admit into a non-empty inbox does
848    /// NOT re-fire — which is precisely the starvation the fix removes: the
849    /// consumer drains a bounded slice, so a non-empty inbox is the normal state
850    /// of a subscriber that has fallen behind, and withholding its wake is what
851    /// ratchets it to the depth cap and a permanent shed.
852    #[test]
853    fn notifier_fires_for_every_admitted_envelope() {
854        let inbox = SubscriptionInbox::new();
855        let fires = Arc::new(AtomicUsize::new(0));
856        let counter = Arc::clone(&fires);
857        inbox.install_notifier(Arc::new(move || {
858            counter.fetch_add(1, Ordering::Relaxed);
859        }));
860
861        // First admit into an empty inbox fires.
862        assert_eq!(inbox.admit(envelope(b"a")), InboxAdmission::Admitted);
863        assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
864
865        // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
866        // may not have reached this envelope's slice, and R6 coalescing means a
867        // redundant marker costs one mailbox atom, never a second slice of work.
868        assert_eq!(inbox.admit(envelope(b"b")), InboxAdmission::Admitted);
869        assert_eq!(
870            fires.load(Ordering::Relaxed),
871            2,
872            "an admit into a non-empty inbox still fires"
873        );
874
875        // Drain to empty, then admit again: still exactly one fire per admit.
876        assert!(inbox.pop().is_some());
877        assert!(inbox.pop().is_some());
878        assert_eq!(inbox.admit(envelope(b"c")), InboxAdmission::Admitted);
879        assert_eq!(
880            fires.load(Ordering::Relaxed),
881            3,
882            "one fire per admitted envelope, whatever the queue depth was"
883        );
884    }
885
886    #[test]
887    fn shared_budget_is_spent_across_all_a_connections_inboxes() {
888        let one = envelope(b"payload-one");
889        let two = envelope(b"payload-two");
890        // A budget large enough for exactly ONE of the two envelopes.
891        let cap = admitted_bytes(&one);
892        let budget = ConnectionInboxBudget::new(cap);
893
894        let inbox_a = SubscriptionInbox::new();
895        let inbox_b = SubscriptionInbox::new();
896        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
897        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
898
899        // Inbox A admits its envelope, consuming the whole shared budget.
900        assert_eq!(inbox_a.admit(one), InboxAdmission::Admitted);
901        assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
902
903        // Inbox B — a SIBLING subscription — is refused: the budget is connection
904        // scoped, not per-inbox, so A's fill denies B.
905        assert_eq!(inbox_b.admit(two), InboxAdmission::BudgetExceeded);
906        assert!(
907            inbox_b.is_overflowed(),
908            "the sibling that overflowed the shared budget is shed"
909        );
910        assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
911
912        // Draining A releases its bytes back to the SHARED budget, so B could then
913        // admit (charge/release symmetry across siblings).
914        assert!(inbox_a.pop().is_some());
915        assert_eq!(
916            budget.used(),
917            0,
918            "release returns bytes to the shared budget"
919        );
920    }
921
922    #[test]
923    fn overflow_sheds_and_does_not_grow_memory() {
924        let env = envelope(b"x");
925        let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
926        let inbox = SubscriptionInbox::new();
927        inbox.install_budget(budget, usize::MAX);
928
929        assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
930        // The next admit overflows: refused, marked for shedding, and NOT queued —
931        // the queue length does not grow past the bound.
932        assert_eq!(inbox.admit(env), InboxAdmission::BudgetExceeded);
933        assert!(inbox.is_overflowed());
934        assert_eq!(
935            inbox.len(),
936            1,
937            "the overflowed envelope is dropped, not queued"
938        );
939    }
940
941    #[test]
942    fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
943        // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
944        let budget = ConnectionInboxBudget::new(usize::MAX);
945        let inbox = SubscriptionInbox::new();
946        inbox.install_budget(budget, 2); // depth cap of 2 envelopes
947
948        assert_eq!(inbox.admit(envelope(b"1")), InboxAdmission::Admitted);
949        assert_eq!(inbox.admit(envelope(b"2")), InboxAdmission::Admitted);
950        // The third trips the fairness cap even though bytes are available.
951        assert_eq!(inbox.admit(envelope(b"3")), InboxAdmission::FairnessTripped);
952        assert!(inbox.is_overflowed());
953        assert_eq!(
954            inbox.len(),
955            2,
956            "the fairness trip holds the inbox at its cap"
957        );
958    }
959
960    #[test]
961    fn charge_and_release_are_exact() {
962        let budget = ConnectionInboxBudget::new(1024 * 1024);
963        let inbox = SubscriptionInbox::new();
964        inbox.install_budget(Arc::clone(&budget), usize::MAX);
965
966        let a = envelope(b"first-envelope");
967        let b = envelope(b"second-longer-envelope-payload");
968        let charge = admitted_bytes(&a) + admitted_bytes(&b);
969        assert_eq!(inbox.admit(a), InboxAdmission::Admitted);
970        assert_eq!(inbox.admit(b), InboxAdmission::Admitted);
971        assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
972
973        assert!(inbox.pop().is_some());
974        assert!(inbox.pop().is_some());
975        assert_eq!(
976            budget.used(),
977            0,
978            "every admitted byte is released on dequeue — exact symmetry"
979        );
980    }
981
982    /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
983    /// shape — an overflowed inbox is near-full by construction) releases every
984    /// charged byte back to the shared budget, so a sibling subscription can
985    /// admit again. Without the close-release, one shed strands its whole share
986    /// of the 4 MiB budget forever.
987    #[test]
988    fn close_releases_queued_charges_so_siblings_recover() {
989        // Budget sized so inbox A can queue a 256-envelope backlog (the §5
990        // fairness-cap depth) and exhaust the shared budget doing it.
991        let one = envelope(b"backlog-envelope-payload");
992        let unit = admitted_bytes(&one);
993        let budget = ConnectionInboxBudget::new(unit * 256);
994
995        let inbox_a = SubscriptionInbox::new();
996        let inbox_b = SubscriptionInbox::new();
997        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
998        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
999
1000        // Queue the full 256-envelope backlog on A, consuming the whole budget.
1001        for _ in 0..256 {
1002            assert_eq!(inbox_a.admit(one.clone()), InboxAdmission::Admitted);
1003        }
1004        assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
1005        // The sibling is starved (the shed trigger condition).
1006        assert_eq!(inbox_b.admit(one.clone()), InboxAdmission::BudgetExceeded);
1007
1008        // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
1009        // returns to the shared budget in one atomic close.
1010        inbox_a.close();
1011        assert_eq!(
1012            budget.used(),
1013            0,
1014            "close releases the entire queued backlog back to the shared budget"
1015        );
1016        // The sibling recovers: it can admit again.
1017        assert_eq!(
1018            inbox_b.admit(one),
1019            InboxAdmission::Admitted,
1020            "a sibling admits again after the other inbox is shed"
1021        );
1022    }
1023
1024    /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1025    /// the budget, so a shed subscription can never re-accumulate cost while the
1026    /// channel actor's asynchronous EXIT prune is still in flight.
1027    #[test]
1028    fn closed_inbox_refuses_without_charging() {
1029        let env = envelope(b"post-close");
1030        let budget = ConnectionInboxBudget::new(1024 * 1024);
1031        let inbox = SubscriptionInbox::new();
1032        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1033
1034        inbox.close();
1035        assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1036        assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1037        assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1038    }
1039
1040    /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1041    /// called `close`, the last handle dropping still releases the queued charges
1042    /// (release-by-construction: a release that cannot be omitted).
1043    #[test]
1044    fn drop_backstop_releases_queued_charges() {
1045        let env = envelope(b"dropped-while-queued");
1046        let unit = admitted_bytes(&env);
1047        let budget = ConnectionInboxBudget::new(1024 * 1024);
1048        {
1049            let inbox = SubscriptionInbox::new();
1050            inbox.install_budget(Arc::clone(&budget), usize::MAX);
1051            assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
1052            assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1053            assert_eq!(budget.used(), unit * 2);
1054            // No close() call: the Arc drops here.
1055        }
1056        assert_eq!(
1057            budget.used(),
1058            0,
1059            "dropping the last inbox handle releases every queued charge"
1060        );
1061    }
1062
1063    /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1064    /// nothing (the queue was drained into the release).
1065    #[test]
1066    fn close_is_idempotent_and_drains_the_queue() {
1067        let env = envelope(b"x");
1068        let budget = ConnectionInboxBudget::new(1024 * 1024);
1069        let inbox = SubscriptionInbox::new();
1070        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1071        assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1072
1073        inbox.close();
1074        inbox.close(); // second close is a no-op, not a double release
1075        assert_eq!(budget.used(), 0);
1076        assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1077    }
1078
1079    /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1080    /// budget was installed carries a charge of 0 — its dequeue releases exactly
1081    /// 0 against the later-installed budget, never bytes it did not charge. The
1082    /// production subscribe path installs the budget at inbox construction so
1083    /// this window is structurally closed; this pins the defensive invariant
1084    /// that makes release byte-identical to charge on EVERY entry regardless.
1085    #[test]
1086    fn per_entry_charge_ownership_survives_budget_install() {
1087        let uncharged = envelope(b"admitted-before-budget-install");
1088        let charged = envelope(b"admitted-after-budget-install");
1089        let inbox = SubscriptionInbox::new();
1090
1091        // Admitted with no budget installed: charge ownership 0.
1092        assert_eq!(inbox.admit(uncharged), InboxAdmission::Admitted);
1093
1094        let budget = ConnectionInboxBudget::new(1024 * 1024);
1095        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1096        let unit = admitted_bytes(&charged);
1097        assert_eq!(inbox.admit(charged), InboxAdmission::Admitted);
1098        assert_eq!(budget.used(), unit, "only the post-install entry charged");
1099
1100        // Popping the uncharged entry releases exactly 0 — the budget cannot
1101        // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1102        // that were never charged.
1103        assert!(inbox.pop().is_some());
1104        assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1105        assert!(inbox.pop().is_some());
1106        assert_eq!(
1107            budget.used(),
1108            0,
1109            "the charged entry released its exact charge"
1110        );
1111    }
1112
1113    /// Review round 1 item 5 (install recheck): installing a notifier onto an
1114    /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1115    /// admitted while there was no notifier to fire, so the install regenerates
1116    /// their wake and one can never be lost to install ordering. (The production
1117    /// subscribe path installs at construction, when the queue is guaranteed
1118    /// empty; this pins the defensive invariant.)
1119    #[test]
1120    fn notifier_install_onto_non_empty_inbox_fires_once() {
1121        let inbox = SubscriptionInbox::new();
1122        assert_eq!(
1123            inbox.admit(envelope(b"pre-install")),
1124            InboxAdmission::Admitted
1125        );
1126
1127        let fires = Arc::new(AtomicUsize::new(0));
1128        let counter = Arc::clone(&fires);
1129        inbox.install_notifier(Arc::new(move || {
1130            counter.fetch_add(1, Ordering::Relaxed);
1131        }));
1132        assert_eq!(
1133            fires.load(Ordering::Relaxed),
1134            1,
1135            "install onto a non-empty inbox regenerates exactly one wake"
1136        );
1137
1138        // The install is a ONE-OFF regeneration, not an extra fire per admit: a
1139        // subsequent admit adds exactly its own one fire.
1140        assert_eq!(inbox.admit(envelope(b"second")), InboxAdmission::Admitted);
1141        assert_eq!(fires.load(Ordering::Relaxed), 2);
1142    }
1143}