Skip to content

HTTP/3 Response stream not closed #1122

@Deniskore

Description

@Deniskore

Reproducible minimal example:

use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::prelude::*;
use std::net::SocketAddr;

pub struct Http3Server {
    addr: SocketAddr,
    rustls_config: RustlsConfig,
}

impl Http3Server {
    pub fn new(addr: SocketAddr, cert: Vec<u8>, key: Vec<u8>) -> Self {
        let rustls_config = RustlsConfig::new(Keycert::new().cert(cert).key(key));
        Self { addr, rustls_config }
    }

    pub async fn run(self) {
        let router = Router::new()
            .push(Router::with_path("/hello").get(hello))
            .push(Router::with_path("/").get(index));

        let tcp_listener = TcpListener::new(self.addr).rustls(self.rustls_config.clone());

        let quinn_listener = QuinnListener::new(
            self.rustls_config.build_quinn_config().unwrap(),
            self.addr,
        );

        let acceptor = quinn_listener.join(tcp_listener).bind().await;

        Server::new(acceptor).serve(router).await;
    }
}

#[handler]
async fn hello() -> &'static str {
    "Hello from /hello"
}

#[handler]
async fn index() -> &'static str {
    "Welcome to /"
}

#[tokio::main]
async fn main() {
    // openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
    tracing_subscriber::fmt().init();
    let cert = include_bytes!("../cert.pem").to_vec();
    let key = include_bytes!("../key.pem").to_vec();
    let addr: SocketAddr = "0.0.0.0:4443".parse().unwrap();
    let server = Http3Server::new(addr, cert, key);
    server.run().await;
}

Client to check the stream ended flag:

import asyncio
import ssl
from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import QuicEvent

class HttpClient(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._http = H3Connection(self._quic)
        self._stream_ended = asyncio.Event()

    def quic_event_received(self, event: QuicEvent):
        for http_event in self._http.handle_event(event):
            if isinstance(http_event, HeadersReceived):
                print("🔹 Headers received:")
                for name, value in http_event.headers:
                    print(f"  {name.decode()}: {value.decode()}")
            elif isinstance(http_event, DataReceived):
                print(f"🔹 Body received: {http_event.data.decode()}")
                if http_event.stream_ended:
                    print("✅ Stream ended.")
                    self._stream_ended.set()

    async def get(self, authority: str, path: str = "/"):
        stream_id = self._quic.get_next_available_stream_id()
        headers = [
            (b":method", b"GET"),
            (b":scheme", b"https"),
            (b":authority", authority.encode()),
            (b":path", path.encode()),
        ]
        self._http.send_headers(stream_id=stream_id, headers=headers, end_stream=True)
        self.transmit()
        await self._stream_ended.wait()

async def main():
    host = "google.com"
    # host = "127.0.0.1"
    # port = 4443
    port = 443
    path = "/"

    configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
    configuration.verify_mode = ssl.CERT_NONE  # for testing

    async with connect(host, port, configuration=configuration, create_protocol=HttpClient) as client:
        await client.get(authority=host, path=path)

if __name__ == "__main__":
    asyncio.run(main())

If I make a request to google.com using HTTP/3, I see the following:

🔹 Headers received:
  :status: 301
  location: https://www.google.com/
  content-type: text/html; charset=UTF-8
  content-security-policy-report-only: object-src 'none';base-uri 'self';script-src 'nonce-paQ1mVBB_NN8msjiAu8Pzw' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
  date: Fri, 25 Apr 2025 08:58:13 GMT
  expires: Sun, 25 May 2025 08:58:13 GMT
  cache-control: public, max-age=2592000
  server: gws
  content-length: 220
  x-xss-protection: 0
  x-frame-options: SAMEORIGIN
  alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
🔹 Body received: <HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/">here</A>.
</BODY></HTML>

🔹 Body received: 
✅ Stream ended.

If I make a request to the Salvo server provided above:

11:59:09 ❯ python http3.py                                                                                                                                                                      (http3) 
🔹 Headers received:
  :status: 200
  alt-svc: h3=":4443"; ma=2592000,h3-29=":4443"; ma=2592000
  content-type: text/plain; charset=utf-8
🔹 Body received: Welcome to /

It gets stuck and never finishes.

Salvo HTTP/3 server responds with headers and body but the client does not receive the stream end signal (stream_ended is never true). In contrast, requests to google.com succeed and the client detects the stream ending correctly.

I also tried using stream::once with res.stream, but the result was the same. According to the documentation, Salvo does not require manually closing streams.

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