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 the inbox's empty→non-empty transition (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 the inbox's empty→non-empty transition. `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 the
177/// empty→non-empty transition and enforces the connection-scoped byte budget plus
178/// the per-inbox 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 the empty→non-empty transition,
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    /// an install onto an already-non-empty inbox produces the wake whose edge was
251    /// consumed before the notifier existed, so a wake can never be lost to
252    /// install ordering. On the normal construction path the queue is empty and
253    /// this is a no-op.
254    pub(crate) fn install_notifier(&self, notifier: InboxNotifier) {
255        let fire = {
256            let Ok(mut state) = self.state.lock() else {
257                return;
258            };
259            let pending = !state.queue.is_empty();
260            let handle = notifier.clone();
261            state.notifier = Some(notifier);
262            pending.then_some(handle)
263        };
264        if let Some(notifier) = fire {
265            notifier();
266        }
267    }
268
269    /// Admits `envelope` under the byte budget and fairness trip, charging the
270    /// serialized bytes as admitted and firing the wake notifier on the
271    /// empty→non-empty transition. On budget/fairness refusal the sticky overflow
272    /// marker is set and the envelope dropped (memory never grows past the
273    /// bound); a closed inbox refuses without charging or marking.
274    ///
275    /// The notifier fires OUTSIDE the state lock so the publishing actor's slice
276    /// never holds the inbox lock across the scheduler enqueue.
277    pub(crate) fn admit(&self, envelope: Envelope) -> InboxAdmission {
278        // Serialize once, before the lock: the admitted byte count is the wire
279        // size (§5 denomination). The entry stores the amount actually CHARGED
280        // (0 when no budget is installed), so dequeue/close releases exactly
281        // what enqueue charged.
282        let bytes = encode_envelope(&envelope).len();
283        let notifier = {
284            let Ok(mut state) = self.state.lock() else {
285                // A poisoned inbox lock is terminal for this subscription; treat it
286                // as a shed rather than silently dropping into a dead inbox.
287                self.overflowed.store(true, Ordering::Release);
288                return InboxAdmission::BudgetExceeded;
289            };
290            if state.closed {
291                return InboxAdmission::Closed;
292            }
293            if state.queue.len() >= state.depth_cap {
294                self.overflowed.store(true, Ordering::Release);
295                let notifier = state.notifier.clone();
296                drop(state);
297                if let Some(notifier) = notifier {
298                    notifier();
299                }
300                return InboxAdmission::FairnessTripped;
301            }
302            let charged = match state.budget.as_ref() {
303                Some(budget) => {
304                    if !budget.try_charge(bytes) {
305                        self.overflowed.store(true, Ordering::Release);
306                        let notifier = state.notifier.clone();
307                        drop(state);
308                        if let Some(notifier) = notifier {
309                            notifier();
310                        }
311                        return InboxAdmission::BudgetExceeded;
312                    }
313                    bytes
314                }
315                None => 0,
316            };
317            let was_empty = state.queue.is_empty();
318            state.queue.push_back((envelope, charged));
319            // Fire only on the empty→non-empty edge: a parked connection needs one
320            // wake to drain the whole burst, and coalescing is harmless (R6).
321            if was_empty {
322                state.notifier.clone()
323            } else {
324                None
325            }
326        };
327        if let Some(notifier) = notifier {
328            notifier();
329        }
330        InboxAdmission::Admitted
331    }
332
333    /// Removes and returns the next envelope, releasing its CHARGED bytes back to
334    /// the shared budget (exact charge/release symmetry).
335    pub(crate) fn pop(&self) -> Option<Envelope> {
336        let (envelope, charged, budget) = {
337            let mut state = self.state.lock().ok()?;
338            let (envelope, charged) = state.queue.pop_front()?;
339            (envelope, charged, state.budget.clone())
340        };
341        // Release the charged bytes AFTER dropping the state lock so the shared
342        // budget's atomic is never touched while the inbox lock is held.
343        if let Some(budget) = budget {
344            budget.release(charged);
345        }
346        Some(envelope)
347    }
348
349    /// Non-consuming race-barrier query used after a connection arms readiness.
350    pub(crate) fn has_pending(&self) -> bool {
351        self.state.lock().is_ok_and(|state| !state.queue.is_empty())
352    }
353
354    /// Atomically closes the inbox, releasing every queued charge back to the
355    /// shared budget: under the lock it marks the inbox closed, drains all
356    /// entries, and detaches the notifier and budget; the summed release happens
357    /// outside the lock. Idempotent. Admissions after close are refused without
358    /// charging ([`InboxAdmission::Closed`]).
359    ///
360    /// This is the release-by-construction seam: explicit unsubscribe, overflow
361    /// shed, and connection teardown ALL reach it through the subscription
362    /// handle's drop (see [`SubscriptionInner::drop`]), and the inbox's own `Drop`
363    /// is the final backstop — no teardown path can strand queued bytes on the
364    /// connection-lifetime budget.
365    pub(crate) fn close(&self) {
366        let (released, budget) = {
367            let Ok(mut state) = self.state.lock() else {
368                return;
369            };
370            if state.closed {
371                return;
372            }
373            state.closed = true;
374            let released: usize = state
375                .queue
376                .drain(..)
377                .map(|(_envelope, charged)| charged)
378                .sum();
379            state.notifier = None;
380            (released, state.budget.take())
381        };
382        if let Some(budget) = budget {
383            budget.release(released);
384        }
385    }
386
387    /// Whether this subscription has been marked for shedding by an overflow.
388    pub(crate) fn is_overflowed(&self) -> bool {
389        self.overflowed.load(Ordering::Acquire)
390    }
391
392    /// Number of queued envelopes (test observability).
393    #[cfg(test)]
394    pub(crate) fn len(&self) -> usize {
395        self.state.lock().map_or(0, |state| state.queue.len())
396    }
397}
398
399impl Drop for SubscriptionInbox {
400    fn drop(&mut self) {
401        // Backstop: if no teardown path ever called `close`, release the queued
402        // charges here so the last Arc dropping can never strand budget bytes.
403        // Idempotent against an earlier close (the closed marker short-circuits).
404        self.close();
405    }
406}
407
408/// A delivery predicate evaluated by the channel actor against each published
409/// envelope. `None` (no predicate) means deliver everything.
410pub(crate) type SubscriptionPredicate = Arc<dyn Fn(&Envelope) -> bool + Send + Sync>;
411
412/// Real beamr native process backing one subscription.
413///
414/// For LOCAL delivery it is an idle handler (mirroring
415/// `aion::worker::link::IdleWorkerProcess`): local envelopes travel through the
416/// shared [`SubscriberInbox`] the channel actor writes and
417/// [`SubscriptionHandle::try_next`] reads. Its other job is to BE a first-class
418/// linkable, killable process whose lifetime equals the subscription's, so the
419/// channel actor detects the subscription dying via a real EXIT signal rather
420/// than by polling a weak pointer.
421///
422/// For CROSS-NODE delivery (SRV-005) it is also the landing point for a remote
423/// publish: a remote node sends a published envelope, encoded by
424/// [`crate::channel::wire::encode_envelope`], as a single beamr binary directly
425/// to this process's pid (the pid the cluster registered in the channel's
426/// distributed process group). The binary lands in this process's mailbox; the
427/// handler decodes it back into an [`Envelope`] and pushes it onto the SAME
428/// inbox a local publish would, so a subscriber observes local and remote
429/// messages identically. Non-binary wakeups (trapped `{EXIT, _, _}` signals) are
430/// drained and ignored.
431struct SubscriberProcess {
432    inbox: SubscriberInbox,
433}
434
435impl NativeHandler for SubscriberProcess {
436    fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
437        // Trapping is set authoritatively at spawn (see `SubscriptionHandle::spawn`)
438        // so it holds before the actor ever links — re-assert it here defensively
439        // for any future restart of this handler.
440        ctx.set_trap_exit(true);
441        // Drain every queued wakeup. A binary message is a remote envelope frame
442        // (SRV-005) to decode and enqueue; everything else (e.g. a trapped
443        // `{EXIT, _, _}` tuple from a crashed actor this subscriber outlives) is
444        // ignored. Death is driven only by an explicit `terminate_process` on
445        // unsubscribe/handle drop.
446        while let Some(message) = ctx.recv() {
447            if let Some(binary) = BinaryRef::new(message) {
448                self.accept_remote_frame(binary.as_bytes());
449            }
450        }
451        NativeOutcome::Wait
452    }
453}
454
455impl SubscriberProcess {
456    /// Decode a remote envelope frame and push it onto the inbox. A frame that
457    /// fails to decode is dropped: a corrupt cross-node payload must never crash
458    /// the subscriber or stall delivery of well-formed messages.
459    fn accept_remote_frame(&self, bytes: &[u8]) {
460        let Ok(envelope) = decode_envelope(bytes) else {
461            return;
462        };
463        // R3: the remote-delivery leg fires the same wake notifier and obeys the
464        // same §5 byte budget as the local leg. An overflow marks the subscription
465        // for shedding (inside `admit`); the frame is dropped rather than growing
466        // server memory.
467        self.inbox.admit(envelope);
468    }
469}
470
471/// The actor-side record of one subscriber: the inbox to deliver into and the
472/// optional predicate to gate delivery. Held by the channel actor INSIDE its
473/// process, keyed by the subscriber process's pid.
474pub(crate) struct SubscriberRegistration {
475    pid: u64,
476    inbox: SubscriberInbox,
477    predicate: Option<SubscriptionPredicate>,
478}
479
480impl SubscriberRegistration {
481    pub(crate) const fn pid(&self) -> u64 {
482        self.pid
483    }
484
485    /// Delivers `envelope` to this subscriber when its predicate accepts it (or
486    /// it has no predicate). Returns `true` when the envelope was pushed onto
487    /// the inbox, `false` when a predicate filtered it out, the inbox refused it
488    /// (§5 overflow — the subscription is then marked for shedding), or the inbox
489    /// is closed. The boolean lets the channel actor count genuine deliveries for
490    /// the delivery-ack signal.
491    pub(crate) fn deliver(&self, envelope: &Envelope) -> bool {
492        if let Some(predicate) = self.predicate.as_ref() {
493            if !predicate(envelope) {
494                return false;
495            }
496        }
497        // R3 + §5: admission charges the connection byte budget, fires the wake
498        // notifier on the empty→non-empty edge, and — on overflow — marks the
499        // subscription for shedding (the server pump sheds it with a typed error
500        // frame). An overflowed envelope is NOT counted as a genuine delivery, so
501        // the delivery-ack signal reflects only envelopes that entered the inbox.
502        matches!(self.inbox.admit(envelope.clone()), InboxAdmission::Admitted)
503    }
504}
505
506impl std::fmt::Debug for SubscriberRegistration {
507    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        formatter
509            .debug_struct("SubscriberRegistration")
510            .field("pid", &self.pid)
511            .field("has_predicate", &self.predicate.is_some())
512            .finish_non_exhaustive()
513    }
514}
515
516/// Handle returned by channel subscriptions for receiving validated envelopes.
517///
518/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
519/// scheduler so the process can be terminated when the subscription ends. The
520/// handle is the subscription's lifetime: dropping the last clone terminates the
521/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
522#[derive(Clone)]
523pub struct SubscriptionHandle {
524    inner: Arc<SubscriptionInner>,
525}
526
527struct SubscriptionInner {
528    pid: u64,
529    inbox: SubscriberInbox,
530    scheduler: Arc<Scheduler>,
531}
532
533impl SubscriptionHandle {
534    /// Spawns a real subscriber process on `scheduler` and returns the handle
535    /// plus its actor-side registration record (carrying any predicate).
536    ///
537    /// # Errors
538    /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
539    /// spawn the subscriber process.
540    pub(crate) fn spawn(
541        scheduler: &Arc<Scheduler>,
542        predicate: Option<SubscriptionPredicate>,
543        install: Option<InboxInstall>,
544    ) -> Result<(Self, SubscriberRegistration), LiminalError> {
545        let inbox: SubscriberInbox = SubscriptionInbox::new();
546        // Install the §5 budget/fairness cap and the R3 wake notifier AT
547        // CONSTRUCTION — strictly before the registration is handed to the
548        // channel actor — so there is no window in which a publish can be
549        // admitted uncharged, past the depth cap, or without a wake.
550        if let Some(install) = install {
551            inbox.install_budget(install.budget, install.depth_cap);
552            if let Some(notifier) = install.notifier {
553                inbox.install_notifier(notifier);
554            }
555        }
556        let process_inbox = Arc::clone(&inbox);
557        let factory = Box::new(move || {
558            Box::new(SubscriberProcess {
559                inbox: Arc::clone(&process_inbox),
560            }) as Box<dyn NativeHandler>
561        });
562        let pid =
563            scheduler
564                .spawn_native(factory)
565                .map_err(|error| LiminalError::SubscriptionFailed {
566                    message: format!("failed to spawn subscriber process: {error:?}"),
567                })?;
568        // Set trap_exit BEFORE the channel actor links to this pid, so an
569        // abnormal actor crash is trapped (delivered as a message the subscriber
570        // drains) instead of cascading across the link and killing the
571        // subscriber. This makes the subscriber outlive a channel-actor crash so
572        // the restarted actor can re-link to it on boot (R2/R4). Setting it
573        // host-side (not in the handler's first slice) removes any race against
574        // the crash: the flag is in place the instant `subscribe` proceeds.
575        scheduler
576            .set_trap_exit(pid, true)
577            .map_err(|error| LiminalError::SubscriptionFailed {
578                message: format!("failed to set trap_exit on subscriber process {pid}: {error:?}"),
579            })?;
580        let handle = Self {
581            inner: Arc::new(SubscriptionInner {
582                pid,
583                inbox: Arc::clone(&inbox),
584                scheduler: Arc::clone(scheduler),
585            }),
586        };
587        let registration = SubscriberRegistration {
588            pid,
589            inbox,
590            predicate,
591        };
592        Ok((handle, registration))
593    }
594
595    /// Returns the beamr pid of the subscriber process this handle owns.
596    #[must_use]
597    pub(crate) fn pid(&self) -> u64 {
598        self.inner.pid
599    }
600
601    /// Attempts to receive the next delivered envelope without blocking.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
606    pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
607        // Dequeue releases the envelope's admitted bytes back to the shared
608        // connection budget (§5 charge/release symmetry).
609        Ok(self.inner.inbox.pop())
610    }
611
612    /// Whether an envelope is available without consuming it.
613    #[must_use]
614    pub fn has_pending(&self) -> bool {
615        self.inner.inbox.has_pending()
616    }
617
618    /// Whether an overflow has marked this subscription for shedding (§5).
619    #[must_use]
620    pub fn is_overflowed(&self) -> bool {
621        self.inner.inbox.is_overflowed()
622    }
623}
624
625impl std::fmt::Debug for SubscriptionHandle {
626    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        formatter
628            .debug_struct("SubscriptionHandle")
629            .field("pid", &self.inner.pid)
630            .finish_non_exhaustive()
631    }
632}
633
634impl Drop for SubscriptionInner {
635    fn drop(&mut self) {
636        // Close the inbox FIRST: atomically mark it closed, drain queued entries,
637        // and release every charged byte back to the shared connection budget
638        // (§5 — queued bytes must never be stranded on the connection-lifetime
639        // budget by unsubscribe, shed, or teardown; all of them funnel through
640        // this drop). Post-close deliveries from the channel actor (whose EXIT
641        // prune below is asynchronous) are refused without charging.
642        self.inbox.close();
643        // Terminating the subscriber process fires the bidirectional link to the
644        // channel actor, which traps the EXIT and removes this subscriber from
645        // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
646        self.scheduler
647            .terminate_process(self.pid, ExitReason::Normal);
648    }
649}
650
651/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
652/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
653///
654/// This proves the production subscriber handler — the same `NativeHandler` the
655/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
656/// cooperative scheduler that a browser host drives. There is no toy stand-in:
657/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
658/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
659/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
660/// handler's own `accept_remote_frame` path and lands in the shared inbox a
661/// [`SubscriptionHandle::try_next`] would read.
662///
663/// The handler runs cooperatively AS-IS: its `handle` only touches
664/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
665/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
666/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
667/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
668/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
669/// native.
670#[cfg(test)]
671#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
672mod cooperative_smoke {
673    use std::cell::RefCell;
674    use std::rc::Rc;
675    use std::sync::Arc;
676
677    use beamr::atom::AtomTable;
678    use beamr::ets::copy_term_to_ets;
679    use beamr::module::ModuleRegistry;
680    use beamr::native::BifRegistryImpl;
681    use beamr::process::heap::Heap;
682    use beamr::scheduler::WasmScheduler;
683    use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
684
685    use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
686    use crate::channel::SchemaId;
687    use crate::channel::wire::encode_envelope;
688    use crate::envelope::{Envelope, PublisherId};
689
690    /// Build a cooperative scheduler the way a wasm host holds it (single
691    /// `Rc<RefCell<…>>` on one thread).
692    fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
693        let atom_table = Arc::new(AtomTable::with_common_atoms());
694        let modules = Arc::new(ModuleRegistry::new());
695        let bifs = Arc::new(BifRegistryImpl::new());
696        Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
697    }
698
699    /// Encode `envelope` into the production wire frame and wrap it as a
700    /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
701    /// remote node hands a published frame to a subscriber pid (SRV-005).
702    fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
703        let bytes = encode_envelope(envelope);
704        let shared = SharedBinary::new(bytes);
705        // A ProcBin reference needs three heap words; copy it into ETS-owned
706        // memory so the scratch heap can be dropped before delivery.
707        let mut scratch = Heap::new(8);
708        let words = scratch
709            .alloc_slice(3)
710            .expect("scratch heap holds a proc-bin reference");
711        let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
712        copy_term_to_ets(term).expect("frame copies into an owned binary")
713    }
714
715    fn sample_envelope() -> Envelope {
716        // A whole-millisecond timestamp so the round-trip through the wire codec
717        // (which carries millisecond resolution, see `channel::wire`) is exact;
718        // `Utc::now()` sub-millisecond precision would otherwise be truncated on
719        // decode and is irrelevant to what this smoke proves.
720        let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
721            .single()
722            .expect("valid fixed millisecond timestamp");
723        Envelope::with_timestamp(
724            b"{\"value\":42}".to_vec(),
725            None,
726            SchemaId::new(),
727            PublisherId::from("publisher-cooperative"),
728            timestamp,
729        )
730    }
731
732    #[test]
733    fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
734        let scheduler = cooperative_scheduler();
735
736        // The shared inbox the subscriber pushes decoded envelopes onto — the
737        // exact channel the threaded `SubscriptionHandle::try_next` reads.
738        let inbox: SubscriberInbox = SubscriptionInbox::new();
739        let process_inbox = Arc::clone(&inbox);
740
741        // Spawn the GENUINE production subscriber handler as a first-class native
742        // process on the cooperative scheduler.
743        let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
744            Box::new(SubscriberProcess {
745                inbox: Arc::clone(&process_inbox),
746            }) as Box<dyn beamr::native::native_process::NativeHandler>
747        }));
748
749        // First turn: the handler runs once, asserts trap_exit, finds an empty
750        // mailbox, and parks (`Wait`). No envelope has been delivered yet.
751        scheduler.borrow_mut().run_until_idle();
752        assert_eq!(
753            inbox.len(),
754            0,
755            "no envelope is delivered before one is published"
756        );
757
758        // Publish: deliver a real encoded frame as a beamr binary straight to the
759        // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
760        // the parked process.
761        let published = sample_envelope();
762        let frame = frame_as_owned_binary(&published);
763        scheduler
764            .borrow_mut()
765            .send_owned(pid, &frame)
766            .expect("frame is delivered to the live subscriber pid");
767
768        // Pump turns: the woken handler drains the binary, decodes it through its
769        // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
770        let mut delivered = None;
771        for _ in 0..8 {
772            scheduler.borrow_mut().run_until_idle();
773            let next = inbox.pop();
774            if let Some(envelope) = next {
775                delivered = Some(envelope);
776                break;
777            }
778        }
779
780        assert_eq!(
781            delivered.as_ref(),
782            Some(&published),
783            "the real subscriber decoded and delivered the published envelope"
784        );
785    }
786}
787
788/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on the
789/// empty→non-empty edge; the shared byte budget is spent across ALL a
790/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
791/// fairness trip stops one inbox starving its siblings; and charge/release is
792/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
793/// with no scheduler — the server-side wake wiring and shed are tested there.
794#[cfg(test)]
795#[allow(clippy::expect_used)]
796mod inbox_bounding {
797    use std::sync::Arc;
798    use std::sync::atomic::{AtomicUsize, Ordering};
799
800    use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
801    use crate::channel::SchemaId;
802    use crate::channel::wire::encode_envelope;
803    use crate::envelope::{Envelope, PublisherId};
804
805    fn envelope(payload: &[u8]) -> Envelope {
806        Envelope::new(
807            payload.to_vec(),
808            None,
809            SchemaId::new(),
810            PublisherId::from("inbox-bounding-test"),
811        )
812    }
813
814    fn admitted_bytes(env: &Envelope) -> usize {
815        encode_envelope(env).len()
816    }
817
818    #[test]
819    fn notifier_fires_only_on_empty_to_non_empty_transition() {
820        let inbox = SubscriptionInbox::new();
821        let fires = Arc::new(AtomicUsize::new(0));
822        let counter = Arc::clone(&fires);
823        inbox.install_notifier(Arc::new(move || {
824            counter.fetch_add(1, Ordering::Relaxed);
825        }));
826
827        // First admit into an empty inbox: the edge fires exactly once.
828        assert_eq!(inbox.admit(envelope(b"a")), InboxAdmission::Admitted);
829        assert_eq!(
830            fires.load(Ordering::Relaxed),
831            1,
832            "empty→non-empty fires once"
833        );
834
835        // A second admit into a NON-empty inbox does not re-fire: one wake drains
836        // the whole burst (coalescing is R6-harmless).
837        assert_eq!(inbox.admit(envelope(b"b")), InboxAdmission::Admitted);
838        assert_eq!(
839            fires.load(Ordering::Relaxed),
840            1,
841            "no re-fire while the inbox stays non-empty"
842        );
843
844        // Drain to empty, then admit again: the edge fires a second time.
845        assert!(inbox.pop().is_some());
846        assert!(inbox.pop().is_some());
847        assert_eq!(inbox.admit(envelope(b"c")), InboxAdmission::Admitted);
848        assert_eq!(
849            fires.load(Ordering::Relaxed),
850            2,
851            "a fresh empty→non-empty edge fires again"
852        );
853    }
854
855    #[test]
856    fn shared_budget_is_spent_across_all_a_connections_inboxes() {
857        let one = envelope(b"payload-one");
858        let two = envelope(b"payload-two");
859        // A budget large enough for exactly ONE of the two envelopes.
860        let cap = admitted_bytes(&one);
861        let budget = ConnectionInboxBudget::new(cap);
862
863        let inbox_a = SubscriptionInbox::new();
864        let inbox_b = SubscriptionInbox::new();
865        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
866        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
867
868        // Inbox A admits its envelope, consuming the whole shared budget.
869        assert_eq!(inbox_a.admit(one), InboxAdmission::Admitted);
870        assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
871
872        // Inbox B — a SIBLING subscription — is refused: the budget is connection
873        // scoped, not per-inbox, so A's fill denies B.
874        assert_eq!(inbox_b.admit(two), InboxAdmission::BudgetExceeded);
875        assert!(
876            inbox_b.is_overflowed(),
877            "the sibling that overflowed the shared budget is shed"
878        );
879        assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
880
881        // Draining A releases its bytes back to the SHARED budget, so B could then
882        // admit (charge/release symmetry across siblings).
883        assert!(inbox_a.pop().is_some());
884        assert_eq!(
885            budget.used(),
886            0,
887            "release returns bytes to the shared budget"
888        );
889    }
890
891    #[test]
892    fn overflow_sheds_and_does_not_grow_memory() {
893        let env = envelope(b"x");
894        let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
895        let inbox = SubscriptionInbox::new();
896        inbox.install_budget(budget, usize::MAX);
897
898        assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
899        // The next admit overflows: refused, marked for shedding, and NOT queued —
900        // the queue length does not grow past the bound.
901        assert_eq!(inbox.admit(env), InboxAdmission::BudgetExceeded);
902        assert!(inbox.is_overflowed());
903        assert_eq!(
904            inbox.len(),
905            1,
906            "the overflowed envelope is dropped, not queued"
907        );
908    }
909
910    #[test]
911    fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
912        // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
913        let budget = ConnectionInboxBudget::new(usize::MAX);
914        let inbox = SubscriptionInbox::new();
915        inbox.install_budget(budget, 2); // depth cap of 2 envelopes
916
917        assert_eq!(inbox.admit(envelope(b"1")), InboxAdmission::Admitted);
918        assert_eq!(inbox.admit(envelope(b"2")), InboxAdmission::Admitted);
919        // The third trips the fairness cap even though bytes are available.
920        assert_eq!(inbox.admit(envelope(b"3")), InboxAdmission::FairnessTripped);
921        assert!(inbox.is_overflowed());
922        assert_eq!(
923            inbox.len(),
924            2,
925            "the fairness trip holds the inbox at its cap"
926        );
927    }
928
929    #[test]
930    fn charge_and_release_are_exact() {
931        let budget = ConnectionInboxBudget::new(1024 * 1024);
932        let inbox = SubscriptionInbox::new();
933        inbox.install_budget(Arc::clone(&budget), usize::MAX);
934
935        let a = envelope(b"first-envelope");
936        let b = envelope(b"second-longer-envelope-payload");
937        let charge = admitted_bytes(&a) + admitted_bytes(&b);
938        assert_eq!(inbox.admit(a), InboxAdmission::Admitted);
939        assert_eq!(inbox.admit(b), InboxAdmission::Admitted);
940        assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
941
942        assert!(inbox.pop().is_some());
943        assert!(inbox.pop().is_some());
944        assert_eq!(
945            budget.used(),
946            0,
947            "every admitted byte is released on dequeue — exact symmetry"
948        );
949    }
950
951    /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
952    /// shape — an overflowed inbox is near-full by construction) releases every
953    /// charged byte back to the shared budget, so a sibling subscription can
954    /// admit again. Without the close-release, one shed strands its whole share
955    /// of the 4 MiB budget forever.
956    #[test]
957    fn close_releases_queued_charges_so_siblings_recover() {
958        // Budget sized so inbox A can queue a 256-envelope backlog (the §5
959        // fairness-cap depth) and exhaust the shared budget doing it.
960        let one = envelope(b"backlog-envelope-payload");
961        let unit = admitted_bytes(&one);
962        let budget = ConnectionInboxBudget::new(unit * 256);
963
964        let inbox_a = SubscriptionInbox::new();
965        let inbox_b = SubscriptionInbox::new();
966        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
967        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
968
969        // Queue the full 256-envelope backlog on A, consuming the whole budget.
970        for _ in 0..256 {
971            assert_eq!(inbox_a.admit(one.clone()), InboxAdmission::Admitted);
972        }
973        assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
974        // The sibling is starved (the shed trigger condition).
975        assert_eq!(inbox_b.admit(one.clone()), InboxAdmission::BudgetExceeded);
976
977        // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
978        // returns to the shared budget in one atomic close.
979        inbox_a.close();
980        assert_eq!(
981            budget.used(),
982            0,
983            "close releases the entire queued backlog back to the shared budget"
984        );
985        // The sibling recovers: it can admit again.
986        assert_eq!(
987            inbox_b.admit(one),
988            InboxAdmission::Admitted,
989            "a sibling admits again after the other inbox is shed"
990        );
991    }
992
993    /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
994    /// the budget, so a shed subscription can never re-accumulate cost while the
995    /// channel actor's asynchronous EXIT prune is still in flight.
996    #[test]
997    fn closed_inbox_refuses_without_charging() {
998        let env = envelope(b"post-close");
999        let budget = ConnectionInboxBudget::new(1024 * 1024);
1000        let inbox = SubscriptionInbox::new();
1001        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1002
1003        inbox.close();
1004        assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1005        assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1006        assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1007    }
1008
1009    /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1010    /// called `close`, the last handle dropping still releases the queued charges
1011    /// (release-by-construction: a release that cannot be omitted).
1012    #[test]
1013    fn drop_backstop_releases_queued_charges() {
1014        let env = envelope(b"dropped-while-queued");
1015        let unit = admitted_bytes(&env);
1016        let budget = ConnectionInboxBudget::new(1024 * 1024);
1017        {
1018            let inbox = SubscriptionInbox::new();
1019            inbox.install_budget(Arc::clone(&budget), usize::MAX);
1020            assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
1021            assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1022            assert_eq!(budget.used(), unit * 2);
1023            // No close() call: the Arc drops here.
1024        }
1025        assert_eq!(
1026            budget.used(),
1027            0,
1028            "dropping the last inbox handle releases every queued charge"
1029        );
1030    }
1031
1032    /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1033    /// nothing (the queue was drained into the release).
1034    #[test]
1035    fn close_is_idempotent_and_drains_the_queue() {
1036        let env = envelope(b"x");
1037        let budget = ConnectionInboxBudget::new(1024 * 1024);
1038        let inbox = SubscriptionInbox::new();
1039        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1040        assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1041
1042        inbox.close();
1043        inbox.close(); // second close is a no-op, not a double release
1044        assert_eq!(budget.used(), 0);
1045        assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1046    }
1047
1048    /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1049    /// budget was installed carries a charge of 0 — its dequeue releases exactly
1050    /// 0 against the later-installed budget, never bytes it did not charge. The
1051    /// production subscribe path installs the budget at inbox construction so
1052    /// this window is structurally closed; this pins the defensive invariant
1053    /// that makes release byte-identical to charge on EVERY entry regardless.
1054    #[test]
1055    fn per_entry_charge_ownership_survives_budget_install() {
1056        let uncharged = envelope(b"admitted-before-budget-install");
1057        let charged = envelope(b"admitted-after-budget-install");
1058        let inbox = SubscriptionInbox::new();
1059
1060        // Admitted with no budget installed: charge ownership 0.
1061        assert_eq!(inbox.admit(uncharged), InboxAdmission::Admitted);
1062
1063        let budget = ConnectionInboxBudget::new(1024 * 1024);
1064        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1065        let unit = admitted_bytes(&charged);
1066        assert_eq!(inbox.admit(charged), InboxAdmission::Admitted);
1067        assert_eq!(budget.used(), unit, "only the post-install entry charged");
1068
1069        // Popping the uncharged entry releases exactly 0 — the budget cannot
1070        // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1071        // that were never charged.
1072        assert!(inbox.pop().is_some());
1073        assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1074        assert!(inbox.pop().is_some());
1075        assert_eq!(
1076            budget.used(),
1077            0,
1078            "the charged entry released its exact charge"
1079        );
1080    }
1081
1082    /// Review round 1 item 5 (install recheck): installing a notifier onto an
1083    /// ALREADY-NON-EMPTY inbox fires it exactly once — the wake whose
1084    /// empty→non-empty edge was consumed before the notifier existed is
1085    /// regenerated at install, so a wake can never be lost to install ordering.
1086    /// (The production subscribe path installs at construction, when the queue is
1087    /// guaranteed empty; this pins the defensive invariant.)
1088    #[test]
1089    fn notifier_install_onto_non_empty_inbox_fires_once() {
1090        let inbox = SubscriptionInbox::new();
1091        assert_eq!(
1092            inbox.admit(envelope(b"pre-install")),
1093            InboxAdmission::Admitted
1094        );
1095
1096        let fires = Arc::new(AtomicUsize::new(0));
1097        let counter = Arc::clone(&fires);
1098        inbox.install_notifier(Arc::new(move || {
1099            counter.fetch_add(1, Ordering::Relaxed);
1100        }));
1101        assert_eq!(
1102            fires.load(Ordering::Relaxed),
1103            1,
1104            "install onto a non-empty inbox regenerates exactly one wake"
1105        );
1106
1107        // A subsequent admit onto the still-non-empty inbox does not re-fire.
1108        assert_eq!(inbox.admit(envelope(b"second")), InboxAdmission::Admitted);
1109        assert_eq!(fires.load(Ordering::Relaxed), 1);
1110    }
1111}