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 BinaryRef::new(message).is_some() {
468 // beamr 0.20.0 ties byte access to a `HeapBorrow` witness, and
469 // `NativeContext` exposes no borrow of the process heap, so the
470 // frame is deep-copied into ETS-owned storage and read under
471 // the copy's own witness — the one borrow-correct path a
472 // native handler has (beamr#36 tracks a direct accessor). A
473 // frame whose copy fails is dropped, same as one that fails to
474 // decode: a corrupt cross-node payload must never crash the
475 // subscriber or stall delivery of well-formed messages.
476 if let Ok(owned) = beamr::ets::copy_term_to_ets(message)
477 && let Some(binary) = BinaryRef::new(owned.root())
478 {
479 self.accept_remote_frame(binary.as_bytes(owned.borrow_terms()));
480 }
481 }
482 }
483 NativeOutcome::Wait
484 }
485}
486
487impl SubscriberProcess {
488 /// Decode a remote envelope frame and push it onto the inbox. A frame that
489 /// fails to decode is dropped: a corrupt cross-node payload must never crash
490 /// the subscriber or stall delivery of well-formed messages.
491 fn accept_remote_frame(&self, bytes: &[u8]) {
492 let Ok(envelope) = decode_envelope(bytes) else {
493 return;
494 };
495 // R3: the remote-delivery leg fires the same wake notifier and obeys the
496 // same §5 byte budget as the local leg. An overflow marks the subscription
497 // for shedding (inside `admit`); the frame is dropped rather than growing
498 // server memory.
499 self.inbox.admit(envelope);
500 }
501}
502
503/// The actor-side record of one subscriber: the inbox to deliver into and the
504/// optional predicate to gate delivery. Held by the channel actor INSIDE its
505/// process, keyed by the subscriber process's pid.
506pub(crate) struct SubscriberRegistration {
507 pid: u64,
508 inbox: SubscriberInbox,
509 predicate: Option<SubscriptionPredicate>,
510}
511
512impl SubscriberRegistration {
513 pub(crate) const fn pid(&self) -> u64 {
514 self.pid
515 }
516
517 /// Delivers `envelope` to this subscriber when its predicate accepts it (or
518 /// it has no predicate). Returns `true` when the envelope was pushed onto
519 /// the inbox, `false` when a predicate filtered it out, the inbox refused it
520 /// (§5 overflow — the subscription is then marked for shedding), or the inbox
521 /// is closed. The boolean lets the channel actor count genuine deliveries for
522 /// the delivery-ack signal.
523 pub(crate) fn deliver(&self, envelope: &Envelope) -> bool {
524 if let Some(predicate) = self.predicate.as_ref() {
525 if !predicate(envelope) {
526 return false;
527 }
528 }
529 // R3 + §5: admission charges the connection byte budget, fires the wake
530 // notifier for every admitted envelope, and — on overflow — marks the
531 // subscription for shedding (the server pump sheds it with a typed error
532 // frame). An overflowed envelope is NOT counted as a genuine delivery, so
533 // the delivery-ack signal reflects only envelopes that entered the inbox.
534 matches!(self.inbox.admit(envelope.clone()), InboxAdmission::Admitted)
535 }
536}
537
538impl std::fmt::Debug for SubscriberRegistration {
539 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 formatter
541 .debug_struct("SubscriberRegistration")
542 .field("pid", &self.pid)
543 .field("has_predicate", &self.predicate.is_some())
544 .finish_non_exhaustive()
545 }
546}
547
548/// Handle returned by channel subscriptions for receiving validated envelopes.
549///
550/// Owns the subscriber's beamr pid, the shared inbox, and a clone of the
551/// scheduler so the process can be terminated when the subscription ends. The
552/// handle is the subscription's lifetime: dropping the last clone terminates the
553/// subscriber process, whose EXIT prunes the channel actor's fan-out list.
554#[derive(Clone)]
555pub struct SubscriptionHandle {
556 inner: Arc<SubscriptionInner>,
557}
558
559struct SubscriptionInner {
560 pid: u64,
561 inbox: SubscriberInbox,
562 scheduler: Arc<Scheduler>,
563}
564
565impl SubscriptionHandle {
566 /// Spawns a real subscriber process on `scheduler` and returns the handle
567 /// plus its actor-side registration record (carrying any predicate).
568 ///
569 /// # Errors
570 /// Returns [`LiminalError::SubscriptionFailed`] when the scheduler cannot
571 /// spawn the subscriber process.
572 pub(crate) fn spawn(
573 scheduler: &Arc<Scheduler>,
574 predicate: Option<SubscriptionPredicate>,
575 install: Option<InboxInstall>,
576 ) -> Result<(Self, SubscriberRegistration), LiminalError> {
577 let inbox: SubscriberInbox = SubscriptionInbox::new();
578 // Install the §5 budget/fairness cap and the R3 wake notifier AT
579 // CONSTRUCTION — strictly before the registration is handed to the
580 // channel actor — so there is no window in which a publish can be
581 // admitted uncharged, past the depth cap, or without a wake.
582 if let Some(install) = install {
583 inbox.install_budget(install.budget, install.depth_cap);
584 if let Some(notifier) = install.notifier {
585 inbox.install_notifier(notifier);
586 }
587 }
588 let process_inbox = Arc::clone(&inbox);
589 let factory = Box::new(move || {
590 Box::new(SubscriberProcess {
591 inbox: Arc::clone(&process_inbox),
592 }) as Box<dyn NativeHandler>
593 });
594 // trap_exit is set on the process BEFORE it is published as runnable
595 // (0.16.1 `spawn_native_trap_exit` — pre-runnable by construction), so
596 // an abnormal channel-actor crash is trapped (delivered as a message
597 // the subscriber drains) instead of cascading across the link and
598 // killing the subscriber. This makes the subscriber outlive a
599 // channel-actor crash so the restarted actor can re-link to it on boot
600 // (R2/R4): the flag is in place before the process's first slice, with
601 // no post-spawn window for `set_trap_exit` to race (or return
602 // `NoCaller` against a mid-first-slice process).
603 let pid = scheduler.spawn_native_trap_exit(factory).map_err(|error| {
604 LiminalError::SubscriptionFailed {
605 message: format!("failed to spawn subscriber process: {error:?}"),
606 }
607 })?;
608 let handle = Self {
609 inner: Arc::new(SubscriptionInner {
610 pid,
611 inbox: Arc::clone(&inbox),
612 scheduler: Arc::clone(scheduler),
613 }),
614 };
615 let registration = SubscriberRegistration {
616 pid,
617 inbox,
618 predicate,
619 };
620 Ok((handle, registration))
621 }
622
623 /// Returns the beamr pid of the subscriber process this handle owns.
624 #[must_use]
625 pub(crate) fn pid(&self) -> u64 {
626 self.inner.pid
627 }
628
629 /// Attempts to receive the next delivered envelope without blocking.
630 ///
631 /// # Errors
632 ///
633 /// Returns [`LiminalError::SubscriptionFailed`] when the subscription inbox cannot be read.
634 pub fn try_next(&self) -> Result<Option<Envelope>, LiminalError> {
635 // Dequeue releases the envelope's admitted bytes back to the shared
636 // connection budget (§5 charge/release symmetry).
637 Ok(self.inner.inbox.pop())
638 }
639
640 /// Whether an envelope is available without consuming it.
641 #[must_use]
642 pub fn has_pending(&self) -> bool {
643 self.inner.inbox.has_pending()
644 }
645
646 /// Whether an overflow has marked this subscription for shedding (§5).
647 #[must_use]
648 pub fn is_overflowed(&self) -> bool {
649 self.inner.inbox.is_overflowed()
650 }
651}
652
653impl std::fmt::Debug for SubscriptionHandle {
654 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655 formatter
656 .debug_struct("SubscriptionHandle")
657 .field("pid", &self.inner.pid)
658 .finish_non_exhaustive()
659 }
660}
661
662impl Drop for SubscriptionInner {
663 fn drop(&mut self) {
664 // Close the inbox FIRST: atomically mark it closed, drain queued entries,
665 // and release every charged byte back to the shared connection budget
666 // (§5 — queued bytes must never be stranded on the connection-lifetime
667 // budget by unsubscribe, shed, or teardown; all of them funnel through
668 // this drop). Post-close deliveries from the channel actor (whose EXIT
669 // prune below is asynchronous) are refused without charging.
670 self.inbox.close();
671 // Terminating the subscriber process fires the bidirectional link to the
672 // channel actor, which traps the EXIT and removes this subscriber from
673 // its fan-out list. This is the real-beamr unsubscribe-on-drop path.
674 self.scheduler
675 .terminate_process(self.pid, ExitReason::Normal);
676 }
677}
678
679/// WR-9b: the REAL [`SubscriberProcess`] running on beamr's cooperative
680/// (single-threaded / wasm) [`beamr::scheduler::WasmScheduler`].
681///
682/// This proves the production subscriber handler — the same `NativeHandler` the
683/// threaded [`SubscriptionHandle::spawn`] spawns — runs unchanged on the
684/// cooperative scheduler that a browser host drives. There is no toy stand-in:
685/// the test spawns the genuine [`SubscriberProcess`], delivers a genuine
686/// [`crate::channel::wire::encode_envelope`] frame as a real beamr binary, pumps
687/// cooperative `run_until_idle` turns, and asserts the envelope is decoded by the
688/// handler's own `accept_remote_frame` path and lands in the shared inbox a
689/// [`SubscriptionHandle::try_next`] would read.
690///
691/// The handler runs cooperatively AS-IS: its `handle` only touches
692/// platform-neutral [`NativeContext`] capabilities (`set_trap_exit`, `recv`),
693/// [`BinaryRef`], and [`decode_envelope`] — none of which reach for threads,
694/// tokio, sockets, or a `SharedState`. The only wiring the smoke supplies is the
695/// cooperative driver (spawn + owned-binary delivery + turn pump), exactly the
696/// host-side seam the threaded `SubscriptionHandle`/channel-actor provide on
697/// native.
698#[cfg(test)]
699#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
700mod cooperative_smoke {
701 use std::cell::RefCell;
702 use std::rc::Rc;
703 use std::sync::Arc;
704
705 use beamr::atom::AtomTable;
706 use beamr::ets::copy_term_to_ets;
707 use beamr::module::ModuleRegistry;
708 use beamr::native::BifRegistryImpl;
709 use beamr::process::heap::Heap;
710 use beamr::scheduler::WasmScheduler;
711 use beamr::term::shared_binary::{SharedBinary, write_proc_bin};
712
713 use super::{SubscriberInbox, SubscriberProcess, SubscriptionInbox};
714 use crate::channel::SchemaId;
715 use crate::channel::wire::encode_envelope;
716 use crate::envelope::{Envelope, PublisherId};
717
718 /// Build a cooperative scheduler the way a wasm host holds it (single
719 /// `Rc<RefCell<…>>` on one thread).
720 fn cooperative_scheduler() -> Rc<RefCell<WasmScheduler>> {
721 let atom_table = Arc::new(AtomTable::with_common_atoms());
722 let modules = Arc::new(ModuleRegistry::new());
723 let bifs = Arc::new(BifRegistryImpl::new());
724 Rc::new(RefCell::new(WasmScheduler::new(atom_table, modules, bifs)))
725 }
726
727 /// Encode `envelope` into the production wire frame and wrap it as a
728 /// heap-independent beamr binary term ready for `send_owned`, mirroring how a
729 /// remote node hands a published frame to a subscriber pid (SRV-005).
730 fn frame_as_owned_binary(envelope: &Envelope) -> beamr::ets::OwnedTerm {
731 let bytes = encode_envelope(envelope);
732 let shared = SharedBinary::new(bytes);
733 // A ProcBin reference needs three heap words; copy it into ETS-owned
734 // memory so the scratch heap can be dropped before delivery.
735 let mut scratch = Heap::new(8);
736 let words = scratch
737 .alloc_slice(3)
738 .expect("scratch heap holds a proc-bin reference");
739 let term = write_proc_bin(words, &shared).expect("proc-bin term writes");
740 copy_term_to_ets(term).expect("frame copies into an owned binary")
741 }
742
743 fn sample_envelope() -> Envelope {
744 // A whole-millisecond timestamp so the round-trip through the wire codec
745 // (which carries millisecond resolution, see `channel::wire`) is exact;
746 // `Utc::now()` sub-millisecond precision would otherwise be truncated on
747 // decode and is irrelevant to what this smoke proves.
748 let timestamp = chrono::TimeZone::timestamp_millis_opt(&chrono::Utc, 1_700_000_000_123)
749 .single()
750 .expect("valid fixed millisecond timestamp");
751 Envelope::with_timestamp(
752 b"{\"value\":42}".to_vec(),
753 None,
754 SchemaId::new(),
755 PublisherId::from("publisher-cooperative"),
756 timestamp,
757 )
758 }
759
760 #[test]
761 fn real_subscriber_process_delivers_a_published_envelope_cooperatively() {
762 let scheduler = cooperative_scheduler();
763
764 // The shared inbox the subscriber pushes decoded envelopes onto — the
765 // exact channel the threaded `SubscriptionHandle::try_next` reads.
766 let inbox: SubscriberInbox = SubscriptionInbox::new();
767 let process_inbox = Arc::clone(&inbox);
768
769 // Spawn the GENUINE production subscriber handler as a first-class native
770 // process on the cooperative scheduler.
771 let pid = scheduler.borrow_mut().spawn_native_root(Box::new(move || {
772 Box::new(SubscriberProcess {
773 inbox: Arc::clone(&process_inbox),
774 }) as Box<dyn beamr::native::native_process::NativeHandler>
775 }));
776
777 // First turn: the handler runs once, asserts trap_exit, finds an empty
778 // mailbox, and parks (`Wait`). No envelope has been delivered yet.
779 scheduler.borrow_mut().run_until_idle();
780 assert_eq!(
781 inbox.len(),
782 0,
783 "no envelope is delivered before one is published"
784 );
785
786 // Publish: deliver a real encoded frame as a beamr binary straight to the
787 // subscriber pid, exactly as a remote publish lands (SRV-005). This wakes
788 // the parked process.
789 let published = sample_envelope();
790 let frame = frame_as_owned_binary(&published);
791 scheduler
792 .borrow_mut()
793 .send_owned(pid, &frame)
794 .expect("frame is delivered to the live subscriber pid");
795
796 // Pump turns: the woken handler drains the binary, decodes it through its
797 // own `accept_remote_frame` path, and pushes the envelope onto the inbox.
798 let mut delivered = None;
799 for _ in 0..8 {
800 scheduler.borrow_mut().run_until_idle();
801 let next = inbox.pop();
802 if let Some(envelope) = next {
803 delivered = Some(envelope);
804 break;
805 }
806 }
807
808 assert_eq!(
809 delivered.as_ref(),
810 Some(&published),
811 "the real subscriber decoded and delivered the published envelope"
812 );
813 }
814}
815
816/// R3 (§1.2(2)) + §5 inbox-bounding library core: the notifier fires on every
817/// admitted envelope; the shared byte budget is spent across ALL a
818/// connection's inboxes; overflow sheds the offending subscription; the per-inbox
819/// fairness trip stops one inbox starving its siblings; and charge/release is
820/// exact. These exercise [`SubscriptionInbox`]/[`ConnectionInboxBudget`] directly,
821/// with no scheduler — the server-side wake wiring and shed are tested there.
822#[cfg(test)]
823#[allow(clippy::expect_used)]
824mod inbox_bounding {
825 use std::sync::Arc;
826 use std::sync::atomic::{AtomicUsize, Ordering};
827
828 use super::{ConnectionInboxBudget, InboxAdmission, SubscriptionInbox};
829 use crate::channel::SchemaId;
830 use crate::channel::wire::encode_envelope;
831 use crate::envelope::{Envelope, PublisherId};
832
833 fn envelope(payload: &[u8]) -> Envelope {
834 Envelope::new(
835 payload.to_vec(),
836 None,
837 SchemaId::new(),
838 PublisherId::from("inbox-bounding-test"),
839 )
840 }
841
842 fn admitted_bytes(env: &Envelope) -> usize {
843 encode_envelope(env).len()
844 }
845
846 /// The level-triggered wake contract (P0 #55). This test previously asserted
847 /// the edge-triggered form — that a second admit into a non-empty inbox does
848 /// NOT re-fire — which is precisely the starvation the fix removes: the
849 /// consumer drains a bounded slice, so a non-empty inbox is the normal state
850 /// of a subscriber that has fallen behind, and withholding its wake is what
851 /// ratchets it to the depth cap and a permanent shed.
852 #[test]
853 fn notifier_fires_for_every_admitted_envelope() {
854 let inbox = SubscriptionInbox::new();
855 let fires = Arc::new(AtomicUsize::new(0));
856 let counter = Arc::clone(&fires);
857 inbox.install_notifier(Arc::new(move || {
858 counter.fetch_add(1, Ordering::Relaxed);
859 }));
860
861 // First admit into an empty inbox fires.
862 assert_eq!(inbox.admit(envelope(b"a")), InboxAdmission::Admitted);
863 assert_eq!(fires.load(Ordering::Relaxed), 1, "the first admit fires");
864
865 // A second admit into a STILL-NON-EMPTY inbox fires again: the consumer
866 // may not have reached this envelope's slice, and R6 coalescing means a
867 // redundant marker costs one mailbox atom, never a second slice of work.
868 assert_eq!(inbox.admit(envelope(b"b")), InboxAdmission::Admitted);
869 assert_eq!(
870 fires.load(Ordering::Relaxed),
871 2,
872 "an admit into a non-empty inbox still fires"
873 );
874
875 // Drain to empty, then admit again: still exactly one fire per admit.
876 assert!(inbox.pop().is_some());
877 assert!(inbox.pop().is_some());
878 assert_eq!(inbox.admit(envelope(b"c")), InboxAdmission::Admitted);
879 assert_eq!(
880 fires.load(Ordering::Relaxed),
881 3,
882 "one fire per admitted envelope, whatever the queue depth was"
883 );
884 }
885
886 #[test]
887 fn shared_budget_is_spent_across_all_a_connections_inboxes() {
888 let one = envelope(b"payload-one");
889 let two = envelope(b"payload-two");
890 // A budget large enough for exactly ONE of the two envelopes.
891 let cap = admitted_bytes(&one);
892 let budget = ConnectionInboxBudget::new(cap);
893
894 let inbox_a = SubscriptionInbox::new();
895 let inbox_b = SubscriptionInbox::new();
896 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
897 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
898
899 // Inbox A admits its envelope, consuming the whole shared budget.
900 assert_eq!(inbox_a.admit(one), InboxAdmission::Admitted);
901 assert_eq!(budget.used(), cap, "the shared budget is now fully spent");
902
903 // Inbox B — a SIBLING subscription — is refused: the budget is connection
904 // scoped, not per-inbox, so A's fill denies B.
905 assert_eq!(inbox_b.admit(two), InboxAdmission::BudgetExceeded);
906 assert!(
907 inbox_b.is_overflowed(),
908 "the sibling that overflowed the shared budget is shed"
909 );
910 assert!(!inbox_a.is_overflowed(), "the inbox that fit is not shed");
911
912 // Draining A releases its bytes back to the SHARED budget, so B could then
913 // admit (charge/release symmetry across siblings).
914 assert!(inbox_a.pop().is_some());
915 assert_eq!(
916 budget.used(),
917 0,
918 "release returns bytes to the shared budget"
919 );
920 }
921
922 #[test]
923 fn overflow_sheds_and_does_not_grow_memory() {
924 let env = envelope(b"x");
925 let budget = ConnectionInboxBudget::new(admitted_bytes(&env)); // room for one
926 let inbox = SubscriptionInbox::new();
927 inbox.install_budget(budget, usize::MAX);
928
929 assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
930 // The next admit overflows: refused, marked for shedding, and NOT queued —
931 // the queue length does not grow past the bound.
932 assert_eq!(inbox.admit(env), InboxAdmission::BudgetExceeded);
933 assert!(inbox.is_overflowed());
934 assert_eq!(
935 inbox.len(),
936 1,
937 "the overflowed envelope is dropped, not queued"
938 );
939 }
940
941 #[test]
942 fn per_inbox_fairness_trip_stops_one_inbox_starving_siblings() {
943 // A huge byte budget so the FAIRNESS count — not the budget — is the trip.
944 let budget = ConnectionInboxBudget::new(usize::MAX);
945 let inbox = SubscriptionInbox::new();
946 inbox.install_budget(budget, 2); // depth cap of 2 envelopes
947
948 assert_eq!(inbox.admit(envelope(b"1")), InboxAdmission::Admitted);
949 assert_eq!(inbox.admit(envelope(b"2")), InboxAdmission::Admitted);
950 // The third trips the fairness cap even though bytes are available.
951 assert_eq!(inbox.admit(envelope(b"3")), InboxAdmission::FairnessTripped);
952 assert!(inbox.is_overflowed());
953 assert_eq!(
954 inbox.len(),
955 2,
956 "the fairness trip holds the inbox at its cap"
957 );
958 }
959
960 #[test]
961 fn charge_and_release_are_exact() {
962 let budget = ConnectionInboxBudget::new(1024 * 1024);
963 let inbox = SubscriptionInbox::new();
964 inbox.install_budget(Arc::clone(&budget), usize::MAX);
965
966 let a = envelope(b"first-envelope");
967 let b = envelope(b"second-longer-envelope-payload");
968 let charge = admitted_bytes(&a) + admitted_bytes(&b);
969 assert_eq!(inbox.admit(a), InboxAdmission::Admitted);
970 assert_eq!(inbox.admit(b), InboxAdmission::Admitted);
971 assert_eq!(budget.used(), charge, "used == sum of admitted bytes");
972
973 assert!(inbox.pop().is_some());
974 assert!(inbox.pop().is_some());
975 assert_eq!(
976 budget.used(),
977 0,
978 "every admitted byte is released on dequeue — exact symmetry"
979 );
980 }
981
982 /// Review round 1 item 4: closing an inbox with a QUEUED backlog (the shed
983 /// shape — an overflowed inbox is near-full by construction) releases every
984 /// charged byte back to the shared budget, so a sibling subscription can
985 /// admit again. Without the close-release, one shed strands its whole share
986 /// of the 4 MiB budget forever.
987 #[test]
988 fn close_releases_queued_charges_so_siblings_recover() {
989 // Budget sized so inbox A can queue a 256-envelope backlog (the §5
990 // fairness-cap depth) and exhaust the shared budget doing it.
991 let one = envelope(b"backlog-envelope-payload");
992 let unit = admitted_bytes(&one);
993 let budget = ConnectionInboxBudget::new(unit * 256);
994
995 let inbox_a = SubscriptionInbox::new();
996 let inbox_b = SubscriptionInbox::new();
997 inbox_a.install_budget(Arc::clone(&budget), usize::MAX);
998 inbox_b.install_budget(Arc::clone(&budget), usize::MAX);
999
1000 // Queue the full 256-envelope backlog on A, consuming the whole budget.
1001 for _ in 0..256 {
1002 assert_eq!(inbox_a.admit(one.clone()), InboxAdmission::Admitted);
1003 }
1004 assert_eq!(budget.used(), unit * 256, "the backlog holds the budget");
1005 // The sibling is starved (the shed trigger condition).
1006 assert_eq!(inbox_b.admit(one.clone()), InboxAdmission::BudgetExceeded);
1007
1008 // Shed/unsubscribe/teardown all funnel through close: EVERY queued charge
1009 // returns to the shared budget in one atomic close.
1010 inbox_a.close();
1011 assert_eq!(
1012 budget.used(),
1013 0,
1014 "close releases the entire queued backlog back to the shared budget"
1015 );
1016 // The sibling recovers: it can admit again.
1017 assert_eq!(
1018 inbox_b.admit(one),
1019 InboxAdmission::Admitted,
1020 "a sibling admits again after the other inbox is shed"
1021 );
1022 }
1023
1024 /// Review round 1 item 4: a closed inbox refuses admissions WITHOUT charging
1025 /// the budget, so a shed subscription can never re-accumulate cost while the
1026 /// channel actor's asynchronous EXIT prune is still in flight.
1027 #[test]
1028 fn closed_inbox_refuses_without_charging() {
1029 let env = envelope(b"post-close");
1030 let budget = ConnectionInboxBudget::new(1024 * 1024);
1031 let inbox = SubscriptionInbox::new();
1032 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1033
1034 inbox.close();
1035 assert_eq!(inbox.admit(env), InboxAdmission::Closed);
1036 assert_eq!(budget.used(), 0, "a closed inbox never charges the budget");
1037 assert_eq!(inbox.len(), 0, "a closed inbox never queues");
1038 }
1039
1040 /// Review round 1 item 4: the `Drop` backstop — if no teardown path ever
1041 /// called `close`, the last handle dropping still releases the queued charges
1042 /// (release-by-construction: a release that cannot be omitted).
1043 #[test]
1044 fn drop_backstop_releases_queued_charges() {
1045 let env = envelope(b"dropped-while-queued");
1046 let unit = admitted_bytes(&env);
1047 let budget = ConnectionInboxBudget::new(1024 * 1024);
1048 {
1049 let inbox = SubscriptionInbox::new();
1050 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1051 assert_eq!(inbox.admit(env.clone()), InboxAdmission::Admitted);
1052 assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1053 assert_eq!(budget.used(), unit * 2);
1054 // No close() call: the Arc drops here.
1055 }
1056 assert_eq!(
1057 budget.used(),
1058 0,
1059 "dropping the last inbox handle releases every queued charge"
1060 );
1061 }
1062
1063 /// Review round 1 item 4: close is idempotent, and pop-after-close finds
1064 /// nothing (the queue was drained into the release).
1065 #[test]
1066 fn close_is_idempotent_and_drains_the_queue() {
1067 let env = envelope(b"x");
1068 let budget = ConnectionInboxBudget::new(1024 * 1024);
1069 let inbox = SubscriptionInbox::new();
1070 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1071 assert_eq!(inbox.admit(env), InboxAdmission::Admitted);
1072
1073 inbox.close();
1074 inbox.close(); // second close is a no-op, not a double release
1075 assert_eq!(budget.used(), 0);
1076 assert!(inbox.pop().is_none(), "a closed inbox holds nothing");
1077 }
1078
1079 /// Review round 1 item 5 (charge ownership): an envelope admitted BEFORE the
1080 /// budget was installed carries a charge of 0 — its dequeue releases exactly
1081 /// 0 against the later-installed budget, never bytes it did not charge. The
1082 /// production subscribe path installs the budget at inbox construction so
1083 /// this window is structurally closed; this pins the defensive invariant
1084 /// that makes release byte-identical to charge on EVERY entry regardless.
1085 #[test]
1086 fn per_entry_charge_ownership_survives_budget_install() {
1087 let uncharged = envelope(b"admitted-before-budget-install");
1088 let charged = envelope(b"admitted-after-budget-install");
1089 let inbox = SubscriptionInbox::new();
1090
1091 // Admitted with no budget installed: charge ownership 0.
1092 assert_eq!(inbox.admit(uncharged), InboxAdmission::Admitted);
1093
1094 let budget = ConnectionInboxBudget::new(1024 * 1024);
1095 inbox.install_budget(Arc::clone(&budget), usize::MAX);
1096 let unit = admitted_bytes(&charged);
1097 assert_eq!(inbox.admit(charged), InboxAdmission::Admitted);
1098 assert_eq!(budget.used(), unit, "only the post-install entry charged");
1099
1100 // Popping the uncharged entry releases exactly 0 — the budget cannot
1101 // under-count (over-admitting past the signed 4 MiB) by releasing bytes
1102 // that were never charged.
1103 assert!(inbox.pop().is_some());
1104 assert_eq!(budget.used(), unit, "the uncharged entry released nothing");
1105 assert!(inbox.pop().is_some());
1106 assert_eq!(
1107 budget.used(),
1108 0,
1109 "the charged entry released its exact charge"
1110 );
1111 }
1112
1113 /// Review round 1 item 5 (install recheck): installing a notifier onto an
1114 /// ALREADY-NON-EMPTY inbox fires it exactly once — those envelopes were
1115 /// admitted while there was no notifier to fire, so the install regenerates
1116 /// their wake and one can never be lost to install ordering. (The production
1117 /// subscribe path installs at construction, when the queue is guaranteed
1118 /// empty; this pins the defensive invariant.)
1119 #[test]
1120 fn notifier_install_onto_non_empty_inbox_fires_once() {
1121 let inbox = SubscriptionInbox::new();
1122 assert_eq!(
1123 inbox.admit(envelope(b"pre-install")),
1124 InboxAdmission::Admitted
1125 );
1126
1127 let fires = Arc::new(AtomicUsize::new(0));
1128 let counter = Arc::clone(&fires);
1129 inbox.install_notifier(Arc::new(move || {
1130 counter.fetch_add(1, Ordering::Relaxed);
1131 }));
1132 assert_eq!(
1133 fires.load(Ordering::Relaxed),
1134 1,
1135 "install onto a non-empty inbox regenerates exactly one wake"
1136 );
1137
1138 // The install is a ONE-OFF regeneration, not an extra fire per admit: a
1139 // subsequent admit adds exactly its own one fire.
1140 assert_eq!(inbox.admit(envelope(b"second")), InboxAdmission::Admitted);
1141 assert_eq!(fires.load(Ordering::Relaxed), 2);
1142 }
1143}