Skip to main content

spdy_mux/
session.rs

1use std::sync::atomic::{
2    AtomicU64,
3    AtomicUsize,
4    Ordering,
5};
6use std::time::Instant;
7
8use tokio_util::sync::CancellationToken;
9
10use crate::error::Error;
11use crate::mux::{
12    MuxConfig,
13    MuxHandle,
14};
15use crate::stream::Stream;
16use crate::transport::{
17    WsFrameReader,
18    WsFrameWriter,
19};
20
21/// Per-handle load metrics for P2C routing.
22/// Cost = (inflight_streams + 1) * rtt_estimate_ns. Lower cost = preferred
23/// handle.
24///
25/// RTT timestamps are tracked per-call via the [`RttSample`] guard, not
26/// stored in shared state. This avoids the race where two concurrent opens
27/// would clobber each other's start timestamps.
28struct HandleMetrics {
29    /// Exponentially weighted RTT estimate in nanoseconds.
30    rtt_ns: AtomicU64,
31}
32
33impl HandleMetrics {
34    const fn new() -> Self {
35        Self {
36            // seed with 1ms to avoid zero-cost bias before first measurement.
37            rtt_ns: AtomicU64::new(1_000_000),
38        }
39    }
40
41    /// Begin an RTT measurement. The returned guard records the sample
42    /// when you call `complete()`. If dropped without calling `complete()`,
43    /// no measurement is recorded — that's intentional for early-return
44    /// paths (closed handle, capacity exhausted).
45    fn start_sample(&self) -> RttSample<'_> {
46        RttSample {
47            metrics: self,
48            start: Instant::now(),
49        }
50    }
51
52    /// Update the RTT estimate using Peak-EWMA: adopt new peaks immediately,
53    /// decay toward measurements below the peak.
54    ///
55    /// Uses a compare-and-swap loop so concurrent updates from racing opens
56    /// don't lose samples. Failure to CAS just retries; the cost is bounded
57    /// by the number of concurrent opens on one handle (typically 1-2).
58    fn record_rtt(&self, elapsed_ns: u64) {
59        let mut prev = self.rtt_ns.load(Ordering::Relaxed);
60        loop {
61            let next = if elapsed_ns > prev {
62                // new peak: adopt immediately for fast spike adaptation.
63                elapsed_ns
64            } else {
65                // decay toward current measurement: new = prev*0.9 + elapsed*0.1
66                (prev / 10) * 9 + elapsed_ns / 10
67            };
68            match self.rtt_ns.compare_exchange_weak(
69                prev,
70                next,
71                Ordering::Relaxed,
72                Ordering::Relaxed,
73            ) {
74                Ok(_) => return,
75                Err(actual) => prev = actual,
76            }
77        }
78    }
79
80    /// P2C cost metric: (inflight + 1) × rtt_estimate.
81    fn cost(&self, inflight: usize) -> u64 {
82        let rtt = self.rtt_ns.load(Ordering::Relaxed);
83        rtt.saturating_mul((inflight as u64).saturating_add(1))
84    }
85}
86
87/// Per-call RTT measurement. Calling [`complete`] records the elapsed
88/// time. Dropping without completing is intentional (early-return paths).
89struct RttSample<'a> {
90    metrics: &'a HandleMetrics,
91    start: Instant,
92}
93
94impl RttSample<'_> {
95    fn complete(self) {
96        let elapsed_ns = self.start.elapsed().as_nanos();
97        // cap at u64::MAX (won't happen in practice but defensive).
98        let elapsed_ns = u64::try_from(elapsed_ns).unwrap_or(u64::MAX);
99        self.metrics.record_rtt(elapsed_ns);
100    }
101}
102
103/// SPDY/3.1 session: one or more transport connections carrying paired
104/// streams to a SPDY peer.
105///
106/// When `pool_size > 1`, each transport gets its own reader/writer task
107/// pair and streams are distributed via power-of-two-choices across the
108/// pool for parallel writes at high concurrency. Pool size 1 keeps the
109/// original single-connection behaviour.
110///
111/// # Transport break contract
112///
113/// When a transport closes or errors, every stream on that handle
114/// receives `BrokenPipe`. The session doesn't reconnect. Layers above
115/// (typically a forwarder) open a fresh session on transport failure.
116pub struct Session {
117    pool: Vec<MuxHandle>,
118    metrics: Vec<HandleMetrics>,
119    next: AtomicUsize,
120    cancel: CancellationToken,
121}
122
123impl Session {
124    /// Create a session with explicit configuration from pre-split WebSocket
125    /// transport pairs.
126    ///
127    /// Each `(writer, reader)` pair gets its own `MuxHandle` with independent
128    /// reader/writer tasks. All handshakes and initial PING roundtrips
129    /// complete before this method returns. Streams are then distributed
130    /// round-robin across the pool.
131    ///
132    /// # Graceful degradation
133    ///
134    /// If some connections fail their initial PING but at least one succeeds,
135    /// the session proceeds with the healthy subset. Only returns an error
136    /// when ALL connections fail (or the input is empty).
137    pub async fn with_config<W, R>(
138        connections: Vec<(W, R)>, cancel: CancellationToken, config: MuxConfig,
139    ) -> Result<Self, Error>
140    where
141        W: WsFrameWriter + 'static,
142        R: WsFrameReader + 'static,
143    {
144        if connections.is_empty() {
145            return Err(Error::MuxClosed);
146        }
147        let total = connections.len();
148        let mut pool = Vec::with_capacity(total);
149        let mut last_error = None;
150        for (i, (writer, reader)) in connections.into_iter().enumerate() {
151            match MuxHandle::spawn(writer, reader, cancel.clone(), config.clone()).await {
152                Ok(mux) => pool.push(mux),
153                Err(e) => {
154                    tracing::warn!(
155                        index = i,
156                        total,
157                        error = %e,
158                        "SPDY pool: connection {}/{} failed initial PING, skipping",
159                        i + 1,
160                        total,
161                    );
162                    last_error = Some(e);
163                }
164            }
165        }
166        if pool.is_empty() {
167            // all connections failed: propagate the last error.
168            return Err(last_error.unwrap_or(Error::MuxClosed));
169        }
170        if pool.len() < total {
171            tracing::info!(
172                healthy = pool.len(),
173                total,
174                "SPDY pool: proceeding with {}/{} connections",
175                pool.len(),
176                total,
177            );
178        }
179        let metrics = (0..pool.len()).map(|_| HandleMetrics::new()).collect();
180        Ok(Self {
181            pool,
182            metrics,
183            next: AtomicUsize::new(0),
184            cancel,
185        })
186    }
187
188    /// Open a paired stream using power-of-two-choices with Peak-EWMA
189    /// load estimation.
190    ///
191    /// Picks two random live handles, compares their cost
192    /// (inflight × RTT estimate), and opens on the cheaper one. Falls back
193    /// to a round-robin scan when both picks are at capacity or closed.
194    ///
195    /// `error_headers` and `data_headers` are passed verbatim to the codec
196    /// as the SYN_STREAM header block for the respective stream. The
197    /// session doesn't interpret them.
198    pub async fn open_stream_pair(
199        &self, error_headers: Vec<(String, String)>, data_headers: Vec<(String, String)>,
200    ) -> Result<Stream, Error> {
201        let pool_size = self.pool.len();
202
203        if pool_size >= 2 {
204            let (a, b) = self.pick_two(pool_size);
205            let preferred = if self.handle_cost(a) <= self.handle_cost(b) {
206                [a, b]
207            } else {
208                [b, a]
209            };
210            for &idx in &preferred {
211                if let Some(stream) = self
212                    .try_open(idx, error_headers.clone(), data_headers.clone())
213                    .await?
214                {
215                    return Ok(stream);
216                }
217            }
218        }
219
220        for round in 0..pool_size {
221            let idx = self.next.fetch_add(1, Ordering::Relaxed) % pool_size;
222            if let Some(stream) = self
223                .try_open(idx, error_headers.clone(), data_headers.clone())
224                .await?
225            {
226                return Ok(stream);
227            }
228            tracing::debug!(
229                handle = idx,
230                round,
231                "SPDY session: handle unavailable, trying next"
232            );
233        }
234
235        Err(Error::CapacityExhausted {
236            in_use: self.in_use(),
237            limit: self.capacity() as u32,
238        })
239    }
240
241    /// Try to open a stream on the given handle index.
242    /// Returns Ok(Some(stream)) on success, Ok(None) if handle is closed or
243    /// at capacity, Err on fatal errors.
244    ///
245    /// RTT is measured per-call via [`RttSample`], only recorded on success
246    /// to avoid contaminating the load estimate with capacity-rejection
247    /// latency (which is fast and unrepresentative of actual stream-open
248    /// cost).
249    async fn try_open(
250        &self, idx: usize, error_headers: Vec<(String, String)>,
251        data_headers: Vec<(String, String)>,
252    ) -> Result<Option<Stream>, Error> {
253        let mux = &self.pool[idx];
254        if mux.is_closed() {
255            return Ok(None);
256        }
257        let sample = self.metrics[idx].start_sample();
258        match mux.open_stream_pair(error_headers, data_headers).await {
259            Ok(stream) => {
260                sample.complete();
261                tracing::debug!(
262                    handle = idx,
263                    active = mux.active_pairs(),
264                    cost = self.handle_cost(idx),
265                    "SPDY session: stream opened via P2C"
266                );
267                Ok(Some(stream))
268            }
269            Err(Error::CapacityExhausted { .. }) => {
270                // sample dropped without complete(): no spurious RTT record.
271                Ok(None)
272            }
273            Err(e) => Err(e),
274        }
275    }
276
277    /// P2C cost for a handle: inflight × rtt_estimate.
278    /// Closed handles get u64::MAX cost (never selected).
279    fn handle_cost(&self, idx: usize) -> u64 {
280        let mux = &self.pool[idx];
281        if mux.is_closed() {
282            return u64::MAX;
283        }
284        self.metrics[idx].cost(mux.active_pairs())
285    }
286
287    /// Pick two distinct random indices using xorshift on the atomic counter.
288    /// Cheap and good enough for load balancing (no rand dependency needed).
289    fn pick_two(&self, pool_size: usize) -> (usize, usize) {
290        // use fetch_add as a cheap entropy source.
291        let seed = self.next.fetch_add(1, Ordering::Relaxed) as u64;
292        let a = (seed % pool_size as u64) as usize;
293        // LCG multiplier from Knuth's MMIX (also used by PCG family).
294        let b = ((seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1)) % pool_size as u64)
295            as usize;
296        if a == b {
297            (a, (a + 1) % pool_size)
298        } else {
299            (a, b)
300        }
301    }
302
303    /// Total capacity across all live pool members (hard cap).
304    pub fn capacity(&self) -> usize {
305        self.pool
306            .iter()
307            .filter(|m| !m.is_closed())
308            .map(|m| m.max_concurrent() as usize)
309            .sum()
310    }
311
312    /// Total operating capacity across all live pool members (scheduling cap).
313    pub fn operating_capacity(&self) -> usize {
314        self.pool
315            .iter()
316            .filter(|m| !m.is_closed())
317            .map(MuxHandle::operating_capacity)
318            .sum()
319    }
320
321    pub fn in_use(&self) -> usize {
322        self.pool.iter().map(MuxHandle::active_pairs).sum()
323    }
324
325    pub fn available(&self) -> usize {
326        self.capacity().saturating_sub(self.in_use())
327    }
328
329    pub fn is_full(&self) -> bool {
330        self.pool
331            .iter()
332            .all(|m| m.is_closed() || m.active_pairs() >= m.max_concurrent() as usize)
333    }
334
335    /// Returns true when all underlying WebSockets have closed.
336    pub fn is_drained(&self) -> bool {
337        self.pool.iter().all(MuxHandle::is_closed)
338    }
339
340    pub fn cancellation_token(&self) -> CancellationToken {
341        self.cancel.clone()
342    }
343
344    /// Close the SPDY session by cancelling the mux tasks.
345    pub async fn close(self) -> Result<(), Error> {
346        self.cancel.cancel();
347        Ok(())
348    }
349}