NIOAsyncChannel corrupted HTTP/2 stream: why?

no matter what i try, i cannot seem to send an HTTP/2 response using the new async API without corrupting the HTTP/2 stream.

extension NIOAsyncChannelOutboundWriter<HTTP2Frame.FramePayload>
{
    func finish(
        with message: ...) async throws
    {
        if  let body:ByteBuffer = message.content
        {
            try await self.write(.headers(.init(headers: message.headers, endStream: false)))
            try await self.write(.data(.init(data: .byteBuffer(body), endStream: true)))
        }
        else
        {
            try await self.write(.headers(.init(headers: message.headers, endStream: true)))
        }

        self.finish()
    }
}
$ curl --http2 -v https://localhost
*   Trying 127.0.0.1:443...
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN, offering h2
...
* Using HTTP2, server supports multiplexing
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* Using Stream ID: 1 (easy handle 0x5607cf6a1e90)
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET / HTTP/2
> Host: localhost
> user-agent: curl/7.81.0
> accept: */*
> 
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)
* stopped the pause stream!
* Connection #0 to host localhost left intact
curl: (92) HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)

here’s the part that iterates the async sequence:

for try await stream:NIOAsyncChannel<
    HTTP2Frame.FramePayload,
    HTTP2Frame.FramePayload> in streams.inbound
{
    let message:HTTP.ServerMessage<Authority, HPACKHeaders> = ...

    try await stream.outbound.finish(with: message)

    //  pause for debugging
    try await Task.sleep(for: .seconds(10))

    try await connection.channel.close()
}

it worked fine with the old channel handler-based API! what on earth am i doing wrong?

hah. after two hours of debugging, i have discovered it is spooky HPACK action at a distance.

i was sending too many headers.

[
    ":scheme": "https", 
    ":authority": authority.domain, 
    ":status": "\(status)"
]

for some reason :scheme and :authority causes NIO, or curl, or firefox, or all three, to ignore the headers frame. then when NIO sends rstStream to terminate the stream, curl/firefox gets confused and thinks the stream was reset for no reason.

moral of the story: it’s always HPACKHeaders’ fault!

in all seriousness though, this type could really benefit from a builder API. nobody should be encoding pseudo-headers manually! it’s just asking for trouble…

//  one day...
let headers:HPACKHeaders = .server(status: 200)
{
    $0["content-type"] = "\(contentType)"
    $0["content-length"] = "\(length)"
    $0["etag"] = "\"\(hash)\""
}
1 Like

I agree, don't use the type directly, use the new types: https://github.com/apple/swift-nio-extras/blob/main/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift