spdy_mux/transport.rs
1//! Transport abstraction for the SPDY multiplexer.
2//!
3//! The mux is generic over [`WsFrameWriter`] and [`WsFrameReader`] traits,
4//! decoupling SPDY framing from the transport. Two adapters ship
5//! with the crate:
6//!
7//! - [`FastWsWriter`] / [`FastWsReader`]: WebSocket transport via the
8//! `fastwebsockets` library. Used when SPDY frames are tunnelled inside
9//! WebSocket binary messages (`SPDY/3.1+portforward.k8s.io`).
10//! - [`RawSpdyWriter`] / [`RawSpdyReader`]: raw transport over any `AsyncRead`
11//! / `AsyncWrite`. Used when SPDY frames flow directly over an HTTP-upgraded
12//! connection (legacy `kubectl port-forward` wire protocol, no WebSocket
13//! framing).
14//!
15//! # Masking copy budget
16//!
17//! WebSocket client role requires frame masking. The adapter costs exactly
18//! one copy per write:
19//!
20//! - `Payload::Borrowed`: `write_frame` copies the borrowed slice into an
21//! internal buffer and masks there. One allocation plus one copy.
22//! - `Payload::Bytes(BytesMut)`: `write_frame` masks the `BytesMut` in-place.
23//! One allocation (the `Bytes` to `BytesMut` conversion) plus zero copy for
24//! masking. Net: one copy total, same as Borrowed.
25//!
26//! `Payload::Borrowed` keeps the simpler path; both copy once. If profiling
27//! shows masking as a bottleneck, switch to `Payload::Bytes` with
28//! `BytesMut::from(&payload[..])` to eliminate the second allocation inside
29//! fastwebsockets (mask in-place instead of allocate-copy-mask).
30
31use std::future::Future;
32
33use bytes::{
34 Bytes,
35 BytesMut,
36};
37use tokio::io::{
38 AsyncRead,
39 AsyncReadExt,
40 AsyncWrite,
41 AsyncWriteExt,
42};
43
44/// Transport-level error for WebSocket read/write operations.
45#[derive(Debug)]
46pub enum TransportError {
47 /// Error from the fastwebsockets library.
48 FastWebSocket(fastwebsockets::WebSocketError),
49 /// Generic I/O error (e.g., broken pipe, connection reset).
50 Io(std::io::Error),
51}
52
53impl std::fmt::Display for TransportError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::FastWebSocket(e) => write!(f, "fastwebsockets: {e}"),
57 Self::Io(e) => write!(f, "transport I/O: {e}"),
58 }
59 }
60}
61
62impl std::error::Error for TransportError {
63 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64 match self {
65 Self::FastWebSocket(e) => Some(e),
66 Self::Io(e) => Some(e),
67 }
68 }
69}
70
71impl From<fastwebsockets::WebSocketError> for TransportError {
72 fn from(e: fastwebsockets::WebSocketError) -> Self {
73 Self::FastWebSocket(e)
74 }
75}
76
77impl From<std::io::Error> for TransportError {
78 fn from(e: std::io::Error) -> Self {
79 Self::Io(e)
80 }
81}
82
83/// Decoded WebSocket message returned by [`WsFrameReader`].
84///
85/// The mux reader matches on these variants to dispatch SPDY frames, handle
86/// WS-level keepalive (Ping triggers Pong), and detect connection close.
87///
88/// Skip-class frames (Pong, Text, Continuation) are absorbed inside the
89/// adapter's read loop and never reach the mux. The mux reader's idle timer
90/// resets on any successful return from `read_message()`, so absorbed frames
91/// still count as activity.
92pub enum WsMessage {
93 /// Binary payload containing one or more SPDY frames.
94 Binary(Bytes),
95 /// WebSocket-level PING from the peer. The mux reader enqueues a
96 /// `SendWsPong` command so the writer responds.
97 Ping(Bytes),
98 /// WebSocket close frame. The mux reader breaks its loop.
99 Close,
100}
101
102/// Async writer for WebSocket binary frames.
103///
104/// The fastwebsockets adapter writes directly to the socket. Callers MUST
105/// call [`flush`](WsFrameWriter::flush) after a batch of
106/// [`write_binary`](WsFrameWriter::write_binary) calls to ensure data reaches
107/// the peer.
108pub trait WsFrameWriter: Send {
109 /// Write a binary WebSocket frame. May buffer internally.
110 ///
111 /// The `payload` is the complete SPDY frame (header + data), already
112 /// encoded by the codec. The adapter wraps it in a WS binary frame.
113 fn write_binary(
114 &mut self, payload: Bytes,
115 ) -> impl Future<Output = Result<(), TransportError>> + Send;
116
117 /// Write a WebSocket PONG frame in response to a peer PING.
118 fn write_pong(
119 &mut self, payload: Bytes,
120 ) -> impl Future<Output = Result<(), TransportError>> + Send;
121
122 /// Flush all buffered data to the underlying transport.
123 ///
124 /// For tungstenite, flushes the Sink's internal buffer.
125 /// For fastwebsockets, this is a no-op. See [`FastWsWriter::flush`]
126 /// for the rationale.
127 fn flush(&mut self) -> impl Future<Output = Result<(), TransportError>> + Send;
128
129 /// Send a WebSocket Close frame and shut down the write half.
130 fn close(&mut self) -> impl Future<Output = Result<(), TransportError>> + Send;
131}
132
133/// Async reader for WebSocket frames.
134///
135/// Returns [`WsMessage`] variants for binary data, pings, and close.
136/// Skip-class frames (Pong, Text, Continuation) are absorbed inside the
137/// read loop; the mux never sees them.
138pub trait WsFrameReader: Send {
139 /// Read the next actionable WebSocket message.
140 ///
141 /// Returns `None` when the connection is closed (EOF). Returns
142 /// `Some(Err(_))` on transport error. The mux reader breaks its loop
143 /// on both cases.
144 ///
145 /// Pong, Text, and Continuation frames are silently consumed inside
146 /// the adapter's loop and never returned.
147 fn read_message(
148 &mut self,
149 ) -> impl Future<Output = Option<Result<WsMessage, TransportError>>> + Send;
150}
151
152/// fastwebsockets write half adapter.
153///
154/// Wraps `WebSocketWrite<WriteHalf<S>>`. Each `write_binary` call writes
155/// directly to the underlying stream via `AsyncWrite::write_all` /
156/// `write_vectored` (no WS-level buffering).
157pub struct FastWsWriter<S: AsyncWrite + Unpin + Send> {
158 ws: fastwebsockets::WebSocketWrite<S>,
159}
160
161impl<S: AsyncWrite + Unpin + Send> WsFrameWriter for FastWsWriter<S> {
162 async fn write_binary(&mut self, payload: Bytes) -> Result<(), TransportError> {
163 // Payload::Borrowed(&payload) borrows from the function parameter.
164 // the borrow lives for the duration of write_frame. `payload` is owned
165 // by this stack frame and outlives the await point.
166 //
167 // fastwebsockets copies the borrowed slice during masking (one copy,
168 // unavoidable for WS client role). See module-level doc for the full
169 // copy budget analysis.
170 let frame = fastwebsockets::Frame::binary(fastwebsockets::Payload::Borrowed(&payload));
171 self.ws.write_frame(frame).await?;
172 Ok(())
173 }
174
175 async fn write_pong(&mut self, payload: Bytes) -> Result<(), TransportError> {
176 // same lifetime reasoning as write_binary: payload is owned by this
177 // stack frame, borrow is valid across the write_frame await.
178 let frame = fastwebsockets::Frame::pong(fastwebsockets::Payload::Borrowed(&payload));
179 self.ws.write_frame(frame).await?;
180 Ok(())
181 }
182
183 async fn flush(&mut self) -> Result<(), TransportError> {
184 // no-op. fastwebsockets writes through AsyncWrite::write_all (for
185 // small frames) or write_vectored (for frames > writev_threshold),
186 // both of which guarantee data has been written to the underlying
187 // stream before returning.
188 //
189 // no application-level buffer exists at this layer:
190 //
191 // - fastwebsockets has no internal write queue. write_frame writes directly to
192 // the stream and returns only after write_all completes.
193 //
194 // - the TLS layer (openssl, configured by kube-rs) emits a TLS record per write
195 // call. There is no record-coalescing buffer that flush() would drain.
196 //
197 // - the TCP layer has TCP_NODELAY set (in client.rs socket config), disabling
198 // Nagle's algorithm. Data enters the kernel send buffer and is transmitted
199 // immediately.
200 //
201 // if a future change introduces a buffering layer between this
202 // adapter and the socket (e.g., BufWriter for write coalescing),
203 // this method must be updated to propagate flush.
204 Ok(())
205 }
206
207 async fn close(&mut self) -> Result<(), TransportError> {
208 let frame = fastwebsockets::Frame::close(1000, &[]);
209 self.ws.write_frame(frame).await?;
210 Ok(())
211 }
212}
213
214/// fastwebsockets read half adapter.
215///
216/// Wraps `FragmentCollectorRead<ReadHalf<S>>` for safe handling of any
217/// unexpected WS fragmentation (negligible overhead in the common non-
218/// fragmented case: one branch per frame, no allocation).
219///
220/// `auto_pong` and `auto_close` are disabled. PING and Close frames are
221/// surfaced as [`WsMessage::Ping`] and [`WsMessage::Close`] so the mux
222/// reader handles them through the existing command path (SendWsPong,
223/// break loop). Pong, Text, and Continuation frames are absorbed in the
224/// read loop; the mux never sees them.
225pub struct FastWsReader<S: AsyncRead + Unpin + Send> {
226 ws: fastwebsockets::FragmentCollectorRead<S>,
227}
228
229impl<S: AsyncRead + Unpin + Send> FastWsReader<S> {
230 pub const fn new(ws: fastwebsockets::FragmentCollectorRead<S>) -> Self {
231 Self { ws }
232 }
233}
234
235impl<S: AsyncRead + Unpin + Send> WsFrameReader for FastWsReader<S> {
236 async fn read_message(&mut self) -> Option<Result<WsMessage, TransportError>> {
237 // loop absorbs skip-class frames (Pong, Text, Continuation).
238 // the mux reader's idle timer resets on any successful return,
239 // so absorbed frames still count as activity.
240 loop {
241 // no-op send_fn: auto_pong and auto_close are disabled, so
242 // the callback is never invoked. PING/PONG is handled at the
243 // mux level via MuxCommand::SendWsPong.
244 let frame = match self
245 .ws
246 .read_frame(&mut |_| async { Ok::<(), fastwebsockets::WebSocketError>(()) })
247 .await
248 {
249 Ok(f) => f,
250 // ConnectionClose: peer initiated a clean close, treat as EOF.
251 Err(fastwebsockets::WebSocketError::ConnectionClosed) => return None,
252 // all other errors (IoError, protocol violations) are
253 // transport failures. Both EOF (None) and transport error
254 // (Some(Err)) reach the same mux teardown: the reader breaks
255 // its loop, calls cancel.cancel(), the supervisor fires,
256 // pending_replies fail with MuxClosed, and send windows are
257 // poisoned. The distinction affects only the log level
258 // (debug for EOF, warn for error).
259 Err(e) => return Some(Err(TransportError::FastWebSocket(e))),
260 };
261
262 match frame.opcode {
263 fastwebsockets::OpCode::Binary => {
264 // zero-copy path: fastwebsockets stores read payloads as
265 // Payload::Bytes(BytesMut) via split_to from internal
266 // buffer. freeze() converts to Bytes via refcount bump,
267 // O(1), no copy.
268 let bytes = payload_to_bytes(frame.payload);
269 return Some(Ok(WsMessage::Binary(bytes)));
270 }
271 fastwebsockets::OpCode::Ping => {
272 let bytes = payload_to_bytes(frame.payload);
273 return Some(Ok(WsMessage::Ping(bytes)));
274 }
275 fastwebsockets::OpCode::Close => return Some(Ok(WsMessage::Close)),
276 // skip-class: absorb and re-read. the mux reader's idle
277 // timer resets on any successful read_message() return,
278 // not on specific variants.
279 fastwebsockets::OpCode::Pong => {}
280 fastwebsockets::OpCode::Text | fastwebsockets::OpCode::Continuation => {
281 tracing::warn!(
282 opcode = ?frame.opcode,
283 "fastwebsockets reader: unexpected opcode from K8s apiserver, \
284 absorbing (SPDY tunnel expects only Binary frames)"
285 );
286 }
287 }
288 }
289 }
290}
291
292/// Extract `Bytes` from a fastwebsockets `Payload` with minimal copying.
293///
294/// - `Payload::Bytes(BytesMut)`: `freeze()`, O(1) zero-copy.
295/// - `Payload::Owned(Vec<u8>)`: `Bytes::from(vec)`, O(1) takes ownership.
296/// - `Payload::Borrowed` / `BorrowedMut`: forces a copy. Should not happen on
297/// the read path since fastwebsockets always returns `Payload::Bytes` for
298/// data read from the socket.
299fn payload_to_bytes(payload: fastwebsockets::Payload<'_>) -> Bytes {
300 match payload {
301 fastwebsockets::Payload::Bytes(bm) => bm.freeze(),
302 fastwebsockets::Payload::Owned(v) => Bytes::from(v),
303 fastwebsockets::Payload::Borrowed(b) => Bytes::copy_from_slice(b),
304 fastwebsockets::Payload::BorrowedMut(b) => Bytes::copy_from_slice(b),
305 }
306}
307
308/// Construct fastwebsockets writer/reader adapters from a raw upgraded
309/// HTTP connection.
310///
311/// Uses [`fastwebsockets::after_handshake_split`] to create pre-split
312/// read/write halves from the already-upgraded stream. Configures the
313/// WebSocket for SPDY tunnel use:
314///
315/// - `auto_pong = false`: PING/PONG handled at the mux level.
316/// - `auto_close = false`: Close handled at the mux level.
317/// - `set_auto_apply_mask = true` (default): Client role masking.
318/// - `set_writev = true` (default): Vectored I/O for large frames.
319pub fn split_fastws<S>(
320 stream: S,
321) -> (
322 FastWsWriter<tokio::io::WriteHalf<S>>,
323 FastWsReader<tokio::io::ReadHalf<S>>,
324)
325where
326 S: AsyncRead + AsyncWrite + Unpin + Send,
327{
328 let (read_half, write_half) = tokio::io::split(stream);
329
330 let (mut ws_read, ws_write) =
331 fastwebsockets::after_handshake_split(read_half, write_half, fastwebsockets::Role::Client);
332
333 // disable auto_pong/auto_close on the read half so PING and Close
334 // frames surface as WsMessage variants for the mux to handle.
335 ws_read.set_auto_pong(false);
336 ws_read.set_auto_close(false);
337
338 let frag_read = fastwebsockets::FragmentCollectorRead::new(ws_read);
339
340 (FastWsWriter { ws: ws_write }, FastWsReader::new(frag_read))
341}
342
343/// Read buffer size for the raw SPDY transport. Sized to drain a typical
344/// 256 KiB socket receive buffer in a few reads while keeping the per-task
345/// stack allocation modest.
346const RAW_READ_BUF_SIZE: usize = 16 * 1024;
347
348/// Raw-transport write half: writes SPDY frame bytes directly to the
349/// underlying [`AsyncWrite`] with no WebSocket framing.
350///
351/// Used when SPDY/3.1 flows over an HTTP/1.1 upgraded connection (the
352/// legacy `kubectl port-forward` wire protocol). Each `write_binary` call
353/// writes one full SPDY frame.
354pub struct RawSpdyWriter<W: AsyncWrite + Unpin + Send> {
355 inner: W,
356}
357
358impl<W: AsyncWrite + Unpin + Send> RawSpdyWriter<W> {
359 pub const fn new(inner: W) -> Self {
360 Self { inner }
361 }
362}
363
364impl<W: AsyncWrite + Unpin + Send> WsFrameWriter for RawSpdyWriter<W> {
365 async fn write_binary(&mut self, payload: Bytes) -> Result<(), TransportError> {
366 // SPDY frames are self-delimiting (8-byte header carries the
367 // payload length), so writing the encoded frame bytes verbatim
368 // is correct.
369 self.inner.write_all(&payload).await?;
370 Ok(())
371 }
372
373 async fn write_pong(&mut self, _payload: Bytes) -> Result<(), TransportError> {
374 // raw SPDY has no WebSocket-level PING/PONG. the mux only invokes
375 // this in response to a `WsMessage::Ping`, which the raw reader
376 // never produces, so this path is unreachable in practice.
377 Ok(())
378 }
379
380 async fn flush(&mut self) -> Result<(), TransportError> {
381 self.inner.flush().await?;
382 Ok(())
383 }
384
385 async fn close(&mut self) -> Result<(), TransportError> {
386 self.inner.shutdown().await?;
387 Ok(())
388 }
389}
390
391/// Raw-transport read half: reads SPDY frame bytes from the underlying
392/// [`AsyncRead`] and yields them as [`WsMessage::Binary`] chunks. The mux
393/// reader accumulates the chunks into its own buffer and decodes complete
394/// SPDY frames from there, so chunk boundaries need not align with frame
395/// boundaries.
396pub struct RawSpdyReader<R: AsyncRead + Unpin + Send> {
397 inner: R,
398 buf: BytesMut,
399}
400
401impl<R: AsyncRead + Unpin + Send> RawSpdyReader<R> {
402 pub fn new(inner: R) -> Self {
403 Self {
404 inner,
405 buf: BytesMut::with_capacity(RAW_READ_BUF_SIZE),
406 }
407 }
408}
409
410impl<R: AsyncRead + Unpin + Send> WsFrameReader for RawSpdyReader<R> {
411 async fn read_message(&mut self) -> Option<Result<WsMessage, TransportError>> {
412 // ensure the buffer has spare capacity. read_buf appends in-place
413 // and grows the buffer if needed.
414 if self.buf.capacity() == self.buf.len() {
415 self.buf.reserve(RAW_READ_BUF_SIZE);
416 }
417 match self.inner.read_buf(&mut self.buf).await {
418 Ok(0) => None,
419 Ok(_) => {
420 let chunk = self.buf.split().freeze();
421 Some(Ok(WsMessage::Binary(chunk)))
422 }
423 Err(e) => Some(Err(TransportError::Io(e))),
424 }
425 }
426}
427
428/// Construct raw SPDY writer/reader adapters from an already-upgraded
429/// connection. Use this for the legacy `Upgrade: SPDY/3.1` path where the
430/// apiserver speaks raw SPDY frames (no WebSocket envelope).
431pub fn split_raw_spdy<S>(
432 stream: S,
433) -> (
434 RawSpdyWriter<tokio::io::WriteHalf<S>>,
435 RawSpdyReader<tokio::io::ReadHalf<S>>,
436)
437where
438 S: AsyncRead + AsyncWrite + Unpin + Send,
439{
440 let (read_half, write_half) = tokio::io::split(stream);
441 (
442 RawSpdyWriter::new(write_half),
443 RawSpdyReader::new(read_half),
444 )
445}