liminal/channel/subscription.rs
1//! LIM-002 R2/R3: subscriptions backed by real beamr processes.
2//!
3//! Each subscription owns a real, scheduler-supervised beamr native process
4//! (a [`SubscriberProcess`]) plus the in-memory inbox the channel actor delivers
5//! matching envelopes into. The channel actor LINKS to this process's pid on
6//! `Subscribe`; when the [`SubscriptionHandle`] is dropped (or the caller
7//! unsubscribes) the process is terminated, the link fires an `{EXIT, pid, _}`
8//! signal, and the trapping channel actor removes the dead subscriber from its
9//! fan-out list. There is NO weak-Arc polling: liveness is observed structurally
10//! through the beamr link/EXIT path, exactly as the conversation actor observes
11//! its participants (`conversation/actor/beam.rs`).
12//!
13//! R3 predicates live INSIDE the channel actor process: a [`SubscriptionPredicate`]
14//! is a boxed `Fn(&Envelope) -> bool` owned by the actor's subscriber
15//! registration and evaluated at delivery time. This mirrors the participant
16//! `behaviour` pattern (a boxed trait object the process owns); for an in-memory
17//! ephemeral channel there is no need for a serialisable predicate, so a closure
18//! the actor holds is the simplest faithful design.
19
20use std::collections::VecDeque;
21use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
22use std::sync::{Arc, Mutex};
23
24use beamr::native::native_process::{NativeContext, NativeHandler, NativeOutcome};
25use beamr::process::ExitReason;
26use beamr::scheduler::Scheduler;
27use beamr::term::binary_ref::BinaryRef;
28
29use crate::channel::wire::{decode_envelope, encode_envelope};
30use crate::envelope::Envelope;
31use crate::error::LiminalError;
32
33/// A shared, cloneable in-memory inbox a subscriber receives delivered envelopes
34/// on. See [`SubscriptionInbox`].
35pub(crate) type SubscriberInbox = Arc<SubscriptionInbox>;
36
37/// A wake callback fired on EVERY envelope admitted to the inbox (R3, §1.2(2)).
38///
39/// The server installs one that fires the CONNECTION scheduler's `READY` marker,
40/// so a publish into a parked connection's inbox wakes it. It is called from the
41/// PUBLISHING actor's slice (the channel actor for local delivery, the subscriber
42/// process for a remote frame), so it must be cheap and non-blocking — a single
43/// `enqueue_atom_message`. `None` (no notifier installed) is the standalone
44/// library / test case: nothing to wake, delivery still lands in the inbox.
45pub type InboxNotifier = Arc<dyn Fn() + Send + Sync>;
46
47/// One shared inbox-byte budget per connection (§5).
48///
49/// Spent across ALL that connection's subscription inboxes. The accounting unit is
50/// serialized envelope bytes AS ADMITTED — charged at enqueue, released at dequeue
51/// — so the signed 4 MiB product is exact and envelope-size-independent, not a
52/// per-inbox count bounding a variable the design does not control.
53#[derive(Debug)]
54pub struct ConnectionInboxBudget {
55 used: AtomicUsize,
56 cap: usize,
57}
58
59impl ConnectionInboxBudget {
60 /// Creates a shared budget with `cap` bytes of headroom across all the
61 /// connection's inboxes.
62 #[must_use]
63 pub fn new(cap: usize) -> Arc<Self> {
64 Arc::new(Self {
65 used: AtomicUsize::new(0),
66 cap,
67 })
68 }
69
70 /// Attempts to charge `bytes`. Returns `true` and reserves the bytes when they
71 /// fit within the remaining budget, `false` (reserving nothing) on overflow. A
72 /// CAS loop keeps the reservation exact under concurrent charges from several
73 /// inboxes — no transient over-charge is ever observable.
74 fn try_charge(&self, bytes: usize) -> bool {
75 let mut current = self.used.load(Ordering::Acquire);
76 loop {
77 let Some(projected) = current.checked_add(bytes) else {
78 return false;
79 };
80 if projected > self.cap {
81 return false;
82 }
83 match self.used.compare_exchange_weak(
84 current,
85 projected,
86 Ordering::AcqRel,
87 Ordering::Acquire,
88 ) {
89 Ok(_) => return true,
90 Err(observed) => current = observed,
91 }
92 }
93 }
94
95 /// Releases `bytes` previously charged (at dequeue). Saturating so a double
96 /// release can never wrap the counter below zero.
97 fn release(&self, bytes: usize) {
98 let mut current = self.used.load(Ordering::Acquire);
99 loop {
100 let next = current.saturating_sub(bytes);
101 match self.used.compare_exchange_weak(
102 current,
103 next,
104 Ordering::AcqRel,
105 Ordering::Acquire,
106 ) {
107 Ok(_) => return,
108 Err(observed) => current = observed,
109 }
110 }
111 }
112
113 /// Bytes currently reserved across the connection's inboxes.
114 #[cfg(test)]
115 pub(crate) fn used(&self) -> usize {
116 self.used.load(Ordering::Acquire)
117 }
118}
119
120/// Everything a server connection installs onto a subscription's inbox.
121///
122/// Carries the shared §5 byte budget, the per-inbox fairness cap, and the R3
123/// wake notifier. Passed INTO the subscribe call so the installation happens at
124/// inbox construction — strictly BEFORE the registration is published to the
125/// channel actor — closing the pre-install window in which envelopes could be
126/// admitted uncharged or without a wake.
127pub struct InboxInstall {
128 /// Shared per-connection byte budget (§5).
129 pub budget: Arc<ConnectionInboxBudget>,
130 /// Per-inbox envelope-count fairness trip (§5).
131 pub depth_cap: usize,
132 /// R3 wake notifier fired on every envelope admitted to the inbox. `None`
133 /// when the caller has no waker (scheduler-free unit tests).
134 pub notifier: Option<InboxNotifier>,
135}
136
137impl std::fmt::Debug for InboxInstall {
138 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 formatter
140 .debug_struct("InboxInstall")
141 .field("depth_cap", &self.depth_cap)
142 .field("has_notifier", &self.notifier.is_some())
143 .finish_non_exhaustive()
144 }
145}
146
147/// Mutable inbox state guarded by one lock: the queued envelopes (each carrying
148/// the exact bytes CHARGED for it, so release is symmetric with charge), the
149/// installed shared budget and per-inbox fairness cap, the wake notifier, and
150/// the closed marker.
151struct InboxState {
152 /// Each entry is `(envelope, charged_bytes)` — the amount actually charged to
153 /// the shared budget at enqueue (0 when no budget was installed at admit
154 /// time), released verbatim at dequeue/close. Storing the CHARGE, not the
155 /// size, makes release byte-identical to charge on every entry even across a
156 /// budget install, so the budget can never under- or over-release.
157 queue: VecDeque<(Envelope, usize)>,
158 /// Shared per-connection byte budget (§5). `None` = unbounded (standalone
159 /// library use / tests), preserving the pre-bounding behaviour exactly.
160 budget: Option<Arc<ConnectionInboxBudget>>,
161 /// Per-inbox envelope-count secondary fairness trip (§5). `usize::MAX` = off;
162 /// stops one subscription starving its siblings inside the shared byte budget.
163 depth_cap: usize,
164 /// Wake callback (R3). `None` until the connection installs one.
165 notifier: Option<InboxNotifier>,
166 /// Terminal marker set by [`SubscriptionInbox::close`]: admissions are refused
167 /// WITHOUT charging, and all queued charges have been released. Closing is the
168 /// release-by-construction seam — every teardown path (explicit unsubscribe,
169 /// overflow shed, connection teardown, and the `Drop` backstop) funnels
170 /// through it, so queued bytes can never be stranded on the connection-lifetime
171 /// budget.
172 closed: bool,
173}
174
175/// The shared subscription inbox (R3 + §5). Replaces the bare
176/// `Arc<Mutex<VecDeque<Envelope>>>`: it fires a wake notifier on every admitted
177/// envelope and enforces the connection-scoped byte budget plus the per-inbox
178/// fairness trip, shedding the offending subscription on overflow.
179pub(crate) struct SubscriptionInbox {
180 state: Mutex<InboxState>,
181 /// Sticky overflow marker: set when an admission is refused by the byte budget
182 /// or the fairness trip. The server-side delivery pump observes it and sheds
183 /// this subscription with a typed error frame, mirroring the outbound overflow
184 /// policy (a slow consumer sheds its own subscription; it cannot grow server
185 /// memory without bound). Sticky (never cleared) because a shed is terminal.
186 overflowed: AtomicBool,
187}
188
189impl std::fmt::Debug for SubscriptionInbox {
190 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 formatter
192 .debug_struct("SubscriptionInbox")
193 .field("overflowed", &self.overflowed.load(Ordering::Acquire))
194 .finish_non_exhaustive()
195 }
196}
197
198/// Why an inbox admission was refused (§5). The budget/fairness refusals set the
199/// sticky overflow marker and drop the envelope rather than growing memory; a
200/// closed inbox refuses without charging and without marking.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub(crate) enum InboxAdmission {
203 /// The envelope was admitted (and, when it made the inbox non-empty, the wake
204 /// notifier was fired).
205 Admitted,
206 /// The shared connection byte budget (§5) had no room; the subscription is
207 /// shed.
208 BudgetExceeded,
209 /// The per-inbox fairness trip (§5) is full; the subscription is shed.
210 FairnessTripped,
211 /// The inbox was closed (unsubscribe/shed/teardown): the envelope is dropped
212 /// without charging the budget — a closed inbox can never re-accumulate cost.
213 Closed,
214}
215
216impl SubscriptionInbox {
217 /// Creates an unbounded, notifier-less inbox — the standalone/default shape,
218 /// byte-identical to the pre-bounding behaviour. A server connection passes an
219 /// [`InboxInstall`] through subscribe so budget/cap/notifier are installed at
220 /// construction instead.
221 pub(crate) fn new() -> Arc<Self> {
222 Arc::new(Self {
223 state: Mutex::new(InboxState {
224 queue: VecDeque::new(),
225 budget: None,
226 depth_cap: usize::MAX,
227 notifier: None,
228 closed: false,
229 }),
230 overflowed: AtomicBool::new(false),
231 })
232 }
233
234 /// Installs the connection's shared byte budget and per-inbox fairness cap
235 /// (§5). Runs at inbox construction (via [`InboxInstall`]) — before the
236 /// registration is published to the channel actor — so no envelope can be
237 /// admitted uncharged.
238 pub(crate) fn install_budget(&self, budget: Arc<ConnectionInboxBudget>, depth_cap: usize) {
239 if let Ok(mut state) = self.state.lock() {
240 state.budget = Some(budget);
241 state.depth_cap = depth_cap;
242 }
243 }
244
245 /// Installs the wake notifier (R3), fired on every admitted envelope,
246 /// capturing the connection scheduler's enqueue handle (§1.2(2)).
247 ///
248 /// Defensive invariant: the install RECHECKS non-emptiness under the lock and
249 /// fires the notifier (outside the lock) when envelopes are already queued —
250 /// those envelopes were admitted while there was no notifier to fire, so
251 /// without this recheck their wake would be lost to install ordering. On the
252 /// normal construction path the queue is empty and this is a no-op.
253 pub(crate) fn install_notifier(&self, notifier: InboxNotifier) {
254 let fire = {
255 let Ok(mut state) = self.state.lock() else {
256 return;
257 };
258 let pending = !state.queue.is_empty();
259 let handle = notifier.clone();
260 state.notifier = Some(notifier);
261 pending.then_some(handle)
262 };
263 if let Some(notifier) = fire {
264 notifier();
265 }
266 }
267
268 /// Admits `envelope` under the byte budget and fairness trip, charging the
269 /// serialized bytes as admitted and firing the wake notifier for EVERY
270 /// admitted envelope (level-triggered — see the fire site below for why the
271 /// edge-triggered form starved a subscriber that fell more than one delivery
272 /// slice behind). On budget/fairness refusal the sticky overflow marker is set
273 /// and the envelope dropped (memory never grows past the bound); a closed
274 /// inbox refuses without charging or marking.
275 ///
276 /// The notifier fires OUTSIDE the state lock so the publishing actor's slice
277 /// never holds the inbox lock across the scheduler enqueue.
278 pub(crate) fn admit(&self, envelope: Envelope) -> InboxAdmission {
279 // Serialize once, before the lock: the admitted byte count is the wire
280 // size (§5 denomination). The entry stores the amount actually CHARGED
281 // (0 when no budget is installed), so dequeue/close releases exactly
282 // what enqueue charged.
283 let bytes = encode_envelope(&envelope).len();
284 let notifier = {
285 let Ok(mut state) = self.state.lock() else {
286 // A poisoned inbox lock is terminal for this subscription; treat it
287 // as a shed rather than silently dropping into a dead inbox.
288 self.overflowed.store(true, Ordering::Release);
289 return InboxAdmission::BudgetExceeded;
290 };
291 if state.closed {
292 return InboxAdmission::Closed;
293 }
294 if state.queue.len() >= state.depth_cap {
295 self.overflowed.store(true, Ordering::Release);
296 let notifier = state.notifier.clone();
297 drop(state);
298 if let Some(notifier) = notifier {
299 notifier();
300 }
301 return InboxAdmission::FairnessTripped;
302 }
303 let charged = match state.budget.as_ref() {
304 Some(budget) => {
305 if !budget.try_charge(bytes) {
306 self.overflowed.store(true, Ordering::Release);
307 let notifier = state.notifier.clone();
308 drop(state);
309 if let Some(notifier) = notifier {
310 notifier();
311 }
312 return InboxAdmission::BudgetExceeded;
313 }
314 bytes
315 }
316 None => 0,
317 };
318 state.queue.push_back((envelope, charged));
319 // LEVEL-TRIGGERED, not edge-triggered: EVERY successful enqueue fires.
320 //
321 // The consumer drains a BOUNDED slice (the server's delivery pump: 32
322 // envelopes per connection slice), so an inbox more than a slice deep
323 // does NOT empty when it is serviced. Under the edge rule a subscriber
324 // in exactly that state — the normal state of anyone who has fallen
325 // behind — earned one wake for an entire burst and none afterwards,
326 // and every later envelope arrived with no wake attached to it at all.
327 // That is a lost-wake hazard on its face, and it is removed here.
328 //
329 // HONEST SCOPE, measured — this is NOT what starves a subscriber at
330 // today's bytes, and it must not be cited as if it were. A/B over 120
331 // fresh-boot iterations per arm (gate-logs/p0-55/) found the edge and
332 // level forms indistinguishable: 51.7% vs 53.3% of boots lost a
333 // subscriber to the depth-cap shed. The reason is R6 coalescing
334 // itself. N fires collapse into one mailbox drain, so turning one wake
335 // into N cannot buy the connection a single extra SLICE, and slices —
336 // not wakes — are what drain the queue. The variable that does move it
337 // is the pump's per-slice budget (32 -> 256 took the same harness to
338 // 0/120), which is a cross-connection fairness knob and not this
339 // file's to turn. See the report accompanying this lane.
340 //
341 // What firing every time costs: one non-blocking `enqueue_atom_message`
342 // per admitted envelope, which R6 coalescing collapses to one slice.
343 // An idle inbox admits nothing and so still fires nothing — the
344 // zero-cost-at-rest property is unchanged.
345 state.notifier.clone()
346 };
347 if let Some(notifier) = notifier {
348 notifier();
349 }
350 InboxAdmission::Admitted
351 }
352
353 /// Removes and returns the next envelope, releasing its CHARGED bytes back to
354 /// the shared budget (exact charge/release symmetry).
355 pub(crate) fn pop(&self) -> Option<Envelope> {
356 let (envelope, charged, budget) = {
357 let mut state = self.state.lock().ok()?;
358 let (envelope, charged) = state.queue.pop_front()?;
359 (envelope, charged, state.budget.clone())
360 };
361 // Release the charged bytes AFTER dropping the state lock so the shared
362 // budget's atomic is never touched while the inbox lock is held.
363 if let Some(budget) = budget {
364 budget.release(charged);
365 }
366 Some(envelope)
367 }
368
369 /// Non-consuming race-barrier query used after a connection arms readiness.
370 pub(crate) fn has_pending(&self) -> bool {
371 self.state.lock().is_ok_and(|state| !state.queue.is_empty())
372 }
373
374 /// Atomically closes the inbox, releasing every queued charge back to the
375 /// shared budget: under the lock it marks the inbox closed, drains all
376 /// entries, and detaches the notifier and budget; the summed release happens
377 /// outside the lock. Idempotent. Admissions after close are refused without
378 /// charging ([`InboxAdmission::Closed`]).
379 ///
380 /// This is the release-by-construction seam: explicit unsubscribe, overflow
381 /// shed, and connection teardown ALL reach it through the subscription
382 /// handle's drop (see [`SubscriptionInner::drop`]), and the inbox's own `Drop`
383 /// is the final backstop — no teardown path can strand queued bytes on the
384 /// connection-lifetime budget.
385 pub(crate) fn close(&self) {
386 let (released, budget) = {
387 let Ok(mut state) = self.state.lock() else {
388 return;
389 };
390 if state.closed {
391 return;
392 }
393 state.closed = true;
394 let released: usize = state
395 .queue
396 .drain(..)
397 .map(|(_envelope, charged)| charged)
398 .sum();
399 state.notifier = None;
400 (released, state.budget.take())
401 };
402 if let Some(budget) = budget {
403 budget.release(released);
404 }
405 }
406
407 /// Whether this subscription has been marked for shedding by an overflow.
408 pub(crate) fn is_overflowed(&self) -> bool {
409 self.overflowed.load(Ordering::Acquire)
410 }
411
412 /// Number of queued envelopes (test observability).
413 #[cfg(test)]
414 pub(crate) fn len(&self) -> usize {
415 self.state.lock().map_or(0, |state| state.queue.len())
416 }
417}
418
419impl Drop for SubscriptionInbox {
420 fn drop(&mut self) {
421 // Backstop: if no teardown path ever called `close`, release the queued
422 // charges here so the last Arc dropping can never strand budget bytes.
423 // Idempotent against an earlier close (the closed marker short-circuits).
424 self.close();
425 }
426}
427
428/// A delivery predicate evaluated by the channel actor against each published
429/// envelope. `None` (no predicate) means deliver everything.
430pub(crate) type SubscriptionPredicate = Arc<dyn Fn(&Envelope) -> bool + Send + Sync>;
431
432/// Real beamr native process backing one subscription.
433///
434/// For LOCAL delivery it is an idle handler (mirroring
435/// `aion::worker::link::IdleWorkerProcess`): local envelopes travel through the
436/// shared [`SubscriberInbox`] the channel actor writes and
437/// [`SubscriptionHandle::try_next`] reads. Its other job is to BE a first-class
438/// linkable, killable process whose lifetime equals the subscription's, so the
439/// channel actor detects the subscription dying via a real EXIT signal rather
440/// than by polling a weak pointer.
441///
442/// For CROSS-NODE delivery (SRV-005) it is also the landing point for a remote
443/// publish: a remote node sends a published envelope, encoded by
444/// [`crate::channel::wire::encode_envelope`], as a single beamr binary directly
445/// to this process's pid (the pid the cluster registered in the channel's
446/// distributed process group). The binary lands in this process's mailbox; the
447/// handler decodes it back into an [`Envelope`] and pushes it onto the SAME
448/// inbox a local publish would, so a subscriber observes local and remote
449/// messages identically. Non-binary wakeups (trapped `{EXIT, _, _}` signals) are
450/// drained and ignored.
451struct SubscriberProcess {
452 inbox: SubscriberInbox,
453}
454
455impl NativeHandler for SubscriberProcess {
456 fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome {
457 // Trapping is set authoritatively at spawn (see `SubscriptionHandle::spawn`)
458 // so it holds before the actor ever links — re-assert it here defensively
459 // for any future restart of this handler.
460 ctx.set_trap_exit(true);
461 // Drain every queued wakeup. A binary message is a remote envelope frame
462 // (SRV-005) to decode and enqueue; everything else (e.g. a trapped
463 // `{EXIT, _, _}` tuple from a crashed actor this subscriber outlives) is
464 // ignored. Death is driven only by an explicit `terminate_process` on
465 // unsubscribe/handle drop.
466 while let Some(message) = ctx.recv() {
467 if let Some(binary) = BinaryRef::new(message) {
468 self.accept_remote_frame(binary.as_bytes());
469 }
470 }
471 NativeOutcome::Wait
472 }
473}
474
475impl SubscriberProcess {
476 /// Decode a remote envelope frame and push it onto the inbox. A frame that
477 /// fails to decode is dropped: a corrupt cross-node payload must never crash
478 /// the subscriber or stall delivery of well-formed messages.
479 fn accept_remote_frame(&self, bytes: &[u8]) {
480 let Ok(envelope) = decode_envelope(bytes) else {
481 return;
482 };
483 // R3: the remote-delivery leg fires the same wake notifier and obeys the
484 // same §5 byte budget as the local leg. An overflow marks the subscription
485 // for shedding (inside `admit`); the frame is dropped rather than growing
486 // server memory.
487 self.inbox.admit(envelope);
488 }
489}
490
491/// The actor-side record of one subscriber: the inbox to deliver into and the
492/// optional predicate to gate delivery. Held by the channel actor INSIDE its
493/// process, keyed by the subscriber process's pid.
494pub(crate) struct SubscriberRegistration {
495 pid: u64,
496 inbox: SubscriberInbox,
497 predicate: Option<SubscriptionPredicate>,
498}
499
500impl SubscriberRegistration {
501 pub(crate) const fn pid(&self) -> u64 {
502 self.pid
503 }
504
505 /// Delivers `envelope` to this subscriber when its predicate accepts it (or
506 /// it has no predicate). Returns `true` when the envelope was pushed onto
507 /// the inbox, `false` when a predicate filtered it out, the inbox refused it
508 /// (§5 overflow — the subscription is then marked for shedding), or the inbox
509 /// is closed. The boolean lets the channel actor count genuine deliveries for
510 /// the delivery-ack signal.
511 pub(crate) fn deliver(&self, envelope: &Envelope) -> bool {
512 if let Some(predicate) = self.predicate.as_ref() {
513 if !predicate(envelope) {
514 return false;
515 }
516 }
517 // R3 + §5: admission charges the connection byte budget, fires the wake
518 // notifier for every admitted envelope, and — on overflow — marks the
519 // subscription for shedding (the server pump sheds it with a typed error
520 // frame). An overflowed envelope is NOT counted as a genuine delivery, so
521 // the delivery-ack signal reflects only envelopes that entered the inbox.
522 matches!(self.inbox.admit(envelope.clone()), InboxAdmission::Admitted)
523 }
524}
525
526impl std::fmt::Debug for SubscriberRegistration {
527 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 formatter
529 .debug_struct("SubscriberRegistration")
530 .field("pid", &self.pid)
531 .field("has_predicate", &self.predicate.is_some())
532 .finish_non_exhaustive()
533 }
534}
535
536/// Handle returned by channel subscriptions for receiving validated envelopes.
537///
538/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
539/// scheduler so the process can be terminated when the subscription ends. The
540/// handle is the subscription's lifetime: dropping the last clone terminates the
541/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
542#[derive(Clone)]
543pub struct SubscriptionHandle {
544 inner: Arc<SubscriptionInner>,
545}
546
547struct SubscriptionInner {
548 pid: u64,
549 inbox: SubscriberInbox,
550 scheduler: Arc<Scheduler>,
551}
552
553impl SubscriptionHandle {
554 /// Spawns a real subscriber process on `scheduler` and returns the handle
555 /// plus its actor-side registration record (carrying any predicate).
556 ///
557 /// # Errors
558 /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
559 /// spawn the subscriber process.
560 pub(crate) fn spawn(
561 scheduler: &Arc<Scheduler>,
562 predicate: Option<SubscriptionPredicate>,
563 install: Option<InboxInstall>,
564 ) -> Result<(Self, SubscriberRegistration), LiminalError> {
565 let inbox: SubscriberInbox = SubscriptionInbox::new();
566 // Install the §5 budget/fairness cap and the R3 wake notifier AT
567 // CONSTRUCTION — strictly before the registration is handed to the
568 // channel actor — so there is no window in which a publish can be
569 // admitted uncharged, past the depth cap, or without a wake.
570 if let Some(install) = install {
571 inbox.install_budget(install.budget, install.depth_cap);
572 if let Some(notifier) = install.notifier {
573 inbox.install_notifier(notifier);
574 }
575 }
576 let process_inbox = Arc::clone(&inbox);
577 let factory = Box::new(move || {
578 Box::new(SubscriberProcess {
579 inbox: Arc::clone(&process_inbox),
580 }) as Box<dyn NativeHandler>
581 });
582 // trap_exit is set on the process BEFORE it is published as runnable
583 // (0.16.1 `spawn_native_trap_exit` — pre-runnable by construction), so
584 // an abnormal channel-actor crash is trapped (delivered as a message
585 // the subscriber drains) instead of cascading across the link and
586 // killing the subscriber. This makes the subscriber outlive a
587 // channel-actor crash so the restarted actor can re-link to it on boot
588 // (R2/R4): the flag is in place before the process's first slice, with
589 // no post-spawn window for `set_trap_exit` to race (or return
590 // `NoCaller` against a mid-first-slice process).
591 let pid = scheduler.spawn_native_trap_exit(factory).map_err(|error| {
592 LiminalError::SubscriptionFailed {
593 message: format!("failed to spawn subscriber process: {error:?}"),
594 }
595 })?;
596 let handle = Self {
597 inner: Arc::new(SubscriptionInner {
598 pid,
599 inbox: Arc::clone(&inbox),
600 scheduler: Arc::clone(scheduler),
601 }),
602 };
603 let registration = SubscriberRegistration {
604 pid,
605 inbox,
606 predicate,
607 };
608 Ok((handle, registration))
609 }
610
611 /// Returns the beamr pid of the subscriber process this handle owns.
612 #[must_use]
613 pub(crate) fn pid(&self) -> u64 {
614 self.inner.pid
615 }
616
617 /// Attempts to receive the next delivered envelope without blocking.
618 ///
619 /// # Errors
620 ///
621 /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
622 pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
623 // Dequeue releases the envelope's admitted bytes back to the shared
624 // connection budget (§5 charge/release symmetry).
625 Ok(self.inner.inbox.pop())
626 }
627
628 /// Whether an envelope is available without consuming it.
629 #[must_use]
630 pub fn has_pending(&self) -> bool {
631 self.inner.inbox.has_pending()
632 }
633
634 /// Whether an overflow has marked this subscription for shedding (§5).
635 #[must_use]
636 pub fn is_overflowed(&self) -> bool {
637 self.inner.inbox.is_overflowed()
638 }
639}
640
641impl std::fmt::Debug for SubscriptionHandle {
642 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643 formatter
644 .debug_struct("SubscriptionHandle")
645 .field("pid", &self.inner.pid)
646 .finish_non_exhaustive()
647 }
648}
649
650impl Drop for SubscriptionInner {
651 fn drop(&mut self) {
652 // Close the inbox FIRST: atomically mark it closed, drain queued entries,
653 // and release every charged byte back to the shared connection budget
654 // (§5 — queued bytes must never be stranded on the connection-lifetime
655 // budget by unsubscribe, shed, or teardown; all of them funnel through
656 // this drop). Post-close deliveries from the channel actor (whose EXIT
657 // prune below is asynchronous) are refused without charging.
658 self.inbox.close();
659 // Terminating the subscriber process fires the bidirectional link to the
660 // channel actor, which traps the EXIT and removes this subscriber from
661 // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
662 self.scheduler
663 .terminate_process(self.pid, ExitReason::Normal);
664 }
665}
666
667/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
668/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
669///
670/// This proves the production subscriber handler — the same `NativeHandler` the
671/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
672/// cooperative scheduler that a browser host drives. There is no toy stand-in:
673/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
674/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
675/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
676/// handler's own `accept_remote_frame` path and lands in the shared inbox a
677/// [`SubscriptionHandle::try_next`] would read.
678///
679/// The handler runs cooperatively AS-IS: its `handle` only touches
680/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
681/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
682/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
683/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
684/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
685/// native.
686#[cfg(test)]
687#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
688mod cooperative_smoke {
689 use std::cell::RefCell;
690 use std::rc::Rc;
691 use std::sync::Arc;
692
693 use beamr::atom::AtomTable;
694 use beamr::ets::copy_term_to_ets;
695 use beamr::module::ModuleRegistry;
696 use beamr::native::BifRegistryImpl;
697 use beamr::process::heap::Heap;
698 use beamr::scheduler::WasmScheduler;
699 use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
700
701 use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
702 use crate::channel::SchemaId;
703 use crate::channel::wire::encode_envelope;
704 use crate::envelope::{Envelope, PublisherId};
705
706 /// Build a cooperative scheduler the way a wasm host holds it (single
707 /// `Rc<RefCell<…>>` on one thread).
708 fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
709 let atom_table = Arc::new(AtomTable::with_common_atoms());
710 let modules = Arc::new(ModuleRegistry::new());
711 let bifs = Arc::new(BifRegistryImpl::new());
712 Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
713 }
714
715 /// Encode `envelope` into the production wire frame and wrap it as a
716 /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
717 /// remote node hands a published frame to a subscriber pid (SRV-005).
718 fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
719 let bytes = encode_envelope(envelope);
720 let shared = SharedBinary::new(bytes);
721 // A ProcBin reference needs three heap words; copy it into ETS-owned
722 // memory so the scratch heap can be dropped before delivery.
723 let mut scratch = Heap::new(8);
724 let words = scratch
725 .alloc_slice(3)
726 .expect("scratch heap holds a proc-bin reference");
727 let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
728 copy_term_to_ets(term).expect("frame copies into an owned binary")
729 }
730
731 fn sample_envelope() -> Envelope {
732 // A whole-millisecond timestamp so the round-trip through the wire codec
733 // (which carries millisecond resolution, see `channel::wire`) is exact;
734 // `Utc::now()` sub-millisecond precision would otherwise be truncated on
735 // decode and is irrelevant to what this smoke proves.
736 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
737 .single()
738 .expect("valid fixed millisecond timestamp");
739 Envelope::with_timestamp(
740 b"{\"value\":42}".to_vec(),
741 None,
742 SchemaId::new(),
743 PublisherId::from("publisher-cooperative"),
744 timestamp,
745 )
746 }
747
748 #[test]
749 fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
750 let scheduler = cooperative_scheduler();
751
752 // The shared inbox the subscriber pushes decoded envelopes onto — the
753 // exact channel the threaded `SubscriptionHandle::try_next` reads.
754 let inbox: SubscriberInbox = SubscriptionInbox::new();
755 let process_inbox = Arc::clone(&inbox);
756
757 // Spawn the GENUINE production subscriber handler as a first-class native
758 // process on the cooperative scheduler.
759 let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
760 Box::new(SubscriberProcess {
761 inbox: Arc::clone(&process_inbox),
762 }) as Box<dyn beamr::native::native_process::NativeHandler>
763 }));
764
765 // First turn: the handler runs once, asserts trap_exit, finds an empty
766 // mailbox, and parks (`Wait`). No envelope has been delivered yet.
767 scheduler.borrow_mut().run_until_idle();
768 assert_eq!(
769 inbox.len(),
770 0,
771 "no envelope is delivered before one is published"
772 );
773
774 // Publish: deliver a real encoded frame as a beamr binary straight to the
775 // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
776 // the parked process.
777 let published = sample_envelope();
778 let frame = frame_as_owned_binary(&published);
779 scheduler
780 .borrow_mut()
781 .send_owned(pid, &frame)
782 .expect("frame is delivered to the live subscriber pid");
783
784 // Pump turns: the woken handler drains the binary, decodes it through its
785 // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
786 let mut delivered = None;
787 for _ in 0..8 {
788 scheduler.borrow_mut().run_until_idle();
789 let next = inbox.pop();
790 if let Some(envelope) = next {
791 delivered = Some(envelope);
792 break;
793 }
794 }
795
796 assert_eq!(
797 delivered.as_ref(),
798 Some(&published),
799 "the real subscriber decoded and delivered the published envelope"
800 );
801 }
802}
803
804/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
805/// admitted envelope; the shared byte budget is spent across ALL a
806/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
807/// fairness trip stops one inbox starving its siblings; and charge/release is
808/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
809/// with no scheduler — the server-side wake wiring and shed are tested there.
810#[cfg(test)]
811#[allow(clippy::expect_used)]
812mod inbox_bounding {
813 use std::sync::Arc;
814 use std::sync::atomic::{AtomicUsize, Ordering};
815
816 use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
817 use crate::channel::SchemaId;
818 use crate::channel::wire::encode_envelope;
819 use crate::envelope::{Envelope, PublisherId};
820
821 fn envelope(payload: &[u8]) -> Envelope {
822 Envelope::new(
823 payload.to_vec(),
824 None,
825 SchemaId::new(),
826 PublisherId::from("inbox-bounding-test"),
827 )
828 }
829
830 fn admitted_bytes(env: &Envelope) -> usize {
831 encode_envelope(env).len()
832 }
833
834 /// The level-triggered wake contract (P0 #55). This test previously asserted
835 /// the edge-triggered form — that a second admit into a non-empty inbox does
836 /// NOT re-fire — which is precisely the starvation the fix removes: the
837 /// consumer drains a bounded slice, so a non-empty inbox is the normal state
838 /// of a subscriber that has fallen behind, and withholding its wake is what
839 /// ratchets it to the depth cap and a permanent shed.
840 #[test]
841 fn notifier_fires_for_every_admitted_envelope() {
842 let inbox = SubscriptionInbox::new();
843 let fires = Arc::new(AtomicUsize::new(0));
844 let counter = Arc::clone(&fires);
845 inbox.install_notifier(Arc::new(move || {
846 counter.fetch_add(1, Ordering::Relaxed);
847 }));
848
849 // First admit into an empty inbox fires.
850 assert_eq!(inbox.admit(envelope(b"a")), InboxAdmission::Admitted);
851 assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
852
853 // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
854 // may not have reached this envelope's slice, and R6 coalescing means a
855 // redundant marker costs one mailbox atom, never a second slice of work.
856 assert_eq!(inbox.admit(envelope(b"b")), InboxAdmission::Admitted);
857 assert_eq!(
858 fires.load(Ordering::Relaxed),
859 2,
860 "an admit into a non-empty inbox still fires"
861 );
862
863 // Drain to empty, then admit again: still exactly one fire per admit.
864 assert!(inbox.pop().is_some());
865 assert!(inbox.pop().is_some());
866 assert_eq!(inbox.admit(envelope(b"c")), InboxAdmission::Admitted);
867 assert_eq!(
868 fires.load(Ordering::Relaxed),
869 3,
870 "one fire per admitted envelope, whatever the queue depth was"
871 );
872 }
873
874 #[test]
875 fn shared_budget_is_spent_across_all_a_connections_inboxes() {
876 let one = envelope(b"payload-one");
877 let two = envelope(b"payload-two");
878 // A budget large enough for exactly ONE of the two envelopes.
879 let cap = admitted_bytes(&one);
880 let budget = ConnectionInboxBudget::new(cap);
881
882 let inbox_a = SubscriptionInbox::new();
883 let inbox_b = SubscriptionInbox::new();
884 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
885 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
886
887 // Inbox A admits its envelope, consuming the whole shared budget.
888 assert_eq!(inbox_a.admit(one), InboxAdmission::Admitted);
889 assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
890
891 // Inbox B — a SIBLING subscription — is refused: the budget is connection
892 // scoped, not per-inbox, so A's fill denies B.
893 assert_eq!(inbox_b.admit(two), InboxAdmission::BudgetExceeded);
894 assert!(
895 inbox_b.is_overflowed(),
896 "the sibling that overflowed the shared budget is shed"
897 );
898 assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
899
900 // Draining A releases its bytes back to the SHARED budget, so B could then
901 // admit (charge/release symmetry across siblings).
902 assert!(inbox_a.pop().is_some());
903 assert_eq!(
904 budget.used(),
905 0,
906 "release returns bytes to the shared budget"
907 );
908 }
909
910 #[test]
911 fn overflow_sheds_and_does_not_grow_memory() {
912 let env = envelope(b"x");
913 let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
914 let inbox = SubscriptionInbox::new();
915 inbox.install_budget(budget, usize::MAX);
916
917 assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
918 // The next admit overflows: refused, marked for shedding, and NOT queued —
919 // the queue length does not grow past the bound.
920 assert_eq!(inbox.admit(env), InboxAdmission::BudgetExceeded);
921 assert!(inbox.is_overflowed());
922 assert_eq!(
923 inbox.len(),
924 1,
925 "the overflowed envelope is dropped, not queued"
926 );
927 }
928
929 #[test]
930 fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
931 // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
932 let budget = ConnectionInboxBudget::new(usize::MAX);
933 let inbox = SubscriptionInbox::new();
934 inbox.install_budget(budget, 2); // depth cap of 2 envelopes
935
936 assert_eq!(inbox.admit(envelope(b"1")), InboxAdmission::Admitted);
937 assert_eq!(inbox.admit(envelope(b"2")), InboxAdmission::Admitted);
938 // The third trips the fairness cap even though bytes are available.
939 assert_eq!(inbox.admit(envelope(b"3")), InboxAdmission::FairnessTripped);
940 assert!(inbox.is_overflowed());
941 assert_eq!(
942 inbox.len(),
943 2,
944 "the fairness trip holds the inbox at its cap"
945 );
946 }
947
948 #[test]
949 fn charge_and_release_are_exact() {
950 let budget = ConnectionInboxBudget::new(1024 * 1024);
951 let inbox = SubscriptionInbox::new();
952 inbox.install_budget(Arc::clone(&budget), usize::MAX);
953
954 let a = envelope(b"first-envelope");
955 let b = envelope(b"second-longer-envelope-payload");
956 let charge = admitted_bytes(&a) + admitted_bytes(&b);
957 assert_eq!(inbox.admit(a), InboxAdmission::Admitted);
958 assert_eq!(inbox.admit(b), InboxAdmission::Admitted);
959 assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
960
961 assert!(inbox.pop().is_some());
962 assert!(inbox.pop().is_some());
963 assert_eq!(
964 budget.used(),
965 0,
966 "every admitted byte is released on dequeue — exact symmetry"
967 );
968 }
969
970 /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
971 /// shape — an overflowed inbox is near-full by construction) releases every
972 /// charged byte back to the shared budget, so a sibling subscription can
973 /// admit again. Without the close-release, one shed strands its whole share
974 /// of the 4 MiB budget forever.
975 #[test]
976 fn close_releases_queued_charges_so_siblings_recover() {
977 // Budget sized so inbox A can queue a 256-envelope backlog (the §5
978 // fairness-cap depth) and exhaust the shared budget doing it.
979 let one = envelope(b"backlog-envelope-payload");
980 let unit = admitted_bytes(&one);
981 let budget = ConnectionInboxBudget::new(unit * 256);
982
983 let inbox_a = SubscriptionInbox::new();
984 let inbox_b = SubscriptionInbox::new();
985 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
986 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
987
988 // Queue the full 256-envelope backlog on A, consuming the whole budget.
989 for _ in 0..256 {
990 assert_eq!(inbox_a.admit(one.clone()), InboxAdmission::Admitted);
991 }
992 assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
993 // The sibling is starved (the shed trigger condition).
994 assert_eq!(inbox_b.admit(one.clone()), InboxAdmission::BudgetExceeded);
995
996 // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
997 // returns to the shared budget in one atomic close.
998 inbox_a.close();
999 assert_eq!(
1000 budget.used(),
1001 0,
1002 "close releases the entire queued backlog back to the shared budget"
1003 );
1004 // The sibling recovers: it can admit again.
1005 assert_eq!(
1006 inbox_b.admit(one),
1007 InboxAdmission::Admitted,
1008 "a sibling admits again after the other inbox is shed"
1009 );
1010 }
1011
1012 /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1013 /// the budget, so a shed subscription can never re-accumulate cost while the
1014 /// channel actor's asynchronous EXIT prune is still in flight.
1015 #[test]
1016 fn closed_inbox_refuses_without_charging() {
1017 let env = envelope(b"post-close");
1018 let budget = ConnectionInboxBudget::new(1024 * 1024);
1019 let inbox = SubscriptionInbox::new();
1020 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1021
1022 inbox.close();
1023 assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1024 assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1025 assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1026 }
1027
1028 /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1029 /// called `close`, the last handle dropping still releases the queued charges
1030 /// (release-by-construction: a release that cannot be omitted).
1031 #[test]
1032 fn drop_backstop_releases_queued_charges() {
1033 let env = envelope(b"dropped-while-queued");
1034 let unit = admitted_bytes(&env);
1035 let budget = ConnectionInboxBudget::new(1024 * 1024);
1036 {
1037 let inbox = SubscriptionInbox::new();
1038 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1039 assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
1040 assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1041 assert_eq!(budget.used(), unit * 2);
1042 // No close() call: the Arc drops here.
1043 }
1044 assert_eq!(
1045 budget.used(),
1046 0,
1047 "dropping the last inbox handle releases every queued charge"
1048 );
1049 }
1050
1051 /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1052 /// nothing (the queue was drained into the release).
1053 #[test]
1054 fn close_is_idempotent_and_drains_the_queue() {
1055 let env = envelope(b"x");
1056 let budget = ConnectionInboxBudget::new(1024 * 1024);
1057 let inbox = SubscriptionInbox::new();
1058 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1059 assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1060
1061 inbox.close();
1062 inbox.close(); // second close is a no-op, not a double release
1063 assert_eq!(budget.used(), 0);
1064 assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1065 }
1066
1067 /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1068 /// budget was installed carries a charge of 0 — its dequeue releases exactly
1069 /// 0 against the later-installed budget, never bytes it did not charge. The
1070 /// production subscribe path installs the budget at inbox construction so
1071 /// this window is structurally closed; this pins the defensive invariant
1072 /// that makes release byte-identical to charge on EVERY entry regardless.
1073 #[test]
1074 fn per_entry_charge_ownership_survives_budget_install() {
1075 let uncharged = envelope(b"admitted-before-budget-install");
1076 let charged = envelope(b"admitted-after-budget-install");
1077 let inbox = SubscriptionInbox::new();
1078
1079 // Admitted with no budget installed: charge ownership 0.
1080 assert_eq!(inbox.admit(uncharged), InboxAdmission::Admitted);
1081
1082 let budget = ConnectionInboxBudget::new(1024 * 1024);
1083 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1084 let unit = admitted_bytes(&charged);
1085 assert_eq!(inbox.admit(charged), InboxAdmission::Admitted);
1086 assert_eq!(budget.used(), unit, "only the post-install entry charged");
1087
1088 // Popping the uncharged entry releases exactly 0 — the budget cannot
1089 // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1090 // that were never charged.
1091 assert!(inbox.pop().is_some());
1092 assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1093 assert!(inbox.pop().is_some());
1094 assert_eq!(
1095 budget.used(),
1096 0,
1097 "the charged entry released its exact charge"
1098 );
1099 }
1100
1101 /// Review round 1 item 5 (install recheck): installing a notifier onto an
1102 /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1103 /// admitted while there was no notifier to fire, so the install regenerates
1104 /// their wake and one can never be lost to install ordering. (The production
1105 /// subscribe path installs at construction, when the queue is guaranteed
1106 /// empty; this pins the defensive invariant.)
1107 #[test]
1108 fn notifier_install_onto_non_empty_inbox_fires_once() {
1109 let inbox = SubscriptionInbox::new();
1110 assert_eq!(
1111 inbox.admit(envelope(b"pre-install")),
1112 InboxAdmission::Admitted
1113 );
1114
1115 let fires = Arc::new(AtomicUsize::new(0));
1116 let counter = Arc::clone(&fires);
1117 inbox.install_notifier(Arc::new(move || {
1118 counter.fetch_add(1, Ordering::Relaxed);
1119 }));
1120 assert_eq!(
1121 fires.load(Ordering::Relaxed),
1122 1,
1123 "install onto a non-empty inbox regenerates exactly one wake"
1124 );
1125
1126 // The install is a ONE-OFF regeneration, not an extra fire per admit: a
1127 // subsequent admit adds exactly its own one fire.
1128 assert_eq!(inbox.admit(envelope(b"second")), InboxAdmission::Admitted);
1129 assert_eq!(fires.load(Ordering::Relaxed), 2);
1130 }
1131}