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}