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, OnceLock};
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::admission::{default_capacity, defer_after_append};
30use crate::channel::schema::SchemaId;
31use crate::channel::wire::{decode_envelope, encode_envelope};
32use crate::durability::bridge::block_on;
33use crate::durability::{ConsumerCursor, DurableStore, MessageEnvelope, replay_range};
34use crate::envelope::{Envelope, PublisherId};
35use crate::error::LiminalError;
36use crate::pressure::{CapacityError, CapacityTracker, ConsumerCapacity, PressureSignal};
37
38/// A shared, cloneable in-memory inbox a subscriber receives delivered envelopes
39/// on. See [`SubscriptionInbox`].
40pub(crate) type SubscriberInbox = Arc<SubscriptionInbox>;
41
42/// A wake callback fired on EVERY envelope admitted to the inbox (R3, §1.2(2)).
43///
44/// The server installs one that fires the CONNECTION scheduler's `READY` marker,
45/// so a publish into a parked connection's inbox wakes it. It is called from the
46/// PUBLISHING actor's slice (the channel actor for local delivery, the subscriber
47/// process for a remote frame), so it must be cheap and non-blocking — a single
48/// `enqueue_atom_message`. `None` (no notifier installed) is the standalone
49/// library / test case: nothing to wake, delivery still lands in the inbox.
50pub type InboxNotifier = Arc<dyn Fn() + Send + Sync>;
51
52/// One shared inbox-byte budget per connection (§5).
53///
54/// Spent across ALL that connection's subscription inboxes. The accounting unit is
55/// serialized envelope bytes AS ADMITTED — charged at enqueue, released at dequeue
56/// — so the signed 4 MiB product is exact and envelope-size-independent, not a
57/// per-inbox count bounding a variable the design does not control.
58#[derive(Debug)]
59pub struct ConnectionInboxBudget {
60    used: AtomicUsize,
61    cap: usize,
62}
63
64impl ConnectionInboxBudget {
65    /// Creates a shared budget with `cap` bytes of headroom across all the
66    /// connection's inboxes.
67    #[must_use]
68    pub fn new(cap: usize) -> Arc<Self> {
69        Arc::new(Self {
70            used: AtomicUsize::new(0),
71            cap,
72        })
73    }
74
75    /// Attempts to charge `bytes`. Returns `true` and reserves the bytes when they
76    /// fit within the remaining budget, `false` (reserving nothing) on overflow. A
77    /// CAS loop keeps the reservation exact under concurrent charges from several
78    /// inboxes — no transient over-charge is ever observable.
79    fn try_charge(&self, bytes: usize) -> bool {
80        let mut current = self.used.load(Ordering::Acquire);
81        loop {
82            let Some(projected) = current.checked_add(bytes) else {
83                return false;
84            };
85            if projected > self.cap {
86                return false;
87            }
88            match self.used.compare_exchange_weak(
89                current,
90                projected,
91                Ordering::AcqRel,
92                Ordering::Acquire,
93            ) {
94                Ok(_) => return true,
95                Err(observed) => current = observed,
96            }
97        }
98    }
99
100    /// Releases `bytes` previously charged (at dequeue). Saturating so a double
101    /// release can never wrap the counter below zero.
102    fn release(&self, bytes: usize) {
103        let mut current = self.used.load(Ordering::Acquire);
104        loop {
105            let next = current.saturating_sub(bytes);
106            match self.used.compare_exchange_weak(
107                current,
108                next,
109                Ordering::AcqRel,
110                Ordering::Acquire,
111            ) {
112                Ok(_) => return,
113                Err(observed) => current = observed,
114            }
115        }
116    }
117
118    /// Bytes currently reserved across the connection's inboxes.
119    #[cfg(test)]
120    pub(crate) fn used(&self) -> usize {
121        self.used.load(Ordering::Acquire)
122    }
123}
124
125/// Everything a server connection installs onto a subscription's inbox.
126///
127/// Carries the shared §5 byte budget, the per-inbox fairness cap, and the R3
128/// wake notifier. Passed INTO the subscribe call so the installation happens at
129/// inbox construction — strictly BEFORE the registration is published to the
130/// channel actor — closing the pre-install window in which envelopes could be
131/// admitted uncharged or without a wake.
132pub struct InboxInstall {
133    /// Shared per-connection byte budget (§5).
134    pub budget: Arc<ConnectionInboxBudget>,
135    /// Per-inbox envelope-count fairness trip (§5).
136    pub depth_cap: usize,
137    /// R3 wake notifier fired on every envelope admitted to the inbox. `None`
138    /// when the caller has no waker (scheduler-free unit tests).
139    pub notifier: Option<InboxNotifier>,
140    /// A1 §2: the consumer's declared capacity — the in-flight window the
141    /// `Subscribe` frame's `max_in_flight` carries, paired with the bus-policy
142    /// buffer band. `None` keeps
143    /// [`crate::channel::admission::default_capacity`], so an inbox is bounded
144    /// whether or not the caller declared anything: there is no opt-out.
145    ///
146    /// Declared HERE rather than through a separate `subscribe_with_capacity`
147    /// because this install is the seam that was deliberately unified to make
148    /// installation happen at inbox construction, strictly before the
149    /// registration reaches the actor. A second subscribe entry point would
150    /// fork that ordering guarantee.
151    pub capacity: Option<ConsumerCapacity>,
152}
153
154impl std::fmt::Debug for InboxInstall {
155    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        formatter
157            .debug_struct("InboxInstall")
158            .field("depth_cap", &self.depth_cap)
159            .field("has_notifier", &self.notifier.is_some())
160            .field("capacity", &self.capacity)
161            .finish_non_exhaustive()
162    }
163}
164
165/// Mutable inbox state guarded by one lock: the queued envelopes (each carrying
166/// the exact bytes CHARGED for it, so release is symmetric with charge), the
167/// installed shared budget and per-inbox fairness cap, the wake notifier, and
168/// the closed marker.
169struct InboxState {
170    /// Each entry is `(envelope, charged_bytes)` — the amount actually charged to
171    /// the shared budget at enqueue (0 when no budget was installed at admit
172    /// time), released verbatim at dequeue/close. Storing the CHARGE, not the
173    /// size, makes release byte-identical to charge on every entry even across a
174    /// budget install, so the budget can never under- or over-release.
175    queue: VecDeque<(Envelope, usize, Option<u64>)>,
176    /// Shared per-connection byte budget (§5). `None` = unbounded (standalone
177    /// library use / tests), preserving the pre-bounding behaviour exactly.
178    budget: Option<Arc<ConnectionInboxBudget>>,
179    /// Per-inbox envelope-count secondary fairness trip (§5). `usize::MAX` = off;
180    /// stops one subscription starving its siblings inside the shared byte budget.
181    depth_cap: usize,
182    /// Wake callback (R3). `None` until the connection installs one.
183    notifier: Option<InboxNotifier>,
184    /// Terminal marker set by [`SubscriptionInbox::close`]: admissions are refused
185    /// WITHOUT charging, and all queued charges have been released. Closing is the
186    /// release-by-construction seam — every teardown path (explicit unsubscribe,
187    /// overflow shed, connection teardown, and the `Drop` backstop) funnels
188    /// through it, so queued bytes can never be stranded on the connection-lifetime
189    /// budget.
190    closed: bool,
191    /// A1 §2: the consumer's declared capacity. The two bands are DERIVED from
192    /// `queue.len()` against it on every admission — never stored, never
193    /// mutated — so `queue.len() == in_flight_band + buffered_band` holds by
194    /// construction and no counter can drift or underflow.
195    capacity: ConsumerCapacity,
196    /// A1 §4: this durable subscriber missed a live push and is converging via
197    /// replay. While set, EVERY live push is shed, so the host-side refill owns
198    /// the queue and in-order delivery is preserved. Cleared only when a refill
199    /// read reaches the log head with no shed racing it.
200    ///
201    /// Never set on an ephemeral channel: there is no log to catch up from, so
202    /// an ephemeral overflow is a plain per-message Reject and nothing more.
203    lagging: bool,
204    /// Bumped on every live push shed while `lagging`. The refill loop reads it
205    /// before a head read and clears `lagging` only if it is unchanged
206    /// afterwards, so a push shed during the read window cannot be lost to a
207    /// "caught up" verdict taken before it arrived.
208    shed_generation: u64,
209    /// A1 §4: the next durable sequence this subscriber has not been offered.
210    /// Advanced by every live push that was queued (or predicate-filtered) and
211    /// by every refilled entry; frozen the moment a shed sets `lagging`, which
212    /// is what makes the frozen value the exact start of the missed range.
213    ///
214    /// MONOTONE (round 2). Advancing is `max(current, position + 1)`, never a
215    /// bare assignment. Under the ordered fan-out this is the same number every
216    /// time; if that ordering is ever broken again, monotonicity is what stops a
217    /// low-positioned push dragging the cursor back over messages this
218    /// subscriber already holds and sending the next refill to re-offer them.
219    next_replay_seq: u64,
220    /// A1 §4 exactly-once: one past the highest durable sequence the REPLAY
221    /// DOOR has offered to (or filtered past for) this subscriber. The
222    /// comparand of the live-push suppression guard, and advanced by nothing
223    /// else — not by a live push, not by the join seed.
224    ///
225    /// Split from `next_replay_seq` in round 2. The cursor answers "where does
226    /// the missed range start", which every door moves; the guard asks "did the
227    /// refill already hand this subscriber this exact position", which only the
228    /// refill can answer. Collapsing the two made a live push that arrived
229    /// behind a higher-positioned sibling look like one that had already been
230    /// delivered, and it was silently dropped.
231    replay_offered_seq: u64,
232}
233
234/// The shared subscription inbox (R3 + §5). Replaces the bare
235/// `Arc<Mutex<VecDeque<Envelope>>>`: it fires a wake notifier on every admitted
236/// envelope and enforces the connection-scoped byte budget plus the per-inbox
237/// fairness trip, shedding the offending subscription on overflow.
238pub(crate) struct SubscriptionInbox {
239    state: Mutex<InboxState>,
240    /// Sticky overflow marker: set when an admission is refused by the byte budget
241    /// or the fairness trip. The server-side delivery pump observes it and sheds
242    /// this subscription with a typed error frame, mirroring the outbound overflow
243    /// policy (a slow consumer sheds its own subscription; it cannot grow server
244    /// memory without bound). Sticky (never cleared) because a shed is terminal.
245    overflowed: AtomicBool,
246}
247
248impl std::fmt::Debug for SubscriptionInbox {
249    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        formatter
251            .debug_struct("SubscriptionInbox")
252            .field("overflowed", &self.overflowed.load(Ordering::Acquire))
253            .finish_non_exhaustive()
254    }
255}
256
257/// What an inbox did with an offered envelope.
258///
259/// Two independent regimes meet here and MUST NOT be collapsed:
260///
261/// * **A1 pacing** ([`Admitted`](Self::Admitted) / [`Deferred`](Self::Deferred)
262///   / [`Rejected`](Self::Rejected)) — per-message, non-terminal, and
263///   de-escalating. A resuming consumer walks Rejected → Deferred → Admitted as
264///   its queue drains, and NONE of these three touch the sticky overflow
265///   marker. A1's Reject sheds one message for one subscriber; it does not end
266///   the subscription.
267/// * **§5 memory safety** ([`BudgetExceeded`](Self::BudgetExceeded) /
268///   [`FairnessTripped`](Self::FairnessTripped)) — terminal. These set the
269///   sticky overflow marker and the server pump sheds the whole subscription
270///   with a typed error frame. They stay exactly as they were: the byte budget
271///   is the memory backstop, A1's counts are the pacing bound. With the
272///   defaults (128 + 1024 = 1152 envelopes against a 4096 depth cap) A1 bites
273///   first, so a §5 shed remains what it has always been — a genuine budget
274///   violation, not a slow consumer.
275///
276/// A closed inbox refuses without charging and without marking either way.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub(crate) enum InboxAdmission {
279    /// A1 Accept band: the envelope was queued with in-flight credit to spare,
280    /// and the wake notifier fired.
281    Admitted(PressureSignal),
282    /// A1 Defer band: the in-flight window is exhausted but the buffer band has
283    /// room, so the envelope WAS queued and the wake notifier fired. Defer is
284    /// purely a pacing signal to the producer — the bus already holds the
285    /// message and the consumer's next pop is its "redelivery".
286    Deferred(PressureSignal),
287    /// A1 Reject band: the buffer band is full, so the envelope is shed for
288    /// this subscriber and NOT queued. Non-terminal — the sticky overflow
289    /// marker is untouched.
290    Rejected(PressureSignal),
291    /// A1 §4 exactly-once: this durable envelope was ALREADY offered to this
292    /// subscriber — its sequence is below the REPLAY WATERMARK, so the
293    /// host-side refill read it out of the log and offered it before this live
294    /// push arrived. Suppressed here, not queued again.
295    ///
296    /// The watermark is the refill's own record, moved by no other door. A push
297    /// that merely arrives behind a higher-positioned sibling is NOT this: it
298    /// was offered by nobody, and it is queued.
299    ///
300    /// Counts as a delivery ([`Self::is_queued`]) because it genuinely was
301    /// one: the envelope entered this inbox, exactly once, by the other door.
302    /// Its signal never says Reject — a message the bus took custody of was
303    /// not shed.
304    AlreadyOffered(PressureSignal),
305    /// The shared connection byte budget (§5) had no room; the subscription is
306    /// shed. Carries the bands the envelope was shed AT, in the A1 vocabulary
307    /// — see [`Self::shed_signal`] for why that is not [`Self::signal`].
308    BudgetExceeded(PressureSignal),
309    /// The per-inbox fairness trip (§5) is full; the subscription is shed.
310    /// Carries the bands it was shed at, as [`Self::BudgetExceeded`] does.
311    FairnessTripped(PressureSignal),
312    /// The inbox was closed (unsubscribe/shed/teardown): the envelope is dropped
313    /// without charging the budget — a closed inbox can never re-accumulate cost.
314    Closed,
315}
316
317impl InboxAdmission {
318    /// Whether the envelope reached this subscriber's queue. True for the two
319    /// A1 bands that admit (Accept and Defer) and for
320    /// [`AlreadyOffered`](Self::AlreadyOffered) — where it reached the queue on
321    /// an earlier, replay-side offer of the SAME durable position — and false
322    /// for every shed and refusal. This is what the delivery-ack count means:
323    /// "the envelope entered the inbox", counted once per envelope.
324    pub(crate) const fn is_queued(&self) -> bool {
325        matches!(
326            *self,
327            Self::Admitted(_) | Self::Deferred(_) | Self::AlreadyOffered(_)
328        )
329    }
330
331    /// The A1 pressure signal this admission carries, when it took one. The
332    /// §5 refusals and a closed inbox took no pressure decision and so carry
333    /// none — they are not pacing outcomes and must never be aggregated as if
334    /// they were.
335    pub(crate) const fn signal(&self) -> Option<&PressureSignal> {
336        match *self {
337            Self::Admitted(ref signal)
338            | Self::Deferred(ref signal)
339            | Self::Rejected(ref signal)
340            | Self::AlreadyOffered(ref signal) => Some(signal),
341            Self::BudgetExceeded(_) | Self::FairnessTripped(_) | Self::Closed => None,
342        }
343    }
344
345    /// The bands a §5 refusal shed this envelope at, always a
346    /// [`PressureSignal::Reject`].
347    ///
348    /// Deliberately NOT folded into [`Self::signal`]: a §5 refusal is a
349    /// memory-safety verdict about bytes, not a pacing verdict about a slow
350    /// consumer, and it must never be reported as one. But it IS a
351    /// delivered-to-nobody outcome for its subscriber, and the producer's
352    /// aggregate answers exactly one question — did the bus take custody. With
353    /// these outcomes invisible to the aggregate, a publish EVERY subscriber
354    /// dropped resolved to the zero-subscriber `Accept` sentinel and
355    /// `is_admitted()` lied; `PressureAggregate::record_dropped` is this
356    /// accessor's only caller and the one place the two regimes meet.
357    ///
358    /// `Closed` carries none: a closed inbox belongs to a subscription that is
359    /// already gone, and counting it would turn a publish landing during a
360    /// teardown into a producer-visible Reject.
361    pub(crate) const fn shed_signal(&self) -> Option<&PressureSignal> {
362        match *self {
363            Self::BudgetExceeded(ref signal) | Self::FairnessTripped(ref signal) => Some(signal),
364            Self::Admitted(_)
365            | Self::Deferred(_)
366            | Self::Rejected(_)
367            | Self::AlreadyOffered(_)
368            | Self::Closed => None,
369        }
370    }
371}
372
373/// The A1 decision for an inbox holding `queued` envelopes under `capacity`.
374///
375/// Counts are DERIVED from the authoritative queue length (§0.1/§2) and the
376/// verdict is [`CapacityTracker::pressure_signal`] — the complete, already-
377/// tested decision model in `pressure/capacity.rs`, reused rather than
378/// reimplemented. The tracker's `record_*` mutators are never called here, so
379/// the underflow class they can produce cannot arise on this path.
380const fn derived_signal(capacity: &ConsumerCapacity, queued: usize) -> PressureSignal {
381    CapacityTracker::derived(*capacity, queued).pressure_signal()
382}
383
384/// A §5 refusal, expressed in the A1 vocabulary: the verdict is Reject (the
385/// envelope was shed for this subscriber and reached nobody) and the bands are
386/// the REAL occupancy at the moment of the refusal, split by the same
387/// [`CapacityTracker::derived`] authority the pacing decision reads. Nothing is
388/// invented and no second count is kept.
389const fn shed_signal(capacity: &ConsumerCapacity, queued: usize) -> PressureSignal {
390    let tracker = CapacityTracker::derived(*capacity, queued);
391    PressureSignal::reject(
392        tracker.current_in_flight(),
393        capacity.max_in_flight,
394        tracker.current_buffer_depth(),
395        capacity.max_buffer_depth,
396    )
397}
398
399impl SubscriptionInbox {
400    /// Creates an unbounded, notifier-less inbox — the standalone/default shape,
401    /// byte-identical to the pre-bounding behaviour. A server connection passes an
402    /// [`InboxInstall`] through subscribe so budget/cap/notifier are installed at
403    /// construction instead.
404    /// The byte budget and fairness cap are off (the standalone/default shape,
405    /// byte-identical to the pre-bounding behaviour); the A1 capacity is NOT.
406    /// Every inbox is bounded by the A1 bands from construction, with no
407    /// opt-out (§2), which is precisely what closes the unbounded-inbox hole
408    /// for a plain library `subscribe()`.
409    pub(crate) fn new() -> Arc<Self> {
410        Arc::new(Self {
411            state: Mutex::new(InboxState {
412                queue: VecDeque::new(),
413                budget: None,
414                depth_cap: usize::MAX,
415                notifier: None,
416                closed: false,
417                capacity: default_capacity(),
418                lagging: false,
419                shed_generation: 0,
420                next_replay_seq: 0,
421                replay_offered_seq: 0,
422            }),
423            overflowed: AtomicBool::new(false),
424        })
425    }
426
427    /// Installs the connection's shared byte budget and per-inbox fairness cap
428    /// (§5). Runs at inbox construction (via [`InboxInstall`]) — before the
429    /// registration is published to the channel actor — so no envelope can be
430    /// admitted uncharged.
431    pub(crate) fn install_budget(&self, budget: Arc<ConnectionInboxBudget>, depth_cap: usize) {
432        if let Ok(mut state) = self.state.lock() {
433            state.budget = Some(budget);
434            state.depth_cap = depth_cap;
435        }
436    }
437
438    /// Installs the consumer's declared A1 capacity (§2), replacing the
439    /// defaults. Runs at inbox construction on the same pre-registration
440    /// ordering guarantee as the budget install, so the first envelope the
441    /// actor can possibly deliver is already decided against the declared
442    /// window.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`CapacityError::InvalidCapacity`] when a band is zero. A zero
447    /// window would make every publish a Reject, which is a configuration
448    /// fault, not a pressure decision — so it is REFUSED, aloud.
449    ///
450    /// It used to return early and say nothing, which is worse than either
451    /// answer: the inbox kept the library defaults (128 + 1024) while the
452    /// caller had every reason to believe its declared window was in force. A
453    /// declared window must install or refuse; a subscription running under a
454    /// bound nobody agreed to is invisible, and a failed subscribe is not.
455    pub(crate) fn install_capacity(&self, capacity: ConsumerCapacity) -> Result<(), CapacityError> {
456        capacity.validate()?;
457        if let Ok(mut state) = self.state.lock() {
458            state.capacity = capacity;
459        }
460        Ok(())
461    }
462
463    /// Seeds the replay cursor at the durable log head the subscription joined
464    /// at (§4). A gap this subscriber never had cannot be a gap it has to
465    /// catch up on, so a fresh subscription's missed range starts where it
466    /// started listening — not at sequence zero, which would replay the whole
467    /// history on the first shed.
468    ///
469    /// It seeds the REPLAY ORIGIN only, never the suppression watermark. A row
470    /// appended just below the head can still be fanned out after this
471    /// registration lands, and that live push is DELIVERED: the fan-out list is
472    /// the authority on who is a live subscriber, this subscription was on it,
473    /// and no door ever offered that position. Suppressing it would drop a
474    /// message the producer is told was delivered, to buy nothing — the refill
475    /// starts at the seed, so it can never re-offer the position and there is
476    /// no duplicate to prevent.
477    pub(crate) fn seed_replay_cursor(&self, head: u64) {
478        if let Ok(mut state) = self.state.lock() {
479            state.next_replay_seq = head;
480        }
481    }
482
483    /// Installs the wake notifier (R3), fired on every admitted envelope,
484    /// capturing the connection scheduler's enqueue handle (§1.2(2)).
485    ///
486    /// Defensive invariant: the install RECHECKS non-emptiness under the lock and
487    /// fires the notifier (outside the lock) when envelopes are already queued —
488    /// those envelopes were admitted while there was no notifier to fire, so
489    /// without this recheck their wake would be lost to install ordering. On the
490    /// normal construction path the queue is empty and this is a no-op.
491    pub(crate) fn install_notifier(&self, notifier: InboxNotifier) {
492        let fire = {
493            let Ok(mut state) = self.state.lock() else {
494                return;
495            };
496            let pending = !state.queue.is_empty();
497            let handle = notifier.clone();
498            state.notifier = Some(notifier);
499            pending.then_some(handle)
500        };
501        if let Some(notifier) = fire {
502            notifier();
503        }
504    }
505
506    /// Admits `envelope` under the byte budget and fairness trip, charging the
507    /// serialized bytes as admitted and firing the wake notifier for EVERY
508    /// admitted envelope (level-triggered — see the fire site below for why the
509    /// edge-triggered form starved a subscriber that fell more than one delivery
510    /// slice behind). On budget/fairness refusal the sticky overflow marker is set
511    /// and the envelope dropped (memory never grows past the bound); a closed
512    /// inbox refuses without charging or marking.
513    ///
514    /// The notifier fires OUTSIDE the state lock so the publishing actor's slice
515    /// never holds the inbox lock across the scheduler enqueue.
516    pub(crate) fn admit(&self, envelope: Envelope) -> InboxAdmission {
517        self.admit_at(envelope, None)
518    }
519
520    /// [`Self::admit`] carrying the envelope's durable sequence, when the
521    /// publishing channel has one (A1 §4).
522    ///
523    /// The position is what makes auto-catch-up possible: a queued envelope
524    /// advances the replay cursor past itself, and the first shed FREEZES the
525    /// cursor at the message it lost, so the missed range is exactly
526    /// `[next_replay_seq, head)` with no bookkeeping that could disagree with
527    /// the queue.
528    ///
529    /// That sentence assumes durable positions arrive here in ASCENDING ORDER,
530    /// and they do: the fan-out command is enqueued under the append lock that
531    /// assigned the position and the actor drains its queue in push order (see
532    /// `ChannelHandle::persist_and_enqueue`). The cursor still advances by
533    /// `max` rather than assignment, so a future reordering costs a duplicate
534    /// at worst and never a message.
535    ///
536    /// The A1 band decision is taken FIRST — ahead of the §5 depth cap and byte
537    /// budget — and never touches the sticky overflow marker, so an A1 Reject
538    /// paces one message instead of killing the subscription. It reads
539    /// `queue.len()` under the SAME lock that then pushes, so decide+push is
540    /// atomic against a concurrent [`Self::pop`] and concurrent publishers
541    /// serialise on it (the publish-time TOCTOU §2 closes).
542    pub(crate) fn admit_at(
543        &self,
544        envelope: Envelope,
545        durable_position: Option<u64>,
546    ) -> InboxAdmission {
547        self.admit_inner(envelope, durable_position, false)
548    }
549
550    /// The replay door (A1 §4): admits one entry the host-side refill read out
551    /// of the durable log, through the SAME bounded admission a live push takes.
552    ///
553    /// Returns whether the entry was queued; `false` stops the refill loop and
554    /// leaves the subscriber lagging, so the next pop retries. This is the ONLY
555    /// writer allowed to move the replay cursor while the gap is open — a live
556    /// push is shed instead, which is what preserves in-order delivery.
557    ///
558    /// It is also the only writer of `replay_offered_seq`, alongside
559    /// [`Self::note_replayed_filtered`]: this door is the one that can offer a
560    /// position ahead of that position's own live push, so this door's record
561    /// is the only honest basis for suppressing that push when it arrives.
562    pub(crate) fn admit_replayed(&self, envelope: Envelope, sequence: u64) -> bool {
563        self.admit_inner(envelope, Some(sequence), true).is_queued()
564    }
565
566    /// Steps the replay cursor past an entry the refill read but this
567    /// subscriber's predicate filtered out. Unlike [`Self::note_filtered`] this
568    /// advances WHILE lagging, because the refill owns the cursor then.
569    ///
570    /// It advances the replay watermark too: the refill has DEALT with this
571    /// position — a live push for it would be the second offer of a message
572    /// this subscription has already decided it does not want, and the guard
573    /// must suppress it exactly as it suppresses a re-offered queued one.
574    pub(crate) fn note_replayed_filtered(&self, sequence: u64) {
575        if let Ok(mut state) = self.state.lock()
576            && !state.closed
577        {
578            let next = sequence.saturating_add(1);
579            state.next_replay_seq = state.next_replay_seq.max(next);
580            state.replay_offered_seq = state.replay_offered_seq.max(next);
581        }
582    }
583
584    /// The one admission body. `replay` distinguishes the host-side refill from
585    /// a live push: only a live push is shed while lagging, and only a live
586    /// push can OPEN a gap.
587    fn admit_inner(
588        &self,
589        envelope: Envelope,
590        durable_position: Option<u64>,
591        replay: bool,
592    ) -> InboxAdmission {
593        // Serialize once, before the lock: the admitted byte count is the wire
594        // size (§5 denomination). The entry stores the amount actually CHARGED
595        // (0 when no budget is installed), so dequeue/close releases exactly
596        // what enqueue charged.
597        let bytes = encode_envelope(&envelope).len();
598        let signal;
599        let notifier = {
600            let Ok(mut state) = self.state.lock() else {
601                // A poisoned inbox lock is terminal for this subscription; treat it
602                // as a shed rather than silently dropping into a dead inbox.
603                //
604                // Zero bands, matching `capacity_bound`'s poisoned-lock reading:
605                // an inbox whose lock is poisoned admits nothing and holds no
606                // live buffer, so there is no occupancy to report. The verdict
607                // — shed, reached nobody — is the part that is certain.
608                self.overflowed.store(true, Ordering::Release);
609                return InboxAdmission::BudgetExceeded(PressureSignal::reject(0, 0, 0, 0));
610            };
611            if state.closed {
612                return InboxAdmission::Closed;
613            }
614            // A1 §4 EXACTLY ONCE, and the seam that makes it true.
615            //
616            // The durable publish path appends and THEN fans out
617            // (`ChannelHandle::publish_with_delivery`), so a row is readable
618            // from the log strictly before its live push reaches any inbox. The
619            // host-side refill reads the log. It can therefore read, offer, and
620            // step past a row whose live push has not happened yet — and then
621            // clear `lagging` legitimately, because nothing was SHED and the
622            // shed generation is unchanged. The live push then arrives at a
623            // caught-up subscriber and would be queued a SECOND time.
624            //
625            // The comparand is `replay_offered_seq`, and it is deliberately NOT
626            // the replay cursor (round 2). Only the replay door can offer a
627            // position ahead of that position's own live push, so only the
628            // replay door can create the duplicate this guard exists to
629            // suppress — and `replay_offered_seq` is advanced by the replay
630            // door alone. A live push below it was genuinely handed to this
631            // subscriber already, out of the log, and its second offer is
632            // suppressed here.
633            //
634            // WHY NOT THE CURSOR. `next_replay_seq` is advanced by every door,
635            // a live push included, and a live push moves it PAST ITSELF. Test
636            // a live push against it and any push that arrives behind a
637            // higher-positioned sibling reads as already-offered although
638            // nothing ever offered it — silent loss, counted to the producer as
639            // a delivery (`is_queued`), with nothing shed to make it
640            // recoverable. That is strictly worse than the duplicate, and it is
641            // what round 1 shipped.
642            //
643            // The publish path is now ordered — the fan-out command is enqueued
644            // under the append lock that assigned the position
645            // (`ChannelHandle::persist_and_enqueue`) — so an inversion cannot
646            // reach this line at all. This guard does not RELY on that: it is
647            // the second, independent reason the loss cannot happen, and the
648            // one that keeps the failure direction safe (a duplicate, never a
649            // loss) if the publish path is ever reordered again.
650            //
651            // Checked BEFORE the lagging shed on purpose: an already-offered
652            // envelope must not bump the shed generation, or a refill would
653            // loop chasing a gap that a delivered message opened.
654            if !replay
655                && let Some(position) = durable_position
656                && position < state.replay_offered_seq
657            {
658                // Never a Reject: the bus took custody of this envelope for
659                // this subscriber. The bands still report the real occupancy,
660                // so the producer's pacing hint stays honest.
661                let signal = defer_after_append(derived_signal(&state.capacity, state.queue.len()));
662                return InboxAdmission::AlreadyOffered(signal);
663            }
664            // A1 §4: a lagging durable subscriber sheds EVERY live push so the
665            // host-side refill can deliver the missed range in order. Its live
666            // band is closed by policy, which is reported as a full buffer band
667            // — true of the live path, and it keeps the aggregate's
668            // `Reject => delivered to nobody` exact.
669            if state.lagging && !replay {
670                state.shed_generation = state.shed_generation.wrapping_add(1);
671                let capacity = state.capacity;
672                return InboxAdmission::Rejected(PressureSignal::reject(
673                    capacity.max_in_flight,
674                    capacity.max_in_flight,
675                    capacity.max_buffer_depth,
676                    capacity.max_buffer_depth,
677                ));
678            }
679            signal = derived_signal(&state.capacity, state.queue.len());
680            if matches!(signal, PressureSignal::Reject { .. }) {
681                // Shed for THIS message only: nothing is queued, nothing is
682                // charged, and `overflowed` stays exactly as it was. The next
683                // pop moves this subscriber back into the Defer band.
684                //
685                // On a durable channel the shed OPENS a gap: the cursor is
686                // already at this sequence and is now frozen there, so the
687                // missed range starts exactly at the message that was lost. A
688                // refill Reject means the free band was already full and is not
689                // a new gap — the subscriber is lagging already.
690                if durable_position.is_some() && !replay {
691                    state.lagging = true;
692                    state.shed_generation = state.shed_generation.wrapping_add(1);
693                }
694                return InboxAdmission::Rejected(signal);
695            }
696            if state.queue.len() >= state.depth_cap {
697                self.overflowed.store(true, Ordering::Release);
698                let shed = shed_signal(&state.capacity, state.queue.len());
699                let notifier = state.notifier.clone();
700                drop(state);
701                if let Some(notifier) = notifier {
702                    notifier();
703                }
704                return InboxAdmission::FairnessTripped(shed);
705            }
706            let charged = match state.budget.as_ref() {
707                Some(budget) => {
708                    if !budget.try_charge(bytes) {
709                        self.overflowed.store(true, Ordering::Release);
710                        let shed = shed_signal(&state.capacity, state.queue.len());
711                        let notifier = state.notifier.clone();
712                        drop(state);
713                        if let Some(notifier) = notifier {
714                            notifier();
715                        }
716                        return InboxAdmission::BudgetExceeded(shed);
717                    }
718                    bytes
719                }
720                None => 0,
721            };
722            state.queue.push_back((envelope, charged, durable_position));
723            // A1 §4: this envelope is now this subscriber's, so the replay
724            // cursor steps past it. Done under the SAME lock as the push, so a
725            // concurrent shed cannot interleave and leave the cursor pointing
726            // at a message that was in fact delivered.
727            //
728            // `max`, not assignment: the cursor names where the missed range
729            // STARTS, and it must never be dragged back below a position this
730            // subscriber already holds — a refill from there would re-read and
731            // re-offer delivered messages.
732            //
733            // The replay watermark moves only on the replay door's own offers.
734            // That is the whole point of it being a separate number: it is the
735            // record of what the REFILL handed over, and a live push is not
736            // evidence about that.
737            if let Some(position) = durable_position {
738                let next = position.saturating_add(1);
739                state.next_replay_seq = state.next_replay_seq.max(next);
740                if replay {
741                    state.replay_offered_seq = state.replay_offered_seq.max(next);
742                }
743            }
744            // LEVEL-TRIGGERED, not edge-triggered: EVERY successful enqueue fires.
745            //
746            // The consumer drains a BOUNDED slice (the server's delivery pump: 32
747            // envelopes per connection slice), so an inbox more than a slice deep
748            // does NOT empty when it is serviced. Under the edge rule a subscriber
749            // in exactly that state — the normal state of anyone who has fallen
750            // behind — earned one wake for an entire burst and none afterwards,
751            // and every later envelope arrived with no wake attached to it at all.
752            // That is a lost-wake hazard on its face, and it is removed here.
753            //
754            // HONEST SCOPE, measured — this is NOT what starves a subscriber at
755            // today's bytes, and it must not be cited as if it were. A/B over 120
756            // fresh-boot iterations per arm (gate-logs/p0-55/) found the edge and
757            // level forms indistinguishable: 51.7% vs 53.3% of boots lost a
758            // subscriber to the depth-cap shed. The reason is R6 coalescing
759            // itself. N fires collapse into one mailbox drain, so turning one wake
760            // into N cannot buy the connection a single extra SLICE, and slices —
761            // not wakes — are what drain the queue. The variable that does move it
762            // is the pump's per-slice budget (32 -> 256 took the same harness to
763            // 0/120), which is a cross-connection fairness knob and not this
764            // file's to turn. See the report accompanying this lane.
765            //
766            // What firing every time costs: one non-blocking `enqueue_atom_message`
767            // per admitted envelope, which R6 coalescing collapses to one slice.
768            // An idle inbox admits nothing and so still fires nothing — the
769            // zero-cost-at-rest property is unchanged.
770            state.notifier.clone()
771        };
772        if let Some(notifier) = notifier {
773            notifier();
774        }
775        if matches!(signal, PressureSignal::Defer { .. }) {
776            InboxAdmission::Deferred(signal)
777        } else {
778            InboxAdmission::Admitted(signal)
779        }
780    }
781
782    /// Steps the replay cursor past an envelope this subscriber's predicate
783    /// filtered out (A1 §2 + §4).
784    ///
785    /// A non-matching envelope contributes no backpressure and is not a gap:
786    /// leaving the cursor behind it would make every later refill re-read and
787    /// re-filter it forever. A lagging subscriber's cursor stays frozen — the
788    /// refill owns it until the gap closes.
789    pub(crate) fn note_filtered(&self, durable_position: u64) {
790        if let Ok(mut state) = self.state.lock()
791            && !state.lagging
792            && !state.closed
793        {
794            state.next_replay_seq = state
795                .next_replay_seq
796                .max(durable_position.saturating_add(1));
797        }
798    }
799
800    /// Removes and returns the next envelope, releasing its CHARGED bytes back to
801    /// the shared budget (exact charge/release symmetry).
802    #[cfg(test)]
803    pub(crate) fn pop(&self) -> Option<Envelope> {
804        self.pop_positioned()
805            .ok()
806            .flatten()
807            .map(|(envelope, _)| envelope)
808    }
809
810    fn pop_positioned(&self) -> Result<Option<(Envelope, Option<u64>)>, LiminalError> {
811        let (envelope, charged, position, budget) = {
812            let mut state = self.state.lock().map_err(subscription_error)?;
813            let Some((envelope, charged, position)) = state.queue.pop_front() else {
814                return Ok(None);
815            };
816            (envelope, charged, position, state.budget.clone())
817        };
818        // Release the charged bytes AFTER dropping the state lock so the shared
819        // budget's atomic is never touched while the inbox lock is held.
820        if let Some(budget) = budget {
821            budget.release(charged);
822        }
823        Ok(Some((envelope, position)))
824    }
825
826    /// Non-consuming race-barrier query used after a connection arms readiness.
827    pub(crate) fn has_pending(&self) -> bool {
828        self.state.lock().is_ok_and(|state| !state.queue.is_empty())
829    }
830
831    /// Atomically closes the inbox, releasing every queued charge back to the
832    /// shared budget: under the lock it marks the inbox closed, drains all
833    /// entries, and detaches the notifier and budget; the summed release happens
834    /// outside the lock. Idempotent. Admissions after close are refused without
835    /// charging ([`InboxAdmission::Closed`]).
836    ///
837    /// This is the release-by-construction seam: explicit unsubscribe, overflow
838    /// shed, and connection teardown ALL reach it through the subscription
839    /// handle's drop (see [`SubscriptionInner::drop`]), and the inbox's own `Drop`
840    /// is the final backstop — no teardown path can strand queued bytes on the
841    /// connection-lifetime budget.
842    pub(crate) fn close(&self) {
843        let (released, budget) = {
844            let Ok(mut state) = self.state.lock() else {
845                return;
846            };
847            if state.closed {
848                return;
849            }
850            state.closed = true;
851            let released: usize = state
852                .queue
853                .drain(..)
854                .map(|(_envelope, charged, _position)| charged)
855                .sum();
856            state.notifier = None;
857            (released, state.budget.take())
858        };
859        if let Some(budget) = budget {
860            budget.release(released);
861        }
862    }
863
864    /// Whether this subscription has been marked for shedding by an overflow.
865    pub(crate) fn is_overflowed(&self) -> bool {
866        self.overflowed.load(Ordering::Acquire)
867    }
868
869    /// Number of queued envelopes.
870    pub(crate) fn queued_len(&self) -> usize {
871        self.state.lock().map_or(0, |state| state.queue.len())
872    }
873
874    /// Number of queued envelopes (test observability alias).
875    #[cfg(test)]
876    pub(crate) fn len(&self) -> usize {
877        self.queued_len()
878    }
879
880    /// This inbox's total A1 bound: `max_in_flight + max_buffer_depth`.
881    ///
882    /// A poisoned lock reports `0`, which contributes nothing to the durable
883    /// channel-aggregate watermark — the honest reading, since an inbox whose
884    /// lock is poisoned admits nothing and so holds no live buffer.
885    pub(crate) fn capacity_bound(&self) -> usize {
886        self.state.lock().map_or(0, |state| {
887            state
888                .capacity
889                .max_in_flight
890                .saturating_add(state.capacity.max_buffer_depth)
891        })
892    }
893
894    /// Whether this subscriber is converging on the durable log via replay
895    /// (A1 §4).
896    pub(crate) fn is_lagging(&self) -> bool {
897        self.state.lock().is_ok_and(|state| state.lagging)
898    }
899
900    /// The next refill batch to read, or `None` when none is due (A1 §4).
901    ///
902    /// `Some((generation, cursor, budget))` only when this subscriber is
903    /// lagging AND its queue has drained below the low watermark
904    /// (`max_in_flight / 2`) AND the free buffer band is non-empty. That last
905    /// conjunct is load-bearing: a zero-length read would return an empty batch
906    /// and be indistinguishable from "caught up", which would clear `lagging`
907    /// while the gap was still open.
908    ///
909    /// `budget` is the free band, so a replay batch can never blow the bound it
910    /// exists to serve (§4 "replay batches size themselves to the free buffer
911    /// band").
912    pub(crate) fn refill_plan(&self) -> Option<(u64, u64, usize)> {
913        let state = self.state.lock().ok()?;
914        if !state.lagging || state.closed {
915            return None;
916        }
917        let queued = state.queue.len();
918        let low_watermark = state.capacity.max_in_flight / 2;
919        if queued > low_watermark {
920            return None;
921        }
922        let bound = state
923            .capacity
924            .max_in_flight
925            .saturating_add(state.capacity.max_buffer_depth);
926        let budget = bound.saturating_sub(queued);
927        if budget == 0 {
928            return None;
929        }
930        Some((state.shed_generation, state.next_replay_seq, budget))
931    }
932
933    /// Declares the gap closed, unless a live push was shed since `generation`
934    /// was taken (A1 §4).
935    ///
936    /// Returns `true` when `lagging` was cleared. The generation check is what
937    /// makes the loop-until-caught-up test correct under concurrent appends: a
938    /// push shed while the head read was in flight bumps the generation, so the
939    /// caller loops instead of declaring victory over a message it never
940    /// delivered.
941    pub(crate) fn clear_lagging_if_unchanged(&self, generation: u64) -> bool {
942        let Ok(mut state) = self.state.lock() else {
943            return false;
944        };
945        if state.shed_generation != generation {
946            return false;
947        }
948        state.lagging = false;
949        true
950    }
951}
952
953impl Drop for SubscriptionInbox {
954    fn drop(&mut self) {
955        // Backstop: if no teardown path ever called `close`, release the queued
956        // charges here so the last Arc dropping can never strand budget bytes.
957        // Idempotent against an earlier close (the closed marker short-circuits).
958        self.close();
959    }
960}
961
962/// A delivery predicate evaluated by the channel actor against each published
963/// envelope. `None` (no predicate) means deliver everything.
964pub(crate) type SubscriptionPredicate = Arc<dyn Fn(&Envelope) -> bool + Send + Sync>;
965
966/// Real beamr native process backing one subscription.
967///
968/// For LOCAL delivery it is an idle handler (mirroring
969/// `aion::worker::link::IdleWorkerProcess`): local envelopes travel through the
970/// shared [`SubscriberInbox`] the channel actor writes and
971/// [`SubscriptionHandle::try_next`] reads. Its other job is to BE a first-class
972/// linkable, killable process whose lifetime equals the subscription's, so the
973/// channel actor detects the subscription dying via a real EXIT signal rather
974/// than by polling a weak pointer.
975///
976/// For CROSS-NODE delivery (SRV-005) it is also the landing point for a remote
977/// publish: a remote node sends a published envelope, encoded by
978/// [`crate::channel::wire::encode_envelope`], as a single beamr binary directly
979/// to this process's pid (the pid the cluster registered in the channel's
980/// distributed process group). The binary lands in this process's mailbox; the
981/// handler decodes it back into an [`Envelope`] and pushes it onto the SAME
982/// inbox a local publish would, so a subscriber observes local and remote
983/// messages identically. Non-binary wakeups (trapped `{EXIT, _, _}` signals) are
984/// drained and ignored.
985struct SubscriberProcess {
986    inbox: SubscriberInbox,
987}
988
989impl NativeHandler for SubscriberProcess {
990    fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
991        // Trapping is set authoritatively at spawn (see `SubscriptionHandle::spawn`)
992        // so it holds before the actor ever links — re-assert it here defensively
993        // for any future restart of this handler.
994        ctx.set_trap_exit(true);
995        // Drain every queued wakeup. A binary message is a remote envelope frame
996        // (SRV-005) to decode and enqueue; everything else (e.g. a trapped
997        // `{EXIT, _, _}` tuple from a crashed actor this subscriber outlives) is
998        // ignored. Death is driven only by an explicit `terminate_process` on
999        // unsubscribe/handle drop.
1000        while let Some(message) = ctx.recv() {
1001            if BinaryRef::new(message).is_some() {
1002                // beamr 0.20.0 ties byte access to a `HeapBorrow` witness, and
1003                // `NativeContext` exposes no borrow of the process heap, so the
1004                // frame is deep-copied into ETS-owned storage and read under
1005                // the copy's own witness — the one borrow-correct path a
1006                // native handler has (beamr#36 tracks a direct accessor). A
1007                // frame whose copy fails is dropped, same as one that fails to
1008                // decode: a corrupt cross-node payload must never crash the
1009                // subscriber or stall delivery of well-formed messages.
1010                if let Ok(owned) = beamr::ets::copy_term_to_ets(message)
1011                    && let Some(binary) = BinaryRef::new(owned.root())
1012                {
1013                    self.accept_remote_frame(binary.as_bytes(owned.borrow_terms()));
1014                }
1015            }
1016        }
1017        NativeOutcome::Wait
1018    }
1019}
1020
1021impl SubscriberProcess {
1022    /// Decode a remote envelope frame and push it onto the inbox. A frame that
1023    /// fails to decode is dropped: a corrupt cross-node payload must never crash
1024    /// the subscriber or stall delivery of well-formed messages.
1025    fn accept_remote_frame(&self, bytes: &[u8]) {
1026        let Ok(envelope) = decode_envelope(bytes) else {
1027            return;
1028        };
1029        // R3: the remote-delivery leg fires the same wake notifier and obeys the
1030        // same §5 byte budget as the local leg. An overflow marks the subscription
1031        // for shedding (inside `admit`); the frame is dropped rather than growing
1032        // server memory.
1033        self.inbox.admit(envelope);
1034    }
1035}
1036
1037/// The actor-side record of one subscriber: the inbox to deliver into and the
1038/// optional predicate to gate delivery. Held by the channel actor INSIDE its
1039/// process, keyed by the subscriber process's pid.
1040pub(crate) struct SubscriberRegistration {
1041    pid: u64,
1042    inbox: SubscriberInbox,
1043    predicate: Option<SubscriptionPredicate>,
1044}
1045
1046impl SubscriberRegistration {
1047    pub(crate) const fn pid(&self) -> u64 {
1048        self.pid
1049    }
1050
1051    /// Offers `envelope` to this subscriber, returning the admission outcome —
1052    /// or `None` when the subscriber's predicate filtered it out.
1053    ///
1054    /// `None` is not a refusal and is deliberately distinct from every
1055    /// [`InboxAdmission`]: A1 §2 requires the decision to run **after the
1056    /// predicate**, so a non-matching subscriber contributes no backpressure at
1057    /// all. Folding it in as a Reject would produce false Defer signals for
1058    /// producers on a heavily filtered channel.
1059    ///
1060    /// R3 + §5 are unchanged underneath: admission charges the connection byte
1061    /// budget, fires the wake notifier for every queued envelope, and on a
1062    /// §5 overflow marks the subscription for shedding. Only
1063    /// [`InboxAdmission::is_queued`] counts as a genuine delivery.
1064    pub(crate) fn deliver(
1065        &self,
1066        envelope: &Envelope,
1067        durable_position: Option<u64>,
1068    ) -> Option<InboxAdmission> {
1069        if let Some(predicate) = self.predicate.as_ref() {
1070            if !predicate(envelope) {
1071                // Not this subscriber's message, so not this subscriber's gap:
1072                // step the replay cursor past it (A1 §4).
1073                if let Some(position) = durable_position {
1074                    self.inbox.note_filtered(position);
1075                }
1076                return None;
1077            }
1078        }
1079        Some(self.inbox.admit_at(envelope.clone(), durable_position))
1080    }
1081
1082    /// This subscriber's live-buffer occupancy and its bound, for the durable
1083    /// pre-append watermark (A1 §4). Read HOST-SIDE, off the actor's command
1084    /// slice, and deliberately coarse: no predicate is evaluated.
1085    pub(crate) fn occupancy(&self) -> (usize, usize) {
1086        (self.inbox.queued_len(), self.inbox.capacity_bound())
1087    }
1088}
1089
1090impl std::fmt::Debug for SubscriberRegistration {
1091    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092        formatter
1093            .debug_struct("SubscriberRegistration")
1094            .field("pid", &self.pid)
1095            .field("has_predicate", &self.predicate.is_some())
1096            .finish_non_exhaustive()
1097    }
1098}
1099
1100/// Handle returned by channel subscriptions for receiving validated envelopes.
1101///
1102/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
1103/// scheduler so the process can be terminated when the subscription ends. The
1104/// handle is the subscription's lifetime: dropping the last clone terminates the
1105/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
1106#[derive(Clone)]
1107pub struct SubscriptionHandle {
1108    inner: Arc<SubscriptionInner>,
1109}
1110
1111struct SubscriptionInner {
1112    pid: u64,
1113    inbox: SubscriberInbox,
1114    scheduler: Arc<Scheduler>,
1115    /// A1 §4: everything the host-side catch-up needs, attached by the durable
1116    /// subscribe path. `OnceLock` because it is written exactly once, before
1117    /// the handle is handed to the caller, and read on every `try_next`.
1118    /// Absent on an ephemeral channel — there is nothing to replay from.
1119    refill: OnceLock<DurableRefill>,
1120    /// Serialises concurrent refills of ONE subscription. Contended only when
1121    /// two threads drain the same handle; the loser skips (the winner is
1122    /// already filling the same queue) rather than queueing behind a store
1123    /// read.
1124    refilling: Mutex<()>,
1125    consumer: OnceLock<Mutex<SubscriptionConsumer>>,
1126}
1127
1128struct SubscriptionConsumer {
1129    cursor: ConsumerCursor,
1130    pending: Option<u64>,
1131}
1132
1133fn subscription_error(error: impl std::fmt::Display) -> LiminalError {
1134    LiminalError::SubscriptionFailed {
1135        message: error.to_string(),
1136    }
1137}
1138
1139/// The host-side auto-catch-up source for a lagging durable subscriber
1140/// (A1 §4, graft §0.4).
1141///
1142/// Deliberately held by the SUBSCRIPTION HANDLE, not by the channel actor: the
1143/// implementability judge relocated catch-up off the actor's command slice
1144/// precisely so a blocking store read can never stall the actor for every
1145/// other subscriber on the channel. The refill runs on whichever thread is
1146/// draining this one subscription.
1147struct DurableRefill {
1148    store: Arc<dyn DurableStore>,
1149    /// The channel's durable partition stream key.
1150    stream_key: String,
1151    /// The schema id stamped on refilled envelopes: the channel's schema at
1152    /// subscribe time. The durable record stores validated payload bytes, not
1153    /// the schema that validated them, so this is the only id available and
1154    /// pretending otherwise would be an invention.
1155    schema_id: SchemaId,
1156    /// The subscription's predicate, re-applied to refilled envelopes so a
1157    /// filtered subscription catches up on exactly the messages it would have
1158    /// received live.
1159    predicate: Option<SubscriptionPredicate>,
1160}
1161
1162impl std::fmt::Debug for DurableRefill {
1163    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164        formatter
1165            .debug_struct("DurableRefill")
1166            .field("stream_key", &self.stream_key)
1167            .field("has_predicate", &self.predicate.is_some())
1168            .finish_non_exhaustive()
1169    }
1170}
1171
1172impl SubscriptionHandle {
1173    /// Spawns a real subscriber process on `scheduler` and returns the handle
1174    /// plus its actor-side registration record (carrying any predicate).
1175    ///
1176    /// # Errors
1177    /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
1178    /// spawn the subscriber process.
1179    pub(crate) fn spawn(
1180        scheduler: &Arc<Scheduler>,
1181        predicate: Option<SubscriptionPredicate>,
1182        install: Option<InboxInstall>,
1183    ) -> Result<(Self, SubscriberRegistration), LiminalError> {
1184        let inbox: SubscriberInbox = SubscriptionInbox::new();
1185        // Install the §5 budget/fairness cap and the R3 wake notifier AT
1186        // CONSTRUCTION — strictly before the registration is handed to the
1187        // channel actor — so there is no window in which a publish can be
1188        // admitted uncharged, past the depth cap, or without a wake.
1189        if let Some(install) = install {
1190            inbox.install_budget(install.budget, install.depth_cap);
1191            if let Some(capacity) = install.capacity {
1192                // REFUSES ALOUD. A window the bus cannot honour fails the
1193                // subscribe rather than leaving the caller on defaults it never
1194                // asked for and cannot observe.
1195                inbox.install_capacity(capacity).map_err(|error| {
1196                    LiminalError::SubscriptionFailed {
1197                        message: format!("declared consumer capacity is invalid: {error}"),
1198                    }
1199                })?;
1200            }
1201            if let Some(notifier) = install.notifier {
1202                inbox.install_notifier(notifier);
1203            }
1204        }
1205        let process_inbox = Arc::clone(&inbox);
1206        let factory = Box::new(move || {
1207            Box::new(SubscriberProcess {
1208                inbox: Arc::clone(&process_inbox),
1209            }) as Box<dyn NativeHandler>
1210        });
1211        // trap_exit is set on the process BEFORE it is published as runnable
1212        // (0.16.1 `spawn_native_trap_exit` — pre-runnable by construction), so
1213        // an abnormal channel-actor crash is trapped (delivered as a message
1214        // the subscriber drains) instead of cascading across the link and
1215        // killing the subscriber. This makes the subscriber outlive a
1216        // channel-actor crash so the restarted actor can re-link to it on boot
1217        // (R2/R4): the flag is in place before the process's first slice, with
1218        // no post-spawn window for `set_trap_exit` to race (or return
1219        // `NoCaller` against a mid-first-slice process).
1220        let pid = scheduler.spawn_native_trap_exit(factory).map_err(|error| {
1221            LiminalError::SubscriptionFailed {
1222                message: format!("failed to spawn subscriber process: {error:?}"),
1223            }
1224        })?;
1225        let handle = Self {
1226            inner: Arc::new(SubscriptionInner {
1227                pid,
1228                inbox: Arc::clone(&inbox),
1229                scheduler: Arc::clone(scheduler),
1230                refill: OnceLock::new(),
1231                refilling: Mutex::new(()),
1232                consumer: OnceLock::new(),
1233            }),
1234        };
1235        let registration = SubscriberRegistration {
1236            pid,
1237            inbox,
1238            predicate,
1239        };
1240        Ok((handle, registration))
1241    }
1242
1243    /// Returns the beamr pid of the subscriber process this handle owns.
1244    #[must_use]
1245    pub(crate) fn pid(&self) -> u64 {
1246        self.inner.pid
1247    }
1248
1249    /// Attempts to receive the next delivered envelope without blocking.
1250    ///
1251    /// # Errors
1252    ///
1253    /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
1254    pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
1255        self.try_next_positioned()
1256            .map(|next| next.map(|(envelope, _)| envelope))
1257    }
1258
1259    /// Receives one envelope with its store position, retaining it until acknowledged.
1260    ///
1261    /// # Errors
1262    /// A cursor still awaits its previous acknowledgement, or the inbox/store failed.
1263    pub fn try_next_positioned(&self) -> Result<Option<(Envelope, Option<u64>)>, LiminalError> {
1264        let mut consumer = self
1265            .inner
1266            .consumer
1267            .get()
1268            .map(|consumer| consumer.lock())
1269            .transpose()
1270            .map_err(subscription_error)?;
1271        if consumer
1272            .as_ref()
1273            .is_some_and(|consumer| consumer.pending.is_some())
1274        {
1275            return Err(subscription_error(
1276                "a delivered position still awaits acknowledgement",
1277            ));
1278        }
1279        self.refill_if_lagging()?;
1280        let next = self.inner.inbox.pop_positioned()?;
1281        if let (Some(consumer), Some((_, position))) = (consumer.as_mut(), next.as_ref()) {
1282            consumer.pending = *position;
1283        }
1284        Ok(next)
1285    }
1286
1287    /// Persists the next position only after the consumer has settled this delivery.
1288    ///
1289    /// # Errors
1290    /// The position was not delivered, or checkpoint/flush failed.
1291    pub fn acknowledge_position(&self, position: u64) -> Result<(), LiminalError> {
1292        let consumer = self
1293            .inner
1294            .consumer
1295            .get()
1296            .ok_or_else(|| subscription_error("subscription has no durable consumer"))?;
1297        let refill = self
1298            .inner
1299            .refill
1300            .get()
1301            .ok_or_else(|| subscription_error("subscription has no durable stream"))?;
1302        let mut consumer = consumer.lock().map_err(subscription_error)?;
1303        if consumer.pending != Some(position) {
1304            return Err(subscription_error(
1305                "acknowledgement does not name the delivered position",
1306            ));
1307        }
1308        let next = position
1309            .checked_add(1)
1310            .ok_or_else(|| subscription_error("subscription position overflow"))?;
1311        block_on(consumer.cursor.checkpoint(refill.store.as_ref(), next))
1312            .map_err(subscription_error)?
1313            .map_err(subscription_error)?;
1314        block_on(refill.store.flush())
1315            .map_err(subscription_error)?
1316            .map_err(subscription_error)?;
1317        consumer.pending = None;
1318        drop(consumer);
1319        Ok(())
1320    }
1321
1322    /// The persisted consumer position, absent on an ordinary live subscription.
1323    ///
1324    /// # Errors
1325    /// The consumer lock failed.
1326    pub fn consumer_position(&self) -> Result<Option<u64>, LiminalError> {
1327        self.inner
1328            .consumer
1329            .get()
1330            .map(|consumer| {
1331                consumer
1332                    .lock()
1333                    .map(|consumer| consumer.cursor.current_offset())
1334                    .map_err(subscription_error)
1335            })
1336            .transpose()
1337    }
1338
1339    pub(crate) fn resume_consumer(&self, id: &str) -> Result<(), LiminalError> {
1340        if id.is_empty() {
1341            return Err(subscription_error("durable consumer id is empty"));
1342        }
1343        let refill =
1344            self.inner.refill.get().ok_or_else(|| {
1345                subscription_error("a durable consumer requires a durable channel")
1346            })?;
1347        let cursor = block_on(ConsumerCursor::resume(
1348            id,
1349            &refill.stream_key,
1350            refill.store.as_ref(),
1351        ))
1352        .map_err(subscription_error)?
1353        .map_err(subscription_error)?;
1354        let offset = cursor.current_offset();
1355        self.inner
1356            .consumer
1357            .set(Mutex::new(SubscriptionConsumer {
1358                cursor,
1359                pending: None,
1360            }))
1361            .map_err(|_| subscription_error("durable consumer is already installed"))?;
1362        let mut state = self.inner.inbox.state.lock().map_err(subscription_error)?;
1363        state.next_replay_seq = offset;
1364        state.replay_offered_seq = offset;
1365        state.lagging = true;
1366        drop(state);
1367        Ok(())
1368    }
1369
1370    /// Seeds this subscription's replay cursor at the durable log head it is
1371    /// joining at (A1 §4). Called by the durable subscribe path BEFORE the
1372    /// registration reaches the channel actor, so the first publish this
1373    /// subscription can possibly see is already measured against the right
1374    /// origin.
1375    pub(crate) fn seed_replay_cursor(&self, head: u64) {
1376        self.inner.inbox.seed_replay_cursor(head);
1377    }
1378
1379    /// Attaches the durable catch-up source (A1 §4). Called once by the durable
1380    /// subscribe path before the handle is returned; a second call is a
1381    /// mis-wiring of that path and trips a debug assertion rather than being
1382    /// silently ignored.
1383    pub(crate) fn attach_durable_refill(
1384        &self,
1385        store: Arc<dyn DurableStore>,
1386        stream_key: String,
1387        schema_id: SchemaId,
1388        predicate: Option<SubscriptionPredicate>,
1389    ) {
1390        let attached = self.inner.refill.set(DurableRefill {
1391            store,
1392            stream_key,
1393            schema_id,
1394            predicate,
1395        });
1396        debug_assert!(
1397            attached.is_ok(),
1398            "the refill OnceLock is set exactly once per attach: attach_durable_refill \
1399             was called twice on one subscription, which only the durable subscribe \
1400             path may call and only before the handle is returned"
1401        );
1402    }
1403
1404    /// Replays the missed range into the bounded inbox until this subscriber is
1405    /// caught up or its free band is full (A1 §4, graft §0.4).
1406    ///
1407    /// **Loop-until-caught-up.** The log head can move under concurrent
1408    /// appends, so "caught up" is not a snapshot taken once: each iteration
1409    /// re-reads from the cursor, and the flag clears only when a read comes
1410    /// back empty AND no live push was shed while that read was in flight. The
1411    /// chase is bounded because the pre-append watermark throttles producers
1412    /// while any subscriber lags.
1413    ///
1414    /// **In-order, exactly once.** Live pushes stay shed while `lagging` is
1415    /// set, so nothing can overtake the replay; the cursor advances only past
1416    /// entries this call actually offered, so nothing is offered twice.
1417    ///
1418    /// Recovery runs before dequeue so a store failure leaves the delivery
1419    /// in the inbox and reaches the consumer as an error.
1420    pub(crate) fn refill_if_lagging(&self) -> Result<(), LiminalError> {
1421        let Some(refill) = self.inner.refill.get() else {
1422            return Ok(());
1423        };
1424        if !self.inner.inbox.is_lagging() {
1425            return Ok(());
1426        }
1427        // Another thread draining this same subscription is already refilling
1428        // it; a second concurrent replay would read the same range twice.
1429        let _guard = match self.inner.refilling.try_lock() {
1430            Ok(guard) => guard,
1431            Err(std::sync::TryLockError::WouldBlock) => return Ok(()),
1432            Err(std::sync::TryLockError::Poisoned(error)) => return Err(subscription_error(error)),
1433        };
1434        while let Some((generation, cursor, budget)) = self.inner.inbox.refill_plan() {
1435            let batch = block_on(replay_range(
1436                refill.store.as_ref(),
1437                &refill.stream_key,
1438                cursor,
1439                budget,
1440            ))
1441            .map_err(subscription_error)?
1442            .map_err(subscription_error)?;
1443            if batch.is_empty() {
1444                // The read reached the head. Clear the gap ONLY if no live push
1445                // was shed since the generation was taken; otherwise loop and
1446                // read again for the message that shed.
1447                if self.inner.inbox.clear_lagging_if_unchanged(generation) {
1448                    return Ok(());
1449                }
1450                continue;
1451            }
1452            for (sequence, stored) in batch {
1453                let envelope = refilled_envelope(&stored, refill.schema_id);
1454                let matched = refill
1455                    .predicate
1456                    .as_ref()
1457                    .is_none_or(|predicate| predicate(&envelope));
1458                if matched {
1459                    // Through the SAME bounded admission as a live push. The
1460                    // lagging flag is still set, so `admit_at` would shed this
1461                    // as a live push — the refill therefore goes in through the
1462                    // replay door below, which is the only writer allowed to
1463                    // move the cursor while the gap is open.
1464                    if !self.inner.inbox.admit_replayed(envelope, sequence) {
1465                        return Ok(());
1466                    }
1467                } else {
1468                    self.inner.inbox.note_replayed_filtered(sequence);
1469                }
1470            }
1471        }
1472        Ok(())
1473    }
1474
1475    /// The subscriber process's pid: the id
1476    /// [`ChannelHandle::publish_recording`](crate::channel::ChannelHandle::publish_recording)
1477    /// records for this subscription, and the one a
1478    /// [`ClusterObserver`](crate::channel::ClusterObserver) hears it by.
1479    #[must_use]
1480    pub fn subscriber_pid(&self) -> u64 {
1481        self.inner.pid
1482    }
1483
1484    /// Whether an envelope is available without consuming it.
1485    #[must_use]
1486    pub fn has_pending(&self) -> bool {
1487        self.inner.inbox.has_pending()
1488    }
1489
1490    /// Whether an overflow has marked this subscription for shedding (§5).
1491    #[must_use]
1492    pub fn is_overflowed(&self) -> bool {
1493        self.inner.inbox.is_overflowed()
1494    }
1495}
1496
1497impl std::fmt::Debug for SubscriptionHandle {
1498    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1499        formatter
1500            .debug_struct("SubscriptionHandle")
1501            .field("pid", &self.inner.pid)
1502            .finish_non_exhaustive()
1503    }
1504}
1505
1506impl Drop for SubscriptionInner {
1507    fn drop(&mut self) {
1508        // Close the inbox FIRST: atomically mark it closed, drain queued entries,
1509        // and release every charged byte back to the shared connection budget
1510        // (§5 — queued bytes must never be stranded on the connection-lifetime
1511        // budget by unsubscribe, shed, or teardown; all of them funnel through
1512        // this drop). Post-close deliveries from the channel actor (whose EXIT
1513        // prune below is asynchronous) are refused without charging.
1514        self.inbox.close();
1515        // Terminating the subscriber process fires the bidirectional link to the
1516        // channel actor, which traps the EXIT and removes this subscriber from
1517        // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
1518        self.scheduler
1519            .terminate_process(self.pid, ExitReason::Normal);
1520    }
1521}
1522
1523/// Rebuilds a bus [`Envelope`] from a durable log entry, for the host-side
1524/// refill (A1 §4).
1525///
1526/// Two fields are deliberately NOT reconstructed, because the log does not hold
1527/// them and inventing them would make a refilled envelope claim more than the
1528/// store can support:
1529///
1530/// * **causal context** — the durable publish path persists `None` for it
1531///   (`ChannelHandle::persist_and_enqueue`), so there is nothing to restore;
1532/// * **message id** — the durable record does not store the live envelope's id,
1533///   so a fresh one is minted. A consumer that needs to correlate a replayed
1534///   message with a live one uses its durable position, which is exact.
1535fn refilled_envelope(stored: &MessageEnvelope, schema_id: SchemaId) -> Envelope {
1536    let millis = i64::try_from(stored.timestamp).unwrap_or(i64::MAX);
1537    let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, millis)
1538        .single()
1539        .unwrap_or_else(chrono::Utc::now);
1540    Envelope::with_timestamp(
1541        stored.payload.clone(),
1542        None,
1543        schema_id,
1544        PublisherId::new(stored.publisher_id.clone()),
1545        timestamp,
1546    )
1547}
1548
1549/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
1550/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
1551///
1552/// This proves the production subscriber handler — the same `NativeHandler` the
1553/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
1554/// cooperative scheduler that a browser host drives. There is no toy stand-in:
1555/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
1556/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
1557/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
1558/// handler's own `accept_remote_frame` path and lands in the shared inbox a
1559/// [`SubscriptionHandle::try_next`] would read.
1560///
1561/// The handler runs cooperatively AS-IS: its `handle` only touches
1562/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
1563/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
1564/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
1565/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
1566/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
1567/// native.
1568#[cfg(test)]
1569#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1570mod cooperative_smoke {
1571    use std::cell::RefCell;
1572    use std::rc::Rc;
1573    use std::sync::Arc;
1574
1575    use beamr::atom::AtomTable;
1576    use beamr::ets::copy_term_to_ets;
1577    use beamr::module::ModuleRegistry;
1578    use beamr::native::BifRegistryImpl;
1579    use beamr::process::heap::Heap;
1580    use beamr::scheduler::WasmScheduler;
1581    use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
1582
1583    use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
1584    use crate::channel::SchemaId;
1585    use crate::channel::wire::encode_envelope;
1586    use crate::envelope::{Envelope, PublisherId};
1587
1588    /// Build a cooperative scheduler the way a wasm host holds it (single
1589    /// `Rc<RefCell<…>>` on one thread).
1590    fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
1591        let atom_table = Arc::new(AtomTable::with_common_atoms());
1592        let modules = Arc::new(ModuleRegistry::new());
1593        let bifs = Arc::new(BifRegistryImpl::new());
1594        Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
1595    }
1596
1597    /// Encode `envelope` into the production wire frame and wrap it as a
1598    /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
1599    /// remote node hands a published frame to a subscriber pid (SRV-005).
1600    fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
1601        let bytes = encode_envelope(envelope);
1602        let shared = SharedBinary::new(bytes);
1603        // A ProcBin reference needs three heap words; copy it into ETS-owned
1604        // memory so the scratch heap can be dropped before delivery.
1605        let mut scratch = Heap::new(8);
1606        let words = scratch
1607            .alloc_slice(3)
1608            .expect("scratch heap holds a proc-bin reference");
1609        let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
1610        copy_term_to_ets(term).expect("frame copies into an owned binary")
1611    }
1612
1613    fn sample_envelope() -> Envelope {
1614        // A whole-millisecond timestamp so the round-trip through the wire codec
1615        // (which carries millisecond resolution, see `channel::wire`) is exact;
1616        // `Utc::now()` sub-millisecond precision would otherwise be truncated on
1617        // decode and is irrelevant to what this smoke proves.
1618        let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
1619            .single()
1620            .expect("valid fixed millisecond timestamp");
1621        Envelope::with_timestamp(
1622            b"{\"value\":42}".to_vec(),
1623            None,
1624            SchemaId::new(),
1625            PublisherId::from("publisher-cooperative"),
1626            timestamp,
1627        )
1628    }
1629
1630    #[test]
1631    fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
1632        let scheduler = cooperative_scheduler();
1633
1634        // The shared inbox the subscriber pushes decoded envelopes onto — the
1635        // exact channel the threaded `SubscriptionHandle::try_next` reads.
1636        let inbox: SubscriberInbox = SubscriptionInbox::new();
1637        let process_inbox = Arc::clone(&inbox);
1638
1639        // Spawn the GENUINE production subscriber handler as a first-class native
1640        // process on the cooperative scheduler.
1641        let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
1642            Box::new(SubscriberProcess {
1643                inbox: Arc::clone(&process_inbox),
1644            }) as Box<dyn beamr::native::native_process::NativeHandler>
1645        }));
1646
1647        // First turn: the handler runs once, asserts trap_exit, finds an empty
1648        // mailbox, and parks (`Wait`). No envelope has been delivered yet.
1649        scheduler.borrow_mut().run_until_idle();
1650        assert_eq!(
1651            inbox.len(),
1652            0,
1653            "no envelope is delivered before one is published"
1654        );
1655
1656        // Publish: deliver a real encoded frame as a beamr binary straight to the
1657        // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
1658        // the parked process.
1659        let published = sample_envelope();
1660        let frame = frame_as_owned_binary(&published);
1661        scheduler
1662            .borrow_mut()
1663            .send_owned(pid, &frame)
1664            .expect("frame is delivered to the live subscriber pid");
1665
1666        // Pump turns: the woken handler drains the binary, decodes it through its
1667        // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
1668        let mut delivered = None;
1669        for _ in 0..8 {
1670            scheduler.borrow_mut().run_until_idle();
1671            let next = inbox.pop();
1672            if let Some(envelope) = next {
1673                delivered = Some(envelope);
1674                break;
1675            }
1676        }
1677
1678        assert_eq!(
1679            delivered.as_ref(),
1680            Some(&published),
1681            "the real subscriber decoded and delivered the published envelope"
1682        );
1683    }
1684}
1685
1686/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
1687/// admitted envelope; the shared byte budget is spent across ALL a
1688/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
1689/// fairness trip stops one inbox starving its siblings; and charge/release is
1690/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
1691/// with no scheduler — the server-side wake wiring and shed are tested there.
1692#[cfg(test)]
1693#[allow(clippy::expect_used)]
1694mod inbox_bounding {
1695    use std::sync::Arc;
1696    use std::sync::atomic::{AtomicUsize, Ordering};
1697
1698    use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
1699    use crate::channel::SchemaId;
1700    use crate::channel::wire::encode_envelope;
1701    use crate::envelope::{Envelope, PublisherId};
1702    use crate::pressure::ConsumerCapacity;
1703
1704    fn envelope(payload: &[u8]) -> Envelope {
1705        Envelope::new(
1706            payload.to_vec(),
1707            None,
1708            SchemaId::new(),
1709            PublisherId::from("inbox-bounding-test"),
1710        )
1711    }
1712
1713    fn admitted_bytes(env: &Envelope) -> usize {
1714        encode_envelope(env).len()
1715    }
1716
1717    /// The level-triggered wake contract (P0 #55). This test previously asserted
1718    /// the edge-triggered form — that a second admit into a non-empty inbox does
1719    /// NOT re-fire — which is precisely the starvation the fix removes: the
1720    /// consumer drains a bounded slice, so a non-empty inbox is the normal state
1721    /// of a subscriber that has fallen behind, and withholding its wake is what
1722    /// ratchets it to the depth cap and a permanent shed.
1723    #[test]
1724    fn notifier_fires_for_every_admitted_envelope() {
1725        let inbox = SubscriptionInbox::new();
1726        let fires = Arc::new(AtomicUsize::new(0));
1727        let counter = Arc::clone(&fires);
1728        inbox.install_notifier(Arc::new(move || {
1729            counter.fetch_add(1, Ordering::Relaxed);
1730        }));
1731
1732        // First admit into an empty inbox fires.
1733        assert!(inbox.admit(envelope(b"a")).is_queued());
1734        assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
1735
1736        // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
1737        // may not have reached this envelope's slice, and R6 coalescing means a
1738        // redundant marker costs one mailbox atom, never a second slice of work.
1739        assert!(inbox.admit(envelope(b"b")).is_queued());
1740        assert_eq!(
1741            fires.load(Ordering::Relaxed),
1742            2,
1743            "an admit into a non-empty inbox still fires"
1744        );
1745
1746        // Drain to empty, then admit again: still exactly one fire per admit.
1747        assert!(inbox.pop().is_some());
1748        assert!(inbox.pop().is_some());
1749        assert!(inbox.admit(envelope(b"c")).is_queued());
1750        assert_eq!(
1751            fires.load(Ordering::Relaxed),
1752            3,
1753            "one fire per admitted envelope, whatever the queue depth was"
1754        );
1755    }
1756
1757    #[test]
1758    fn shared_budget_is_spent_across_all_a_connections_inboxes() {
1759        let one = envelope(b"payload-one");
1760        let two = envelope(b"payload-two");
1761        // A budget large enough for exactly ONE of the two envelopes.
1762        let cap = admitted_bytes(&one);
1763        let budget = ConnectionInboxBudget::new(cap);
1764
1765        let inbox_a = SubscriptionInbox::new();
1766        let inbox_b = SubscriptionInbox::new();
1767        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1768        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1769
1770        // Inbox A admits its envelope, consuming the whole shared budget.
1771        assert!(inbox_a.admit(one).is_queued());
1772        assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
1773
1774        // Inbox B — a SIBLING subscription — is refused: the budget is connection
1775        // scoped, not per-inbox, so A's fill denies B.
1776        assert!(matches!(
1777            inbox_b.admit(two),
1778            InboxAdmission::BudgetExceeded(_)
1779        ));
1780        assert!(
1781            inbox_b.is_overflowed(),
1782            "the sibling that overflowed the shared budget is shed"
1783        );
1784        assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
1785
1786        // Draining A releases its bytes back to the SHARED budget, so B could then
1787        // admit (charge/release symmetry across siblings).
1788        assert!(inbox_a.pop().is_some());
1789        assert_eq!(
1790            budget.used(),
1791            0,
1792            "release returns bytes to the shared budget"
1793        );
1794    }
1795
1796    #[test]
1797    fn overflow_sheds_and_does_not_grow_memory() {
1798        let env = envelope(b"x");
1799        let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
1800        let inbox = SubscriptionInbox::new();
1801        inbox.install_budget(budget, usize::MAX);
1802
1803        assert!(inbox.admit(env.clone()).is_queued());
1804        // The next admit overflows: refused, marked for shedding, and NOT queued —
1805        // the queue length does not grow past the bound.
1806        assert!(matches!(
1807            inbox.admit(env),
1808            InboxAdmission::BudgetExceeded(_)
1809        ));
1810        assert!(inbox.is_overflowed());
1811        assert_eq!(
1812            inbox.len(),
1813            1,
1814            "the overflowed envelope is dropped, not queued"
1815        );
1816    }
1817
1818    #[test]
1819    fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
1820        // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
1821        let budget = ConnectionInboxBudget::new(usize::MAX);
1822        let inbox = SubscriptionInbox::new();
1823        inbox.install_budget(budget, 2); // depth cap of 2 envelopes
1824
1825        assert!(inbox.admit(envelope(b"1")).is_queued());
1826        assert!(inbox.admit(envelope(b"2")).is_queued());
1827        // The third trips the fairness cap even though bytes are available.
1828        assert!(matches!(
1829            inbox.admit(envelope(b"3")),
1830            InboxAdmission::FairnessTripped(_)
1831        ));
1832        assert!(inbox.is_overflowed());
1833        assert_eq!(
1834            inbox.len(),
1835            2,
1836            "the fairness trip holds the inbox at its cap"
1837        );
1838    }
1839
1840    #[test]
1841    fn charge_and_release_are_exact() {
1842        let budget = ConnectionInboxBudget::new(1024 * 1024);
1843        let inbox = SubscriptionInbox::new();
1844        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1845
1846        let a = envelope(b"first-envelope");
1847        let b = envelope(b"second-longer-envelope-payload");
1848        let charge = admitted_bytes(&a) + admitted_bytes(&b);
1849        assert!(inbox.admit(a).is_queued());
1850        assert!(inbox.admit(b).is_queued());
1851        assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
1852
1853        assert!(inbox.pop().is_some());
1854        assert!(inbox.pop().is_some());
1855        assert_eq!(
1856            budget.used(),
1857            0,
1858            "every admitted byte is released on dequeue — exact symmetry"
1859        );
1860    }
1861
1862    /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
1863    /// shape — an overflowed inbox is near-full by construction) releases every
1864    /// charged byte back to the shared budget, so a sibling subscription can
1865    /// admit again. Without the close-release, one shed strands its whole share
1866    /// of the 4 MiB budget forever.
1867    #[test]
1868    fn close_releases_queued_charges_so_siblings_recover() {
1869        // Budget sized so inbox A can queue a 256-envelope backlog (the §5
1870        // fairness-cap depth) and exhaust the shared budget doing it.
1871        let one = envelope(b"backlog-envelope-payload");
1872        let unit = admitted_bytes(&one);
1873        let budget = ConnectionInboxBudget::new(unit * 256);
1874
1875        let inbox_a = SubscriptionInbox::new();
1876        let inbox_b = SubscriptionInbox::new();
1877        inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1878        inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1879
1880        // Queue the full 256-envelope backlog on A, consuming the whole budget.
1881        for _ in 0..256 {
1882            assert!(inbox_a.admit(one.clone()).is_queued());
1883        }
1884        assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
1885        // The sibling is starved (the shed trigger condition).
1886        assert!(matches!(
1887            inbox_b.admit(one.clone()),
1888            InboxAdmission::BudgetExceeded(_)
1889        ));
1890
1891        // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
1892        // returns to the shared budget in one atomic close.
1893        inbox_a.close();
1894        assert_eq!(
1895            budget.used(),
1896            0,
1897            "close releases the entire queued backlog back to the shared budget"
1898        );
1899        // The sibling recovers: it can admit again.
1900        assert!(
1901            inbox_b.admit(one).is_queued(),
1902            "a sibling admits again after the other inbox is shed"
1903        );
1904    }
1905
1906    /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1907    /// the budget, so a shed subscription can never re-accumulate cost while the
1908    /// channel actor's asynchronous EXIT prune is still in flight.
1909    #[test]
1910    fn closed_inbox_refuses_without_charging() {
1911        let env = envelope(b"post-close");
1912        let budget = ConnectionInboxBudget::new(1024 * 1024);
1913        let inbox = SubscriptionInbox::new();
1914        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1915
1916        inbox.close();
1917        assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1918        assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1919        assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1920    }
1921
1922    /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1923    /// called `close`, the last handle dropping still releases the queued charges
1924    /// (release-by-construction: a release that cannot be omitted).
1925    #[test]
1926    fn drop_backstop_releases_queued_charges() {
1927        let env = envelope(b"dropped-while-queued");
1928        let unit = admitted_bytes(&env);
1929        let budget = ConnectionInboxBudget::new(1024 * 1024);
1930        {
1931            let inbox = SubscriptionInbox::new();
1932            inbox.install_budget(Arc::clone(&budget), usize::MAX);
1933            assert!(inbox.admit(env.clone()).is_queued());
1934            assert!(inbox.admit(env).is_queued());
1935            assert_eq!(budget.used(), unit * 2);
1936            // No close() call: the Arc drops here.
1937        }
1938        assert_eq!(
1939            budget.used(),
1940            0,
1941            "dropping the last inbox handle releases every queued charge"
1942        );
1943    }
1944
1945    /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1946    /// nothing (the queue was drained into the release).
1947    #[test]
1948    fn close_is_idempotent_and_drains_the_queue() {
1949        let env = envelope(b"x");
1950        let budget = ConnectionInboxBudget::new(1024 * 1024);
1951        let inbox = SubscriptionInbox::new();
1952        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1953        assert!(inbox.admit(env).is_queued());
1954
1955        inbox.close();
1956        inbox.close(); // second close is a no-op, not a double release
1957        assert_eq!(budget.used(), 0);
1958        assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1959    }
1960
1961    /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1962    /// budget was installed carries a charge of 0 — its dequeue releases exactly
1963    /// 0 against the later-installed budget, never bytes it did not charge. The
1964    /// production subscribe path installs the budget at inbox construction so
1965    /// this window is structurally closed; this pins the defensive invariant
1966    /// that makes release byte-identical to charge on EVERY entry regardless.
1967    #[test]
1968    fn per_entry_charge_ownership_survives_budget_install() {
1969        let uncharged = envelope(b"admitted-before-budget-install");
1970        let charged = envelope(b"admitted-after-budget-install");
1971        let inbox = SubscriptionInbox::new();
1972
1973        // Admitted with no budget installed: charge ownership 0.
1974        assert!(inbox.admit(uncharged).is_queued());
1975
1976        let budget = ConnectionInboxBudget::new(1024 * 1024);
1977        inbox.install_budget(Arc::clone(&budget), usize::MAX);
1978        let unit = admitted_bytes(&charged);
1979        assert!(inbox.admit(charged).is_queued());
1980        assert_eq!(budget.used(), unit, "only the post-install entry charged");
1981
1982        // Popping the uncharged entry releases exactly 0 — the budget cannot
1983        // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1984        // that were never charged.
1985        assert!(inbox.pop().is_some());
1986        assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1987        assert!(inbox.pop().is_some());
1988        assert_eq!(
1989            budget.used(),
1990            0,
1991            "the charged entry released its exact charge"
1992        );
1993    }
1994
1995    /// Review round 1 item 5 (install recheck): installing a notifier onto an
1996    /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1997    /// admitted while there was no notifier to fire, so the install regenerates
1998    /// their wake and one can never be lost to install ordering. (The production
1999    /// subscribe path installs at construction, when the queue is guaranteed
2000    /// empty; this pins the defensive invariant.)
2001    #[test]
2002    fn notifier_install_onto_non_empty_inbox_fires_once() {
2003        let inbox = SubscriptionInbox::new();
2004        assert!(inbox.admit(envelope(b"pre-install")).is_queued());
2005
2006        let fires = Arc::new(AtomicUsize::new(0));
2007        let counter = Arc::clone(&fires);
2008        inbox.install_notifier(Arc::new(move || {
2009            counter.fetch_add(1, Ordering::Relaxed);
2010        }));
2011        assert_eq!(
2012            fires.load(Ordering::Relaxed),
2013            1,
2014            "install onto a non-empty inbox regenerates exactly one wake"
2015        );
2016
2017        // The install is a ONE-OFF regeneration, not an extra fire per admit: a
2018        // subsequent admit adds exactly its own one fire.
2019        assert!(inbox.admit(envelope(b"second")).is_queued());
2020        assert_eq!(fires.load(Ordering::Relaxed), 2);
2021    }
2022
2023    /// **PIN (round 2) — the exactly-once guard suppresses what the REPLAY DOOR
2024    /// offered, and nothing else.**
2025    ///
2026    /// A live push that arrives behind a higher-positioned sibling has been
2027    /// offered by nobody. Suppressing it is silent loss, and worse than the
2028    /// duplicate the guard exists to prevent: nothing is shed, so `lagging` is
2029    /// never set and auto-catch-up can never recover it, while
2030    /// [`InboxAdmission::is_queued`] still reports it to the producer as
2031    /// delivered.
2032    ///
2033    /// The publish path is ordered so that this cannot happen in production
2034    /// (the fan-out command is enqueued under the append lock). This pin is the
2035    /// inbox's OWN control on the same claim: whatever the publish path does,
2036    /// the guard's comparand may only be advanced by a door that genuinely made
2037    /// an offer, so the worst an inversion could ever cost is a duplicate —
2038    /// never a lost message.
2039    #[test]
2040    fn a_live_push_is_not_suppressed_by_a_higher_positioned_sibling() {
2041        let inbox = SubscriptionInbox::new();
2042
2043        assert!(
2044            inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued(),
2045            "the higher-positioned push is queued"
2046        );
2047        let behind = inbox.admit_at(envelope(b"position-3"), Some(3));
2048        assert!(
2049            !matches!(behind, InboxAdmission::AlreadyOffered(_)),
2050            "position 3 was offered by NOBODY — its only sin is arriving behind \
2051             position 5, and suppressing it loses it silently; got {behind:?}"
2052        );
2053        assert!(behind.is_queued(), "position 3 must reach the queue");
2054        assert_eq!(
2055            inbox.queued_len(),
2056            2,
2057            "both live pushes are in the queue, in arrival order"
2058        );
2059    }
2060
2061    /// **PIN (round 2), the anti-vacuity partner** — the guard still fires for
2062    /// the position the replay door actually offered. Without this, the pin
2063    /// above passes on a guard that was simply deleted.
2064    #[test]
2065    fn a_live_push_the_replay_door_already_offered_is_suppressed() {
2066        let inbox = SubscriptionInbox::new();
2067
2068        assert!(
2069            inbox.admit_replayed(envelope(b"replayed-5"), 5),
2070            "the replay door offers position 5"
2071        );
2072        let live = inbox.admit_at(envelope(b"live-5"), Some(5));
2073        assert!(
2074            matches!(live, InboxAdmission::AlreadyOffered(_)),
2075            "position 5 was already offered by the refill; got {live:?}"
2076        );
2077        assert_eq!(
2078            inbox.queued_len(),
2079            1,
2080            "the suppressed push did not enter the queue a second time"
2081        );
2082    }
2083
2084    /// **PIN (round 2) — the refill cursor never backsteps onto a position that
2085    /// was already delivered.**
2086    ///
2087    /// The cursor names where the missed range STARTS. Assigning it
2088    /// `position + 1` unconditionally lets a live push that arrives behind a
2089    /// higher-positioned sibling drag it backwards over messages this
2090    /// subscriber already has, and the next refill then re-reads and re-offers
2091    /// them — the duplicate this whole seam exists to prevent, arriving through
2092    /// the other door.
2093    #[test]
2094    fn the_refill_cursor_never_backsteps_below_a_delivered_position() {
2095        let inbox = SubscriptionInbox::new();
2096        inbox
2097            .install_capacity(ConsumerCapacity::new(1, 1).expect("1/1 is a legal capacity"))
2098            .expect("a legal capacity installs");
2099
2100        assert!(inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued());
2101        assert!(inbox.admit_at(envelope(b"position-3"), Some(3)).is_queued());
2102        // The bound (1 + 1) is now full, so this one is shed and OPENS the gap.
2103        assert!(matches!(
2104            inbox.admit_at(envelope(b"position-6"), Some(6)),
2105            InboxAdmission::Rejected(_)
2106        ));
2107        assert!(inbox.is_lagging(), "the shed opened a gap");
2108
2109        assert!(inbox.pop().is_some());
2110        assert!(inbox.pop().is_some());
2111        let (_generation, cursor, _budget) = inbox
2112            .refill_plan()
2113            .expect("a drained lagging inbox has a refill due");
2114        assert_eq!(
2115            cursor, 6,
2116            "the missed range starts above every delivered position; a cursor of \
2117             4 would send the refill back over positions 4 and 5, and position 5 \
2118             is already in this subscriber's hands"
2119        );
2120    }
2121}