Skip to content

Connection deadlock under bidirectional bulk data: both peers stop reading while a PONG/PING/SETTINGS write is pending (poll_ready gates poll_next) #975

Open

Description

@jeremyjpj0916

Summary

Under bulk data in both directions on one connection, two h2 peers can deadlock permanently. Each holds megabytes of unread data in its socket receive buffer while waiting to write a pending control frame (PONG, user PING or SETTINGS). Both stop reading, so neither can ever write.

It reproduces reliably with hyper's adaptive_window(true) on both peers over TLS, about 1 run in 3 with the program below. It reproduces on h2 0.4.19 and 0.4.20 (hyper 1.8.1, tokio-rustls 0.26, macOS loopback).

This is different from #853 (capacity held by pending_open streams): here both connection tasks are parked in poll_ready behind a control-frame write.

Cause, from traces

proto::Connection::poll2 calls ready!(self.poll_ready(cx))? before every codec.poll_next(cx). poll_ready first flushes the pending PONG, the user PING, SETTINGS / SETTINGS ACK and refusals through the codec. If the codec cannot accept the frame because the peer is not reading (send buffer full of DATA), poll_ready returns Pending, and the connection stops reading its socket.

When both peers reach that state at the same time, the connection deadlocks. A local copy of h2 0.4.19 with a log line added at each poll_ready early return (no behavior change) shows it:

46.550620 Client  send frame=Ping { ack: false }    // hyper BDP ping
46.552787 Server  send frame=Ping { ack: false }    // hyper BDP ping
46.553122 Client  received frame=Ping { ack: false }
46.553237 Server  received frame=Ping { ack: false }
46.553240 Server  poll_ready Pending: PONG waiting for write capacity; reads blocked
46.553332 Client  poll_ready Pending: PONG waiting for write capacity; reads blocked
   (no further frames from either side)
51.599096 Client  poll_ready Pending: PONG waiting for write capacity; reads blocked   // woken again, still blocked

In another stalled run the trigger was a SETTINGS change instead:

  • the client queued Settings { initial_window_size: 3213342 } after a BDP update;
  • that frame and every later frame from the client (including a new request's HEADERS) were never written;
  • the server also stopped reading.

netstat on a stalled connection (other connections on the same server at 0/0):

server end: Recv-Q 1,857,875  Send-Q 605,740
client end: Recv-Q 1,048,108  Send-Q 605,636

Once stalled, a new headers-only request on the connection (not flow controlled) never completes. Nothing recovers it short of closing the connection.

Adaptive window makes the state likely because both peers emit control frames throughout a transfer: BDP PINGs, their PONGs, and SETTINGS changes. Client-only keepalive PINGs with fixed windows did not reproduce it in 12 runs: the pinging side keeps reading, so the other side's PONG drains eventually. Plain TCP did not reproduce it in 18 runs; TLS's extra buffering probably widens the window. The gate itself is transport-independent.

Reproduction matrix

TLS, 10 streams per connection, echo server that collects the full request and then replies. 1 MiB × 100 streams and 5 MiB × 50 streams, 6 runs each, 4 s load plus a 30 s bounded drain.

client adaptive server adaptive runs that deadlocked
on on 4 / 12
on off 1 / 12
off on 0 / 12
off off 0 / 12

h2 0.4.20, both adaptive: 2 / 12.

Possible direction

  • Let poll2 keep reading frames while a control-frame write is pending. Backpressure would then apply only to producing new outbound frames; PONG / SETTINGS ACK are a few bytes and could be queued even when DATA cannot be.
  • Alternatively, keep reading as long as the pending control frame has been accepted into the write buffer, and only stop when that buffer is truly full.

Happy to test a patch against this reproduction.

Reproduction program

Run with TLS=1 ./adaptive_repro 1 1 5242880 50 4 6 collect 10. Arguments: client_adaptive, server_adaptive, body bytes, streams, seconds, reps, collect, streams per connection.

Optional settings:

  • KEEPALIVE_MS=50 adds client keepalive pings;
  • TRACE_FILE=... TRACE_FILTER=h2::codec=debug writes frame logs.

A stall prints STALL lines, a PROBE ... TIMED OUT line, and the netstat queues.

Cargo.toml
[package]
name = "adaptive_repro"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
hyper = { version = "=1.8.1", features = ["client", "server", "http2"] }
hyper-util = { version = "=0.1.20", features = ["tokio"] }
http-body-util = "0.1"
bytes = "1"
h2 = "=0.4.19"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rcgen = "0.14"
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }

[profile.release]
debug = false
src/main.rs
//! Bounded reduction of #6073: hyper HTTP/2 client and streaming echo server,
//! adaptive window on/off per side, many concurrent POST/echo streams. A
//! watchdog reports any in-flight stream with no progress for STALL_SECS.
//!
//! usage: adaptive_repro <client_adaptive 0|1> <server_adaptive 0|1> <body_bytes>
//!        <streams> <secs> <reps>
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use tokio::net::{TcpListener, TcpStream};

const STALL_SECS: u64 = 5;

struct Tls {
    acceptor: tokio_rustls::TlsAcceptor,
    connector: tokio_rustls::TlsConnector,
}

fn make_tls() -> Tls {
    let _ = rustls::crypto::ring::default_provider().install_default();
    let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
    let cert = ck.cert.der().clone();
    let key = rustls::pki_types::PrivateKeyDer::Pkcs8(ck.signing_key.serialize_der().into());
    let mut server = rustls::ServerConfig::builder()
        .with_no_client_auth()
        .with_single_cert(vec![cert.clone()], key)
        .unwrap();
    server.alpn_protocols = vec![b"h2".to_vec()];
    let mut roots = rustls::RootCertStore::empty();
    roots.add(cert).unwrap();
    let mut client = rustls::ClientConfig::builder()
        .with_root_certificates(roots)
        .with_no_client_auth();
    client.alpn_protocols = vec![b"h2".to_vec()];
    Tls {
        acceptor: tokio_rustls::TlsAcceptor::from(Arc::new(server)),
        connector: tokio_rustls::TlsConnector::from(Arc::new(client)),
    }
}
const DRAIN_LIMIT: Duration = Duration::from_secs(30);

fn now_ms(start: Instant) -> u64 {
    start.elapsed().as_millis() as u64
}

struct StreamState {
    in_flight: AtomicUsize,
    last_progress_ms: AtomicU64,
    received: AtomicU64,
    /// 0 = request sent, waiting for response headers; 1 = headers received.
    phase: AtomicUsize,
}

async fn serve(listener: TcpListener, adaptive: bool, collect: bool, tls: Option<tokio_rustls::TlsAcceptor>) {
    loop {
        let Ok((tcp, _)) = listener.accept().await else { return };
        tcp.set_nodelay(true).ok();
        let tls = tls.clone();
        tokio::spawn(async move {
            let mut b = hyper::server::conn::http2::Builder::new(TokioExecutor::new());
            b.timer(TokioTimer::new());
            if adaptive {
                b.adaptive_window(true);
            } else {
                b.initial_stream_window_size(8 << 20).initial_connection_window_size(32 << 20);
            }
            b.max_frame_size(1 << 20);
            let svc = hyper::service::service_fn(move |req: Request<Incoming>| async move {
                let body: http_body_util::combinators::BoxBody<Bytes, hyper::Error> = if collect {
                    // Like the old proto_backend: read the whole request first.
                    let bytes = req.into_body().collect().await.map(|c| c.to_bytes()).unwrap_or_default();
                    Full::new(bytes).map_err(|never| match never {}).boxed()
                } else {
                    req.into_body().boxed()
                };
                Ok::<_, std::convert::Infallible>(Response::new(body))
            });
            match tls {
                Some(acceptor) => {
                    let Ok(stream) = acceptor.accept(tcp).await else { return };
                    let _ = b.serve_connection(TokioIo::new(stream), svc).await;
                }
                None => {
                    let _ = b.serve_connection(TokioIo::new(tcp), svc).await;
                }
            }
        });
    }
}

async fn one_rep(client_adaptive: bool, server_adaptive: bool, size: usize, streams: usize, secs: u64, collect: bool, per_conn: usize) -> (u64, usize, bool) {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let tls = (std::env::var("TLS").as_deref() == Ok("1")).then(make_tls);
    let server = tokio::spawn(serve(listener, server_adaptive, collect, tls.as_ref().map(|t| t.acceptor.clone())));

    let num_conns = streams.div_ceil(per_conn).max(1);
    let mut senders = Vec::new();
    let mut drivers = Vec::new();
    for _ in 0..num_conns {
        let tcp = TcpStream::connect(addr).await.unwrap();
        tcp.set_nodelay(true).ok();
        let mut b = hyper::client::conn::http2::Builder::new(TokioExecutor::new());
        b.timer(TokioTimer::new());
        if client_adaptive {
            b.adaptive_window(true);
        } else {
            b.initial_stream_window_size(8 << 20).initial_connection_window_size(32 << 20);
        }
        b.max_frame_size(1 << 20);
        if let Ok(ms) = std::env::var("KEEPALIVE_MS") {
            b.keep_alive_interval(Duration::from_millis(ms.parse().unwrap()))
                .keep_alive_while_idle(true);
        }
        let sender = match &tls {
            Some(t) => {
                let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
                let stream = t.connector.connect(name, tcp).await.unwrap();
                let (sender, conn) = b.handshake::<_, Full<Bytes>>(TokioIo::new(stream)).await.unwrap();
                drivers.push(tokio::spawn(async move { let _ = conn.await; }));
                sender
            }
            None => {
                let (sender, conn) = b.handshake::<_, Full<Bytes>>(TokioIo::new(tcp)).await.unwrap();
                drivers.push(tokio::spawn(async move { let _ = conn.await; }));
                sender
            }
        };
        senders.push(sender);
    }

    let start = Instant::now();
    let deadline = start + Duration::from_secs(secs);
    let payload = Bytes::from(vec![7u8; size]);
    let completed = Arc::new(AtomicU64::new(0));
    let states: Vec<Arc<StreamState>> = (0..streams)
        .map(|_| Arc::new(StreamState { in_flight: AtomicUsize::new(0), last_progress_ms: AtomicU64::new(0), received: AtomicU64::new(0), phase: AtomicUsize::new(0) }))
        .collect();

    let mut workers = Vec::new();
    for (i, state) in states.iter().enumerate() {
        let mut sender = senders[i % num_conns].clone();
        let payload = payload.clone();
        let state = Arc::clone(state);
        let completed = Arc::clone(&completed);
        workers.push(tokio::spawn(async move {
            while Instant::now() < deadline {
                state.in_flight.store(1, Ordering::Relaxed);
                state.received.store(0, Ordering::Relaxed);
                state.phase.store(0, Ordering::Relaxed);
                state.last_progress_ms.store(now_ms(start), Ordering::Relaxed);
                let req = Request::post("/echo").body(Full::new(payload.clone())).unwrap();
                let Ok(resp) = sender.send_request(req).await else { break };
                state.phase.store(1, Ordering::Relaxed);
                state.last_progress_ms.store(now_ms(start), Ordering::Relaxed);
                let mut body = resp.into_body();
                while let Some(frame) = body.frame().await {
                    let Ok(frame) = frame else { break };
                    if let Some(d) = frame.data_ref() {
                        state.received.fetch_add(d.len() as u64, Ordering::Relaxed);
                        state.last_progress_ms.store(now_ms(start), Ordering::Relaxed);
                    }
                }
                state.in_flight.store(0, Ordering::Relaxed);
                completed.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

    // Watchdog
    let wd_states = states.clone();
    let wd_senders = senders.clone();
    let wd_num_conns = num_conns;
    let wd_port = addr.port();
    let stalls = Arc::new(AtomicUsize::new(0));
    let wd_stalls = Arc::clone(&stalls);
    let watchdog = tokio::spawn(async move {
        let mut reported = vec![false; wd_states.len()];
        let mut probed = vec![false; wd_num_conns];
        loop {
            tokio::time::sleep(Duration::from_millis(250)).await;
            let t = now_ms(start);
            for (i, s) in wd_states.iter().enumerate() {
                let idle = t.saturating_sub(s.last_progress_ms.load(Ordering::Relaxed));
                if s.in_flight.load(Ordering::Relaxed) == 1 && idle >= STALL_SECS * 1000 {
                    if !reported[i] {
                        reported[i] = true;
                        wd_stalls.fetch_add(1, Ordering::Relaxed);
                        if !probed[i % wd_num_conns] {
                            probed[i % wd_num_conns] = true;
                            let mut probe = wd_senders[i % wd_num_conns].clone();
                            let conn = i % wd_num_conns;
                            tokio::spawn(async move {
                                // Headers-only exchange: not flow controlled.
                                let req = Request::post("/echo").body(Full::new(Bytes::new())).unwrap();
                                let t0 = Instant::now();
                                let outcome = tokio::time::timeout(Duration::from_secs(3), async {
                                    let resp = probe.send_request(req).await?;
                                    resp.into_body().collect().await.map(|_| ())
                                }).await;
                                if outcome.is_err() {
                                    if let Ok(out) = std::process::Command::new("netstat").args(["-an", "-p", "tcp"]).output() {
                                        let text = String::from_utf8_lossy(&out.stdout);
                                        eprintln!("  SOCKETS (Recv-Q Send-Q local foreign) for server port {wd_port}:");
                                        for line in text.lines().filter(|l| l.contains(&format!(".{wd_port} ")) && l.contains("ESTABLISHED")) {
                                            eprintln!("    {line}");
                                        }
                                    }
                                }
                                eprintln!("  PROBE conn#{conn}: {} after {}ms", match outcome { Ok(Ok(())) => "empty-body request COMPLETED (driver alive)".to_string(), Ok(Err(e)) => format!("error {e}"), Err(_) => "TIMED OUT (driver not progressing)".to_string() }, t0.elapsed().as_millis());
                            });
                        }
                        eprintln!("  STALL stream#{i} at t={t}ms idle={idle}ms phase={} received={}/{size}", if s.phase.load(Ordering::Relaxed) == 0 { "awaiting-headers" } else { "awaiting-body" }, s.received.load(Ordering::Relaxed));
                    }
                } else {
                    reported[i] = false;
                }
            }
        }
    });

    let drain_deadline = tokio::time::Instant::from_std(deadline + DRAIN_LIMIT);
    let mut hung = false;
    for mut w in workers {
        if tokio::time::timeout_at(drain_deadline, &mut w).await.is_err() {
            w.abort();
            hung = true;
        }
    }
    watchdog.abort();
    for d in drivers { d.abort(); }
    server.abort();
    (completed.load(Ordering::Relaxed), stalls.load(Ordering::Relaxed), hung)
}

#[tokio::main]
async fn main() {
    if let Ok(path) = std::env::var("TRACE_FILE") {
        let file = std::fs::File::create(path).unwrap();
        tracing_subscriber::fmt()
            .with_env_filter(tracing_subscriber::EnvFilter::new(
                std::env::var("TRACE_FILTER").unwrap_or_else(|_| "h2::codec=debug,h2::proto=trace,hyper::proto::h2=trace".to_string()),
            ))
            .with_writer(std::sync::Mutex::new(file))
            .with_ansi(false)
            .with_thread_ids(true)
            .init();
    }
    let a: Vec<String> = std::env::args().collect();
    let ca = a[1] == "1";
    let sa = a[2] == "1";
    let size: usize = a[3].parse().unwrap();
    let streams: usize = a[4].parse().unwrap();
    let secs: u64 = a[5].parse().unwrap();
    let reps: usize = a[6].parse().unwrap();
    let collect = a.get(7).map(|m| m == "collect").unwrap_or(false);
    let per_conn: usize = a.get(8).map(|v| v.parse().unwrap()).unwrap_or(usize::MAX);
    let (mut stalled_reps, mut hung_reps) = (0, 0);
    for rep in 0..reps {
        let t = Instant::now();
        let (done, stalls, hung) = one_rep(ca, sa, size, streams, secs, collect, per_conn).await;
        if stalls > 0 { stalled_reps += 1; }
        if hung { hung_reps += 1; }
        println!("rep={rep} client_adaptive={ca} server_adaptive={sa} size={size} streams={streams} completed={done} stalls={stalls} hung_drain={hung} wall={:.1}s", t.elapsed().as_secs_f64());
    }
    println!("SUMMARY collect={collect} per_conn={per_conn} client_adaptive={ca} server_adaptive={sa} size={size} streams={streams} reps={reps} stalled_reps={stalled_reps} hung_reps={hung_reps}");
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions