Skip to main content

solana_net_utils/
token_bucket.rs

1//! This module contains [`TokenBucket`], which provides ability to limit
2//! rate of certain events, while allowing bursts through.
3//! [`KeyedRateLimiter`] allows to rate-limit multiple keyed items, such
4//! as connections.
5#[cfg(feature = "shuttle-test")]
6use std::sync::Arc;
7use {
8    cfg_if::cfg_if,
9    dashmap::{DashMap, mapref::entry::Entry},
10    solana_svm_type_overrides::sync::atomic::{AtomicU64, AtomicUsize, Ordering},
11    std::{borrow::Borrow, cmp::Reverse, hash::Hash, time::Instant},
12};
13
14/// Enforces a rate limit on the volume of requests per unit time.
15///
16/// Instances update the amount of tokens upon access, and thus does not need to
17/// be constantly polled to refill. Uses atomics internally so should be
18/// relatively cheap to access from many threads
19pub struct TokenBucket {
20    new_tokens_per_us: f64,
21    max_tokens: u64,
22    /// bucket creation
23    base_time: Instant,
24    tokens: AtomicU64,
25    /// time of last update in us since base_time
26    last_update: AtomicU64,
27    /// time unused in last token creation round
28    credit_time_us: AtomicU64,
29    /// Per-bucket time source for shuttle tests, replacing Instant::now().
30    /// Shared via Arc so cloned buckets (e.g. in KeyedRateLimiter) use the same clock.
31    #[cfg(feature = "shuttle-test")]
32    pub time_us_override: Arc<AtomicU64>,
33}
34
35// If changing this impl, make sure to run benches and ensure they do not panic.
36// much of the testing is impossible outside of real multithreading in release mode.
37impl TokenBucket {
38    /// Allocate a new TokenBucket
39    pub fn new(initial_tokens: u64, max_tokens: u64, new_tokens_per_second: f64) -> Self {
40        assert!(
41            new_tokens_per_second > 0.0,
42            "Token bucket can not have zero influx rate"
43        );
44        assert!(
45            initial_tokens <= max_tokens,
46            "Can not have more initial tokens than max tokens"
47        );
48        let base_time = Instant::now();
49        TokenBucket {
50            // recompute into us to avoid FP division on every update
51            new_tokens_per_us: new_tokens_per_second / 1e6,
52            max_tokens,
53            tokens: AtomicU64::new(initial_tokens),
54            last_update: AtomicU64::new(0),
55            base_time,
56            credit_time_us: AtomicU64::new(0),
57            #[cfg(feature = "shuttle-test")]
58            time_us_override: Arc::new(AtomicU64::new(0)),
59        }
60    }
61
62    /// Return current amount of tokens in the bucket.
63    /// This may be somewhat inconsistent across threads
64    /// due to Relaxed atomics.
65    #[inline]
66    pub fn current_tokens(&self) -> u64 {
67        let now = self.time_us();
68        self.update_state(now);
69        self.tokens.load(Ordering::Relaxed)
70    }
71
72    /// Attempts to consume tokens from bucket.
73    ///
74    /// On success, returns Ok(amount of tokens left in the bucket).
75    /// On failure, returns Err(amount of tokens missing to fill request).
76    #[inline]
77    pub fn consume_tokens(&self, request_size: u64) -> Result<u64, u64> {
78        let now = self.time_us();
79        self.update_state(now);
80        match self.tokens.fetch_update(
81            Ordering::AcqRel,  // winner publishes new amount
82            Ordering::Acquire, // everyone observed correct number
83            |tokens| {
84                if tokens >= request_size {
85                    Some(tokens.saturating_sub(request_size))
86                } else {
87                    None
88                }
89            },
90        ) {
91            Ok(prev) => Ok(prev.saturating_sub(request_size)),
92            Err(prev) => Err(request_size.saturating_sub(prev)),
93        }
94    }
95
96    /// Consumes up to `request_size` tokens from the bucket, draining whatever
97    /// is available without requiring the full amount.
98    ///
99    /// Returns the number of tokens actually consumed (0..=request_size).
100    /// Unlike [`consume_tokens`](Self::consume_tokens) this never fails — if
101    /// fewer tokens are available than requested, all available tokens are
102    /// taken and the consumed count reflects that.
103    #[inline]
104    pub fn consume_tokens_saturating(&self, request_size: u64) -> u64 {
105        let now = self.time_us();
106        self.update_state(now);
107        let mut consumed = 0u64;
108        let _ = self.tokens.fetch_update(
109            Ordering::AcqRel,  // winner publishes new amount
110            Ordering::Acquire, // everyone observed correct number
111            |tokens| {
112                consumed = tokens.min(request_size);
113                Some(tokens.saturating_sub(consumed))
114            },
115        );
116        consumed
117    }
118
119    /// Adds given amount of tokens, up to a maximum of self.max_tokens.
120    #[inline]
121    pub fn add_tokens(&self, new_tokens: u64) {
122        let _ = self.tokens.fetch_update(
123            Ordering::AcqRel,  // writer publishes new amount
124            Ordering::Acquire, //we fetch the correct amount
125            |tokens| Some(tokens.saturating_add(new_tokens).min(self.max_tokens)),
126        );
127    }
128
129    /// Returns time in microseconds until `num_tokens` worth of new
130    /// tokens can be consumed.
131    ///
132    /// Calculation is performed assuming no demand for smaller
133    /// batches of tokens (actual time may be longer).
134    /// Returns None if num_tokens > bucket capacity.
135    #[inline]
136    pub fn us_to_have_tokens(&self, num_tokens: u64) -> Option<u64> {
137        if num_tokens > self.max_tokens {
138            return None;
139        }
140
141        match num_tokens.checked_sub(self.current_tokens()) {
142            Some(missing) => Some((missing as f64 / self.new_tokens_per_us) as u64),
143            None => Some(0),
144        }
145    }
146
147    /// Retrieves monotonic time since bucket creation.
148    fn time_us(&self) -> u64 {
149        cfg_if! {
150            if #[cfg(feature="shuttle-test")] {
151                self.time_us_override.load(Ordering::Relaxed)
152            } else {
153                let now = Instant::now();
154                let elapsed = now.saturating_duration_since(self.base_time);
155                elapsed.as_micros() as u64
156            }
157        }
158    }
159
160    /// Updates internal state of the bucket by
161    /// depositing new tokens (if appropriate)
162    fn update_state(&self, now: u64) {
163        // fetch last update time
164        let last = self.last_update.load(Ordering::SeqCst);
165
166        // If time has not advanced, nothing to do.
167        if now <= last {
168            return;
169        }
170
171        // Try to claim the interval [last, now].
172        // If we can not claim it, someone else will claim [last..some other time] when they
173        // touch the bucket.
174        // If we can claim interval [last, now], no other thread can credit tokens for it anymore.
175        // If [last, now] is too short to mint any tokens, spare time will be preserved in credit_time_us.
176        match self.last_update.compare_exchange(
177            last,
178            now,
179            Ordering::AcqRel,  // winner publishes new timestamp
180            Ordering::Acquire, // loser observes updates
181        ) {
182            Ok(_) => {
183                // This thread won the race and is responsible for minting tokens
184                let elapsed = now.saturating_sub(last);
185
186                // also add leftovers from previous conversion attempts.
187                // we do not care about who uses the spare_time_us, so relaxed is ok here.
188                let elapsed =
189                    elapsed.saturating_add(self.credit_time_us.swap(0, Ordering::Relaxed));
190
191                let new_tokens_f64 = elapsed as f64 * self.new_tokens_per_us;
192
193                // amount of full tokens to be minted
194                let new_tokens = new_tokens_f64.floor() as u64;
195
196                let time_to_return = if new_tokens >= 1 {
197                    // Credit tokens, saturating at max_tokens
198                    self.add_tokens(new_tokens);
199                    // Fractional remainder of elapsed time (not enough to mint a whole token)
200                    // that will be credited to other minters
201                    (new_tokens_f64.fract() / self.new_tokens_per_us) as u64
202                } else {
203                    // No whole tokens minted → return whole interval
204                    elapsed
205                };
206                // Save unused elapsed time for other threads
207                self.credit_time_us
208                    .fetch_add(time_to_return, Ordering::Relaxed);
209            }
210            Err(_) => {
211                // Another thread advanced last_update first → nothing we can do now.
212            }
213        }
214    }
215}
216
217impl Clone for TokenBucket {
218    /// Clones the TokenBucket with approximate state
219    /// of the original. While this will never return an object in an
220    /// invalid state, using this in a contended environment is not recommended.
221    fn clone(&self) -> Self {
222        Self {
223            new_tokens_per_us: self.new_tokens_per_us,
224            max_tokens: self.max_tokens,
225            base_time: self.base_time,
226            tokens: AtomicU64::new(self.tokens.load(Ordering::Relaxed)),
227            last_update: AtomicU64::new(self.last_update.load(Ordering::Relaxed)),
228            credit_time_us: AtomicU64::new(self.credit_time_us.load(Ordering::Relaxed)),
229            // Cloned buckets share the same time source so they see the same clock
230            #[cfg(feature = "shuttle-test")]
231            time_us_override: Arc::clone(&self.time_us_override),
232        }
233    }
234}
235
236/// Provides rate limiting for multiple contexts at the same time
237///
238/// This can use e.g. IP address as a Key.
239/// Internally this is a [DashMap] of [TokenBucket] instances
240/// that are created on demand using a prototype [TokenBucket]
241/// to copy initial state from.
242/// Uses LazyLru logic under the hood to keep the amount of items
243/// under control.
244pub struct KeyedRateLimiter<K>
245where
246    K: Hash + Eq,
247{
248    data: DashMap<K, TokenBucket>,
249    target_capacity: usize,
250    prototype_bucket: TokenBucket,
251    countdown_to_shrink: AtomicUsize,
252    approx_len: AtomicUsize,
253    shrink_interval: usize,
254}
255
256impl<K> KeyedRateLimiter<K>
257where
258    K: Hash + Eq,
259{
260    /// Creates a new KeyedRateLimiter with a specified target capacity and shard amount for the
261    /// underlying DashMap. This uses a LazyLRU style eviction policy, so actual memory consumption
262    /// will be <= 2 * target_capacity.
263    ///
264    /// shard_amount must be greater than 0 and be a power of two; otherwise this function panics.
265    /// target_capacity must be >= shard_amount; otherwise this function panics.
266    #[allow(clippy::arithmetic_side_effects)]
267    pub fn new(target_capacity: usize, prototype_bucket: TokenBucket, shard_amount: usize) -> Self {
268        assert!(
269            shard_amount > 0 && shard_amount.is_power_of_two(),
270            "KeyedRateLimiter shard_amount ({shard_amount}) must be > 0 and a power of two"
271        );
272        assert!(
273            target_capacity >= shard_amount,
274            "KeyedRateLimiter target_capacity ({target_capacity}) must be >= shard_amount \
275             ({shard_amount})"
276        );
277        let shrink_interval = target_capacity / 4;
278        Self {
279            data: DashMap::with_capacity_and_shard_amount(target_capacity * 2, shard_amount),
280            target_capacity,
281            prototype_bucket,
282            countdown_to_shrink: AtomicUsize::new(shrink_interval),
283            approx_len: AtomicUsize::new(0),
284            shrink_interval,
285        }
286    }
287
288    /// Fetches amount of tokens available for key.
289    ///
290    /// Returns None if no bucket exists for the key provided
291    #[inline]
292    pub fn current_tokens(&self, key: impl Borrow<K>) -> Option<u64> {
293        let bucket = self.data.get(key.borrow())?;
294        Some(bucket.current_tokens())
295    }
296
297    /// Consumes request_size tokens from a bucket at given key.
298    ///
299    /// On success, returns Ok(amount of tokens left in the bucket)
300    /// On failure, returns Err(amount of tokens missing to fill request)
301    /// If no bucket exists at key, a new bucket will be allocated, and normal policy will be applied to it
302    /// Outdated buckets may be evicted on an LRU basis.
303    pub fn consume_tokens(&self, key: K, request_size: u64) -> Result<u64, u64> {
304        let (entry_added, res) = {
305            let bucket = self.data.entry(key);
306            match bucket {
307                Entry::Occupied(entry) => (false, entry.get().consume_tokens(request_size)),
308                Entry::Vacant(entry) => {
309                    // if the key is not in the LRU, we need to allocate a new bucket
310                    let bucket = self.prototype_bucket.clone();
311                    let res = bucket.consume_tokens(request_size);
312                    entry.insert(bucket);
313                    (true, res)
314                }
315            }
316        };
317
318        if entry_added {
319            if let Ok(count) =
320                self.countdown_to_shrink
321                    .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
322                        if v == 0 {
323                            // reset the countup to starting position
324                            // thus preventing other threads from racing for locks
325                            None
326                        } else {
327                            Some(v.saturating_sub(1))
328                        }
329                    })
330            {
331                if count == 1 {
332                    // the last "previous" value we will see before counter reaches zero
333                    self.maybe_shrink();
334                    self.countdown_to_shrink
335                        .store(self.shrink_interval, Ordering::Relaxed);
336                }
337            } else {
338                self.approx_len.fetch_add(1, Ordering::Relaxed);
339            }
340        }
341        res
342    }
343
344    /// Returns approximate amount of entries in the datastructure.
345    /// Should be within ~10% of the true amount.
346    #[inline]
347    pub fn len_approx(&self) -> usize {
348        self.approx_len.load(Ordering::Relaxed)
349    }
350
351    // apply lazy-LRU eviction policy to each DashMap shard.
352    // Allowing side-effects here since overflows here are not
353    // actually possible
354    #[allow(clippy::arithmetic_side_effects)]
355    fn maybe_shrink(&self) {
356        let mut actual_len = 0;
357        let target_shard_size = self.target_capacity / self.data.shards().len();
358        if target_shard_size == 0 {
359            return;
360        }
361        let mut entries = Vec::with_capacity(target_shard_size * 2);
362        for shardlock in self.data.shards() {
363            let mut shard = shardlock.write();
364
365            if shard.len() <= target_shard_size * 3 / 2 {
366                actual_len += shard.len();
367                continue;
368            }
369            entries.clear();
370            entries.extend(
371                shard.drain().map(|(key, value)| {
372                    (key, value.get().last_update.load(Ordering::SeqCst), value)
373                }),
374            );
375
376            entries.select_nth_unstable_by_key(target_shard_size, |(_, last_update, _)| {
377                Reverse(*last_update)
378            });
379
380            shard.extend(
381                entries
382                    .drain(..)
383                    .take(target_shard_size)
384                    .map(|(key, _last_update, value)| (key, value)),
385            );
386            debug_assert!(shard.len() <= target_shard_size);
387            actual_len += shard.len();
388        }
389        self.approx_len.store(actual_len, Ordering::Relaxed);
390    }
391
392    /// Set the auto-shrink interval. Set to 0 to disable shrinking.
393    /// During writes we want to check for length, but not too often
394    /// to reduce probability of lock contention, so keeping this
395    /// large is good for perf (at cost of memory use)
396    pub fn set_shrink_interval(&mut self, interval: usize) {
397        self.shrink_interval = interval;
398    }
399
400    /// Get the auto-shrink interval.
401    pub fn shrink_interval(&self) -> usize {
402        self.shrink_interval
403    }
404}
405
406#[cfg(test)]
407pub mod test {
408    use {
409        super::*,
410        solana_svm_type_overrides::thread,
411        std::{
412            net::{IpAddr, Ipv4Addr},
413            time::Duration,
414        },
415    };
416
417    #[test]
418    fn test_token_bucket_basics() {
419        let tb = TokenBucket::new(100, 100, 1000.0);
420        assert_eq!(tb.current_tokens(), 100);
421        tb.consume_tokens(50).expect("Bucket is initially full");
422        tb.consume_tokens(50)
423            .expect("We should still have >50 tokens left");
424        tb.consume_tokens(50)
425            .expect_err("There should not be enough tokens now");
426        thread::sleep(Duration::from_millis(50));
427        assert!(
428            tb.current_tokens() > 40,
429            "We should be refilling at ~1 token per millisecond"
430        );
431        assert!(
432            tb.current_tokens() < 70,
433            "We should be refilling at ~1 token per millisecond"
434        );
435        tb.consume_tokens(40)
436            .expect("Bucket should have enough for another request now");
437        thread::sleep(Duration::from_millis(120));
438        assert_eq!(tb.current_tokens(), 100, "Bucket should not overfill");
439    }
440
441    #[test]
442    fn test_consume_tokens_saturating_consume() {
443        // new bucket with very slow refill (so it never actually refills);
444        let tb = TokenBucket::new(100, 100, 0.00001);
445
446        let consumed = tb.consume_tokens_saturating(42);
447        assert_eq!(consumed, 42, "Should have consumed exactly 42 tokens");
448        assert_eq!(
449            tb.current_tokens(),
450            58,
451            "Bucket should have 58 tokens after consuming 42"
452        );
453
454        let consumed = tb.consume_tokens_saturating(100);
455        assert_eq!(consumed, 58, "Should have consumed all available tokens");
456        assert_eq!(
457            tb.current_tokens(),
458            0,
459            "Bucket should be empty after full consume"
460        );
461
462        let consumed = tb.consume_tokens_saturating(10);
463        assert_eq!(
464            consumed, 0,
465            "Should have consumed 0 tokens as bucket is empty"
466        );
467        let consumed = tb.consume_tokens_saturating(0);
468        assert_eq!(consumed, 0);
469        assert_eq!(
470            tb.current_tokens(),
471            0,
472            "Bucket should be empty after full consume"
473        );
474    }
475
476    #[test]
477    fn test_token_bucket_us_to_have_tokens() {
478        let tb = TokenBucket::new(1000, 1000, 1000.0);
479        assert_eq!(tb.current_tokens(), 1000);
480        tb.consume_tokens(1000).expect("Bucket is initially full");
481        assert!(
482            tb.current_tokens() < 100,
483            "Shoult not have many tokens left in bucket"
484        );
485
486        let t = tb
487            .us_to_have_tokens(500)
488            .expect("500 < bucket capacity (1000)")
489            / 1000; // convert to ms
490        assert!(t > 100, "time to fill should be ~ 500ms (got {t})");
491        assert!(t <= 500, "time to fill should be less than 500ms (got {t})");
492    }
493
494    #[test]
495    fn test_keyed_rate_limiter() {
496        let prototype_bucket = TokenBucket::new(100, 100, 1000.0);
497        let rl = KeyedRateLimiter::new(8, prototype_bucket, 2);
498        let ip1 = IpAddr::V4(Ipv4Addr::from_bits(1234));
499        let ip2 = IpAddr::V4(Ipv4Addr::from_bits(4321));
500        assert_eq!(rl.current_tokens(ip1), None, "Initially no buckets exist");
501        rl.consume_tokens(ip1, 50)
502            .expect("Bucket is initially full");
503        rl.consume_tokens(ip1, 50)
504            .expect("We should still have >50 tokens left");
505        rl.consume_tokens(ip1, 50)
506            .expect_err("There should not be enough tokens now");
507        rl.consume_tokens(ip2, 50)
508            .expect("Bucket is initially full");
509        rl.consume_tokens(ip2, 50)
510            .expect("We should still have >50 tokens left");
511        rl.consume_tokens(ip2, 50)
512            .expect_err("There should not be enough tokens now");
513        std::thread::sleep(Duration::from_millis(50));
514        assert!(
515            rl.current_tokens(ip1).unwrap() > 40,
516            "We should be refilling at ~1 token per millisecond"
517        );
518        assert!(
519            rl.current_tokens(ip1).unwrap() < 70,
520            "We should be refilling at ~1 token per millisecond"
521        );
522        rl.consume_tokens(ip1, 40)
523            .expect("Bucket should have enough for another request now");
524        thread::sleep(Duration::from_millis(120));
525        assert_eq!(
526            rl.current_tokens(ip1),
527            Some(100),
528            "Bucket should not overfill"
529        );
530        assert_eq!(
531            rl.current_tokens(ip2),
532            Some(100),
533            "Bucket should not overfill"
534        );
535
536        rl.consume_tokens(ip2, 100).expect("Bucket should be full");
537        // go several times over the capacity of the TB to make sure old record
538        // is erased no matter in which bucket it lands
539        for ip in 0..64 {
540            let ip = IpAddr::V4(Ipv4Addr::from_bits(ip));
541            rl.consume_tokens(ip, 50).unwrap();
542        }
543        assert_eq!(
544            rl.current_tokens(ip1),
545            None,
546            "Very old record should have been erased"
547        );
548        rl.consume_tokens(ip2, 100)
549            .expect("New bucket should have been made for ip2");
550    }
551
552    #[test]
553    #[should_panic(expected = "must be >= shard_amount")]
554    fn test_keyed_rate_limiter_capacity_less_than_shards_panics() {
555        let tb = TokenBucket::new(1, 1, 1.0);
556        // target_capacity (1) < shard_amount (2) should panic
557        let _ = KeyedRateLimiter::<u64>::new(1, tb, 2);
558    }
559
560    #[cfg(feature = "shuttle-test")]
561    #[test]
562    fn shuttle_test_consume_tokens_saturating_race() {
563        use {shuttle::sync::atomic::AtomicBool, std::sync::Arc};
564        shuttle::check_random(
565            || {
566                let test_duration_us = 2500;
567                let run = Arc::new(AtomicBool::new(true));
568                let tb = Arc::new(TokenBucket::new(10, 20, 5000.0));
569                let time = Arc::clone(&tb.time_us_override);
570
571                // time advancement thread
572                let time_advancer = {
573                    let run = Arc::clone(&run);
574                    thread::spawn(move || {
575                        let mut current_time = 0;
576                        while current_time < test_duration_us && run.load(Ordering::SeqCst) {
577                            let increment = 100; // microseconds
578                            current_time += increment;
579                            time.store(current_time, Ordering::SeqCst);
580                            shuttle::thread::yield_now();
581                        }
582                        run.store(false, Ordering::SeqCst);
583                    })
584                };
585
586                let threads: Vec<_> = (0..2)
587                    .map(|_| {
588                        let run = Arc::clone(&run);
589                        let tb = Arc::clone(&tb);
590                        thread::spawn(move || {
591                            let mut total = 0u64;
592                            while run.load(Ordering::SeqCst) {
593                                total += tb.consume_tokens_saturating(5);
594                                shuttle::thread::yield_now();
595                            }
596                            total
597                        })
598                    })
599                    .collect();
600
601                time_advancer.join().unwrap();
602                let received: u64 = threads.into_iter().map(|t| t.join().unwrap()).sum();
603
604                // Initial tokens: 10, refill rate: 5000 tokens/sec (5 tokens/ms)
605                // In 2.5ms: initial 10 + refill 12.5 = 22.5 lifetime tokens
606                // (max_tokens caps instantaneous level, not cumulative throughput)
607                // Saturating consume drains aggressively so should capture most of them.
608                assert!(
609                    received <= 23,
610                    "Should not consume more tokens than were minted: {received}"
611                );
612                assert!(
613                    received >= 10,
614                    "Should consume at least the initial tokens: {received}"
615                );
616            },
617            100,
618        );
619    }
620
621    #[cfg(feature = "shuttle-test")]
622    #[test]
623    fn shuttle_test_token_bucket_race() {
624        use {shuttle::sync::atomic::AtomicBool, std::sync::Arc};
625        shuttle::check_random(
626            || {
627                let test_duration_us = 2500;
628                let run = Arc::new(AtomicBool::new(true));
629                let tb = Arc::new(TokenBucket::new(10, 20, 5000.0));
630                let time = Arc::clone(&tb.time_us_override);
631
632                // time advancement thread
633                let time_advancer = {
634                    let run = Arc::clone(&run);
635                    thread::spawn(move || {
636                        let mut current_time = 0;
637                        while current_time < test_duration_us && run.load(Ordering::SeqCst) {
638                            let increment = 100; // microseconds
639                            current_time += increment;
640                            time.store(current_time, Ordering::SeqCst);
641                            shuttle::thread::yield_now();
642                        }
643                        run.store(false, Ordering::SeqCst);
644                    })
645                };
646
647                let threads: Vec<_> = (0..2)
648                    .map(|_| {
649                        let run = Arc::clone(&run);
650                        let tb = Arc::clone(&tb);
651                        thread::spawn(move || {
652                            let mut total = 0;
653                            while run.load(Ordering::SeqCst) {
654                                if tb.consume_tokens(5).is_ok() {
655                                    total += 1;
656                                }
657                                shuttle::thread::yield_now();
658                            }
659                            total
660                        })
661                    })
662                    .collect();
663
664                time_advancer.join().unwrap();
665                let received = threads.into_iter().map(|t| t.join().unwrap()).sum();
666
667                // Initial tokens: 10, refill rate: 5000 tokens/sec (5 tokens/ms)
668                // In 2ms: 10 + (5 * 2) = 20 tokens total
669                // Each consumption: 5 tokens → 4 total consumptions expected
670                assert_eq!(4, received);
671            },
672            100,
673        );
674    }
675}