Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions dev/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,36 @@ three things both rejected designs foundered on. Whatever else #179 wants, a
non-blocking `tryWrite()` on HTTP/1, has to be argued on its own; this measurement
does not support it.

### Where the copy stops paying

Coalescing costs one user-space copy of the chunk, so it pays only while that copy
is cheaper than the two syscalls it removes. Body held at 1 MiB, chunk size moved,
five runs per cell, median:

| chunk | three writes | one write | gain |
|---|---|---|---|
| 1 KiB | 49 | 132 | +167% |
| 4 KiB | 226 | 374 | +65% |
| 16 KiB | 857 | 1319 | +54% |
| 32 KiB | 1486 | 1860 | +25% |
| 64 KiB | 2606 | 2197 | −16% |
| 128 KiB | 3616 | 3206 | −11% |
| 256 KiB | 4457 | 3546 | −20% |

The crossing is between 32 and 64 KiB, so `H1_CHUNK_COALESCE_MAX` is 32 KiB and a
larger chunk keeps the three-write path.

### The shipped change, verified against the noise

Runs of the same build drift by up to 9% on this machine, which is wider than some
of the gains above, so the shipped change was re-measured by alternating the two
builds — start, three `wrk` runs, stop, swap — three rounds each.

| chunk | three writes | shipped | |
|---|---|---|---|
| 64 KiB | 2362, 2409, 2511 | 2473, 2573, 2577 | same code path either side of the threshold; the spread is the noise floor |
| 4 KiB | 197, 243, 208 | 367, 376, 377 | +81%, and every run of one build is outside the other's range |

## 2026-08-19 — cost of the per-chunk flush on a streamed response (#170)

Base commit 22a8d37 plus the #170 working tree. Machine: WSL2, Linux 6.6.114.1,
Expand Down
12 changes: 10 additions & 2 deletions dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,16 @@ designs were worked out and both fail on something mechanical.
`io_pipe_writev_cb` sends no NOTIFY and frees the request itself.

What it decides: the win needs no queue, no second writer and no per-response
structure, so it is not an argument for #179. Next step is to land the coalesced
frame as its own change, with the copy or with a new ext/async op.
structure, so it is not an argument for #179.

- [x] **Send a streamed chunk as one write below 32 KiB.** Done. The threshold is
where the copy stops paying, measured: +25% at a 32 KiB chunk, −16% at 64 KiB, so
a larger chunk keeps the copy-free three-write path. Verified against a 9% noise
floor by alternating the two builds — +81% at 4 KiB chunks, and no difference at
64 KiB, where both take the same path. Test
`tests/phpt/server/h1/029-h1-chunk-coalesce.phpt` reads the raw response and
checks the chunk-size lines on both sides of the threshold. The copy stays until
ext/async gains an awaitable vectored write.

- [ ] **Answer from the queues the connection already has.** Plaintext:
`out_pending_buf` carries a byte count, a high-water predicate on the same knob,
Expand Down
50 changes: 47 additions & 3 deletions src/http1/http1_stream.c
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@
/* Maximum hex chunk-size line (16 hex digits for 64-bit len) + CRLF. */
#define H1_CHUNK_HEADER_MAX 18

/* Largest frame — size line, body and CRLF together — that goes out as one
* copied write instead of three separate ones. Coalescing trades two syscalls
* and two scheduler round-trips against one copy of the chunk, and the two are
* worth the same somewhere between 32 and 64 KiB: measured at a 1 MiB body,
* +25% at a 32 KiB chunk and -16% at 64 KiB (dev/BENCHMARKS.md, 2026-08-20,
* plaintext, wrk on loopback — the crossing moves with the machine).
*
* The bound is on the frame and not on the chunk because of TLS, where
* tls_push splits anything larger than the plaintext ring: a frame one byte
* over spends a second ring cycle on a TLS record carrying six bytes. The two
* numbers are independent — one is a measured crossing, the other a buffer
* size — so the assert below catches them drifting apart rather than tying
* the plaintext decision to a TLS constant. */
#define H1_CHUNK_COALESCE_MAX (32 * 1024)

ZEND_STATIC_ASSERT(H1_CHUNK_COALESCE_MAX <= HTTP_TLS_PLAINTEXT_RING_BYTES,
"a coalesced frame must fit one TLS plaintext ring cycle");

static bool h1_emit_headers_once(http1_request_ctx_t *ctx)
{
http_connection_t *conn = ctx->conn;
Expand Down Expand Up @@ -149,9 +167,35 @@ static int h1_stream_append_chunk(void *opaque, zend_string *chunk,
return HTTP_STREAM_APPEND_STREAM_DEAD;
}

if (!http_connection_send(conn, header, (size_t)header_len) ||
!http_connection_send(conn, ZSTR_VAL(chunk), chunk_len) ||
!http_connection_send(conn, "\r\n", 2)) {
/* One write per frame while the copy is cheaper than the two syscalls it
* removes; a large chunk keeps the three-write path and stays copy-free.
* Each http_connection_send suspends the handler until its write
* completes, so the count of them is the count of scheduler round-trips.
*
* Both branches hand a buffer the caller owns to a write that outlives the
* call when a cancellation lands mid-flight: libuv keeps the pointer until
* its completion callback, while dispose only marks the request pending.
* Closing that needs a write which reports its status AND takes the buffer
* over — today's ABI offers one or the other, never both. */
const size_t frame_len = (size_t)header_len + chunk_len + 2;
bool frame_ok;

if (frame_len <= H1_CHUNK_COALESCE_MAX) {
char *const frame = emalloc(frame_len);

memcpy(frame, header, (size_t)header_len);
memcpy(frame + header_len, ZSTR_VAL(chunk), chunk_len);
memcpy(frame + header_len + chunk_len, "\r\n", 2);

frame_ok = http_connection_send(conn, frame, frame_len);
efree(frame);
} else {
frame_ok = http_connection_send(conn, header, (size_t)header_len)
&& http_connection_send(conn, ZSTR_VAL(chunk), chunk_len)
&& http_connection_send(conn, "\r\n", 2);
}

if (!frame_ok) {
/* The write is how the peer's departure becomes visible on H1 — record
* it so isWritable() can answer without a second doomed write. */
ctx->stream_dead = true;
Expand Down
93 changes: 93 additions & 0 deletions tests/phpt/server/h1/029-h1-chunk-coalesce.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
--TEST--
HttpResponse::write() — chunk framing is identical either side of the coalescing threshold
--EXTENSIONS--
true_async_server
true_async
--FILE--
<?php
/* A chunk at or below H1_CHUNK_COALESCE_MAX leaves as one write, a larger one
* as three (src/http1/http1_stream.c). The split is an optimisation and must
* not show on the wire: this reads the raw response and checks the chunk-size
* lines against the lengths the handler wrote, on both sides of the 32 KiB
* boundary, plus the de-chunked body byte for byte. */

use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\spawn;
use function Async\await;

require_once __DIR__ . '/../_free_port.inc';

$port = tas_free_port();
$sizes = [1024, 32 * 1024, 32 * 1024 + 1, 64 * 1024];

$expected = '';
foreach ($sizes as $i => $n) {
$expected .= str_repeat(chr(65 + $i), $n);
}

$server = new HttpServer((new HttpServerConfig())
->addListener('127.0.0.1', $port)
->setReadTimeout(10)->setWriteTimeout(10));

$server->addHttpHandler(function ($req, $res) use ($sizes, $server) {
$res->setStatusCode(200)->setHeader('Content-Type', 'application/octet-stream');

foreach ($sizes as $i => $n) {
$res->write(str_repeat(chr(65 + $i), $n));
}

$res->end();
$server->stop();
});

$cli = spawn(function () use ($port, $sizes, $expected) {
usleep(30000);
$fp = stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 5);
stream_set_timeout($fp, 5);
fwrite($fp, "GET /stream HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");

$wire = '';
while (!feof($fp)) {
$c = fread($fp, 65536);
if ($c === '' || $c === false) break;
$wire .= $c;
}
fclose($fp);

[$head, $rest] = explode("\r\n\r\n", $wire, 2);
echo "chunked=", (int)(bool)preg_match('/^transfer-encoding:\s*chunked/mi', $head), "\n";

/* Walk the chunked body by hand: every size line must match what the
* handler wrote, in order, and the terminator must be the last thing. */
$body = '';
$seen = [];
$off = 0;
while (true) {
$eol = strpos($rest, "\r\n", $off);
if ($eol === false) { echo "TRUNCATED\n"; break; }
$len = hexdec(substr($rest, $off, $eol - $off));
$off = $eol + 2;
if ($len === 0) break;
$seen[] = $len;
$body .= substr($rest, $off, $len);
$off += $len + 2;
}

echo "sizes_match=", (int)($seen === $sizes), "\n";
echo "sizes=", implode(',', $seen), "\n";
echo "body_len=", strlen($body), "\n";
echo "body_match=", (int)($body === $expected), "\n";
});

$server->start();
await($cli);
echo "done\n";
?>
--EXPECT--
chunked=1
sizes_match=1
sizes=1024,32768,32769,65536
body_len=132097
body_match=1
done