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}