Skip to main content

sozu_lib/protocol/mux/
stream.rs

1//! Per-request stream state shared by the H1 and H2 mux paths.
2//!
3//! A [`Stream`] owns the front/back kawa buffers, HTTP context, and metrics
4//! for a single request/response pair. [`StreamParts`] splits it along the
5//! read/write axis so callers can borrow both sides of the pipe at the same
6//! time without fighting the borrow checker.
7
8use std::{
9    cell::RefCell,
10    fmt::Debug,
11    rc::{Rc, Weak},
12    time::Duration,
13};
14
15use mio::Token;
16use sozu_command::logging::ansi_palette;
17
18use super::{GenericHttpStream, Position};
19use crate::metrics::names;
20use crate::{
21    L7ListenerHandler, ListenerHandler, Protocol, SessionMetrics, pool::Pool,
22    protocol::http::editor::HttpContext,
23};
24
25/// Module-level prefix used on every log line emitted from the stream module.
26/// Streams have no direct peer reference so a single `MUX-STREAM` label is
27/// used, colored bold bright-white (uniform across every protocol) when the
28/// logger supports ANSI.
29macro_rules! log_module_context {
30    () => {{
31        let (open, reset, _, _, _) = ansi_palette();
32        format!("{open}MUX-STREAM{reset}\t >>>", open = open, reset = reset)
33    }};
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum StreamState {
38    Idle,
39    /// the Stream is asking for connection, this will trigger a call to connect
40    Link,
41    /// the Stream is linked to a Client (note that the client might not be connected)
42    Linked(Token),
43    /// the Stream was linked to a Client, but the connection closed, the client was removed
44    /// and this Stream could not be retried (it should be terminated)
45    Unlinked,
46    /// the Stream is unlinked and can be reused
47    Recycle,
48}
49
50impl StreamState {
51    pub fn is_open(&self) -> bool {
52        !matches!(self, StreamState::Idle | StreamState::Recycle)
53    }
54}
55
56pub struct Stream {
57    pub window: i32,
58    pub attempts: u8,
59    pub state: StreamState,
60    /// True when the frontend connection has received end_of_stream from the client.
61    pub front_received_end_of_stream: bool,
62    /// True when the backend connection has received end_of_stream from the backend server.
63    pub back_received_end_of_stream: bool,
64    /// Tracks total DATA payload bytes received on the frontend for content-length validation (RFC 9113 §8.1.1)
65    pub front_data_received: usize,
66    /// Tracks total DATA payload bytes received on the backend for content-length validation (RFC 9113 §8.1.1)
67    pub back_data_received: usize,
68    /// True when `gauge_add!(names::http::ACTIVE_REQUESTS, 1)` was emitted for this stream.
69    /// Prevents underflow when `generate_access_log` is called for streams that never
70    /// had their request fully parsed (idle timeouts, malformed requests).
71    pub request_counted: bool,
72    pub front: GenericHttpStream,
73    pub back: GenericHttpStream,
74    pub context: HttpContext,
75    pub metrics: SessionMetrics,
76}
77
78struct KawaSummary<'a>(&'a GenericHttpStream);
79impl Debug for KawaSummary<'_> {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("Kawa")
82            .field("kind", &self.0.kind)
83            .field("parsing_phase", &self.0.parsing_phase)
84            .field("body_size", &self.0.body_size)
85            .field("consumed", &self.0.consumed)
86            .field("expects", &self.0.expects)
87            .field("blocks", &self.0.blocks.len())
88            .field("out", &self.0.out.len())
89            .field("storage_start", &self.0.storage.start)
90            .field("storage_head", &self.0.storage.head)
91            .field("storage_end", &self.0.storage.end)
92            .finish()
93    }
94}
95impl Debug for Stream {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("Stream")
98            .field("window", &self.window)
99            .field("attempts", &self.attempts)
100            .field("state", &self.state)
101            .field(
102                "front_received_end_of_stream",
103                &self.front_received_end_of_stream,
104            )
105            .field(
106                "back_received_end_of_stream",
107                &self.back_received_end_of_stream,
108            )
109            .field("front_data_received", &self.front_data_received)
110            .field("back_data_received", &self.back_data_received)
111            .field("request_counted", &self.request_counted)
112            .field("front", &KawaSummary(&self.front))
113            .field("back", &KawaSummary(&self.back))
114            .field("context", &self.context)
115            .field("metrics", &self.metrics)
116            .finish()
117    }
118}
119
120/// This struct allows to mutably borrow the read and write buffers (dependant on the position)
121/// as well as the context and metrics of a Stream at the same time
122pub struct StreamParts<'a> {
123    pub window: &'a mut i32,
124    pub rbuffer: &'a mut GenericHttpStream,
125    pub wbuffer: &'a mut GenericHttpStream,
126    /// Tracks whether end_of_stream has been received on the read side of this connection.
127    pub received_end_of_stream: &'a mut bool,
128    /// Tracks total DATA payload bytes received on the read side (for content-length validation).
129    pub data_received: &'a mut usize,
130    pub context: &'a mut HttpContext,
131    pub metrics: &'a mut SessionMetrics,
132}
133
134impl Stream {
135    pub fn new(pool: Weak<RefCell<Pool>>, context: HttpContext, window: u32) -> Option<Self> {
136        let (front_buffer, back_buffer) = {
137            let pool = pool.upgrade()?;
138            let mut pool = pool.borrow_mut();
139            match (pool.checkout(), pool.checkout()) {
140                (Some(front_buffer), Some(back_buffer)) => (front_buffer, back_buffer),
141                _ => return None,
142            }
143        };
144        let stream = Self {
145            state: StreamState::Idle,
146            attempts: 0,
147            window: i32::try_from(window).unwrap_or(i32::MAX),
148            front_received_end_of_stream: false,
149            back_received_end_of_stream: false,
150            front_data_received: 0,
151            back_data_received: 0,
152            request_counted: false,
153            front: GenericHttpStream::new(kawa::Kind::Request, kawa::Buffer::new(front_buffer)),
154            back: GenericHttpStream::new(kawa::Kind::Response, kawa::Buffer::new(back_buffer)),
155            context,
156            metrics: SessionMetrics::new(None),
157        };
158        // Post: a freshly checked-out stream is a clean, closed slot — no
159        // request has been counted yet (so `generate_access_log` won't
160        // gauge-underflow `http.active_requests`) and no DATA has been seen on
161        // either half (the content-length reconciliation counters start at 0).
162        debug_assert_eq!(stream.state, StreamState::Idle, "new stream must be Idle");
163        debug_assert!(
164            !stream.state.is_open(),
165            "an Idle stream slot must not report as open"
166        );
167        debug_assert!(
168            !stream.request_counted,
169            "new stream must not have a counted request (gauge-underflow guard)"
170        );
171        debug_assert_eq!(
172            (stream.front_data_received, stream.back_data_received),
173            (0, 0),
174            "new stream DATA counters must start at 0"
175        );
176        #[cfg(debug_assertions)]
177        stream.check_invariants();
178        Some(stream)
179    }
180
181    /// Cross-field invariant sweep for the per-request stream state machine.
182    ///
183    /// Encodes the relationships that must hold for ANY `Stream` regardless of
184    /// the mux path (H1 or H2) that drives it:
185    /// - `state.is_open()` agrees with the `Idle`/`Recycle` discriminants
186    ///   (the open/closed split is the load-bearing predicate for shutdown and
187    ///   slot reuse).
188    /// - a `Recycle` slot is fully reset — no counted request can be left
189    ///   pending on a slot advertised as reusable, or `create_stream` would
190    ///   resurrect a stale `http.active_requests` charge.
191    /// - a `Linked` stream names a backend token; the `Linked(token)`
192    ///   discriminant and `linked_token()` must agree (the access-log and
193    ///   reverse-index lookups both depend on this equivalence).
194    ///
195    /// Compiled only with `debug_assertions`; the optimizer drops every call
196    /// in release. Network input never reaches a hard `assert!` here — these
197    /// fire only on our own logic bugs.
198    #[cfg(debug_assertions)]
199    pub(super) fn check_invariants(&self) {
200        debug_assert_eq!(
201            self.state.is_open(),
202            !matches!(self.state, StreamState::Idle | StreamState::Recycle),
203            "is_open() must agree with the Idle/Recycle discriminants"
204        );
205        if self.state == StreamState::Recycle {
206            debug_assert!(
207                !self.request_counted,
208                "a Recycle slot must not carry a counted request (active-requests leak)"
209            );
210        }
211        // `linked_token()` is the canonical accessor for the backend token; it
212        // must return Some iff the slot is `Linked`, since the reverse index
213        // and the access-log RTT lookup both branch on it.
214        debug_assert_eq!(
215            self.linked_token().is_some(),
216            matches!(self.state, StreamState::Linked(_)),
217            "linked_token() must be Some iff the stream is Linked"
218        );
219    }
220    /// Convenience accessor for the backend token when the stream is `Linked`.
221    /// Used by access-log emission sites to look up the backend socket on the
222    /// owning `Endpoint`/`Router` without re-pattern-matching `state` inline.
223    pub fn linked_token(&self) -> Option<Token> {
224        match self.state {
225            StreamState::Linked(token) => Some(token),
226            _ => None,
227        }
228    }
229
230    /// Returns true when both front and back kawa buffers are in a terminal
231    /// or initial state with no pending data. Used during shutdown to skip
232    /// streams that have already completed their work.
233    pub fn is_quiesced(&self) -> bool {
234        let front_done =
235            (self.front.is_initial() || self.front.is_completed() || self.front.is_terminated())
236                && self.front.storage.is_empty();
237        let back_done =
238            (self.back.is_initial() || self.back.is_completed() || self.back.is_terminated())
239                && self.back.storage.is_empty();
240        front_done && back_done
241    }
242
243    pub fn split(&mut self, position: &Position) -> StreamParts<'_> {
244        // Pre: the front buffer always parses requests and the back buffer
245        // always parses responses. `split` only re-labels them as read/write
246        // for the caller's position — it must never swap their kawa kinds.
247        debug_assert_eq!(
248            self.front.kind,
249            kawa::Kind::Request,
250            "front buffer must hold a Request kawa"
251        );
252        debug_assert_eq!(
253            self.back.kind,
254            kawa::Kind::Response,
255            "back buffer must hold a Response kawa"
256        );
257        match position {
258            Position::Client(..) => StreamParts {
259                window: &mut self.window,
260                rbuffer: &mut self.back,
261                wbuffer: &mut self.front,
262                received_end_of_stream: &mut self.back_received_end_of_stream,
263                data_received: &mut self.back_data_received,
264                context: &mut self.context,
265                metrics: &mut self.metrics,
266            },
267            Position::Server => StreamParts {
268                window: &mut self.window,
269                rbuffer: &mut self.front,
270                wbuffer: &mut self.back,
271                received_end_of_stream: &mut self.front_received_end_of_stream,
272                data_received: &mut self.front_data_received,
273                context: &mut self.context,
274                metrics: &mut self.metrics,
275            },
276        }
277    }
278    /// Emit the access log for this stream.
279    ///
280    /// `client_rtt`/`server_rtt` are passed in by the caller because the
281    /// `Stream` does not own a socket reference — the frontend socket lives
282    /// on the parent `Mux`/connection and the backend socket lives on
283    /// `Router.backends.get(token)`. Each caller snapshots the two
284    /// `getsockopt(TCP_INFO)` values from the sockets it can reach, mirroring
285    /// the inline pattern used by the `kawa_h1`, `pipe`, and TCP-frontend
286    /// access-log sites.
287    pub fn generate_access_log<L>(
288        &mut self,
289        error: bool,
290        message: Option<&str>,
291        listener: Rc<RefCell<L>>,
292        client_rtt: Option<Duration>,
293        server_rtt: Option<Duration>,
294    ) where
295        L: ListenerHandler + L7ListenerHandler,
296    {
297        let context = &self.context;
298        // Fall back to the per-stream timeout discriminator
299        // (`access_log_message`) when the caller did not supply an explicit
300        // `message`. The discriminator is set by `MuxState::timeout` before
301        // `set_default_answer` / `forcefully_terminate_answer` so the
302        // access log can distinguish a timeout-driven 408/504 from a
303        // backend-error 504. Caller-supplied `message` (e.g. parsing
304        // errors) takes precedence when both are present.
305        let message = message.or(context.access_log_message);
306        // Pair the `http.active_requests` gauge `-1` with `request_counted`:
307        // it must transition true -> false exactly once so a re-entry (H1
308        // keep-alive, double access-log on the same stream) cannot
309        // double-decrement the gauge into underflow. `request_counted` is set
310        // true at the matching `gauge_add!(.., 1)` in the H1/H2 readable paths.
311        let was_counted = self.request_counted;
312        if self.request_counted {
313            gauge_add!(names::http::ACTIVE_REQUESTS, -1);
314            self.request_counted = false;
315        }
316        debug_assert!(
317            !self.request_counted,
318            "generate_access_log must leave request_counted false (gauge-underflow guard)"
319        );
320        // The flag may only move true->false here (one `-1`); it must never be
321        // observed flipping back on within this call.
322        debug_assert!(
323            was_counted >= self.request_counted,
324            "request_counted must only clear here, never spontaneously set"
325        );
326        if error {
327            // Labelled with `(cluster_id, backend_id)`; see the matching
328            // emission in `kawa_h1::log_request_error` for the cardinality
329            // contract (`metrics::filter_labels_for_detail`).
330            incr!(
331                "http.errors",
332                context.cluster_id.as_deref(),
333                context.backend_id.as_deref()
334            );
335        }
336        let protocol = match context.protocol {
337            Protocol::HTTP => "http",
338            Protocol::HTTPS => "https",
339            other => {
340                error!(
341                    "{} mux streams only handle HTTP or HTTPS protocols, got {:?}",
342                    log_module_context!(),
343                    other
344                );
345                "unknown"
346            }
347        };
348
349        // Save the HTTP status code of the backend response. Emits the bucket
350        // counter unconditionally, plus the per-code counter from
351        // `crate::metrics::http_status_code_metric_name` when the status is on
352        // the short-list shared with the H1 path (`save_http_status_metric`).
353        let bucket_key = if let Some(status) = context.status {
354            match status {
355                100..=199 => names::http::STATUS_1XX,
356                200..=299 => names::http::STATUS_2XX,
357                300..=399 => names::http::STATUS_3XX,
358                400..=499 => names::http::STATUS_4XX,
359                500..=599 => names::http::STATUS_5XX,
360                _ => names::http::STATUS_OTHER,
361            }
362        } else {
363            "http.status.none"
364        };
365        incr!(
366            bucket_key,
367            context.cluster_id.as_deref(),
368            context.backend_id.as_deref()
369        );
370
371        if let Some(status) = context.status
372            && let Some(per_code) = crate::metrics::http_status_code_metric_name(status)
373        {
374            incr!(
375                per_code,
376                context.cluster_id.as_deref(),
377                context.backend_id.as_deref()
378            );
379        }
380
381        let endpoint = sozu_command::logging::EndpointRecord::Http {
382            method: context.method.as_deref(),
383            authority: context.authority.as_deref(),
384            path: context.path.as_deref(),
385            reason: context.reason.as_deref(),
386            status: context.status,
387        };
388
389        let listener = listener.borrow();
390        let tags = context.authority.as_deref().and_then(|host| {
391            let hostname = match host.split_once(':') {
392                None => host,
393                Some((hostname, _)) => hostname,
394            };
395            listener.get_tags(hostname)
396        });
397
398        log_access! {
399            error,
400            on_failure: { incr!(names::access_logs::UNSENT) },
401            message,
402            context: context.log_context(),
403            session_address: context.session_address,
404            backend_address: context.backend_address,
405            protocol,
406            endpoint,
407            tags,
408            client_rtt,
409            server_rtt,
410            service_time: self.metrics.service_time(),
411            response_time: self.metrics.backend_response_time(),
412            request_time: self.metrics.request_time(),
413            start_time_ns: self.metrics.start_wall_ns(),
414            bytes_in: self.metrics.bin,
415            bytes_out: self.metrics.bout,
416            user_agent: context.user_agent.as_deref(),
417            x_request_id: context.x_request_id.as_deref(),
418            tls_version: context.tls_version,
419            tls_cipher: context.tls_cipher,
420            tls_sni: context.tls_server_name.as_deref(),
421            tls_alpn: context.tls_alpn,
422            xff_chain: context.xff_chain.as_deref(),
423            #[cfg(feature = "opentelemetry")]
424            otel: context.otel.as_ref(),
425            #[cfg(not(feature = "opentelemetry"))]
426            otel: None,
427        };
428        self.metrics.register_end_of_session(&context.log_context());
429    }
430}