Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A streaming handler can offer a chunk without waiting for room (#177).** `HttpResponse::tryWrite()` returns false when the outbound queue is full, having queued nothing, so the same chunk can be offered again; a client that has gone still throws `HttpException` 499, because "wait" and "stop" need opposite reactions and one bool cannot carry both. The pair mirrors `WebSocket::send()`/`trySend()` and shares their high-water mark, `HttpServerConfig::setStreamWriteBufferBytes()`. The transport answers where the chunk is queued rather than through a predicate read beforehand: `append_chunk` gained a `nonblocking` argument, and a transport that would have parked returns `HTTP_STREAM_APPEND_BACKPRESSURE` instead. HTTP/1 is the exception in both halves — it keeps no queue of its own, so it never refuses and an accepted chunk waits for the socket as `write()` does; #179 removes that.
- **A refused chunk can be waited out instead of spun on (#177).** `HttpResponse::awaitWritable()` suspends until the outbound queue has room and reports whether it has. Without it the only shapes after a `false` were a sleep-and-retry loop or a fall back to the blocking `send()`, and the drain event each transport already maintains was reachable from C only. The wait belongs to the transport, which keeps its own deadline and re-pumps its drain on each wake — assembling it at the PHP boundary instead would drop all three. HTTP/1 has no queue and so answers at once; a transport that can be full but cannot be waited on answers false rather than true, because a handler told to go ahead would spin without yielding, and on a pool worker that freezes every other request on the thread. The refusal granularity differs by transport and decides which shape is right — HTTP/2 refuses on 8 live slots or `setStreamWriteBufferBytes()`, HTTP/3 on any chunk not yet handed to nghttp3, so on HTTP/3 a refusal is expected once per chunk under a congested path.
- **A handler can ask whether the client is still there (#175).** `HttpResponse::isWritable()` reports whether output is still possible — `end()` was not called, the response is not sealed by `sendFile()`, and the peer has not gone. The only predicate before it was `sendable()`, which also answers false on a full queue, so a streaming loop could not separate "yield and continue" from "stop"; our own SSE example read it as the latter, and so did the loop that truncated a proxied body at ~100 KB in YanGusik/laravel-spawn#60. A false answer from `isWritable()` is final, which is what makes it safe to break on. An optional `is_alive` op on the stream vtable backs it in all four transports; on HTTP/1 a peer's departure only becomes visible when a write fails, so that discovery is recorded on the request and answered afterwards instead of being rediscovered by a second doomed write.

### Changed

- **BC: `write()` streams, and the buffered append moved to `appendBody()` (#180).** `HttpResponse::write()` appended to a buffer and put nothing on the wire until `end()`, while Node, Swoole and Go all stream under that name — both field reports behind this contract work (YanGusik/laravel-spawn#50, #60) came from the API rather than from the adapter's code. A handler that used `write()` for buffered appending keeps parsing and starts streaming: the first call commits status and headers, so every later `setHeader()` or `setStatusCode()` throws where it used to work. Rename those calls to `appendBody()`, which is the old behaviour under a name that says it.
- **BC: `send()` is removed; the call is `write()` (#180).** No alias is kept. An alias would have covered one call in the shipped laravel-spawn adapter (`src/Server/TrueAsyncServer.php:492`) while `isClosed()`, removed in the same release, breaks three others beside it — the adapter needs a release either way, and a deprecated spelling left behind only postpones the same edit. A call to `send()` now fails as an undefined method, at the line that has to change.
- **BC: `isClosed()` is now `isEnded()` (#180).** The method returned `response->closed`, the flag `end()` sets, and reported nothing about the connection — a handler reading it as "the peer is gone" got a wrong answer for the whole life of the response. `isWritable()` is the call that answers liveness. `isClosed()` no longer exists; a call fails as an undefined method.
- **BC: `sendable()` is removed, and its declaration is a tombstone (#180).** One bool answered four questions — closed, sealed by `sendFile()`, detached, full — and `README.md` documented it as a liveness check until #174, which is the loop that truncated a proxied body in YanGusik/laravel-spawn#60. Our own `examples/sse-server.php` broke its loop on it too, and now stops on `!isWritable()`. Calling `sendable()` raises `HttpServerRuntimeException` naming both replacements: `isWritable()` for liveness, `tryWrite()`/`awaitWritable()` for room. The declaration stays one minor release so shipped adapter code is told what to call rather than failing as an undefined method.
- **BC: `getBodyStream()` and `setBodyStream()` are removed (#180).** Neither ever had an implementation: the first returned null, the second threw "Body stream support is not yet implemented". A handler wanting a file on the wire calls `sendFile()`; one wanting incremental output calls `write()`.
- **BC: `RoomDeliveryException` extends `HttpServerException`, not `WebSocketException`.** A build configured with `--disable-websocket` serves rooms, and in it `WebSocketException` does not exist. A handler that caught `WebSocketException` around `Room::send()`, `Room::trySend()` or `HttpServer::send()` no longer catches it — catch `RoomDeliveryException` or `HttpServerException` instead. Nothing else about the exception changed: the `delivered` and `pending` counts and the message are what they were.
- **Rooms build without WebSocket.** The pub/sub core is `src/room/` and no longer knows what a connection is; `--disable-websocket` compiles it, registers `Room` and `RoomDeliveryException`, and delivers a publish from one thread to a `recv()` in another. `getRuntimeStats()` reports the room counters in every build, and the request-shutdown sweep that detaches a subscribed thread now runs in every build — without WebSocket it did not, so such a thread leaked its mailbox and left a live libuv handle behind.

### Fixed

- **A streamed response was held by the compressor until the stream ended (#170).** `HttpResponse::send()` fed every chunk to the encoder in continue mode (`Z_NO_FLUSH`, `ZSTD_e_continue`, `BROTLI_OPERATION_PROCESS`) and a block was closed only by `finish()` at end of stream, so a progress feed, a log tail or a row-by-row export reached the client in one burst at the end. Text compresses well enough that a whole stream fits inside that holdback, which is why the failure hit exactly the payloads people stream; incompressible bodies crossed the buffer on the first chunk and streamed normally. Reproduction in the issue: 350 KB of CSV emitted in five bursts 300 ms apart arrived as one 10 KB burst after 1.5 s under `Accept-Encoding: gzip`, against arrivals every 300 ms without it. The encoder vtable now carries a `flush` op (`Z_SYNC_FLUSH`, `ZSTD_e_flush`, `BROTLI_OPERATION_FLUSH`) and the streaming wrapper calls it once per non-empty chunk the handler hands over, so the boundary the handler chose is the flush granularity. Measured with `013-h1-streaming-gzip-flush.phpt`: a client reading a stream whose handler is still parked decoded 0 of 4600 bytes before, and the whole first chunk after; Brotli and zstd went from 0 bytes on the wire to a decodable block. The cost is one closed block per chunk — 7.7 bytes for gzip, 9.9 for Brotli, 9.8 for zstd, measured over 80 chunks of 51 bytes (`dev/BENCHMARKS.md`) — so a handler streaming row by row trades ratio for immediacy and still sends 4.8 times less than identity. An empty chunk skips the flush, and a buffered response is untouched: it still compresses in one shot.
- **A buffered body was discarded without error when the handler then streamed (#181).** `setBody()`, `appendBody()`, `json()` or `html()` followed by a streaming call put only the streamed chunks on the wire; the buffer was never read, because the streaming path commits its own headers and the buffered dispose path runs only while `streaming` is false. The reverse direction has always thrown, so the failure looked symmetric and reported on one side only. The guard every streaming entry point shares now refuses a non-empty buffer and names both modes; an empty one does not count, since `setBody('')` commits the handler to nothing. Test `tests/phpt/server/core/062-body-api-names.phpt`, route `/mixed`: without the guard the route answers `Transfer-Encoding: chunked` with body `streamed`, with it `Content-Length: 8` and body `buffered`.
- **A cancelled handler could seal a half-written chunk (#177).** An HTTP/1 chunk is three writes — size line, body, CRLF — and the coroutine suspends between them, so a cancellation lands mid-frame: parse-error cancellation, `ThreadPool::stop()`, a scope teardown. `mark_ended` then wrote the terminal zero-chunk regardless, telling the peer the body had ended cleanly and handing the connection on for reuse — while the peer read that terminator as the first bytes of the chunk the orphaned size line had promised. A frame interrupted this way is now recorded as a dead stream: no terminator, and the connection is not kept alive.
- **A dropped chunk was reported as written (#177).** When the reactor's mailbox refused a wire after its retries, `worker_stream_append_chunk` answered OK, so a pool-dispatched handler was told it had written bytes the peer will never see. It now reports the stream dead, which is what the abort already sent on the next call.
- **`sendable()` answered a constant true under compression, and on HTTP/3 (#177).** A transport that can refuse must publish the predicate, because the compressing wrapper reads it to decide whether it may feed the encoder, and an encoder cannot be un-fed: without it a refusal on HTTP/3 threw away a block deflate had already emitted, and the retry the caller was told to make wrote the same bytes into a window that already held them. The compressing stream wrapper installed on the first `write()` carried neither a `sendable` nor an `is_alive` slot, and a NULL slot means "no queue of its own, report writable" — so on a compressed response both questions were answered by the absence of an implementation rather than by the transport holding the queue. HTTP/3 had no `sendable` at all, for a stream that does queue. Both now report from the transport: the wrapper delegates, and HTTP/3 answers whether the previous chunk has reached nghttp3, which is the granularity its `append_chunk` waits on.
- **A streamed response was held by the compressor until the stream ended (#170).** `HttpResponse::write()` fed every chunk to the encoder in continue mode (`Z_NO_FLUSH`, `ZSTD_e_continue`, `BROTLI_OPERATION_PROCESS`) and a block was closed only by `finish()` at end of stream, so a progress feed, a log tail or a row-by-row export reached the client in one burst at the end. Text compresses well enough that a whole stream fits inside that holdback, which is why the failure hit exactly the payloads people stream; incompressible bodies crossed the buffer on the first chunk and streamed normally. Reproduction in the issue: 350 KB of CSV emitted in five bursts 300 ms apart arrived as one 10 KB burst after 1.5 s under `Accept-Encoding: gzip`, against arrivals every 300 ms without it. The encoder vtable now carries a `flush` op (`Z_SYNC_FLUSH`, `ZSTD_e_flush`, `BROTLI_OPERATION_FLUSH`) and the streaming wrapper calls it once per non-empty chunk the handler hands over, so the boundary the handler chose is the flush granularity. Measured with `013-h1-streaming-gzip-flush.phpt`: a client reading a stream whose handler is still parked decoded 0 of 4600 bytes before, and the whole first chunk after; Brotli and zstd went from 0 bytes on the wire to a decodable block. The cost is one closed block per chunk — 7.7 bytes for gzip, 9.9 for Brotli, 9.8 for zstd, measured over 80 chunks of 51 bytes (`dev/BENCHMARKS.md`) — so a handler streaming row by row trades ratio for immediacy and still sends 4.8 times less than identity. An empty chunk skips the flush, and a buffered response is untouched: it still compresses in one shot.
- **A compressing stream wrapper returned a faulted encoder to the pool.** The buffered path destroys an encoder that answered `HTTP_ENC_ERROR`, because its internal state is indeterminate; the streaming path left it attached to the response, and teardown handed it back to the per-thread pool for the next response to reuse. It is now destroyed on the spot, and `mark_ended` writes no trailer when the encoder is gone.

## [0.12.0] - 2026-08-15

### Added
Expand Down
45 changes: 45 additions & 0 deletions dev/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@
One entry per measurement, newest first. An entry names the machine, the build and
the scenario, because a number without them cannot be compared with the next one.

## 2026-08-20 — what the three writes per HTTP/1 chunk cost (#179)

Branch `180-body-rename` at 2e72f41. Machine: WSL2, Linux 6.6.114.1, 16 cores.
PHP 8.6.0-dev ZTS **release** (`--disable-debug`), built for this measurement into
`/home/edmond/php-release-24` from the same php-src the debug build uses, because
the installed release PHP is ABI v0.23 and the extension needs v0.24. Load:
`wrk -t1 -c4 -d6s`, three runs per cell, median reported. Server:
`tests/perf/servers/server_stream.php` in `h1` mode, one worker.

`h1_stream_append_chunk` sends a chunk as three awaited writes — size line, body,
CRLF (`src/http1/http1_stream.c:154`). Counted with `strace -e trace=write,epoll_pwait`
on one request of four 16 KiB chunks: **3 `write(2)` and 3 zero-timeout
`epoll_pwait` per chunk**, whatever the chunk size, plus one write for the headers
and one for the terminator. The loop turn after each write is the coroutine
suspending: `async_io_req_await` returns early only on `req->completed`, and
libuv's inline-write fast path does not fire for back-to-back writes on one stream.

The comparison holds the body at 64 KiB and moves only the chunk count. The second
build differs by one hunk: the three pieces are copied into one buffer and sent as
a single awaited write.

| chunks | chunk | three writes | one write | gain | µs per chunk, before → after |
|---|---|---|---|---|---|
| 1 | 64 KiB | 15746 | 17207 | +9.3% | — |
| 4 | 16 KiB | 8320 | 11876 | +42.7% | 18.9 → 8.7 |
| 16 | 4 KiB | 2938 | 5209 | +77.3% | 18.5 → 8.9 |
| 64 | 1 KiB | 893 | 1650 | +84.8% | 16.8 → 8.7 |

Taken. Per-chunk cost is flat in the chunk size and halves when the frame goes out
as one write: the two extra syscalls and two extra loop turns are worth about
10 µs per chunk. The 1 KiB row is the noisiest — its three base runs were 1135,
893 and 781 — and the others repeat within 3%.

The prototype copies the whole chunk to coalesce it, and still wins by that much.
A vectored write would avoid the copy, but the ABI has no awaitable one:
`io_pipe_writev_cb` (`php-src/ext/async/libuv_reactor.c:4947`) sends no NOTIFY and
frees the request itself, so `ZEND_ASYNC_IO_WRITEV` cannot be awaited. Removing the
copy means adding that op to ext/async.

What this decides for #179: the win is reachable without a queue, without an
ordering hazard between two writers and without a per-response structure — the
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.

## 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
Loading
Loading