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_send → http_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.
Found while trying to give
HttpResponsea non-blocking append (#177), and it outlives that attempt.Every byte of an HTTP/1 response goes through
http_connection_send→http_connection_send_raw, which submits auv_writeand 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 intoout_pending_buf, submitted from the completion callback (:1588-1606). It never suspends. WebSocket'strySend()uses it, andhttp_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 blockingsend(), and the terminal0\r\n\r\nfrommark_endedall take the raw path. A chunk body queued inout_pending_buftherefore 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_atomicwrites straight to SSL while headers went through the ring — plus a high-water mark that readsout_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.