Skip to content

HTTP/1 writes a response through two channels, which blocks a non-blocking append #179

Description

@EdmondDantes

Found while trying to give HttpResponse a non-blocking append (#177), and it outlives that attempt.

Every byte of an HTTP/1 response goes through http_connection_sendhttp_connection_send_raw, which submits a uv_write and awaits its completion (src/core/http_connection.c:1374-1389). So a streamed chunk always parks the handler coroutine, whether or not the socket had room. A non-blocking offer is impossible on this path by construction.

The connection also owns a second writer, http_connection_send_batched, which keeps one write in flight and coalesces the rest into out_pending_buf, submitted from the completion callback (:1588-1606). It never suspends. WebSocket's trySend() uses it, and http_connection_outbound_over_highwater() measures its tail.

Using the second writer for streamed chunks is what does not work today, and the reason is ordering. Headers (h1_emit_headers_once), a blocking send(), and the terminal 0\r\n\r\n from mark_ended all take the raw path. A chunk body queued in out_pending_buf therefore sits behind an in-flight write while the next raw send goes out immediately and reaches the peer first. Chunked framing does not survive that: the client reads the terminal chunk, then parses the stranded body as a chunk-size line, and on a keep-alive connection the desync poisons every pipelined response behind it. TLS has its own version — tls_fsm_send_plaintext_atomic writes straight to SSL while headers went through the ring — plus a high-water mark that reads out_pending_len, a buffer TLS connections never fill.

What is needed

One serialized outbound path per connection for HTTP/1 response bytes — headers, chunks and the terminal chunk through the same writer — with backpressure read from that writer's own depth. A blocking producer then suspends on the drain event rather than on a write completion, and a non-blocking one gets a refusal that means something. That also removes a park from the streaming hot path, where every chunk currently costs one await.

Blocks #177.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions