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::{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)>,
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));
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 pub(crate) fn pop(&self) -> Option<Envelope> {
803 let (envelope, charged, budget) = {
804 let mut state = self.state.lock().ok()?;
805 let (envelope, charged) = state.queue.pop_front()?;
806 (envelope, charged, state.budget.clone())
807 };
808 // Release the charged bytes AFTER dropping the state lock so the shared
809 // budget's atomic is never touched while the inbox lock is held.
810 if let Some(budget) = budget {
811 budget.release(charged);
812 }
813 Some(envelope)
814 }
815
816 /// Non-consuming race-barrier query used after a connection arms readiness.
817 pub(crate) fn has_pending(&self) -> bool {
818 self.state.lock().is_ok_and(|state| !state.queue.is_empty())
819 }
820
821 /// Atomically closes the inbox, releasing every queued charge back to the
822 /// shared budget: under the lock it marks the inbox closed, drains all
823 /// entries, and detaches the notifier and budget; the summed release happens
824 /// outside the lock. Idempotent. Admissions after close are refused without
825 /// charging ([`InboxAdmission::Closed`]).
826 ///
827 /// This is the release-by-construction seam: explicit unsubscribe, overflow
828 /// shed, and connection teardown ALL reach it through the subscription
829 /// handle's drop (see [`SubscriptionInner::drop`]), and the inbox's own `Drop`
830 /// is the final backstop — no teardown path can strand queued bytes on the
831 /// connection-lifetime budget.
832 pub(crate) fn close(&self) {
833 let (released, budget) = {
834 let Ok(mut state) = self.state.lock() else {
835 return;
836 };
837 if state.closed {
838 return;
839 }
840 state.closed = true;
841 let released: usize = state
842 .queue
843 .drain(..)
844 .map(|(_envelope, charged)| charged)
845 .sum();
846 state.notifier = None;
847 (released, state.budget.take())
848 };
849 if let Some(budget) = budget {
850 budget.release(released);
851 }
852 }
853
854 /// Whether this subscription has been marked for shedding by an overflow.
855 pub(crate) fn is_overflowed(&self) -> bool {
856 self.overflowed.load(Ordering::Acquire)
857 }
858
859 /// Number of queued envelopes.
860 pub(crate) fn queued_len(&self) -> usize {
861 self.state.lock().map_or(0, |state| state.queue.len())
862 }
863
864 /// Number of queued envelopes (test observability alias).
865 #[cfg(test)]
866 pub(crate) fn len(&self) -> usize {
867 self.queued_len()
868 }
869
870 /// This inbox's total A1 bound: `max_in_flight + max_buffer_depth`.
871 ///
872 /// A poisoned lock reports `0`, which contributes nothing to the durable
873 /// channel-aggregate watermark — the honest reading, since an inbox whose
874 /// lock is poisoned admits nothing and so holds no live buffer.
875 pub(crate) fn capacity_bound(&self) -> usize {
876 self.state.lock().map_or(0, |state| {
877 state
878 .capacity
879 .max_in_flight
880 .saturating_add(state.capacity.max_buffer_depth)
881 })
882 }
883
884 /// Whether this subscriber is converging on the durable log via replay
885 /// (A1 §4).
886 pub(crate) fn is_lagging(&self) -> bool {
887 self.state.lock().is_ok_and(|state| state.lagging)
888 }
889
890 /// The next refill batch to read, or `None` when none is due (A1 §4).
891 ///
892 /// `Some((generation, cursor, budget))` only when this subscriber is
893 /// lagging AND its queue has drained below the low watermark
894 /// (`max_in_flight / 2`) AND the free buffer band is non-empty. That last
895 /// conjunct is load-bearing: a zero-length read would return an empty batch
896 /// and be indistinguishable from "caught up", which would clear `lagging`
897 /// while the gap was still open.
898 ///
899 /// `budget` is the free band, so a replay batch can never blow the bound it
900 /// exists to serve (§4 "replay batches size themselves to the free buffer
901 /// band").
902 pub(crate) fn refill_plan(&self) -> Option<(u64, u64, usize)> {
903 let state = self.state.lock().ok()?;
904 if !state.lagging || state.closed {
905 return None;
906 }
907 let queued = state.queue.len();
908 let low_watermark = state.capacity.max_in_flight / 2;
909 if queued > low_watermark {
910 return None;
911 }
912 let bound = state
913 .capacity
914 .max_in_flight
915 .saturating_add(state.capacity.max_buffer_depth);
916 let budget = bound.saturating_sub(queued);
917 if budget == 0 {
918 return None;
919 }
920 Some((state.shed_generation, state.next_replay_seq, budget))
921 }
922
923 /// Declares the gap closed, unless a live push was shed since `generation`
924 /// was taken (A1 §4).
925 ///
926 /// Returns `true` when `lagging` was cleared. The generation check is what
927 /// makes the loop-until-caught-up test correct under concurrent appends: a
928 /// push shed while the head read was in flight bumps the generation, so the
929 /// caller loops instead of declaring victory over a message it never
930 /// delivered.
931 pub(crate) fn clear_lagging_if_unchanged(&self, generation: u64) -> bool {
932 let Ok(mut state) = self.state.lock() else {
933 return false;
934 };
935 if state.shed_generation != generation {
936 return false;
937 }
938 state.lagging = false;
939 true
940 }
941}
942
943impl Drop for SubscriptionInbox {
944 fn drop(&mut self) {
945 // Backstop: if no teardown path ever called `close`, release the queued
946 // charges here so the last Arc dropping can never strand budget bytes.
947 // Idempotent against an earlier close (the closed marker short-circuits).
948 self.close();
949 }
950}
951
952/// A delivery predicate evaluated by the channel actor against each published
953/// envelope. `None` (no predicate) means deliver everything.
954pub(crate) type SubscriptionPredicate = Arc<dyn Fn(&Envelope) -> bool + Send + Sync>;
955
956/// Real beamr native process backing one subscription.
957///
958/// For LOCAL delivery it is an idle handler (mirroring
959/// `aion::worker::link::IdleWorkerProcess`): local envelopes travel through the
960/// shared [`SubscriberInbox`] the channel actor writes and
961/// [`SubscriptionHandle::try_next`] reads. Its other job is to BE a first-class
962/// linkable, killable process whose lifetime equals the subscription's, so the
963/// channel actor detects the subscription dying via a real EXIT signal rather
964/// than by polling a weak pointer.
965///
966/// For CROSS-NODE delivery (SRV-005) it is also the landing point for a remote
967/// publish: a remote node sends a published envelope, encoded by
968/// [`crate::channel::wire::encode_envelope`], as a single beamr binary directly
969/// to this process's pid (the pid the cluster registered in the channel's
970/// distributed process group). The binary lands in this process's mailbox; the
971/// handler decodes it back into an [`Envelope`] and pushes it onto the SAME
972/// inbox a local publish would, so a subscriber observes local and remote
973/// messages identically. Non-binary wakeups (trapped `{EXIT, _, _}` signals) are
974/// drained and ignored.
975struct SubscriberProcess {
976 inbox: SubscriberInbox,
977}
978
979impl NativeHandler for SubscriberProcess {
980 fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
981 // Trapping is set authoritatively at spawn (see `SubscriptionHandle::spawn`)
982 // so it holds before the actor ever links — re-assert it here defensively
983 // for any future restart of this handler.
984 ctx.set_trap_exit(true);
985 // Drain every queued wakeup. A binary message is a remote envelope frame
986 // (SRV-005) to decode and enqueue; everything else (e.g. a trapped
987 // `{EXIT, _, _}` tuple from a crashed actor this subscriber outlives) is
988 // ignored. Death is driven only by an explicit `terminate_process` on
989 // unsubscribe/handle drop.
990 while let Some(message) = ctx.recv() {
991 if BinaryRef::new(message).is_some() {
992 // beamr 0.20.0 ties byte access to a `HeapBorrow` witness, and
993 // `NativeContext` exposes no borrow of the process heap, so the
994 // frame is deep-copied into ETS-owned storage and read under
995 // the copy's own witness — the one borrow-correct path a
996 // native handler has (beamr#36 tracks a direct accessor). A
997 // frame whose copy fails is dropped, same as one that fails to
998 // decode: a corrupt cross-node payload must never crash the
999 // subscriber or stall delivery of well-formed messages.
1000 if let Ok(owned) = beamr::ets::copy_term_to_ets(message)
1001 && let Some(binary) = BinaryRef::new(owned.root())
1002 {
1003 self.accept_remote_frame(binary.as_bytes(owned.borrow_terms()));
1004 }
1005 }
1006 }
1007 NativeOutcome::Wait
1008 }
1009}
1010
1011impl SubscriberProcess {
1012 /// Decode a remote envelope frame and push it onto the inbox. A frame that
1013 /// fails to decode is dropped: a corrupt cross-node payload must never crash
1014 /// the subscriber or stall delivery of well-formed messages.
1015 fn accept_remote_frame(&self, bytes: &[u8]) {
1016 let Ok(envelope) = decode_envelope(bytes) else {
1017 return;
1018 };
1019 // R3: the remote-delivery leg fires the same wake notifier and obeys the
1020 // same §5 byte budget as the local leg. An overflow marks the subscription
1021 // for shedding (inside `admit`); the frame is dropped rather than growing
1022 // server memory.
1023 self.inbox.admit(envelope);
1024 }
1025}
1026
1027/// The actor-side record of one subscriber: the inbox to deliver into and the
1028/// optional predicate to gate delivery. Held by the channel actor INSIDE its
1029/// process, keyed by the subscriber process's pid.
1030pub(crate) struct SubscriberRegistration {
1031 pid: u64,
1032 inbox: SubscriberInbox,
1033 predicate: Option<SubscriptionPredicate>,
1034}
1035
1036impl SubscriberRegistration {
1037 pub(crate) const fn pid(&self) -> u64 {
1038 self.pid
1039 }
1040
1041 /// Offers `envelope` to this subscriber, returning the admission outcome —
1042 /// or `None` when the subscriber's predicate filtered it out.
1043 ///
1044 /// `None` is not a refusal and is deliberately distinct from every
1045 /// [`InboxAdmission`]: A1 §2 requires the decision to run **after the
1046 /// predicate**, so a non-matching subscriber contributes no backpressure at
1047 /// all. Folding it in as a Reject would produce false Defer signals for
1048 /// producers on a heavily filtered channel.
1049 ///
1050 /// R3 + §5 are unchanged underneath: admission charges the connection byte
1051 /// budget, fires the wake notifier for every queued envelope, and on a
1052 /// §5 overflow marks the subscription for shedding. Only
1053 /// [`InboxAdmission::is_queued`] counts as a genuine delivery.
1054 pub(crate) fn deliver(
1055 &self,
1056 envelope: &Envelope,
1057 durable_position: Option<u64>,
1058 ) -> Option<InboxAdmission> {
1059 if let Some(predicate) = self.predicate.as_ref() {
1060 if !predicate(envelope) {
1061 // Not this subscriber's message, so not this subscriber's gap:
1062 // step the replay cursor past it (A1 §4).
1063 if let Some(position) = durable_position {
1064 self.inbox.note_filtered(position);
1065 }
1066 return None;
1067 }
1068 }
1069 Some(self.inbox.admit_at(envelope.clone(), durable_position))
1070 }
1071
1072 /// This subscriber's live-buffer occupancy and its bound, for the durable
1073 /// pre-append watermark (A1 §4). Read HOST-SIDE, off the actor's command
1074 /// slice, and deliberately coarse: no predicate is evaluated.
1075 pub(crate) fn occupancy(&self) -> (usize, usize) {
1076 (self.inbox.queued_len(), self.inbox.capacity_bound())
1077 }
1078}
1079
1080impl std::fmt::Debug for SubscriberRegistration {
1081 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1082 formatter
1083 .debug_struct("SubscriberRegistration")
1084 .field("pid", &self.pid)
1085 .field("has_predicate", &self.predicate.is_some())
1086 .finish_non_exhaustive()
1087 }
1088}
1089
1090/// Handle returned by channel subscriptions for receiving validated envelopes.
1091///
1092/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
1093/// scheduler so the process can be terminated when the subscription ends. The
1094/// handle is the subscription's lifetime: dropping the last clone terminates the
1095/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
1096#[derive(Clone)]
1097pub struct SubscriptionHandle {
1098 inner: Arc<SubscriptionInner>,
1099}
1100
1101struct SubscriptionInner {
1102 pid: u64,
1103 inbox: SubscriberInbox,
1104 scheduler: Arc<Scheduler>,
1105 /// A1 §4: everything the host-side catch-up needs, attached by the durable
1106 /// subscribe path. `OnceLock` because it is written exactly once, before
1107 /// the handle is handed to the caller, and read on every `try_next`.
1108 /// Absent on an ephemeral channel — there is nothing to replay from.
1109 refill: OnceLock<DurableRefill>,
1110 /// Serialises concurrent refills of ONE subscription. Contended only when
1111 /// two threads drain the same handle; the loser skips (the winner is
1112 /// already filling the same queue) rather than queueing behind a store
1113 /// read.
1114 refilling: Mutex<()>,
1115}
1116
1117/// The host-side auto-catch-up source for a lagging durable subscriber
1118/// (A1 §4, graft §0.4).
1119///
1120/// Deliberately held by the SUBSCRIPTION HANDLE, not by the channel actor: the
1121/// implementability judge relocated catch-up off the actor's command slice
1122/// precisely so a blocking store read can never stall the actor for every
1123/// other subscriber on the channel. The refill runs on whichever thread is
1124/// draining this one subscription.
1125struct DurableRefill {
1126 store: Arc<dyn DurableStore>,
1127 /// The channel's durable partition stream key.
1128 stream_key: String,
1129 /// The schema id stamped on refilled envelopes: the channel's schema at
1130 /// subscribe time. The durable record stores validated payload bytes, not
1131 /// the schema that validated them, so this is the only id available and
1132 /// pretending otherwise would be an invention.
1133 schema_id: SchemaId,
1134 /// The subscription's predicate, re-applied to refilled envelopes so a
1135 /// filtered subscription catches up on exactly the messages it would have
1136 /// received live.
1137 predicate: Option<SubscriptionPredicate>,
1138}
1139
1140impl std::fmt::Debug for DurableRefill {
1141 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1142 formatter
1143 .debug_struct("DurableRefill")
1144 .field("stream_key", &self.stream_key)
1145 .field("has_predicate", &self.predicate.is_some())
1146 .finish_non_exhaustive()
1147 }
1148}
1149
1150impl SubscriptionHandle {
1151 /// Spawns a real subscriber process on `scheduler` and returns the handle
1152 /// plus its actor-side registration record (carrying any predicate).
1153 ///
1154 /// # Errors
1155 /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
1156 /// spawn the subscriber process.
1157 pub(crate) fn spawn(
1158 scheduler: &Arc<Scheduler>,
1159 predicate: Option<SubscriptionPredicate>,
1160 install: Option<InboxInstall>,
1161 ) -> Result<(Self, SubscriberRegistration), LiminalError> {
1162 let inbox: SubscriberInbox = SubscriptionInbox::new();
1163 // Install the §5 budget/fairness cap and the R3 wake notifier AT
1164 // CONSTRUCTION — strictly before the registration is handed to the
1165 // channel actor — so there is no window in which a publish can be
1166 // admitted uncharged, past the depth cap, or without a wake.
1167 if let Some(install) = install {
1168 inbox.install_budget(install.budget, install.depth_cap);
1169 if let Some(capacity) = install.capacity {
1170 // REFUSES ALOUD. A window the bus cannot honour fails the
1171 // subscribe rather than leaving the caller on defaults it never
1172 // asked for and cannot observe.
1173 inbox.install_capacity(capacity).map_err(|error| {
1174 LiminalError::SubscriptionFailed {
1175 message: format!("declared consumer capacity is invalid: {error}"),
1176 }
1177 })?;
1178 }
1179 if let Some(notifier) = install.notifier {
1180 inbox.install_notifier(notifier);
1181 }
1182 }
1183 let process_inbox = Arc::clone(&inbox);
1184 let factory = Box::new(move || {
1185 Box::new(SubscriberProcess {
1186 inbox: Arc::clone(&process_inbox),
1187 }) as Box<dyn NativeHandler>
1188 });
1189 // trap_exit is set on the process BEFORE it is published as runnable
1190 // (0.16.1 `spawn_native_trap_exit` — pre-runnable by construction), so
1191 // an abnormal channel-actor crash is trapped (delivered as a message
1192 // the subscriber drains) instead of cascading across the link and
1193 // killing the subscriber. This makes the subscriber outlive a
1194 // channel-actor crash so the restarted actor can re-link to it on boot
1195 // (R2/R4): the flag is in place before the process's first slice, with
1196 // no post-spawn window for `set_trap_exit` to race (or return
1197 // `NoCaller` against a mid-first-slice process).
1198 let pid = scheduler.spawn_native_trap_exit(factory).map_err(|error| {
1199 LiminalError::SubscriptionFailed {
1200 message: format!("failed to spawn subscriber process: {error:?}"),
1201 }
1202 })?;
1203 let handle = Self {
1204 inner: Arc::new(SubscriptionInner {
1205 pid,
1206 inbox: Arc::clone(&inbox),
1207 scheduler: Arc::clone(scheduler),
1208 refill: OnceLock::new(),
1209 refilling: Mutex::new(()),
1210 }),
1211 };
1212 let registration = SubscriberRegistration {
1213 pid,
1214 inbox,
1215 predicate,
1216 };
1217 Ok((handle, registration))
1218 }
1219
1220 /// Returns the beamr pid of the subscriber process this handle owns.
1221 #[must_use]
1222 pub(crate) fn pid(&self) -> u64 {
1223 self.inner.pid
1224 }
1225
1226 /// Attempts to receive the next delivered envelope without blocking.
1227 ///
1228 /// # Errors
1229 ///
1230 /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
1231 pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
1232 // Dequeue releases the envelope's admitted bytes back to the shared
1233 // connection budget (§5 charge/release symmetry). It is ALSO the A1 v1
1234 // credit event (§2): the consumer has taken the message out of the
1235 // bus's custody, so the derived bands the next admission reads are one
1236 // envelope freer. There is no separate credit ledger to keep in step.
1237 let next = self.inner.inbox.pop();
1238 // A1 §4 auto-catch-up: the credit this pop released may have taken a
1239 // lagging subscriber below its low watermark. Refilling here — on the
1240 // draining caller's thread, never on the channel actor's command slice
1241 // — is what lets a lagging durable subscriber converge with no
1242 // re-subscribe. A no-op (one atomic-ish lock read) for every subscriber
1243 // that is not lagging, which is every ephemeral subscriber always.
1244 self.refill_if_lagging();
1245 Ok(next)
1246 }
1247
1248 /// Seeds this subscription's replay cursor at the durable log head it is
1249 /// joining at (A1 §4). Called by the durable subscribe path BEFORE the
1250 /// registration reaches the channel actor, so the first publish this
1251 /// subscription can possibly see is already measured against the right
1252 /// origin.
1253 pub(crate) fn seed_replay_cursor(&self, head: u64) {
1254 self.inner.inbox.seed_replay_cursor(head);
1255 }
1256
1257 /// Attaches the durable catch-up source (A1 §4). Called once by the durable
1258 /// subscribe path before the handle is returned; a second call is a
1259 /// mis-wiring of that path and trips a debug assertion rather than being
1260 /// silently ignored.
1261 pub(crate) fn attach_durable_refill(
1262 &self,
1263 store: Arc<dyn DurableStore>,
1264 stream_key: String,
1265 schema_id: SchemaId,
1266 predicate: Option<SubscriptionPredicate>,
1267 ) {
1268 let attached = self.inner.refill.set(DurableRefill {
1269 store,
1270 stream_key,
1271 schema_id,
1272 predicate,
1273 });
1274 debug_assert!(
1275 attached.is_ok(),
1276 "the refill OnceLock is set exactly once per attach: attach_durable_refill \
1277 was called twice on one subscription, which only the durable subscribe \
1278 path may call and only before the handle is returned"
1279 );
1280 }
1281
1282 /// Replays the missed range into the bounded inbox until this subscriber is
1283 /// caught up or its free band is full (A1 §4, graft §0.4).
1284 ///
1285 /// **Loop-until-caught-up.** The log head can move under concurrent
1286 /// appends, so "caught up" is not a snapshot taken once: each iteration
1287 /// re-reads from the cursor, and the flag clears only when a read comes
1288 /// back empty AND no live push was shed while that read was in flight. The
1289 /// chase is bounded because the pre-append watermark throttles producers
1290 /// while any subscriber lags.
1291 ///
1292 /// **In-order, exactly once.** Live pushes stay shed while `lagging` is
1293 /// set, so nothing can overtake the replay; the cursor advances only past
1294 /// entries this call actually offered, so nothing is offered twice.
1295 ///
1296 /// Errors are swallowed on purpose: a refill is opportunistic recovery
1297 /// running inside a consumer's `try_next`, and a store read failure must
1298 /// not turn a successful dequeue into an error. The subscriber stays
1299 /// `lagging` and the next pop retries, which is the same convergence with a
1300 /// longer gap.
1301 fn refill_if_lagging(&self) {
1302 let Some(refill) = self.inner.refill.get() else {
1303 return;
1304 };
1305 if !self.inner.inbox.is_lagging() {
1306 return;
1307 }
1308 // Another thread draining this same subscription is already refilling
1309 // it; a second concurrent replay would read the same range twice.
1310 let Ok(_guard) = self.inner.refilling.try_lock() else {
1311 return;
1312 };
1313 while let Some((generation, cursor, budget)) = self.inner.inbox.refill_plan() {
1314 let Ok(Ok(batch)) = block_on(replay_range(
1315 refill.store.as_ref(),
1316 &refill.stream_key,
1317 cursor,
1318 budget,
1319 )) else {
1320 return;
1321 };
1322 if batch.is_empty() {
1323 // The read reached the head. Clear the gap ONLY if no live push
1324 // was shed since the generation was taken; otherwise loop and
1325 // read again for the message that shed.
1326 if self.inner.inbox.clear_lagging_if_unchanged(generation) {
1327 return;
1328 }
1329 continue;
1330 }
1331 for (sequence, stored) in batch {
1332 let envelope = refilled_envelope(&stored, refill.schema_id);
1333 let matched = refill
1334 .predicate
1335 .as_ref()
1336 .is_none_or(|predicate| predicate(&envelope));
1337 if matched {
1338 // Through the SAME bounded admission as a live push. The
1339 // lagging flag is still set, so `admit_at` would shed this
1340 // as a live push — the refill therefore goes in through the
1341 // replay door below, which is the only writer allowed to
1342 // move the cursor while the gap is open.
1343 if !self.inner.inbox.admit_replayed(envelope, sequence) {
1344 return;
1345 }
1346 } else {
1347 self.inner.inbox.note_replayed_filtered(sequence);
1348 }
1349 }
1350 }
1351 }
1352
1353 /// Whether an envelope is available without consuming it.
1354 #[must_use]
1355 pub fn has_pending(&self) -> bool {
1356 self.inner.inbox.has_pending()
1357 }
1358
1359 /// Whether an overflow has marked this subscription for shedding (§5).
1360 #[must_use]
1361 pub fn is_overflowed(&self) -> bool {
1362 self.inner.inbox.is_overflowed()
1363 }
1364}
1365
1366impl std::fmt::Debug for SubscriptionHandle {
1367 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368 formatter
1369 .debug_struct("SubscriptionHandle")
1370 .field("pid", &self.inner.pid)
1371 .finish_non_exhaustive()
1372 }
1373}
1374
1375impl Drop for SubscriptionInner {
1376 fn drop(&mut self) {
1377 // Close the inbox FIRST: atomically mark it closed, drain queued entries,
1378 // and release every charged byte back to the shared connection budget
1379 // (§5 — queued bytes must never be stranded on the connection-lifetime
1380 // budget by unsubscribe, shed, or teardown; all of them funnel through
1381 // this drop). Post-close deliveries from the channel actor (whose EXIT
1382 // prune below is asynchronous) are refused without charging.
1383 self.inbox.close();
1384 // Terminating the subscriber process fires the bidirectional link to the
1385 // channel actor, which traps the EXIT and removes this subscriber from
1386 // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
1387 self.scheduler
1388 .terminate_process(self.pid, ExitReason::Normal);
1389 }
1390}
1391
1392/// Rebuilds a bus [`Envelope`] from a durable log entry, for the host-side
1393/// refill (A1 §4).
1394///
1395/// Two fields are deliberately NOT reconstructed, because the log does not hold
1396/// them and inventing them would make a refilled envelope claim more than the
1397/// store can support:
1398///
1399/// * **causal context** — the durable publish path persists `None` for it
1400/// (`ChannelHandle::persist_and_enqueue`), so there is nothing to restore;
1401/// * **message id** — the durable record does not store the live envelope's id,
1402/// so a fresh one is minted. A consumer that needs to correlate a replayed
1403/// message with a live one uses its durable position, which is exact.
1404fn refilled_envelope(stored: &MessageEnvelope, schema_id: SchemaId) -> Envelope {
1405 let millis = i64::try_from(stored.timestamp).unwrap_or(i64::MAX);
1406 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, millis)
1407 .single()
1408 .unwrap_or_else(chrono::Utc::now);
1409 Envelope::with_timestamp(
1410 stored.payload.clone(),
1411 None,
1412 schema_id,
1413 PublisherId::new(stored.publisher_id.clone()),
1414 timestamp,
1415 )
1416}
1417
1418/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
1419/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
1420///
1421/// This proves the production subscriber handler — the same `NativeHandler` the
1422/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
1423/// cooperative scheduler that a browser host drives. There is no toy stand-in:
1424/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
1425/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
1426/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
1427/// handler's own `accept_remote_frame` path and lands in the shared inbox a
1428/// [`SubscriptionHandle::try_next`] would read.
1429///
1430/// The handler runs cooperatively AS-IS: its `handle` only touches
1431/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
1432/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
1433/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
1434/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
1435/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
1436/// native.
1437#[cfg(test)]
1438#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1439mod cooperative_smoke {
1440 use std::cell::RefCell;
1441 use std::rc::Rc;
1442 use std::sync::Arc;
1443
1444 use beamr::atom::AtomTable;
1445 use beamr::ets::copy_term_to_ets;
1446 use beamr::module::ModuleRegistry;
1447 use beamr::native::BifRegistryImpl;
1448 use beamr::process::heap::Heap;
1449 use beamr::scheduler::WasmScheduler;
1450 use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
1451
1452 use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
1453 use crate::channel::SchemaId;
1454 use crate::channel::wire::encode_envelope;
1455 use crate::envelope::{Envelope, PublisherId};
1456
1457 /// Build a cooperative scheduler the way a wasm host holds it (single
1458 /// `Rc<RefCell<…>>` on one thread).
1459 fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
1460 let atom_table = Arc::new(AtomTable::with_common_atoms());
1461 let modules = Arc::new(ModuleRegistry::new());
1462 let bifs = Arc::new(BifRegistryImpl::new());
1463 Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
1464 }
1465
1466 /// Encode `envelope` into the production wire frame and wrap it as a
1467 /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
1468 /// remote node hands a published frame to a subscriber pid (SRV-005).
1469 fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
1470 let bytes = encode_envelope(envelope);
1471 let shared = SharedBinary::new(bytes);
1472 // A ProcBin reference needs three heap words; copy it into ETS-owned
1473 // memory so the scratch heap can be dropped before delivery.
1474 let mut scratch = Heap::new(8);
1475 let words = scratch
1476 .alloc_slice(3)
1477 .expect("scratch heap holds a proc-bin reference");
1478 let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
1479 copy_term_to_ets(term).expect("frame copies into an owned binary")
1480 }
1481
1482 fn sample_envelope() -> Envelope {
1483 // A whole-millisecond timestamp so the round-trip through the wire codec
1484 // (which carries millisecond resolution, see `channel::wire`) is exact;
1485 // `Utc::now()` sub-millisecond precision would otherwise be truncated on
1486 // decode and is irrelevant to what this smoke proves.
1487 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
1488 .single()
1489 .expect("valid fixed millisecond timestamp");
1490 Envelope::with_timestamp(
1491 b"{\"value\":42}".to_vec(),
1492 None,
1493 SchemaId::new(),
1494 PublisherId::from("publisher-cooperative"),
1495 timestamp,
1496 )
1497 }
1498
1499 #[test]
1500 fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
1501 let scheduler = cooperative_scheduler();
1502
1503 // The shared inbox the subscriber pushes decoded envelopes onto — the
1504 // exact channel the threaded `SubscriptionHandle::try_next` reads.
1505 let inbox: SubscriberInbox = SubscriptionInbox::new();
1506 let process_inbox = Arc::clone(&inbox);
1507
1508 // Spawn the GENUINE production subscriber handler as a first-class native
1509 // process on the cooperative scheduler.
1510 let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
1511 Box::new(SubscriberProcess {
1512 inbox: Arc::clone(&process_inbox),
1513 }) as Box<dyn beamr::native::native_process::NativeHandler>
1514 }));
1515
1516 // First turn: the handler runs once, asserts trap_exit, finds an empty
1517 // mailbox, and parks (`Wait`). No envelope has been delivered yet.
1518 scheduler.borrow_mut().run_until_idle();
1519 assert_eq!(
1520 inbox.len(),
1521 0,
1522 "no envelope is delivered before one is published"
1523 );
1524
1525 // Publish: deliver a real encoded frame as a beamr binary straight to the
1526 // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
1527 // the parked process.
1528 let published = sample_envelope();
1529 let frame = frame_as_owned_binary(&published);
1530 scheduler
1531 .borrow_mut()
1532 .send_owned(pid, &frame)
1533 .expect("frame is delivered to the live subscriber pid");
1534
1535 // Pump turns: the woken handler drains the binary, decodes it through its
1536 // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
1537 let mut delivered = None;
1538 for _ in 0..8 {
1539 scheduler.borrow_mut().run_until_idle();
1540 let next = inbox.pop();
1541 if let Some(envelope) = next {
1542 delivered = Some(envelope);
1543 break;
1544 }
1545 }
1546
1547 assert_eq!(
1548 delivered.as_ref(),
1549 Some(&published),
1550 "the real subscriber decoded and delivered the published envelope"
1551 );
1552 }
1553}
1554
1555/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
1556/// admitted envelope; the shared byte budget is spent across ALL a
1557/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
1558/// fairness trip stops one inbox starving its siblings; and charge/release is
1559/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
1560/// with no scheduler — the server-side wake wiring and shed are tested there.
1561#[cfg(test)]
1562#[allow(clippy::expect_used)]
1563mod inbox_bounding {
1564 use std::sync::Arc;
1565 use std::sync::atomic::{AtomicUsize, Ordering};
1566
1567 use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
1568 use crate::channel::SchemaId;
1569 use crate::channel::wire::encode_envelope;
1570 use crate::envelope::{Envelope, PublisherId};
1571 use crate::pressure::ConsumerCapacity;
1572
1573 fn envelope(payload: &[u8]) -> Envelope {
1574 Envelope::new(
1575 payload.to_vec(),
1576 None,
1577 SchemaId::new(),
1578 PublisherId::from("inbox-bounding-test"),
1579 )
1580 }
1581
1582 fn admitted_bytes(env: &Envelope) -> usize {
1583 encode_envelope(env).len()
1584 }
1585
1586 /// The level-triggered wake contract (P0 #55). This test previously asserted
1587 /// the edge-triggered form — that a second admit into a non-empty inbox does
1588 /// NOT re-fire — which is precisely the starvation the fix removes: the
1589 /// consumer drains a bounded slice, so a non-empty inbox is the normal state
1590 /// of a subscriber that has fallen behind, and withholding its wake is what
1591 /// ratchets it to the depth cap and a permanent shed.
1592 #[test]
1593 fn notifier_fires_for_every_admitted_envelope() {
1594 let inbox = SubscriptionInbox::new();
1595 let fires = Arc::new(AtomicUsize::new(0));
1596 let counter = Arc::clone(&fires);
1597 inbox.install_notifier(Arc::new(move || {
1598 counter.fetch_add(1, Ordering::Relaxed);
1599 }));
1600
1601 // First admit into an empty inbox fires.
1602 assert!(inbox.admit(envelope(b"a")).is_queued());
1603 assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
1604
1605 // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
1606 // may not have reached this envelope's slice, and R6 coalescing means a
1607 // redundant marker costs one mailbox atom, never a second slice of work.
1608 assert!(inbox.admit(envelope(b"b")).is_queued());
1609 assert_eq!(
1610 fires.load(Ordering::Relaxed),
1611 2,
1612 "an admit into a non-empty inbox still fires"
1613 );
1614
1615 // Drain to empty, then admit again: still exactly one fire per admit.
1616 assert!(inbox.pop().is_some());
1617 assert!(inbox.pop().is_some());
1618 assert!(inbox.admit(envelope(b"c")).is_queued());
1619 assert_eq!(
1620 fires.load(Ordering::Relaxed),
1621 3,
1622 "one fire per admitted envelope, whatever the queue depth was"
1623 );
1624 }
1625
1626 #[test]
1627 fn shared_budget_is_spent_across_all_a_connections_inboxes() {
1628 let one = envelope(b"payload-one");
1629 let two = envelope(b"payload-two");
1630 // A budget large enough for exactly ONE of the two envelopes.
1631 let cap = admitted_bytes(&one);
1632 let budget = ConnectionInboxBudget::new(cap);
1633
1634 let inbox_a = SubscriptionInbox::new();
1635 let inbox_b = SubscriptionInbox::new();
1636 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1637 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1638
1639 // Inbox A admits its envelope, consuming the whole shared budget.
1640 assert!(inbox_a.admit(one).is_queued());
1641 assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
1642
1643 // Inbox B — a SIBLING subscription — is refused: the budget is connection
1644 // scoped, not per-inbox, so A's fill denies B.
1645 assert!(matches!(
1646 inbox_b.admit(two),
1647 InboxAdmission::BudgetExceeded(_)
1648 ));
1649 assert!(
1650 inbox_b.is_overflowed(),
1651 "the sibling that overflowed the shared budget is shed"
1652 );
1653 assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
1654
1655 // Draining A releases its bytes back to the SHARED budget, so B could then
1656 // admit (charge/release symmetry across siblings).
1657 assert!(inbox_a.pop().is_some());
1658 assert_eq!(
1659 budget.used(),
1660 0,
1661 "release returns bytes to the shared budget"
1662 );
1663 }
1664
1665 #[test]
1666 fn overflow_sheds_and_does_not_grow_memory() {
1667 let env = envelope(b"x");
1668 let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
1669 let inbox = SubscriptionInbox::new();
1670 inbox.install_budget(budget, usize::MAX);
1671
1672 assert!(inbox.admit(env.clone()).is_queued());
1673 // The next admit overflows: refused, marked for shedding, and NOT queued —
1674 // the queue length does not grow past the bound.
1675 assert!(matches!(
1676 inbox.admit(env),
1677 InboxAdmission::BudgetExceeded(_)
1678 ));
1679 assert!(inbox.is_overflowed());
1680 assert_eq!(
1681 inbox.len(),
1682 1,
1683 "the overflowed envelope is dropped, not queued"
1684 );
1685 }
1686
1687 #[test]
1688 fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
1689 // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
1690 let budget = ConnectionInboxBudget::new(usize::MAX);
1691 let inbox = SubscriptionInbox::new();
1692 inbox.install_budget(budget, 2); // depth cap of 2 envelopes
1693
1694 assert!(inbox.admit(envelope(b"1")).is_queued());
1695 assert!(inbox.admit(envelope(b"2")).is_queued());
1696 // The third trips the fairness cap even though bytes are available.
1697 assert!(matches!(
1698 inbox.admit(envelope(b"3")),
1699 InboxAdmission::FairnessTripped(_)
1700 ));
1701 assert!(inbox.is_overflowed());
1702 assert_eq!(
1703 inbox.len(),
1704 2,
1705 "the fairness trip holds the inbox at its cap"
1706 );
1707 }
1708
1709 #[test]
1710 fn charge_and_release_are_exact() {
1711 let budget = ConnectionInboxBudget::new(1024 * 1024);
1712 let inbox = SubscriptionInbox::new();
1713 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1714
1715 let a = envelope(b"first-envelope");
1716 let b = envelope(b"second-longer-envelope-payload");
1717 let charge = admitted_bytes(&a) + admitted_bytes(&b);
1718 assert!(inbox.admit(a).is_queued());
1719 assert!(inbox.admit(b).is_queued());
1720 assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
1721
1722 assert!(inbox.pop().is_some());
1723 assert!(inbox.pop().is_some());
1724 assert_eq!(
1725 budget.used(),
1726 0,
1727 "every admitted byte is released on dequeue — exact symmetry"
1728 );
1729 }
1730
1731 /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
1732 /// shape — an overflowed inbox is near-full by construction) releases every
1733 /// charged byte back to the shared budget, so a sibling subscription can
1734 /// admit again. Without the close-release, one shed strands its whole share
1735 /// of the 4 MiB budget forever.
1736 #[test]
1737 fn close_releases_queued_charges_so_siblings_recover() {
1738 // Budget sized so inbox A can queue a 256-envelope backlog (the §5
1739 // fairness-cap depth) and exhaust the shared budget doing it.
1740 let one = envelope(b"backlog-envelope-payload");
1741 let unit = admitted_bytes(&one);
1742 let budget = ConnectionInboxBudget::new(unit * 256);
1743
1744 let inbox_a = SubscriptionInbox::new();
1745 let inbox_b = SubscriptionInbox::new();
1746 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1747 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1748
1749 // Queue the full 256-envelope backlog on A, consuming the whole budget.
1750 for _ in 0..256 {
1751 assert!(inbox_a.admit(one.clone()).is_queued());
1752 }
1753 assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
1754 // The sibling is starved (the shed trigger condition).
1755 assert!(matches!(
1756 inbox_b.admit(one.clone()),
1757 InboxAdmission::BudgetExceeded(_)
1758 ));
1759
1760 // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
1761 // returns to the shared budget in one atomic close.
1762 inbox_a.close();
1763 assert_eq!(
1764 budget.used(),
1765 0,
1766 "close releases the entire queued backlog back to the shared budget"
1767 );
1768 // The sibling recovers: it can admit again.
1769 assert!(
1770 inbox_b.admit(one).is_queued(),
1771 "a sibling admits again after the other inbox is shed"
1772 );
1773 }
1774
1775 /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1776 /// the budget, so a shed subscription can never re-accumulate cost while the
1777 /// channel actor's asynchronous EXIT prune is still in flight.
1778 #[test]
1779 fn closed_inbox_refuses_without_charging() {
1780 let env = envelope(b"post-close");
1781 let budget = ConnectionInboxBudget::new(1024 * 1024);
1782 let inbox = SubscriptionInbox::new();
1783 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1784
1785 inbox.close();
1786 assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1787 assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1788 assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1789 }
1790
1791 /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1792 /// called `close`, the last handle dropping still releases the queued charges
1793 /// (release-by-construction: a release that cannot be omitted).
1794 #[test]
1795 fn drop_backstop_releases_queued_charges() {
1796 let env = envelope(b"dropped-while-queued");
1797 let unit = admitted_bytes(&env);
1798 let budget = ConnectionInboxBudget::new(1024 * 1024);
1799 {
1800 let inbox = SubscriptionInbox::new();
1801 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1802 assert!(inbox.admit(env.clone()).is_queued());
1803 assert!(inbox.admit(env).is_queued());
1804 assert_eq!(budget.used(), unit * 2);
1805 // No close() call: the Arc drops here.
1806 }
1807 assert_eq!(
1808 budget.used(),
1809 0,
1810 "dropping the last inbox handle releases every queued charge"
1811 );
1812 }
1813
1814 /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1815 /// nothing (the queue was drained into the release).
1816 #[test]
1817 fn close_is_idempotent_and_drains_the_queue() {
1818 let env = envelope(b"x");
1819 let budget = ConnectionInboxBudget::new(1024 * 1024);
1820 let inbox = SubscriptionInbox::new();
1821 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1822 assert!(inbox.admit(env).is_queued());
1823
1824 inbox.close();
1825 inbox.close(); // second close is a no-op, not a double release
1826 assert_eq!(budget.used(), 0);
1827 assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1828 }
1829
1830 /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1831 /// budget was installed carries a charge of 0 — its dequeue releases exactly
1832 /// 0 against the later-installed budget, never bytes it did not charge. The
1833 /// production subscribe path installs the budget at inbox construction so
1834 /// this window is structurally closed; this pins the defensive invariant
1835 /// that makes release byte-identical to charge on EVERY entry regardless.
1836 #[test]
1837 fn per_entry_charge_ownership_survives_budget_install() {
1838 let uncharged = envelope(b"admitted-before-budget-install");
1839 let charged = envelope(b"admitted-after-budget-install");
1840 let inbox = SubscriptionInbox::new();
1841
1842 // Admitted with no budget installed: charge ownership 0.
1843 assert!(inbox.admit(uncharged).is_queued());
1844
1845 let budget = ConnectionInboxBudget::new(1024 * 1024);
1846 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1847 let unit = admitted_bytes(&charged);
1848 assert!(inbox.admit(charged).is_queued());
1849 assert_eq!(budget.used(), unit, "only the post-install entry charged");
1850
1851 // Popping the uncharged entry releases exactly 0 — the budget cannot
1852 // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1853 // that were never charged.
1854 assert!(inbox.pop().is_some());
1855 assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1856 assert!(inbox.pop().is_some());
1857 assert_eq!(
1858 budget.used(),
1859 0,
1860 "the charged entry released its exact charge"
1861 );
1862 }
1863
1864 /// Review round 1 item 5 (install recheck): installing a notifier onto an
1865 /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1866 /// admitted while there was no notifier to fire, so the install regenerates
1867 /// their wake and one can never be lost to install ordering. (The production
1868 /// subscribe path installs at construction, when the queue is guaranteed
1869 /// empty; this pins the defensive invariant.)
1870 #[test]
1871 fn notifier_install_onto_non_empty_inbox_fires_once() {
1872 let inbox = SubscriptionInbox::new();
1873 assert!(inbox.admit(envelope(b"pre-install")).is_queued());
1874
1875 let fires = Arc::new(AtomicUsize::new(0));
1876 let counter = Arc::clone(&fires);
1877 inbox.install_notifier(Arc::new(move || {
1878 counter.fetch_add(1, Ordering::Relaxed);
1879 }));
1880 assert_eq!(
1881 fires.load(Ordering::Relaxed),
1882 1,
1883 "install onto a non-empty inbox regenerates exactly one wake"
1884 );
1885
1886 // The install is a ONE-OFF regeneration, not an extra fire per admit: a
1887 // subsequent admit adds exactly its own one fire.
1888 assert!(inbox.admit(envelope(b"second")).is_queued());
1889 assert_eq!(fires.load(Ordering::Relaxed), 2);
1890 }
1891
1892 /// **PIN (round 2) — the exactly-once guard suppresses what the REPLAY DOOR
1893 /// offered, and nothing else.**
1894 ///
1895 /// A live push that arrives behind a higher-positioned sibling has been
1896 /// offered by nobody. Suppressing it is silent loss, and worse than the
1897 /// duplicate the guard exists to prevent: nothing is shed, so `lagging` is
1898 /// never set and auto-catch-up can never recover it, while
1899 /// [`InboxAdmission::is_queued`] still reports it to the producer as
1900 /// delivered.
1901 ///
1902 /// The publish path is ordered so that this cannot happen in production
1903 /// (the fan-out command is enqueued under the append lock). This pin is the
1904 /// inbox's OWN control on the same claim: whatever the publish path does,
1905 /// the guard's comparand may only be advanced by a door that genuinely made
1906 /// an offer, so the worst an inversion could ever cost is a duplicate —
1907 /// never a lost message.
1908 #[test]
1909 fn a_live_push_is_not_suppressed_by_a_higher_positioned_sibling() {
1910 let inbox = SubscriptionInbox::new();
1911
1912 assert!(
1913 inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued(),
1914 "the higher-positioned push is queued"
1915 );
1916 let behind = inbox.admit_at(envelope(b"position-3"), Some(3));
1917 assert!(
1918 !matches!(behind, InboxAdmission::AlreadyOffered(_)),
1919 "position 3 was offered by NOBODY — its only sin is arriving behind \
1920 position 5, and suppressing it loses it silently; got {behind:?}"
1921 );
1922 assert!(behind.is_queued(), "position 3 must reach the queue");
1923 assert_eq!(
1924 inbox.queued_len(),
1925 2,
1926 "both live pushes are in the queue, in arrival order"
1927 );
1928 }
1929
1930 /// **PIN (round 2), the anti-vacuity partner** — the guard still fires for
1931 /// the position the replay door actually offered. Without this, the pin
1932 /// above passes on a guard that was simply deleted.
1933 #[test]
1934 fn a_live_push_the_replay_door_already_offered_is_suppressed() {
1935 let inbox = SubscriptionInbox::new();
1936
1937 assert!(
1938 inbox.admit_replayed(envelope(b"replayed-5"), 5),
1939 "the replay door offers position 5"
1940 );
1941 let live = inbox.admit_at(envelope(b"live-5"), Some(5));
1942 assert!(
1943 matches!(live, InboxAdmission::AlreadyOffered(_)),
1944 "position 5 was already offered by the refill; got {live:?}"
1945 );
1946 assert_eq!(
1947 inbox.queued_len(),
1948 1,
1949 "the suppressed push did not enter the queue a second time"
1950 );
1951 }
1952
1953 /// **PIN (round 2) — the refill cursor never backsteps onto a position that
1954 /// was already delivered.**
1955 ///
1956 /// The cursor names where the missed range STARTS. Assigning it
1957 /// `position + 1` unconditionally lets a live push that arrives behind a
1958 /// higher-positioned sibling drag it backwards over messages this
1959 /// subscriber already has, and the next refill then re-reads and re-offers
1960 /// them — the duplicate this whole seam exists to prevent, arriving through
1961 /// the other door.
1962 #[test]
1963 fn the_refill_cursor_never_backsteps_below_a_delivered_position() {
1964 let inbox = SubscriptionInbox::new();
1965 inbox
1966 .install_capacity(ConsumerCapacity::new(1, 1).expect("1/1 is a legal capacity"))
1967 .expect("a legal capacity installs");
1968
1969 assert!(inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued());
1970 assert!(inbox.admit_at(envelope(b"position-3"), Some(3)).is_queued());
1971 // The bound (1 + 1) is now full, so this one is shed and OPENS the gap.
1972 assert!(matches!(
1973 inbox.admit_at(envelope(b"position-6"), Some(6)),
1974 InboxAdmission::Rejected(_)
1975 ));
1976 assert!(inbox.is_lagging(), "the shed opened a gap");
1977
1978 assert!(inbox.pop().is_some());
1979 assert!(inbox.pop().is_some());
1980 let (_generation, cursor, _budget) = inbox
1981 .refill_plan()
1982 .expect("a drained lagging inbox has a refill due");
1983 assert_eq!(
1984 cursor, 6,
1985 "the missed range starts above every delivered position; a cursor of \
1986 4 would send the refill back over positions 4 and 5, and position 5 \
1987 is already in this subscriber's hands"
1988 );
1989 }
1990}