Skip to content

Unread request data after an early response can deadlock HTTP/2 streams and shutdown #393

Open

Description

@tahsinrk

Hypercorn 0.18.0, h2 4.4.1, h11 0.16.0, Python 3.14.7 on Linux and 3.14.3 on macOS, asyncio worker, cleartext HTTP/2 prior knowledge.

A completed early response can leave incoming request data in the ten-message application queue. The connection reader then waits while putting more body data into that queue, and stream cleanup waits while putting http.disconnect into the same full queue. A second request on the HTTP/2 connection receives no response, and supervisor SIGTERM does not finish within the configured graceful timeout plus two seconds.

This reproduces with a plain ASGI application, without a framework, TLS, a proxy, or a streaming response. It appears distinct from #389 (late data for a stream already removed), #390 (outgoing response backpressure), and #314 (HTTP/1 TLS close-notify failure). The shutdown half overlaps #308: the stuck connection never closes, so Server.wait_closed never returns. With #388 applied, the supervisor exits in about 4.1 seconds, at the graceful timeout, but stream 3 still receives no response. With #391 applied, the result does not change. An idle HTTP/2 connection left open by the client does not delay shutdown (about 0.1 seconds), so the full queue is what holds this connection.

Save the application below as early_app.py and the client as client.py. Install hypercorn==0.18.0 h2==4.4.1, then run:

hypercorn --bind 127.0.0.1:8080 --workers 1 --graceful-timeout 4 early_app:app

Application:

async def app(scope, receive, send):
    if scope["type"] == "lifespan":
        while True:
            message = await receive()
            if message["type"] == "lifespan.startup":
                await send({"type": "lifespan.startup.complete"})
            elif message["type"] == "lifespan.shutdown":
                await send({"type": "lifespan.shutdown.complete"})
                return
    if scope["type"] == "http":
        # Refuse one request without reading its body; ordinary requests succeed.
        status = 413 if scope["path"] == "/early" else 200
        await send({"type": "http.response.start", "status": status,
                    "headers": [(b"content-length", b"2")]})
        await send({"type": "http.response.body", "body": b"ok"})

Client:

import json
import socket
import sys
import time

import h2.config
import h2.connection
import h2.events


def probe(port, case, before_close=None):
    connection = h2.connection.H2Connection(
        config=h2.config.H2Configuration(client_side=True, header_encoding="utf-8")
    )
    connection.initiate_connection()

    def request(stream, method, path, end):
        connection.send_headers(stream, [(":method", method), (":scheme", "http"),
            (":authority", "localhost"), (":path", path)], end_stream=end)

    with socket.create_connection(("127.0.0.1", port), timeout=2) as sock:
        request(1, "POST", "/early", False)
        if case == "unread":
            for _ in range(40):
                connection.send_data(1, b"x" * 1024)
            request(3, "GET", "/next", True)
        else:
            connection.send_data(1, b"x" * 1024)
        sock.sendall(connection.data_to_send())
        statuses, ended, late_sent = {}, set(), False
        started = time.monotonic()
        deadline = started + 8
        error = None
        while 3 not in ended and time.monotonic() < deadline:
            if case == "removed" and 1 in ended and not late_sent:
                time.sleep(0.2)
                connection.send_data(1, b"x" * 4096, end_stream=True)
                request(3, "GET", "/next", True)
                sock.sendall(connection.data_to_send())
                late_sent = True
            sock.settimeout(max(0.001, deadline - time.monotonic()))
            try:
                data = sock.recv(65536)
                if not data:
                    error = "EOF before the following response"
                    break
                for event in connection.receive_data(data):
                    if isinstance(event, h2.events.ResponseReceived):
                        statuses[event.stream_id] = dict(event.headers)[":status"]
                    elif isinstance(event, h2.events.DataReceived):
                        connection.acknowledge_received_data(
                            event.flow_controlled_length, event.stream_id)
                    elif isinstance(event, h2.events.StreamEnded):
                        ended.add(event.stream_id)
                sock.sendall(connection.data_to_send())
            except TimeoutError:
                error = "No following response within 8 seconds"
                break
            except ConnectionError as exc:
                error = type(exc).__name__
                break
        result = {"case": case, "statuses": statuses, "ended": sorted(ended),
                  "next_status": statuses.get(3), "error": error,
                  "elapsed_seconds": round(time.monotonic() - started, 4)}
        if before_close is not None:
            before_close(result)
        return result


if __name__ == "__main__":
    print(json.dumps(probe(int(sys.argv[1]), sys.argv[2])))

In another terminal:

python client.py 8080 unread

The client sends headers, forty valid 1 KiB DATA frames on stream 1, and the following GET on stream 3 together. The body is below the initial connection/stream flow-control windows. Observed: stream 1 receives a complete 413, but stream 3 receives no response within eight seconds. Keeping that client socket open while signaling only the Hypercorn supervisor also leaves it running after six seconds (--graceful-timeout 4). Local verification used an equivalent parent process that kept the client open during that signal; its bounded teardown then terminated the process group.

The source path is H2Protocol._handle_events -> HTTPStream.handle(Body) -> app_queue.put. Response completion separately goes through HTTPStream._send_closed and stream closure -> HTTPStream.handle(StreamClosed) -> another app_queue.put. The application never consumes those messages after its response. Increasing max_app_queue_size moves the point where this waits but does not release a queue whose consumer has finished.

Expected: a completed response/finished application should release unread input so that other HTTP/2 streams and stream cleanup can progress, and should not leave supervisor shutdown waiting on that input queue. This is separate from requiring HTTP/1 connection reuse after an early answer. A local workaround releasing that queue before final response cleanup, then dropping subsequent input to the finished application, restores progress in this reproduction; #389 still needs its separate removed-stream handling.

The same client supports removed to isolate #389: wait for stream 1 to finish, then send late DATA and a following GET.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions