Skip to content

Code 1006 during web socket termination on Google Chrome when outgoing data is queued during the close() #327

@jonathanslenders

Description

@jonathanslenders

Only in Chrome, not in Firefox nor Safari, we get an 1006 status code in the browser in the WebSocket.onerror callback.

The error happens if outgoing data is queued at the point that we call await websocket.close() in the FastAPI application. Placing a short sleep in front of the close() call resolves the issue.

The error only happens when using http/2. Not when using http/1.1.

Using Chrome's built-in chrome://net-export, we can see the following difference between a successful teardown and a failure:

When we have a success (1000 code in Chrome), with a sleep before the close() call, we see the following at the end of the logs:

t=16596 [st=10599]  HTTP2_STREAM_UPDATE_RECV_WINDOW
                    --> delta = -4
                    --> stream_id = 39
                    --> window_size = 6291452
t=16596 [st=10599]  HTTP2_STREAM_UPDATE_RECV_WINDOW
                    --> delta = 4
                    --> stream_id = 39
                    --> window_size = 6291456
t=16596 [st=10599]  WEBSOCKET_RECV_FRAME_HEADER
                    --> final = true
                    --> masked = false
                    --> opcode = 8
                    --> payload_length = 2
                    --> reserved1 = false
                    --> reserved2 = false
                    --> reserved3 = false
t=16596 [st=10599]  WEBSOCKET_SENT_FRAME_HEADER
                    --> final = true
                    --> masked = true
                    --> opcode = 8
                    --> payload_length = 2
                    --> reserved1 = false
                    --> reserved2 = false
                    --> reserved3 = false
t=16596 [st=10599]  HTTP2_STREAM_UPDATE_SEND_WINDOW
                    --> delta = -8
                    --> stream_id = 39
                    --> window_size = 65351
t=16596 [st=10599]  WEBSOCKET_READ_BUFFER_SIZE_CHANGED
                    --> read_buffer_size_in_bytes = 1000
t=16596 [st=10599]  HTTP2_STREAM_UPDATE_SEND_WINDOW
                    --> delta = 8
                    --> stream_id = 39
                    --> window_size = 65359

However, without the sleep, we see the following at the end of the logs:

t=9382 [st=1040]  HTTP2_STREAM_UPDATE_RECV_WINDOW
                  --> delta = 232
                  --> stream_id = 19
                  --> window_size = 5983778
t=9382 [st=1040]  HTTP2_STREAM_UPDATE_RECV_WINDOW
                  --> delta = -4
                  --> stream_id = 19
                  --> window_size = 5983774
t=9392 [st=1050]  WEBSOCKET_READ_BUFFER_SIZE_CHANGED
                  --> read_buffer_size_in_bytes = 131000
t=9408 [st=1066]  WEBSOCKET_RECV_FRAME_HEADER
                  --> final = true
                  --> masked = false
                  --> opcode = 8
                  --> payload_length = 2
                  --> reserved1 = false
                  --> reserved2 = false
                  --> reserved3 = false
t=9416 [st=1074]  WEBSOCKET_SENT_FRAME_HEADER
                  --> final = true
                  --> masked = true
                  --> opcode = 8
                  --> payload_length = 2
                  --> reserved1 = false
                  --> reserved2 = false
                  --> reserved3 = false

This is mostly identical, except some h2 flow control that's missing at the end.

I also notice that Chrome reports a successful 1000 code after we apply the following (obviously incorrect) patch to Hypercorn:

--- a/src/hypercorn/protocol/ws_stream.py
+++ b/src/hypercorn/protocol/ws_stream.py
@@ -300,7 +300,7 @@ class WSStream:
                         reason=message.get("reason"),
                     )
                 )
-                await self.send(EndData(stream_id=self.stream_id))
+                # await self.send(EndData(stream_id=self.stream_id))
             else:
                 raise UnexpectedMessageError(self.state, message["type"])

So, the web browser tries to send a close message after we sent our close message as a result of our ASGI app producing a "websocket.close" message. But somehow it's not able to deliver this close response. I can't tell if the h2 channel is closed at this point or if there is no flow control quota. I would assume that the h2 EndData message only does a half-close, meaning we still have the receive window open for the browser to send its "close" message.

The FastAPI websocket endpoint looks something like this:

@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
    await websocket.accept()
    ... = await websocket.receive_json()

    await websocket.send_text(<lots of data>)

    await websocket.close()

Unfortunately, I don't have a precise reproduction script, but already wanted to share the findings so far in the hope that maybe it does ring a bell.

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