Skip to main content

sozu_lib/protocol/tcp_preread/
shell.rs

1//! I/O shell wiring the sans-io [`SniPrereadCore`] into the TCP session
2//! lifecycle (issue #1279).
3//!
4//! [`SniPreread`] is the socket-owning half of the split described in the
5//! module doc one level up: it accumulates bytes from the raw frontend
6//! socket into the session's own [`Checkout`] (NEVER `consume()`d while
7//! undecided -- byte-for-byte replay to the backend is the whole point),
8//! re-feeds the full accumulated window to [`SniPrereadCore::handle_input`]
9//! on every read, and -- once routed -- stays parked in this state through
10//! backend connect. This mirrors `RelayProxyProtocol`, not `Pipe`:
11//! `Pipe::set_back_socket` does not re-arm inherited buffer writes, so a
12//! premature switch to `Pipe` before the backend socket exists would strand
13//! the connect handshake.
14//!
15//! This module intentionally emits its OWN `tcp.sni_preread.*` decision
16//! metrics (routed / `rejected.<reason>`) and log lines, but does **not** own
17//! the `tcp.sni_preread.active` gauge lifecycle or the eventual state
18//! transition out of `SniPreread` -- both live in `lib/src/tcp.rs` (gauge:
19//! +1 on entry in `TcpSession::new_sni_preread`, -1 in
20//! `TcpSession::upgrade_sni_preread` on the upgrade exit or in
21//! `TcpSession::close`'s `StateMarker::SniPreread` arm on the
22//! reject/teardown exits; transition: `TcpSession::upgrade_sni_preread`),
23//! which alone has the `TcpProxy`/`TcpListener` context (per-cluster
24//! `proxy_protocol`, buffers, listener) needed to pick the right next state
25//! and guarantee the gauge is adjusted exactly once per session, on exactly
26//! one of its three exits (reject / upgrade / teardown).
27
28use std::{net::SocketAddr, time::Instant};
29
30use mio::{Token, net::TcpStream};
31use rusty_ulid::Ulid;
32
33use super::{AlpnMatcher, Input, Output, PrereadConfig, SniPrereadCore};
34use crate::{
35    Readiness, SessionMetrics, SessionResult,
36    metrics::names,
37    pool::Checkout,
38    socket::{SocketHandler, SocketResult},
39    sozu_command::{ready::Ready, state::ClusterId},
40};
41
42/// Per-session prefix for log lines emitted with a [`SniPreread`] in scope.
43/// Renders the canonical `TCP-SNI\tSession(...)\t >>>` envelope, reusing
44/// the `TCP-SNI` tag already established by the core's own `log_context!`
45/// (`tcp_preread/mod.rs`) so operators can grep core decisions and shell
46/// orchestration together.
47macro_rules! log_context {
48    ($self:expr) => {{
49        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
50        format!(
51            "{open}TCP-SNI{reset}\t{grey}Session{reset}({gray}frontend{reset}={white}{frontend}{reset})\t >>>",
52            open = open,
53            reset = reset,
54            grey = grey,
55            gray = gray,
56            white = white,
57            frontend = $self.frontend_token.0,
58        )
59    }};
60}
61
62/// Captured exactly once, when [`Output::Routed`] first fires. The core's
63/// own `decided` latch guarantees the SAME terminal verdict replays on every
64/// subsequent `handle_input` call, so [`SniPreread::outcome`] never changes
65/// once `Some`.
66#[derive(Debug, Clone)]
67pub struct RoutedOutcome {
68    pub cluster: ClusterId,
69    pub content_offset: usize,
70    pub proxy_source: Option<SocketAddr>,
71    pub sni: String,
72    pub alpn: Vec<Vec<u8>>,
73    /// The matched route's configured pattern (trie key) -- `*.example.com`
74    /// for a wildcard route, not the client's concrete SNI. Together with
75    /// `matched_alpn` this is the matched FRONTEND's identity, which
76    /// `TcpSession::upgrade_sni_preread` uses to rebuild the per-frontend
77    /// access-log tags key (`sni_tags_key` in `lib/src/tcp.rs`).
78    pub matched_sni_pattern: String,
79    /// Clone of the winning route entry's [`AlpnMatcher`].
80    pub matched_alpn: AlpnMatcher,
81}
82
83/// TCP session state that owns the frontend socket and the accumulating
84/// [`Checkout`] while [`SniPrereadCore`] decides a route. See the module doc
85/// for the full lifecycle and the exit-accounting contract for
86/// `tcp.sni_preread.active`.
87pub struct SniPreread<Front: SocketHandler> {
88    pub frontend: Front,
89    pub frontend_token: Token,
90    pub frontend_readiness: Readiness,
91    pub backend_readiness: Readiness,
92    pub backend: Option<TcpStream>,
93    pub backend_token: Option<Token>,
94    pub request_id: Ulid,
95    /// The session's own frontend accumulator, growing from wire offset 0.
96    /// NEVER `consume()`d while undecided.
97    pub frontend_buffer: Checkout,
98    /// The listener's `sni_preread_max_bytes` knob (default 16 384) clamped
99    /// to `frontend_buffer.capacity()` and floored at the 5-byte TLS
100    /// record-header minimum -- see `effective_sni_preread_max_bytes` in
101    /// `lib/src/tcp.rs`. Captured once at construction: the buffer's
102    /// capacity is fixed for its lifetime, so this never needs re-deriving.
103    effective_max_bytes: usize,
104    core: SniPrereadCore,
105    outcome: Option<RoutedOutcome>,
106    started_at: Instant,
107}
108
109impl<Front: SocketHandler> SniPreread<Front> {
110    /// Instantiate a new SniPreread SessionState with:
111    /// - frontend_interest: READABLE | HUP | ERROR
112    /// - backend_interest: HUP | ERROR (WRITABLE armed once routed)
113    pub fn new(
114        frontend: Front,
115        frontend_token: Token,
116        request_id: Ulid,
117        frontend_buffer: Checkout,
118        effective_max_bytes: usize,
119    ) -> Self {
120        SniPreread {
121            frontend,
122            frontend_token,
123            frontend_readiness: Readiness {
124                interest: Ready::READABLE | Ready::HUP | Ready::ERROR,
125                event: Ready::EMPTY,
126            },
127            backend_readiness: Readiness {
128                interest: Ready::HUP | Ready::ERROR,
129                event: Ready::EMPTY,
130            },
131            backend: None,
132            backend_token: None,
133            request_id,
134            frontend_buffer,
135            effective_max_bytes,
136            core: SniPrereadCore::new(),
137            outcome: None,
138            started_at: Instant::now(),
139        }
140    }
141
142    pub fn effective_max_bytes(&self) -> usize {
143        self.effective_max_bytes
144    }
145
146    pub fn outcome(&self) -> Option<&RoutedOutcome> {
147        self.outcome.as_ref()
148    }
149
150    pub fn is_routed(&self) -> bool {
151        self.outcome.is_some()
152    }
153
154    pub fn started_at(&self) -> Instant {
155        self.started_at
156    }
157
158    /// Bytes already accumulated from the frontend. Distinguishes a genuine
159    /// mid-preread abort (bytes were seen) from a bare TCP health-check
160    /// connect/FIN with no bytes at all (cf. `expect.rs`'s identical guard).
161    pub fn has_received_bytes(&self) -> bool {
162        self.frontend_buffer.available_data() > 0
163    }
164
165    pub fn front_socket(&self) -> &TcpStream {
166        self.frontend.socket_ref()
167    }
168
169    pub fn back_socket_mut(&mut self) -> Option<&mut TcpStream> {
170        self.backend.as_mut()
171    }
172
173    pub fn set_back_socket(&mut self, socket: TcpStream) {
174        self.backend = Some(socket);
175    }
176
177    pub fn set_back_token(&mut self, token: Token) {
178        self.backend_token = Some(token);
179    }
180
181    /// Read available bytes and feed them to the core. Emits the
182    /// `tcp.sni_preread.routed` / `tcp.sni_preread.rejected.<reason>`
183    /// metrics and log lines itself; the caller (`TcpSession::readable` in
184    /// `lib/src/tcp.rs`) only needs to react to the returned
185    /// [`SessionResult`] and, on a fresh route, sync `TcpSession::cluster_id`
186    /// from [`Self::outcome`].
187    pub fn readable(
188        &mut self,
189        metrics: &mut SessionMetrics,
190        cfg: &PrereadConfig<'_>,
191    ) -> SessionResult {
192        if self.outcome.is_some() {
193            // Already decided -- and deliberately not reading: bytes the
194            // client sends past the routed window must stay in the kernel
195            // socket buffer for the post-upgrade state to consume. Defensive
196            // only since sozu-proxy/sozu#1373: the `Output::Routed` arm of
197            // `handle_output` drops frontend-readable INTEREST at the same
198            // moment it latches the outcome, so `TcpSession::ready_inner`
199            // stops selecting this dispatch entirely. Returning `Continue`
200            // without consuming the (still latched, deliberately preserved)
201            // READABLE event is what spun the session to
202            // `MAX_LOOP_ITERATIONS` while the backend was still connecting.
203            return SessionResult::Continue;
204        }
205
206        // Enforce `effective_max_bytes` as a HARD read bound, not merely an
207        // advisory the core applies on its NeedMore path. The frontend
208        // `Checkout` is sized to the pool buffer (`buffer_size`, 16 393
209        // bytes by default), far larger than a tight
210        // `sni_preread_max_bytes`; draining all of it would read an
211        // oversized-but-COMPLETE ClientHello in full, which the core then
212        // routes (it caps only would-be-NeedMore windows -- see
213        // `mod.rs::need_more_or_too_large`). Capping the read makes an
214        // over-cap hello reach the core INCOMPLETE with `buf.len() ==
215        // max_bytes`, the exact `TooLarge` reject condition. A hello that
216        // fits within the cap still routes, and any coalesced bytes past
217        // the cap stay in the kernel socket buffer for the `Pipe` to
218        // replay byte-for-byte.
219        let cap_remaining = self
220            .effective_max_bytes
221            .saturating_sub(self.frontend_buffer.available_data());
222        let space_before = self.frontend_buffer.available_space();
223        let read_len = space_before.min(cap_remaining);
224        let (sz, socket_result) = self
225            .frontend
226            .socket_read(&mut self.frontend_buffer.space()[..read_len]);
227        // The socket can only write into the free space it was handed.
228        debug_assert!(
229            sz <= read_len,
230            "socket_read cannot return more bytes than the capped space it was handed"
231        );
232
233        if sz > 0 {
234            let data_before = self.frontend_buffer.available_data();
235            self.frontend_buffer.fill(sz);
236            debug_assert_eq!(
237                self.frontend_buffer.available_data(),
238                data_before + sz,
239                "fill must expose exactly the bytes just read"
240            );
241
242            count!(names::backend::BYTES_IN, sz as i64);
243            metrics.bin += sz;
244
245            if socket_result == SocketResult::Error {
246                return self.front_gone(cfg);
247            }
248            if socket_result == SocketResult::WouldBlock {
249                self.frontend_readiness.event.remove(Ready::READABLE);
250            }
251
252            let output = self.core.handle_input(
253                cfg,
254                Input::Bytes {
255                    buf: self.frontend_buffer.data(),
256                    now: Instant::now(),
257                },
258            );
259            return self.handle_output(output);
260        }
261
262        match socket_result {
263            SocketResult::Error | SocketResult::Closed => self.front_gone(cfg),
264            SocketResult::WouldBlock => {
265                self.frontend_readiness.event.remove(Ready::READABLE);
266                SessionResult::Continue
267            }
268            SocketResult::Continue => SessionResult::Continue,
269        }
270    }
271
272    /// Feed the preread deadline firing. Always terminal: a fired deadline
273    /// resolves to `Reject(Fragmented)` (or replays an already-latched
274    /// verdict in the vanishingly unlikely race where routing and the
275    /// deadline land in the same tick -- the core's `decided` latch makes
276    /// that safe either way).
277    pub fn on_timeout(&mut self, cfg: &PrereadConfig<'_>) {
278        let output = self.core.handle_input(
279            cfg,
280            Input::Timeout {
281                now: Instant::now(),
282            },
283        );
284        // The frontend timeout always closes the session regardless; this
285        // call exists purely for its metric/log side effect.
286        let _ = self.handle_output(output);
287    }
288
289    /// Feed a frontend close observed before a routing decision (e.g. HUP
290    /// racing ahead of `readable`'s own 0-byte detection, dispatched from
291    /// `TcpSession::front_hup`). Mirrors `readable`'s
292    /// `SocketResult::Closed` handling: silent when no bytes were ever
293    /// received (the bare TCP health-check pattern), metered otherwise.
294    pub fn on_front_closed(&mut self, cfg: &PrereadConfig<'_>) {
295        let _ = self.front_gone(cfg);
296    }
297
298    /// Shared "the frontend is gone" handling for both the read-path
299    /// (`Ok(0)` / socket error) and the HUP-path (`on_front_closed`).
300    fn front_gone(&mut self, cfg: &PrereadConfig<'_>) -> SessionResult {
301        if self.has_received_bytes() {
302            let output = self.core.handle_input(cfg, Input::FrontClosed);
303            self.handle_output(output)
304        } else {
305            // Bare TCP health-check pattern (SYN/ACK/FIN, no bytes ever
306            // sent): silent, no rejection metric -- cf. `expect.rs`'s
307            // identical guard.
308            trace!(
309                "{} front socket closed with 0 bytes during SNI preread",
310                log_context!(self)
311            );
312            self.frontend_readiness.reset();
313            SessionResult::Close
314        }
315    }
316
317    fn handle_output(&mut self, output: Output) -> SessionResult {
318        match output {
319            Output::NeedMore { .. } => SessionResult::Continue,
320            Output::Routed {
321                cluster,
322                content_offset,
323                proxy_source,
324                sni,
325                alpn,
326                matched_sni_pattern,
327                matched_alpn,
328            } => {
329                debug_assert!(
330                    self.outcome.is_none(),
331                    "a route must be captured at most once per SniPreread lifetime"
332                );
333                let alpn_bytes = alpn
334                    .iter()
335                    .map(Vec::len)
336                    .fold(0usize, usize::saturating_add);
337                info!(
338                    "{} SNI preread routed (cluster_id_bytes={}, sni_bytes={}, alpn_count={}, alpn_bytes={})",
339                    log_context!(self),
340                    cluster.len(),
341                    sni.len(),
342                    alpn.len(),
343                    alpn_bytes,
344                );
345                incr!(names::tcp::sni_preread::ROUTED);
346                // Arm backend-writable interest now so the FIRST backend
347                // connect-writable event (whenever `connect_to_backend`
348                // completes) immediately dispatches into `back_writable`
349                // without waiting for a second, unrelated readiness event --
350                // mirrors `RelayProxyProtocol::readable`'s identical arm
351                // once its own header has been parsed.
352                self.backend_readiness.interest.insert(Ready::WRITABLE);
353                // ...and drop frontend-readable interest in the same breath,
354                // completing the mirror: `relay.rs` drops READABLE on the line
355                // ABOVE the arm above, and copying only half of that pair is
356                // what left this state spinning. From here on
357                // `readable` deliberately reads nothing (the
358                // coalesced tail must stay in the kernel buffer for the
359                // post-upgrade state to replay byte-for-byte), so a frontend
360                // READABLE bit that stays SELECTED is a dispatch that can
361                // never make progress. `tcp_socket_read` returns
362                // `SocketResult::Continue` -- not `WouldBlock` -- when it
363                // fills the capped slice it was handed, which is exactly what
364                // a client coalescing its ClientHello with payload produces,
365                // so the event below is latched and nothing will ever clear
366                // it. Left selected, `front_interest = interest & event` in
367                // `TcpSession::ready_inner` stays readable on every pass and
368                // the session spins to `MAX_LOOP_ITERATIONS` with `bin` stuck
369                // at the cap and `bout == 0` (sozu-proxy/sozu#1373).
370                //
371                // Mask the INTEREST, never the event: `upgrade_sni_preread`
372                // hands `frontend_readiness.event` to the post-upgrade `Pipe`
373                // (`restore_readiness_events`) or `SendProxyProtocol`
374                // (`into_pipe`), both of which re-insert READABLE interest,
375                // and under edge-triggered epoll that carried-over bit is the
376                // only wake-up the coalesced tail will ever get. Clearing the
377                // event here would trade the spin for a hang. HUP and ERROR
378                // stay armed, so a frontend that goes away mid-connect is
379                // still noticed.
380                self.frontend_readiness.interest.remove(Ready::READABLE);
381                self.outcome = Some(RoutedOutcome {
382                    cluster,
383                    content_offset,
384                    proxy_source,
385                    sni,
386                    alpn,
387                    matched_sni_pattern,
388                    matched_alpn,
389                });
390                SessionResult::Continue
391            }
392            Output::Reject(reason) => {
393                debug!("{} SNI preread rejected: {:?}", log_context!(self), reason);
394                incr!(names::tcp::sni_preread::rejected_name(reason));
395                self.frontend_readiness.reset();
396                self.backend_readiness.reset();
397                SessionResult::Close
398            }
399        }
400    }
401
402    /// The `back_writable` dispatch point: only ever reachable once routed
403    /// -- the not-yet-routed guard in
404    /// `TcpSession::attempt_backend_connect_if_needed` (`lib/src/tcp.rs`,
405    /// both call sites inside `ready_inner`) guarantees
406    /// `connect_to_backend` (and therefore any backend-writable event)
407    /// never runs before a route decision. The actual per-`ProxyProtocolConfig`
408    /// dispatch lives in `TcpSession::upgrade_sni_preread` (it needs the
409    /// `TcpProxy`/listener context this struct deliberately does not hold),
410    /// so this just signals the transition.
411    pub fn back_writable(&self) -> SessionResult {
412        debug_assert!(
413            self.outcome.is_some(),
414            "back_writable on SniPreread must only fire after a route decision"
415        );
416        SessionResult::Upgrade
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use std::time::Duration;
423
424    use super::*;
425    use crate::protocol::tcp_preread::{AlpnMatcher, RejectReason};
426    use crate::router::pattern_trie::TrieNode;
427
428    /// `names::tcp::sni_preread::rejected_name` must be a TOTAL function
429    /// over every `RejectReason` variant, each mapping to a DISTINCT dotted
430    /// name -- a new variant added to the core without a matching arm fails
431    /// to compile (see the `match` in `metrics/names.rs`), and this test
432    /// additionally guards against two variants silently sharing one name.
433    #[test]
434    fn every_reject_reason_has_a_distinct_metric_name() {
435        let reasons = [
436            RejectReason::NotTls,
437            RejectReason::MalformedRecord,
438            RejectReason::MalformedHandshake,
439            RejectReason::Fragmented,
440            RejectReason::TooLarge,
441            RejectReason::NoSni,
442            RejectReason::EchOuterAbsent,
443            RejectReason::SniUnmatched,
444            RejectReason::AlpnUnmatched,
445            RejectReason::ProxyHeaderInvalid,
446            RejectReason::FrontClosed,
447        ];
448        let mut names_seen = std::collections::HashSet::new();
449        for reason in reasons {
450            let name = names::tcp::sni_preread::rejected_name(reason);
451            assert!(
452                name.starts_with("tcp.sni_preread.rejected."),
453                "unexpected metric name shape for {reason:?}: {name}"
454            );
455            assert!(
456                names_seen.insert(name),
457                "two RejectReason variants mapped to the same metric name: {name}"
458            );
459        }
460    }
461
462    fn cfg(routes: &TrieNode<Vec<(AlpnMatcher, ClusterId)>>) -> PrereadConfig<'_> {
463        PrereadConfig {
464            routes,
465            inbound_proxy: false,
466            max_bytes: 16 * 1024,
467            timeout: Duration::from_secs(3),
468            accept_wildcard: true,
469        }
470    }
471
472    #[test]
473    fn routed_runtime_log_bounds_cluster_sni_and_alpn() {
474        const CLUSTER_SECRET: &str = "TCP_PREREAD_CLUSTER_SECRET_SENTINEL";
475        const SNI_SECRET: &str = "TCP_PREREAD_SNI_SECRET_SENTINEL";
476        const ALPN_SECRET: &str = "TCP_PREREAD_ALPN_SECRET_SENTINEL";
477
478        let cluster = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
479        let sni = format!("{SNI_SECRET}{}", "x".repeat(4096));
480        let alpn = format!("{ALPN_SECRET}{}", "x".repeat(4096)).into_bytes();
481        let cluster_len = cluster.len();
482        let sni_len = sni.len();
483        let alpn_len = alpn.len();
484        let output = crate::capture_test_logs(move || {
485            use std::net::{TcpListener as StdTcpListener, TcpStream as StdTcpStream};
486
487            use mio::net::TcpStream as MioTcpStream;
488
489            use crate::pool::Pool;
490
491            let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind test listener");
492            let address = listener.local_addr().expect("listener address");
493            let _client = StdTcpStream::connect(address).expect("connect test client");
494            let (server, _) = listener.accept().expect("accept test server");
495            server.set_nonblocking(true).expect("server nonblocking");
496            let mut pool = Pool::with_capacity(1, 1, 16 * 1024);
497            let frontend_buffer = pool.checkout().expect("frontend buffer");
498            let mut preread = SniPreread::new(
499                MioTcpStream::from_std(server),
500                Token(0),
501                Ulid::generate(),
502                frontend_buffer,
503                16 * 1024,
504            );
505
506            assert_eq!(
507                preread.handle_output(Output::Routed {
508                    cluster: cluster.into(),
509                    content_offset: 0,
510                    proxy_source: None,
511                    sni: sni.clone(),
512                    alpn: vec![alpn],
513                    matched_sni_pattern: sni,
514                    matched_alpn: AlpnMatcher::Any,
515                }),
516                SessionResult::Continue
517            );
518        });
519
520        for secret in [CLUSTER_SECRET, SNI_SECRET, ALPN_SECRET] {
521            assert!(
522                !output.contains(secret),
523                "SNI preread routed log leaked {secret}: {output}"
524            );
525        }
526        for metadata in [
527            format!("cluster_id_bytes={cluster_len}"),
528            format!("sni_bytes={sni_len}"),
529            "alpn_count=1".to_owned(),
530            format!("alpn_bytes={alpn_len}"),
531        ] {
532            assert!(
533                output.contains(&metadata),
534                "SNI preread routed log omitted {metadata}: {output}"
535            );
536        }
537        assert!(
538            output.len() <= 512,
539            "SNI preread routed log is not bounded: {} bytes",
540            output.len()
541        );
542    }
543
544    #[test]
545    fn preread_config_helper_shape_is_sane() {
546        // Not a behavioral test of the core (already covered exhaustively
547        // in `tcp_preread/mod.rs`) -- just pins the `PrereadConfig` field
548        // shape this shell constructs against, so a field rename there is
549        // caught here too.
550        let routes = TrieNode::root();
551        let cfg = cfg(&routes);
552        assert!(!cfg.inbound_proxy);
553        assert_eq!(cfg.max_bytes, 16 * 1024);
554        assert!(cfg.accept_wildcard);
555    }
556
557    /// Regression guard for the SNI-preread read cap (sozu-proxy/sozu#1279):
558    /// `readable` must never pull more than
559    /// `effective_max_bytes` into the accumulator in a single read, even
560    /// though the backing buffer is far larger and the socket has far more
561    /// queued. Without the cap the shell drains the whole buffer, so an
562    /// oversized-but-COMPLETE ClientHello is read in full and ROUTED (the core
563    /// caps only would-be-NeedMore windows) instead of rejected `TooLarge` and
564    /// the connection closed.
565    #[test]
566    fn readable_caps_the_accumulator_at_effective_max_bytes() {
567        use std::io::Write as _;
568        use std::net::{TcpListener as StdTcpListener, TcpStream as StdTcpStream};
569
570        use mio::net::TcpStream as MioTcpStream;
571
572        use crate::pool::Pool;
573
574        let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind test listener");
575        let addr = listener.local_addr().expect("listener local addr");
576        let mut client = StdTcpStream::connect(addr).expect("connect test client");
577        let (server, _) = listener.accept().expect("accept test server");
578        server.set_nonblocking(true).expect("server nonblocking");
579
580        // Flood far more than the tight cap, into a buffer far larger than it.
581        let flood = vec![0x16u8; 4096];
582        client.write_all(&flood).expect("write flood");
583        client.flush().ok();
584
585        let mut pool = Pool::with_capacity(1, 1, 16 * 1024);
586        let frontend_buffer = pool.checkout().expect("frontend buffer");
587        assert!(
588            frontend_buffer.available_space() > 4096,
589            "buffer must dwarf the cap for this test to be meaningful"
590        );
591
592        let effective_max_bytes = 16usize;
593        let mut preread = SniPreread::new(
594            MioTcpStream::from_std(server),
595            Token(0),
596            Ulid::generate(),
597            frontend_buffer,
598            effective_max_bytes,
599        );
600
601        // Keep the core cap consistent with the shell cap, exactly as
602        // `TcpListener::preread_config` does (both = effective_max_bytes).
603        let routes = TrieNode::root();
604        let cfg = PrereadConfig {
605            routes: &routes,
606            inbound_proxy: false,
607            max_bytes: effective_max_bytes,
608            timeout: Duration::from_secs(3),
609            accept_wildcard: true,
610        };
611        let mut metrics = SessionMetrics::new(Some(Duration::ZERO));
612
613        let result = preread.readable(&mut metrics, &cfg);
614        assert!(
615            preread.frontend_buffer.available_data() <= effective_max_bytes,
616            "readable accumulated {} bytes, past the {}-byte cap",
617            preread.frontend_buffer.available_data(),
618            effective_max_bytes
619        );
620        // A window that reached the cap without a complete hello rejects and
621        // closes -- it must never sit routed or spin needing more.
622        assert_eq!(
623            result,
624            SessionResult::Close,
625            "an over-cap preread window must reject-and-close"
626        );
627
628        drop(client);
629    }
630
631    /// Regression guard for sozu-proxy/sozu#1373: a routed SNI preread must
632    /// stop advertising frontend-readable interest while it waits for the
633    /// backend connect, or `TcpSession::ready_inner` spins to
634    /// `MAX_LOOP_ITERATIONS` and kills a perfectly healthy session.
635    ///
636    /// A client that coalesces its ClientHello with the first payload bytes
637    /// -- what a `--release` e2e client does every time -- fills the whole
638    /// `effective_max_bytes` window in one read. `tcp_socket_read` returns
639    /// `SocketResult::Continue` (not `WouldBlock`) when it fills the slice it
640    /// was handed, so the frontend READABLE *event* stays latched. The route
641    /// decision lands on that same read, and from then on `readable` is
642    /// deliberately a no-op: the coalesced tail must stay in the kernel
643    /// buffer for the post-upgrade state to replay byte-for-byte. Nothing
644    /// clears the latch, so if READABLE also stays in
645    /// `frontend_readiness.interest`, `front_interest = interest & event`
646    /// (`lib/src/tcp.rs`, the loop's re-entry predicate, mirrored below)
647    /// stays readable on every pass until the 10 000-iteration guard closes
648    /// the connection with `bin` stuck at the cap and `bout == 0`.
649    ///
650    /// The latched *event* must SURVIVE. `upgrade_sni_preread` hands
651    /// `frontend_readiness.event` to the post-upgrade `Pipe`
652    /// (`restore_readiness_events`) or `SendProxyProtocol` (`into_pipe`),
653    /// each of which re-inserts READABLE interest; under edge-triggered
654    /// epoll that carried-over bit is the ONLY wake-up the coalesced tail
655    /// will ever get. Clearing the event instead of masking the interest
656    /// would trade this spin for a hang.
657    ///
658    /// To SEE THIS RED: delete
659    /// `self.frontend_readiness.interest.remove(Ready::READABLE);` from the
660    /// `Output::Routed` arm of `handle_output`.
661    #[test]
662    fn routed_preread_stops_re_arming_frontend_readable_while_connecting() {
663        use std::io::Write as _;
664        use std::net::{TcpListener as StdTcpListener, TcpStream as StdTcpStream};
665
666        use mio::net::TcpStream as MioTcpStream;
667
668        use crate::pool::Pool;
669        use crate::protocol::tcp_preread::parser;
670
671        let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind test listener");
672        let addr = listener.local_addr().expect("listener local addr");
673        let mut client = StdTcpStream::connect(addr).expect("connect test client");
674        let (server, _) = listener.accept().expect("accept test server");
675        server.set_nonblocking(true).expect("server nonblocking");
676
677        // The coalescing shape of sozu-proxy/sozu#1373: one write carrying a
678        // complete ClientHello immediately followed by payload, far more than
679        // the preread cap, so the capped read fills its slice exactly and
680        // `socket_read` reports `Continue` rather than `WouldBlock`.
681        let hello = parser::build_client_hello_wire(&[parser::encode_sni_extension("example.com")]);
682        let effective_max_bytes = 16 * 1024usize;
683        let mut coalesced = hello.clone();
684        coalesced.resize(hello.len() + 2 * effective_max_bytes, 0x17);
685        client.write_all(&coalesced).expect("write coalesced hello");
686        client.flush().ok();
687
688        let mut routes = TrieNode::root();
689        routes.domain_insert(
690            b"example.com".to_vec(),
691            vec![(AlpnMatcher::Any, "cluster-a".into())],
692        );
693        let cfg = PrereadConfig {
694            routes: &routes,
695            inbound_proxy: false,
696            max_bytes: effective_max_bytes,
697            timeout: Duration::from_secs(3),
698            accept_wildcard: true,
699        };
700
701        let mut pool = Pool::with_capacity(1, 1, effective_max_bytes);
702        let frontend_buffer = pool.checkout().expect("frontend buffer");
703        let mut preread = SniPreread::new(
704            MioTcpStream::from_std(server),
705            Token(0),
706            Ulid::generate(),
707            frontend_buffer,
708            effective_max_bytes,
709        );
710        let mut metrics = SessionMetrics::new(Some(Duration::ZERO));
711
712        // epoll delivered one frontend-readable event; the backend is still
713        // `Connecting`, so no backend readiness ever fires during this pass.
714        preread.frontend_readiness.event.insert(Ready::READABLE);
715
716        // `TcpSession::ready_inner`'s re-entry predicate, bounded far below
717        // `MAX_LOOP_ITERATIONS` so the spin is a failed assertion in
718        // milliseconds rather than a 10 000-iteration burn.
719        const DISPATCH_BUDGET: usize = 4;
720        let mut dispatches = 0usize;
721        while (preread.frontend_readiness.interest & preread.frontend_readiness.event).is_readable()
722        {
723            assert!(
724                dispatches < DISPATCH_BUDGET,
725                "routed SNI preread re-armed frontend-readable {DISPATCH_BUDGET} times \
726                 without consuming the event: this is the spin to MAX_LOOP_ITERATIONS \
727                 (interest {:?}, event {:?}, bin {})",
728                preread.frontend_readiness.interest,
729                preread.frontend_readiness.event,
730                metrics.bin
731            );
732            dispatches += 1;
733            assert_eq!(
734                preread.readable(&mut metrics, &cfg),
735                SessionResult::Continue
736            );
737        }
738
739        assert!(
740            preread.is_routed(),
741            "the coalesced hello must route, otherwise this test witnesses nothing"
742        );
743        assert_eq!(
744            metrics.bin, effective_max_bytes,
745            "the routing read must fill the whole cap, the coalescing shape of the bug"
746        );
747        assert!(
748            preread.frontend_readiness.event.is_readable(),
749            "the latched frontend-readable event must survive for the post-upgrade \
750             Pipe/SendProxyProtocol to drain the coalesced tail: under edge-triggered \
751             epoll it is the only wake-up that tail will ever get"
752        );
753        assert!(
754            preread.backend_readiness.interest.is_writable(),
755            "routing must still arm backend-writable so the connect completion upgrades"
756        );
757
758        drop(client);
759    }
760}