Skip to main content

kunobi_daemon/
admission.rs

1//! Independent, bounded capacity for setup, application and control traffic.
2use std::sync::{
3    Arc,
4    atomic::{AtomicU64, AtomicUsize, Ordering},
5};
6
7/// Connection class. Classify authenticated peers before admitting application work.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9#[cfg_attr(kani, derive(kani::Arbitrary))]
10pub enum Pool {
11    /// Connections whose handshake has not finished.
12    Handshake,
13    /// Ordinary application sessions.
14    Application,
15    /// Lifecycle control sessions; never dispatch application work on this budget.
16    Control,
17}
18
19/// Independent limits. A zero limit disables its pool.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct Limits {
22    /// Maximum pending handshakes.
23    pub handshakes: usize,
24    /// Maximum application sessions.
25    pub application: usize,
26    /// Maximum control sessions, even when application capacity is exhausted.
27    pub control: usize,
28}
29impl Default for Limits {
30    fn default() -> Self {
31        Self {
32            handshakes: 32,
33            application: 256,
34            control: 32,
35        }
36    }
37}
38
39/// One pool's observation; counters are independent atomic samples.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct PoolSnapshot {
43    /// Configured capacity.
44    pub limit: usize,
45    /// Permits currently held.
46    pub active: usize,
47    /// Failed admissions since construction.
48    pub rejected: u64,
49}
50struct Capacity {
51    limit: usize,
52    active: AtomicUsize,
53    rejected: AtomicU64,
54}
55
56/// Shared capacity, with no waiting queue or background task.
57pub struct Admission {
58    pools: [Capacity; 3],
59}
60impl Admission {
61    /// Construct separate budgets. Keep one instance per service generation.
62    pub fn new(limits: Limits) -> Self {
63        Self {
64            pools: [limits.handshakes, limits.application, limits.control].map(|limit| Capacity {
65                limit,
66                active: AtomicUsize::new(0),
67                rejected: AtomicU64::new(0),
68            }),
69        }
70    }
71    /// Acquire without waiting. Hold the permit for the whole session.
72    pub fn try_acquire(self: &Arc<Self>, pool: Pool) -> Option<Permit> {
73        let capacity = &self.pools[pool as usize];
74        if capacity
75            .active
76            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |active| {
77                (active < capacity.limit).then(|| active + 1)
78            })
79            .is_err()
80        {
81            capacity.rejected.fetch_add(1, Ordering::Relaxed);
82            return None;
83        }
84        Some(Permit {
85            admission: Arc::clone(self),
86            pool,
87        })
88    }
89    /// Read occupancy without waiting for a connection or writer lock.
90    pub fn snapshot(&self, pool: Pool) -> PoolSnapshot {
91        let capacity = &self.pools[pool as usize];
92        PoolSnapshot {
93            limit: capacity.limit,
94            active: capacity.active.load(Ordering::Relaxed),
95            rejected: capacity.rejected.load(Ordering::Relaxed),
96        }
97    }
98}
99/// Releases one admission slot on drop, including cancellation and errors.
100#[must_use = "hold the permit until the session ends"]
101pub struct Permit {
102    admission: Arc<Admission>,
103    pool: Pool,
104}
105impl Drop for Permit {
106    fn drop(&mut self) {
107        self.admission.pools[self.pool as usize]
108            .active
109            .fetch_sub(1, Ordering::Relaxed);
110    }
111}
112
113/// Properties of the three budgets for every short sequence of admissions and
114/// releases. Atomicity under contention rests on `fetch_update`; this checks
115/// the counting it performs.
116#[cfg(kani)]
117mod proofs {
118    use super::*;
119
120    /// Admissions and releases explored per harness: enough to fill a pool,
121    /// be refused, and release a permit from another pool in between.
122    const STEPS: usize = 4;
123    const POOLS: [Pool; 3] = [Pool::Handshake, Pool::Application, Pool::Control];
124
125    fn small() -> usize {
126        usize::from(kani::any::<u8>() % 3)
127    }
128
129    /// A pool admits exactly while it has capacity, whatever the other pools
130    /// hold, so exhausted application capacity never refuses a control
131    /// session. Every permit returns its own slot when dropped, in any order.
132    #[kani::proof]
133    #[kani::unwind(5)]
134    fn each_pool_admits_up_to_its_own_limit_and_no_further() {
135        let limits = Limits {
136            handshakes: small(),
137            application: small(),
138            control: small(),
139        };
140        let limit = [limits.handshakes, limits.application, limits.control];
141        let admission = Arc::new(Admission::new(limits));
142        // One fixed slot per step rather than a growing Vec: removing from a
143        // Vec at a symbolic index made the solver run out of memory.
144        let mut held: [Option<Permit>; STEPS] = [const { None }; STEPS];
145        let mut active = [0usize; 3];
146        let mut rejected = [0u64; 3];
147
148        for step in 0..STEPS {
149            let release = kani::any_where(|at: &usize| *at < STEPS);
150            if kani::any() && held[release].is_some() {
151                let permit = held[release].take().unwrap();
152                active[permit.pool as usize] -= 1;
153                drop(permit);
154            } else {
155                let pool: Pool = kani::any();
156                let index = pool as usize;
157                match admission.try_acquire(pool) {
158                    Some(permit) => {
159                        assert!(active[index] < limit[index]);
160                        active[index] += 1;
161                        held[step] = Some(permit);
162                    }
163                    None => {
164                        assert!(active[index] == limit[index]);
165                        rejected[index] += 1;
166                    }
167                }
168            }
169
170            for pool in POOLS {
171                let index = pool as usize;
172                let snapshot = admission.snapshot(pool);
173                assert!(snapshot.limit == limit[index]);
174                assert!(snapshot.active == active[index]);
175                assert!(snapshot.rejected == rejected[index]);
176            }
177        }
178    }
179}