Skip to main content

offline_protocol_reliability/
ack_manager.rs

1//! ACK (acknowledgment) management for tracking pending acknowledgments.
2
3use crate::constants::{DEFAULT_ACK_TIMEOUT_MS, DEFAULT_MAX_PENDING_ACKS};
4use chrono::{DateTime, Utc};
5use offline_protocol_core::{MessageId, MessagePriority};
6use std::collections::HashMap;
7use std::time::Duration;
8
9/// Information about an ACK that was evicted due to capacity pressure.
10#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct AckEvictionInfo {
13    /// ID of the message whose ACK tracking was evicted.
14    pub message_id: MessageId,
15    /// Priority of the evicted message.
16    pub priority: MessagePriority,
17}
18
19/// Status of an ACK.
20///
21/// There is no `Received` state: an acknowledged message is removed from
22/// tracking entirely via [`AckManager::remove_ack`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AckStatus {
25    /// Waiting for ACK.
26    Pending,
27    /// ACK timed out.
28    TimedOut,
29}
30
31/// Information about a pending ACK.
32#[derive(Debug, Clone)]
33pub struct PendingAck {
34    /// Message ID waiting for ACK.
35    pub message_id: MessageId,
36
37    /// When the message was sent.
38    pub sent_at: DateTime<Utc>,
39
40    /// Timeout for this ACK (in milliseconds).
41    pub timeout_ms: u64,
42
43    /// Current status.
44    pub status: AckStatus,
45
46    /// Number of times the message has been retried.
47    pub retry_count: u32,
48
49    /// Priority of the message (for eviction decisions).
50    pub priority: MessagePriority,
51
52    /// How far the deadline has been pushed past `sent_at + timeout_ms` while
53    /// the frame was still waiting in a local transport queue. Kept apart from
54    /// `sent_at`, which the sender reads to measure the round trip, and reset
55    /// on every new attempt.
56    pub extended_ms: u64,
57}
58
59impl PendingAck {
60    /// Checks if this ACK has timed out.
61    pub fn is_timed_out(&self) -> bool {
62        if self.status != AckStatus::Pending {
63            return false;
64        }
65
66        let elapsed = Utc::now().signed_duration_since(self.sent_at);
67        elapsed.num_milliseconds() >= self.timeout_ms.saturating_add(self.extended_ms) as i64
68    }
69}
70
71/// Configuration for ACK management.
72#[derive(Debug, Clone)]
73pub struct AckConfig {
74    /// Default ACK timeout in milliseconds.
75    pub default_timeout_ms: u64,
76
77    /// Maximum number of pending ACKs to track.
78    pub max_pending_acks: usize,
79}
80
81impl Default for AckConfig {
82    fn default() -> Self {
83        Self {
84            default_timeout_ms: DEFAULT_ACK_TIMEOUT_MS,
85            max_pending_acks: DEFAULT_MAX_PENDING_ACKS,
86        }
87    }
88}
89
90/// ACK manager for tracking pending acknowledgments.
91pub struct AckManager {
92    config: AckConfig,
93    pending_acks: HashMap<MessageId, PendingAck>,
94}
95
96impl AckManager {
97    /// Creates a new ACK manager with default configuration.
98    pub fn new() -> Self {
99        Self::with_config(AckConfig::default())
100    }
101
102    /// Creates a new ACK manager with custom configuration.
103    pub fn with_config(config: AckConfig) -> Self {
104        Self {
105            config,
106            pending_acks: HashMap::new(),
107        }
108    }
109
110    /// Registers a message as waiting for ACK.
111    ///
112    /// # Arguments
113    ///
114    /// * `message_id` - ID of the message waiting for ACK
115    /// * `timeout_ms` - Optional custom timeout (uses default if None)
116    ///
117    /// # Returns
118    ///
119    /// Returns `Ok(Some(info))` if a lower-priority ACK was evicted to make room,
120    /// `Ok(None)` if no eviction was needed, or `Err` if capacity is full and no
121    /// lower-priority ACK exists to evict.
122    pub fn register_pending_ack(
123        &mut self,
124        message_id: MessageId,
125        timeout_ms: Option<u64>,
126    ) -> crate::Result<Option<AckEvictionInfo>> {
127        self.register_pending_ack_with_priority(message_id, timeout_ms, MessagePriority::Medium)
128    }
129
130    /// Registers a message as waiting for ACK with specified priority.
131    ///
132    /// # Arguments
133    ///
134    /// * `message_id` - ID of the message waiting for ACK
135    /// * `timeout_ms` - Optional custom timeout (uses default if None)
136    /// * `priority` - Message priority for eviction decisions
137    ///
138    /// # Returns
139    ///
140    /// Returns `Ok(Some(info))` if a lower-priority ACK was evicted to make room,
141    /// `Ok(None)` if no eviction was needed, or `Err` if capacity is full and no
142    /// lower-priority ACK exists to evict.
143    pub fn register_pending_ack_with_priority(
144        &mut self,
145        message_id: MessageId,
146        timeout_ms: Option<u64>,
147        priority: MessagePriority,
148    ) -> crate::Result<Option<AckEvictionInfo>> {
149        // At the ceiling, only a strictly lower-priority entry may make room.
150        //
151        // Timed-out entries are deliberately NOT swept here. Marking them
152        // `TimedOut` without handing them to the caller orphaned them: the
153        // engine's timeout sweep reads `is_timed_out`, which is false for an
154        // entry already marked, so those messages were never retried and sat
155        // in the outbox until its seven-day expiry. Only the engine's own
156        // sweep (`drain_timed_out`) marks entries, because only it acts on
157        // the list it gets back.
158        let mut eviction = None;
159        if !self.has_capacity_for(&message_id) {
160            match self.evict_lowest_priority(priority) {
161                Some(info) => eviction = Some(info),
162                None => {
163                    return Err(crate::Error::AckCapacity {
164                        max: self.config.max_pending_acks,
165                    });
166                }
167            }
168        }
169
170        let pending = PendingAck {
171            message_id: message_id.clone(),
172            sent_at: Utc::now(),
173            timeout_ms: timeout_ms.unwrap_or(self.config.default_timeout_ms),
174            status: AckStatus::Pending,
175            retry_count: 0,
176            priority,
177            extended_ms: 0,
178        };
179
180        self.pending_acks.insert(message_id, pending);
181        Ok(eviction)
182    }
183
184    /// Attempts to evict the lowest priority ACK to make room for a higher priority one.
185    /// Returns `Some(info)` if an ACK was evicted, `None` if no lower priority ACK exists.
186    fn evict_lowest_priority(&mut self, new_priority: MessagePriority) -> Option<AckEvictionInfo> {
187        // Find the lowest priority pending ACK
188        let evict_candidate = self
189            .pending_acks
190            .iter()
191            .filter(|(_, ack)| ack.status == AckStatus::Pending)
192            .min_by(|(_, a), (_, b)| {
193                // Compare by priority first (lower priority = evict first)
194                let priority_cmp =
195                    Self::priority_rank(a.priority).cmp(&Self::priority_rank(b.priority));
196                if priority_cmp != std::cmp::Ordering::Equal {
197                    return priority_cmp;
198                }
199                // Within same priority, evict oldest first
200                a.sent_at.cmp(&b.sent_at)
201            })
202            .map(|(id, ack)| (id.clone(), ack.priority));
203
204        if let Some((evict_id, evict_priority)) = evict_candidate {
205            // Only evict if the candidate's priority is strictly lower
206            if Self::priority_rank(evict_priority) < Self::priority_rank(new_priority) {
207                self.pending_acks.remove(&evict_id);
208                return Some(AckEvictionInfo {
209                    message_id: evict_id,
210                    priority: evict_priority,
211                });
212            }
213        }
214
215        None
216    }
217
218    /// Returns a numeric rank for priority (higher = more important).
219    fn priority_rank(priority: MessagePriority) -> u8 {
220        match priority {
221            MessagePriority::Low => 0,
222            MessagePriority::Medium => 1,
223            MessagePriority::High => 2,
224            MessagePriority::Critical => 3,
225        }
226    }
227
228    /// Returns all ACKs that have timed out and marks them as `TimedOut`.
229    ///
230    /// Note: This does NOT remove entries from the map. Entries are kept so that:
231    /// - `increment_retry_count()` can reset them when the message is retried
232    /// - Late ACKs can still be matched via `remove_ack()`
233    ///
234    /// Entries are eventually removed by:
235    /// - `remove_ack()` when ACK is received or max retries exceeded
236    /// - `prune_old_timeouts()` for stale entries
237    pub fn drain_timed_out(&mut self) -> Vec<PendingAck> {
238        let mut timed_out = Vec::new();
239        for pending in self.pending_acks.values_mut() {
240            if pending.is_timed_out() {
241                pending.status = AckStatus::TimedOut;
242                timed_out.push(pending.clone());
243            }
244        }
245        timed_out
246    }
247
248    /// Removes an ACK from tracking (e.g., after successful delivery).
249    pub fn remove_ack(&mut self, message_id: &MessageId) -> Option<PendingAck> {
250        self.pending_acks.remove(message_id)
251    }
252
253    /// Gets all timed out ACKs.
254    ///
255    /// # Returns
256    ///
257    /// Returns a vector of message IDs that have timed out.
258    pub fn get_timed_out_acks(&self) -> Vec<MessageId> {
259        self.pending_acks
260            .values()
261            .filter(|ack| ack.is_timed_out())
262            .map(|ack| ack.message_id.clone())
263            .collect()
264    }
265
266    /// Updates the retry count for a message.
267    pub fn increment_retry_count(&mut self, message_id: &MessageId) {
268        if let Some(pending) = self.pending_acks.get_mut(message_id) {
269            pending.retry_count += 1;
270            pending.sent_at = Utc::now(); // Reset timer for retry
271            pending.extended_ms = 0;
272            pending.status = AckStatus::Pending;
273        }
274    }
275
276    /// Gives a timed-out entry another full timeout because its frame has not
277    /// left the device yet, as long as the attempt is younger than
278    /// `max_wait`. Returns whether it was extended; `false` means the caller
279    /// retries it as usual.
280    ///
281    /// The timer starts when a frame is queued, not when it is written, so on
282    /// a link carrying less than it is offered a frame can time out while it
283    /// is still waiting its turn. Retrying it then appends a second copy
284    /// behind the first, re-sealed under a fresh ratchet generation, which
285    /// makes the queue longer and the receiver's gap wider. `max_wait` bounds
286    /// the grace so a queue that never drains still falls through to the retry
287    /// ladder and the other carriers.
288    pub fn extend_while_queued(&mut self, message_id: &MessageId, max_wait: Duration) -> bool {
289        let Some(pending) = self.pending_acks.get_mut(message_id) else {
290            return false;
291        };
292        let elapsed_ms = Utc::now()
293            .signed_duration_since(pending.sent_at)
294            .num_milliseconds()
295            .max(0) as u64;
296        if elapsed_ms >= max_wait.as_millis().min(u128::from(u64::MAX)) as u64 {
297            return false;
298        }
299        pending.extended_ms = elapsed_ms;
300        pending.status = AckStatus::Pending;
301        true
302    }
303
304    /// Carry an accumulated retry count onto a pending ACK.
305    ///
306    /// `register_pending_ack*` always starts a fresh ACK at `retry_count: 0`.
307    /// When a message is *re-sent* from the retry queue and its prior ACK had
308    /// already been cleared (the resend registers a brand-new ACK), that new ACK
309    /// would otherwise reset the backoff ladder to 0 — pinning `delay_for_retry`
310    /// at its 1s floor for a recipient that never ACKs (an offline peer), which
311    /// re-sends ~once per second forever. Seeding the new ACK with the count the
312    /// retry-queue entry already carries lets the exponential backoff advance
313    /// across resends. No-op if the message has no pending ACK.
314    pub fn set_retry_count(&mut self, message_id: &MessageId, retry_count: u32) {
315        if let Some(pending) = self.pending_acks.get_mut(message_id) {
316            pending.retry_count = retry_count;
317        }
318    }
319
320    /// Gets information about a pending ACK.
321    pub fn get_pending_ack(&self, message_id: &MessageId) -> Option<&PendingAck> {
322        self.pending_acks.get(message_id)
323    }
324
325    /// Gets the number of pending ACKs.
326    pub fn pending_count(&self) -> usize {
327        self.pending_acks.len()
328    }
329
330    /// Whether registering `message_id` would succeed without evicting
331    /// anything: it is already tracked, or the table has a free slot.
332    ///
333    /// A sender asks this **before** it transmits. Asking after is too late:
334    /// the frame is on the wire, and the only honest outcomes left are to
335    /// track it or to have refused it.
336    pub fn has_capacity_for(&self, message_id: &MessageId) -> bool {
337        self.pending_acks.contains_key(message_id)
338            || self.pending_acks.len() < self.config.max_pending_acks
339    }
340
341    /// Checks if a message is waiting for ACK.
342    pub fn is_waiting_for_ack(&self, message_id: &MessageId) -> bool {
343        self.pending_acks.contains_key(message_id)
344    }
345
346    /// Removes all ACKs with TimedOut status that are older than a given duration.
347    ///
348    /// This helps prevent unbounded memory growth.
349    pub fn prune_old_timeouts(&mut self, older_than: Duration) {
350        let now = Utc::now();
351        let cutoff = now
352            - chrono::Duration::from_std(older_than).unwrap_or_else(|_| chrono::Duration::zero());
353
354        self.pending_acks
355            .retain(|_, ack| ack.status != AckStatus::TimedOut || ack.sent_at > cutoff);
356    }
357}
358
359impl Default for AckManager {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use std::thread;
369
370    #[test]
371    fn test_register_and_receive_ack() {
372        let mut manager = AckManager::new();
373        let msg_id = MessageId::new();
374
375        // Register pending ACK
376        manager.register_pending_ack(msg_id.clone(), None).unwrap();
377        assert!(manager.is_waiting_for_ack(&msg_id));
378        assert_eq!(manager.pending_count(), 1);
379
380        // Receive ACK: the entry is removed from tracking entirely
381        let removed = manager.remove_ack(&msg_id).unwrap();
382        assert_eq!(removed.message_id, msg_id);
383        assert!(!manager.is_waiting_for_ack(&msg_id));
384        assert_eq!(manager.pending_count(), 0);
385    }
386
387    #[test]
388    fn test_ack_timeout() {
389        let config = AckConfig {
390            default_timeout_ms: 50, // 50ms for fast test
391            max_pending_acks: 100,
392        };
393        let mut manager = AckManager::with_config(config);
394        let msg_id = MessageId::new();
395
396        manager
397            .register_pending_ack(msg_id.clone(), Some(50))
398            .unwrap();
399
400        // Not timed out immediately
401        assert!(manager.drain_timed_out().is_empty());
402
403        // Wait for timeout
404        thread::sleep(Duration::from_millis(60));
405
406        // Should be timed out now
407        let timed_out = manager.drain_timed_out();
408        assert_eq!(timed_out.len(), 1);
409        assert_eq!(timed_out[0].message_id, msg_id);
410    }
411
412    #[test]
413    fn test_retry_count_increment() {
414        let mut manager = AckManager::new();
415        let msg_id = MessageId::new();
416
417        manager.register_pending_ack(msg_id.clone(), None).unwrap();
418
419        let pending = manager.get_pending_ack(&msg_id).unwrap();
420        assert_eq!(pending.retry_count, 0);
421
422        manager.increment_retry_count(&msg_id);
423
424        let pending = manager.get_pending_ack(&msg_id).unwrap();
425        assert_eq!(pending.retry_count, 1);
426    }
427
428    #[test]
429    fn test_set_retry_count_carries_backoff_forward() {
430        // Pins the flap fix: when a relay-accepted resend registers a FRESH ACK
431        // (the prior one was cleared by an ACK timeout that re-queued the
432        // message), the backoff ladder must be seeded from the accumulated
433        // count instead of resetting to 0. Resetting to 0 pins delay_for_retry
434        // to its 1s floor forever for an offline peer, which floods the relay
435        // and flaps the connection. If a future change reverts set_retry_count
436        // to a no-op or drops the call site, this test fails loudly.
437        let mut manager = AckManager::new();
438        let msg_id = MessageId::new();
439
440        // Fresh ACK always starts at 0 (register hardcodes it).
441        manager.register_pending_ack(msg_id.clone(), None).unwrap();
442        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 0);
443
444        // Carry an accumulated count forward onto the freshly-registered ACK.
445        manager.set_retry_count(&msg_id, 5);
446        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 5);
447
448        // Idempotent/absolute set (not increment).
449        manager.set_retry_count(&msg_id, 7);
450        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 7);
451
452        // No-op for an unknown message (no pending ACK).
453        manager.set_retry_count(&MessageId::new(), 9);
454    }
455
456    #[test]
457    fn test_remove_ack() {
458        let mut manager = AckManager::new();
459        let msg_id = MessageId::new();
460
461        manager.register_pending_ack(msg_id.clone(), None).unwrap();
462        assert!(manager.is_waiting_for_ack(&msg_id));
463
464        manager.remove_ack(&msg_id);
465        assert!(!manager.is_waiting_for_ack(&msg_id));
466    }
467
468    #[test]
469    fn test_max_pending_limit() {
470        let config = AckConfig {
471            default_timeout_ms: 5000,
472            max_pending_acks: 3,
473        };
474        let mut manager = AckManager::with_config(config);
475
476        // Should be able to register up to limit
477        manager
478            .register_pending_ack(MessageId::new(), None)
479            .unwrap();
480        manager
481            .register_pending_ack(MessageId::new(), None)
482            .unwrap();
483        manager
484            .register_pending_ack(MessageId::new(), None)
485            .unwrap();
486
487        // Fourth should fail (same priority can't evict), with the typed
488        // error a sender can recognise and defer on.
489        let result = manager.register_pending_ack(MessageId::new(), None);
490        assert!(matches!(result, Err(crate::Error::AckCapacity { max: 3 })));
491    }
492
493    #[test]
494    fn has_capacity_for_admits_a_tracked_id_at_the_ceiling() {
495        let mut manager = AckManager::with_config(AckConfig {
496            default_timeout_ms: 5000,
497            max_pending_acks: 1,
498        });
499        let tracked = MessageId::new();
500        assert!(manager.has_capacity_for(&tracked));
501        manager.register_pending_ack(tracked.clone(), None).unwrap();
502        // A resend of the tracked message needs no new slot.
503        assert!(manager.has_capacity_for(&tracked));
504        // A new message does.
505        assert!(!manager.has_capacity_for(&MessageId::new()));
506    }
507
508    #[test]
509    fn extend_while_queued_grants_one_more_timeout_within_the_bound() {
510        let mut manager = AckManager::with_config(AckConfig {
511            default_timeout_ms: 10,
512            max_pending_acks: 10,
513        });
514        let id = MessageId::new();
515        manager.register_pending_ack(id.clone(), Some(10)).unwrap();
516        thread::sleep(Duration::from_millis(15));
517        assert_eq!(manager.drain_timed_out().len(), 1);
518
519        assert!(manager.extend_while_queued(&id, Duration::from_secs(60)));
520        assert!(
521            manager.drain_timed_out().is_empty(),
522            "extended for one more timeout"
523        );
524        thread::sleep(Duration::from_millis(15));
525        assert_eq!(manager.drain_timed_out().len(), 1, "and only one");
526
527        // Past the bound it falls through to the retry ladder.
528        assert!(!manager.extend_while_queued(&id, Duration::from_millis(1)));
529        // A new attempt starts from a clean deadline.
530        manager.increment_retry_count(&id);
531        assert_eq!(manager.get_pending_ack(&id).unwrap().extended_ms, 0);
532    }
533
534    /// A refused registration at the ceiling used to mark every timed-out
535    /// entry `TimedOut` and throw the list away. `is_timed_out` is false for a
536    /// marked entry, so the engine's own sweep never saw those messages again
537    /// and they were never retried.
538    #[test]
539    fn a_refused_registration_leaves_timed_out_entries_for_the_sweep() {
540        let mut manager = AckManager::with_config(AckConfig {
541            default_timeout_ms: 10,
542            max_pending_acks: 2,
543        });
544        let first = MessageId::new();
545        let second = MessageId::new();
546        manager
547            .register_pending_ack(first.clone(), Some(10))
548            .unwrap();
549        manager
550            .register_pending_ack(second.clone(), Some(10))
551            .unwrap();
552        thread::sleep(Duration::from_millis(20));
553
554        assert!(manager
555            .register_pending_ack(MessageId::new(), None)
556            .is_err());
557
558        let mut drained: Vec<_> = manager
559            .drain_timed_out()
560            .into_iter()
561            .map(|ack| ack.message_id)
562            .collect();
563        drained.sort_by_key(|id| id.as_str());
564        let mut expected = vec![first, second];
565        expected.sort_by_key(|id| id.as_str());
566        assert_eq!(drained, expected);
567    }
568
569    #[test]
570    fn test_priority_based_eviction() {
571        let config = AckConfig {
572            default_timeout_ms: 5000,
573            max_pending_acks: 2,
574        };
575        let mut manager = AckManager::with_config(config);
576
577        let low_id = MessageId::new();
578        let medium_id = MessageId::new();
579
580        // Register low priority ACK
581        manager
582            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
583            .unwrap();
584        // Register medium priority ACK
585        manager
586            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
587            .unwrap();
588
589        assert_eq!(manager.pending_count(), 2);
590
591        // High priority should evict low priority
592        let high_id = MessageId::new();
593        manager
594            .register_pending_ack_with_priority(high_id.clone(), None, MessagePriority::High)
595            .unwrap();
596
597        assert_eq!(manager.pending_count(), 2);
598        assert!(!manager.is_waiting_for_ack(&low_id)); // Low was evicted
599        assert!(manager.is_waiting_for_ack(&medium_id)); // Medium still present
600        assert!(manager.is_waiting_for_ack(&high_id)); // High was added
601    }
602
603    #[test]
604    fn test_critical_priority_evicts_all() {
605        let config = AckConfig {
606            default_timeout_ms: 5000,
607            max_pending_acks: 1,
608        };
609        let mut manager = AckManager::with_config(config);
610
611        let medium_id = MessageId::new();
612        manager
613            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
614            .unwrap();
615
616        // Critical should evict medium
617        let critical_id = MessageId::new();
618        manager
619            .register_pending_ack_with_priority(
620                critical_id.clone(),
621                None,
622                MessagePriority::Critical,
623            )
624            .unwrap();
625
626        assert_eq!(manager.pending_count(), 1);
627        assert!(!manager.is_waiting_for_ack(&medium_id));
628        assert!(manager.is_waiting_for_ack(&critical_id));
629    }
630
631    #[test]
632    fn test_prune_old_timeouts() {
633        let config = AckConfig {
634            default_timeout_ms: 10,
635            max_pending_acks: 100,
636        };
637        let mut manager = AckManager::with_config(config);
638
639        let msg_id = MessageId::new();
640        manager
641            .register_pending_ack(msg_id.clone(), Some(10))
642            .unwrap();
643
644        // Wait for timeout
645        thread::sleep(Duration::from_millis(20));
646        assert_eq!(manager.drain_timed_out().len(), 1);
647
648        assert_eq!(manager.pending_count(), 1);
649
650        // Prune old timeouts
651        manager.prune_old_timeouts(Duration::from_millis(5));
652        assert_eq!(manager.pending_count(), 0);
653    }
654
655    #[test]
656    fn test_ack_not_found() {
657        let mut manager = AckManager::new();
658        let msg_id = MessageId::new();
659
660        // Removing an ACK for a non-existent message should return None
661        assert!(manager.remove_ack(&msg_id).is_none());
662    }
663
664    #[test]
665    fn test_eviction_returned_from_register() {
666        let config = AckConfig {
667            default_timeout_ms: 5000,
668            max_pending_acks: 2,
669        };
670        let mut manager = AckManager::with_config(config);
671
672        let low_id = MessageId::new();
673        // No eviction when registering into non-full capacity.
674        assert!(manager
675            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
676            .unwrap()
677            .is_none());
678        assert!(manager
679            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::Medium)
680            .unwrap()
681            .is_none());
682
683        // High priority triggers eviction of low — returned in-band.
684        let eviction = manager
685            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::High)
686            .unwrap();
687        let eviction = eviction.expect("should have evicted the low-priority ACK");
688        assert_eq!(eviction.message_id, low_id);
689        assert_eq!(eviction.priority, MessagePriority::Low);
690    }
691}