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 /// The subscriber process's pid: the id
1354 /// [`ChannelHandle::publish_recording`](crate::channel::ChannelHandle::publish_recording)
1355 /// records for this subscription, and the one a
1356 /// [`ClusterObserver`](crate::channel::ClusterObserver) hears it by.
1357 #[must_use]
1358 pub fn subscriber_pid(&self) -> u64 {
1359 self.inner.pid
1360 }
1361
1362 /// Whether an envelope is available without consuming it.
1363 #[must_use]
1364 pub fn has_pending(&self) -> bool {
1365 self.inner.inbox.has_pending()
1366 }
1367
1368 /// Whether an overflow has marked this subscription for shedding (§5).
1369 #[must_use]
1370 pub fn is_overflowed(&self) -> bool {
1371 self.inner.inbox.is_overflowed()
1372 }
1373}
1374
1375impl std::fmt::Debug for SubscriptionHandle {
1376 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1377 formatter
1378 .debug_struct("SubscriptionHandle")
1379 .field("pid", &self.inner.pid)
1380 .finish_non_exhaustive()
1381 }
1382}
1383
1384impl Drop for SubscriptionInner {
1385 fn drop(&mut self) {
1386 // Close the inbox FIRST: atomically mark it closed, drain queued entries,
1387 // and release every charged byte back to the shared connection budget
1388 // (§5 — queued bytes must never be stranded on the connection-lifetime
1389 // budget by unsubscribe, shed, or teardown; all of them funnel through
1390 // this drop). Post-close deliveries from the channel actor (whose EXIT
1391 // prune below is asynchronous) are refused without charging.
1392 self.inbox.close();
1393 // Terminating the subscriber process fires the bidirectional link to the
1394 // channel actor, which traps the EXIT and removes this subscriber from
1395 // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
1396 self.scheduler
1397 .terminate_process(self.pid, ExitReason::Normal);
1398 }
1399}
1400
1401/// Rebuilds a bus [`Envelope`] from a durable log entry, for the host-side
1402/// refill (A1 §4).
1403///
1404/// Two fields are deliberately NOT reconstructed, because the log does not hold
1405/// them and inventing them would make a refilled envelope claim more than the
1406/// store can support:
1407///
1408/// * **causal context** — the durable publish path persists `None` for it
1409/// (`ChannelHandle::persist_and_enqueue`), so there is nothing to restore;
1410/// * **message id** — the durable record does not store the live envelope's id,
1411/// so a fresh one is minted. A consumer that needs to correlate a replayed
1412/// message with a live one uses its durable position, which is exact.
1413fn refilled_envelope(stored: &MessageEnvelope, schema_id: SchemaId) -> Envelope {
1414 let millis = i64::try_from(stored.timestamp).unwrap_or(i64::MAX);
1415 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, millis)
1416 .single()
1417 .unwrap_or_else(chrono::Utc::now);
1418 Envelope::with_timestamp(
1419 stored.payload.clone(),
1420 None,
1421 schema_id,
1422 PublisherId::new(stored.publisher_id.clone()),
1423 timestamp,
1424 )
1425}
1426
1427/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
1428/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
1429///
1430/// This proves the production subscriber handler — the same `NativeHandler` the
1431/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
1432/// cooperative scheduler that a browser host drives. There is no toy stand-in:
1433/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
1434/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
1435/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
1436/// handler's own `accept_remote_frame` path and lands in the shared inbox a
1437/// [`SubscriptionHandle::try_next`] would read.
1438///
1439/// The handler runs cooperatively AS-IS: its `handle` only touches
1440/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
1441/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
1442/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
1443/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
1444/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
1445/// native.
1446#[cfg(test)]
1447#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1448mod cooperative_smoke {
1449 use std::cell::RefCell;
1450 use std::rc::Rc;
1451 use std::sync::Arc;
1452
1453 use beamr::atom::AtomTable;
1454 use beamr::ets::copy_term_to_ets;
1455 use beamr::module::ModuleRegistry;
1456 use beamr::native::BifRegistryImpl;
1457 use beamr::process::heap::Heap;
1458 use beamr::scheduler::WasmScheduler;
1459 use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
1460
1461 use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
1462 use crate::channel::SchemaId;
1463 use crate::channel::wire::encode_envelope;
1464 use crate::envelope::{Envelope, PublisherId};
1465
1466 /// Build a cooperative scheduler the way a wasm host holds it (single
1467 /// `Rc<RefCell<…>>` on one thread).
1468 fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
1469 let atom_table = Arc::new(AtomTable::with_common_atoms());
1470 let modules = Arc::new(ModuleRegistry::new());
1471 let bifs = Arc::new(BifRegistryImpl::new());
1472 Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
1473 }
1474
1475 /// Encode `envelope` into the production wire frame and wrap it as a
1476 /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
1477 /// remote node hands a published frame to a subscriber pid (SRV-005).
1478 fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
1479 let bytes = encode_envelope(envelope);
1480 let shared = SharedBinary::new(bytes);
1481 // A ProcBin reference needs three heap words; copy it into ETS-owned
1482 // memory so the scratch heap can be dropped before delivery.
1483 let mut scratch = Heap::new(8);
1484 let words = scratch
1485 .alloc_slice(3)
1486 .expect("scratch heap holds a proc-bin reference");
1487 let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
1488 copy_term_to_ets(term).expect("frame copies into an owned binary")
1489 }
1490
1491 fn sample_envelope() -> Envelope {
1492 // A whole-millisecond timestamp so the round-trip through the wire codec
1493 // (which carries millisecond resolution, see `channel::wire`) is exact;
1494 // `Utc::now()` sub-millisecond precision would otherwise be truncated on
1495 // decode and is irrelevant to what this smoke proves.
1496 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
1497 .single()
1498 .expect("valid fixed millisecond timestamp");
1499 Envelope::with_timestamp(
1500 b"{\"value\":42}".to_vec(),
1501 None,
1502 SchemaId::new(),
1503 PublisherId::from("publisher-cooperative"),
1504 timestamp,
1505 )
1506 }
1507
1508 #[test]
1509 fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
1510 let scheduler = cooperative_scheduler();
1511
1512 // The shared inbox the subscriber pushes decoded envelopes onto — the
1513 // exact channel the threaded `SubscriptionHandle::try_next` reads.
1514 let inbox: SubscriberInbox = SubscriptionInbox::new();
1515 let process_inbox = Arc::clone(&inbox);
1516
1517 // Spawn the GENUINE production subscriber handler as a first-class native
1518 // process on the cooperative scheduler.
1519 let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
1520 Box::new(SubscriberProcess {
1521 inbox: Arc::clone(&process_inbox),
1522 }) as Box<dyn beamr::native::native_process::NativeHandler>
1523 }));
1524
1525 // First turn: the handler runs once, asserts trap_exit, finds an empty
1526 // mailbox, and parks (`Wait`). No envelope has been delivered yet.
1527 scheduler.borrow_mut().run_until_idle();
1528 assert_eq!(
1529 inbox.len(),
1530 0,
1531 "no envelope is delivered before one is published"
1532 );
1533
1534 // Publish: deliver a real encoded frame as a beamr binary straight to the
1535 // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
1536 // the parked process.
1537 let published = sample_envelope();
1538 let frame = frame_as_owned_binary(&published);
1539 scheduler
1540 .borrow_mut()
1541 .send_owned(pid, &frame)
1542 .expect("frame is delivered to the live subscriber pid");
1543
1544 // Pump turns: the woken handler drains the binary, decodes it through its
1545 // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
1546 let mut delivered = None;
1547 for _ in 0..8 {
1548 scheduler.borrow_mut().run_until_idle();
1549 let next = inbox.pop();
1550 if let Some(envelope) = next {
1551 delivered = Some(envelope);
1552 break;
1553 }
1554 }
1555
1556 assert_eq!(
1557 delivered.as_ref(),
1558 Some(&published),
1559 "the real subscriber decoded and delivered the published envelope"
1560 );
1561 }
1562}
1563
1564/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
1565/// admitted envelope; the shared byte budget is spent across ALL a
1566/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
1567/// fairness trip stops one inbox starving its siblings; and charge/release is
1568/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
1569/// with no scheduler — the server-side wake wiring and shed are tested there.
1570#[cfg(test)]
1571#[allow(clippy::expect_used)]
1572mod inbox_bounding {
1573 use std::sync::Arc;
1574 use std::sync::atomic::{AtomicUsize, Ordering};
1575
1576 use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
1577 use crate::channel::SchemaId;
1578 use crate::channel::wire::encode_envelope;
1579 use crate::envelope::{Envelope, PublisherId};
1580 use crate::pressure::ConsumerCapacity;
1581
1582 fn envelope(payload: &[u8]) -> Envelope {
1583 Envelope::new(
1584 payload.to_vec(),
1585 None,
1586 SchemaId::new(),
1587 PublisherId::from("inbox-bounding-test"),
1588 )
1589 }
1590
1591 fn admitted_bytes(env: &Envelope) -> usize {
1592 encode_envelope(env).len()
1593 }
1594
1595 /// The level-triggered wake contract (P0 #55). This test previously asserted
1596 /// the edge-triggered form — that a second admit into a non-empty inbox does
1597 /// NOT re-fire — which is precisely the starvation the fix removes: the
1598 /// consumer drains a bounded slice, so a non-empty inbox is the normal state
1599 /// of a subscriber that has fallen behind, and withholding its wake is what
1600 /// ratchets it to the depth cap and a permanent shed.
1601 #[test]
1602 fn notifier_fires_for_every_admitted_envelope() {
1603 let inbox = SubscriptionInbox::new();
1604 let fires = Arc::new(AtomicUsize::new(0));
1605 let counter = Arc::clone(&fires);
1606 inbox.install_notifier(Arc::new(move || {
1607 counter.fetch_add(1, Ordering::Relaxed);
1608 }));
1609
1610 // First admit into an empty inbox fires.
1611 assert!(inbox.admit(envelope(b"a")).is_queued());
1612 assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
1613
1614 // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
1615 // may not have reached this envelope's slice, and R6 coalescing means a
1616 // redundant marker costs one mailbox atom, never a second slice of work.
1617 assert!(inbox.admit(envelope(b"b")).is_queued());
1618 assert_eq!(
1619 fires.load(Ordering::Relaxed),
1620 2,
1621 "an admit into a non-empty inbox still fires"
1622 );
1623
1624 // Drain to empty, then admit again: still exactly one fire per admit.
1625 assert!(inbox.pop().is_some());
1626 assert!(inbox.pop().is_some());
1627 assert!(inbox.admit(envelope(b"c")).is_queued());
1628 assert_eq!(
1629 fires.load(Ordering::Relaxed),
1630 3,
1631 "one fire per admitted envelope, whatever the queue depth was"
1632 );
1633 }
1634
1635 #[test]
1636 fn shared_budget_is_spent_across_all_a_connections_inboxes() {
1637 let one = envelope(b"payload-one");
1638 let two = envelope(b"payload-two");
1639 // A budget large enough for exactly ONE of the two envelopes.
1640 let cap = admitted_bytes(&one);
1641 let budget = ConnectionInboxBudget::new(cap);
1642
1643 let inbox_a = SubscriptionInbox::new();
1644 let inbox_b = SubscriptionInbox::new();
1645 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1646 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1647
1648 // Inbox A admits its envelope, consuming the whole shared budget.
1649 assert!(inbox_a.admit(one).is_queued());
1650 assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
1651
1652 // Inbox B — a SIBLING subscription — is refused: the budget is connection
1653 // scoped, not per-inbox, so A's fill denies B.
1654 assert!(matches!(
1655 inbox_b.admit(two),
1656 InboxAdmission::BudgetExceeded(_)
1657 ));
1658 assert!(
1659 inbox_b.is_overflowed(),
1660 "the sibling that overflowed the shared budget is shed"
1661 );
1662 assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
1663
1664 // Draining A releases its bytes back to the SHARED budget, so B could then
1665 // admit (charge/release symmetry across siblings).
1666 assert!(inbox_a.pop().is_some());
1667 assert_eq!(
1668 budget.used(),
1669 0,
1670 "release returns bytes to the shared budget"
1671 );
1672 }
1673
1674 #[test]
1675 fn overflow_sheds_and_does_not_grow_memory() {
1676 let env = envelope(b"x");
1677 let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
1678 let inbox = SubscriptionInbox::new();
1679 inbox.install_budget(budget, usize::MAX);
1680
1681 assert!(inbox.admit(env.clone()).is_queued());
1682 // The next admit overflows: refused, marked for shedding, and NOT queued —
1683 // the queue length does not grow past the bound.
1684 assert!(matches!(
1685 inbox.admit(env),
1686 InboxAdmission::BudgetExceeded(_)
1687 ));
1688 assert!(inbox.is_overflowed());
1689 assert_eq!(
1690 inbox.len(),
1691 1,
1692 "the overflowed envelope is dropped, not queued"
1693 );
1694 }
1695
1696 #[test]
1697 fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
1698 // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
1699 let budget = ConnectionInboxBudget::new(usize::MAX);
1700 let inbox = SubscriptionInbox::new();
1701 inbox.install_budget(budget, 2); // depth cap of 2 envelopes
1702
1703 assert!(inbox.admit(envelope(b"1")).is_queued());
1704 assert!(inbox.admit(envelope(b"2")).is_queued());
1705 // The third trips the fairness cap even though bytes are available.
1706 assert!(matches!(
1707 inbox.admit(envelope(b"3")),
1708 InboxAdmission::FairnessTripped(_)
1709 ));
1710 assert!(inbox.is_overflowed());
1711 assert_eq!(
1712 inbox.len(),
1713 2,
1714 "the fairness trip holds the inbox at its cap"
1715 );
1716 }
1717
1718 #[test]
1719 fn charge_and_release_are_exact() {
1720 let budget = ConnectionInboxBudget::new(1024 * 1024);
1721 let inbox = SubscriptionInbox::new();
1722 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1723
1724 let a = envelope(b"first-envelope");
1725 let b = envelope(b"second-longer-envelope-payload");
1726 let charge = admitted_bytes(&a) + admitted_bytes(&b);
1727 assert!(inbox.admit(a).is_queued());
1728 assert!(inbox.admit(b).is_queued());
1729 assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
1730
1731 assert!(inbox.pop().is_some());
1732 assert!(inbox.pop().is_some());
1733 assert_eq!(
1734 budget.used(),
1735 0,
1736 "every admitted byte is released on dequeue — exact symmetry"
1737 );
1738 }
1739
1740 /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
1741 /// shape — an overflowed inbox is near-full by construction) releases every
1742 /// charged byte back to the shared budget, so a sibling subscription can
1743 /// admit again. Without the close-release, one shed strands its whole share
1744 /// of the 4 MiB budget forever.
1745 #[test]
1746 fn close_releases_queued_charges_so_siblings_recover() {
1747 // Budget sized so inbox A can queue a 256-envelope backlog (the §5
1748 // fairness-cap depth) and exhaust the shared budget doing it.
1749 let one = envelope(b"backlog-envelope-payload");
1750 let unit = admitted_bytes(&one);
1751 let budget = ConnectionInboxBudget::new(unit * 256);
1752
1753 let inbox_a = SubscriptionInbox::new();
1754 let inbox_b = SubscriptionInbox::new();
1755 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
1756 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
1757
1758 // Queue the full 256-envelope backlog on A, consuming the whole budget.
1759 for _ in 0..256 {
1760 assert!(inbox_a.admit(one.clone()).is_queued());
1761 }
1762 assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
1763 // The sibling is starved (the shed trigger condition).
1764 assert!(matches!(
1765 inbox_b.admit(one.clone()),
1766 InboxAdmission::BudgetExceeded(_)
1767 ));
1768
1769 // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
1770 // returns to the shared budget in one atomic close.
1771 inbox_a.close();
1772 assert_eq!(
1773 budget.used(),
1774 0,
1775 "close releases the entire queued backlog back to the shared budget"
1776 );
1777 // The sibling recovers: it can admit again.
1778 assert!(
1779 inbox_b.admit(one).is_queued(),
1780 "a sibling admits again after the other inbox is shed"
1781 );
1782 }
1783
1784 /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1785 /// the budget, so a shed subscription can never re-accumulate cost while the
1786 /// channel actor's asynchronous EXIT prune is still in flight.
1787 #[test]
1788 fn closed_inbox_refuses_without_charging() {
1789 let env = envelope(b"post-close");
1790 let budget = ConnectionInboxBudget::new(1024 * 1024);
1791 let inbox = SubscriptionInbox::new();
1792 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1793
1794 inbox.close();
1795 assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1796 assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1797 assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1798 }
1799
1800 /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1801 /// called `close`, the last handle dropping still releases the queued charges
1802 /// (release-by-construction: a release that cannot be omitted).
1803 #[test]
1804 fn drop_backstop_releases_queued_charges() {
1805 let env = envelope(b"dropped-while-queued");
1806 let unit = admitted_bytes(&env);
1807 let budget = ConnectionInboxBudget::new(1024 * 1024);
1808 {
1809 let inbox = SubscriptionInbox::new();
1810 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1811 assert!(inbox.admit(env.clone()).is_queued());
1812 assert!(inbox.admit(env).is_queued());
1813 assert_eq!(budget.used(), unit * 2);
1814 // No close() call: the Arc drops here.
1815 }
1816 assert_eq!(
1817 budget.used(),
1818 0,
1819 "dropping the last inbox handle releases every queued charge"
1820 );
1821 }
1822
1823 /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1824 /// nothing (the queue was drained into the release).
1825 #[test]
1826 fn close_is_idempotent_and_drains_the_queue() {
1827 let env = envelope(b"x");
1828 let budget = ConnectionInboxBudget::new(1024 * 1024);
1829 let inbox = SubscriptionInbox::new();
1830 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1831 assert!(inbox.admit(env).is_queued());
1832
1833 inbox.close();
1834 inbox.close(); // second close is a no-op, not a double release
1835 assert_eq!(budget.used(), 0);
1836 assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1837 }
1838
1839 /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1840 /// budget was installed carries a charge of 0 — its dequeue releases exactly
1841 /// 0 against the later-installed budget, never bytes it did not charge. The
1842 /// production subscribe path installs the budget at inbox construction so
1843 /// this window is structurally closed; this pins the defensive invariant
1844 /// that makes release byte-identical to charge on EVERY entry regardless.
1845 #[test]
1846 fn per_entry_charge_ownership_survives_budget_install() {
1847 let uncharged = envelope(b"admitted-before-budget-install");
1848 let charged = envelope(b"admitted-after-budget-install");
1849 let inbox = SubscriptionInbox::new();
1850
1851 // Admitted with no budget installed: charge ownership 0.
1852 assert!(inbox.admit(uncharged).is_queued());
1853
1854 let budget = ConnectionInboxBudget::new(1024 * 1024);
1855 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1856 let unit = admitted_bytes(&charged);
1857 assert!(inbox.admit(charged).is_queued());
1858 assert_eq!(budget.used(), unit, "only the post-install entry charged");
1859
1860 // Popping the uncharged entry releases exactly 0 — the budget cannot
1861 // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1862 // that were never charged.
1863 assert!(inbox.pop().is_some());
1864 assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1865 assert!(inbox.pop().is_some());
1866 assert_eq!(
1867 budget.used(),
1868 0,
1869 "the charged entry released its exact charge"
1870 );
1871 }
1872
1873 /// Review round 1 item 5 (install recheck): installing a notifier onto an
1874 /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1875 /// admitted while there was no notifier to fire, so the install regenerates
1876 /// their wake and one can never be lost to install ordering. (The production
1877 /// subscribe path installs at construction, when the queue is guaranteed
1878 /// empty; this pins the defensive invariant.)
1879 #[test]
1880 fn notifier_install_onto_non_empty_inbox_fires_once() {
1881 let inbox = SubscriptionInbox::new();
1882 assert!(inbox.admit(envelope(b"pre-install")).is_queued());
1883
1884 let fires = Arc::new(AtomicUsize::new(0));
1885 let counter = Arc::clone(&fires);
1886 inbox.install_notifier(Arc::new(move || {
1887 counter.fetch_add(1, Ordering::Relaxed);
1888 }));
1889 assert_eq!(
1890 fires.load(Ordering::Relaxed),
1891 1,
1892 "install onto a non-empty inbox regenerates exactly one wake"
1893 );
1894
1895 // The install is a ONE-OFF regeneration, not an extra fire per admit: a
1896 // subsequent admit adds exactly its own one fire.
1897 assert!(inbox.admit(envelope(b"second")).is_queued());
1898 assert_eq!(fires.load(Ordering::Relaxed), 2);
1899 }
1900
1901 /// **PIN (round 2) — the exactly-once guard suppresses what the REPLAY DOOR
1902 /// offered, and nothing else.**
1903 ///
1904 /// A live push that arrives behind a higher-positioned sibling has been
1905 /// offered by nobody. Suppressing it is silent loss, and worse than the
1906 /// duplicate the guard exists to prevent: nothing is shed, so `lagging` is
1907 /// never set and auto-catch-up can never recover it, while
1908 /// [`InboxAdmission::is_queued`] still reports it to the producer as
1909 /// delivered.
1910 ///
1911 /// The publish path is ordered so that this cannot happen in production
1912 /// (the fan-out command is enqueued under the append lock). This pin is the
1913 /// inbox's OWN control on the same claim: whatever the publish path does,
1914 /// the guard's comparand may only be advanced by a door that genuinely made
1915 /// an offer, so the worst an inversion could ever cost is a duplicate —
1916 /// never a lost message.
1917 #[test]
1918 fn a_live_push_is_not_suppressed_by_a_higher_positioned_sibling() {
1919 let inbox = SubscriptionInbox::new();
1920
1921 assert!(
1922 inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued(),
1923 "the higher-positioned push is queued"
1924 );
1925 let behind = inbox.admit_at(envelope(b"position-3"), Some(3));
1926 assert!(
1927 !matches!(behind, InboxAdmission::AlreadyOffered(_)),
1928 "position 3 was offered by NOBODY — its only sin is arriving behind \
1929 position 5, and suppressing it loses it silently; got {behind:?}"
1930 );
1931 assert!(behind.is_queued(), "position 3 must reach the queue");
1932 assert_eq!(
1933 inbox.queued_len(),
1934 2,
1935 "both live pushes are in the queue, in arrival order"
1936 );
1937 }
1938
1939 /// **PIN (round 2), the anti-vacuity partner** — the guard still fires for
1940 /// the position the replay door actually offered. Without this, the pin
1941 /// above passes on a guard that was simply deleted.
1942 #[test]
1943 fn a_live_push_the_replay_door_already_offered_is_suppressed() {
1944 let inbox = SubscriptionInbox::new();
1945
1946 assert!(
1947 inbox.admit_replayed(envelope(b"replayed-5"), 5),
1948 "the replay door offers position 5"
1949 );
1950 let live = inbox.admit_at(envelope(b"live-5"), Some(5));
1951 assert!(
1952 matches!(live, InboxAdmission::AlreadyOffered(_)),
1953 "position 5 was already offered by the refill; got {live:?}"
1954 );
1955 assert_eq!(
1956 inbox.queued_len(),
1957 1,
1958 "the suppressed push did not enter the queue a second time"
1959 );
1960 }
1961
1962 /// **PIN (round 2) — the refill cursor never backsteps onto a position that
1963 /// was already delivered.**
1964 ///
1965 /// The cursor names where the missed range STARTS. Assigning it
1966 /// `position + 1` unconditionally lets a live push that arrives behind a
1967 /// higher-positioned sibling drag it backwards over messages this
1968 /// subscriber already has, and the next refill then re-reads and re-offers
1969 /// them — the duplicate this whole seam exists to prevent, arriving through
1970 /// the other door.
1971 #[test]
1972 fn the_refill_cursor_never_backsteps_below_a_delivered_position() {
1973 let inbox = SubscriptionInbox::new();
1974 inbox
1975 .install_capacity(ConsumerCapacity::new(1, 1).expect("1/1 is a legal capacity"))
1976 .expect("a legal capacity installs");
1977
1978 assert!(inbox.admit_at(envelope(b"position-5"), Some(5)).is_queued());
1979 assert!(inbox.admit_at(envelope(b"position-3"), Some(3)).is_queued());
1980 // The bound (1 + 1) is now full, so this one is shed and OPENS the gap.
1981 assert!(matches!(
1982 inbox.admit_at(envelope(b"position-6"), Some(6)),
1983 InboxAdmission::Rejected(_)
1984 ));
1985 assert!(inbox.is_lagging(), "the shed opened a gap");
1986
1987 assert!(inbox.pop().is_some());
1988 assert!(inbox.pop().is_some());
1989 let (_generation, cursor, _budget) = inbox
1990 .refill_plan()
1991 .expect("a drained lagging inbox has a refill due");
1992 assert_eq!(
1993 cursor, 6,
1994 "the missed range starts above every delivered position; a cursor of \
1995 4 would send the refill back over positions 4 and 5, and position 5 \
1996 is already in this subscriber's hands"
1997 );
1998 }
1999}