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