Skip to content
Merged
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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 `send()` 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
Expand All @@ -18,9 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **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 `send()` 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::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 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
60 changes: 55 additions & 5 deletions dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@ wire unverified. The contract is settled; the steps are ordered so the
documentation lands first, because the reporter is writing a proxy recipe against
it and expects a tag within days.

- [ ] **Document the three body modes first.** `docs/USAGE.md` says nothing about
- [x] **Document the three body modes first.** `docs/USAGE.md` says nothing about
the response body at all. It gains a section naming the modes — buffered
(`setBody`), streamed (`write`), file (`sendFile`) — the state each commits, and
a framing table: a buffered body gets its `Content-Length` computed, an
undeclared stream is chunked or DATA frames, a declared stream keeps the header.
`README.md:277` and the `write()` docblock (`stubs/HttpResponse.php:160`, which
never says that nothing leaves before `end()`) are corrected in the same step.
- [ ] **`isWritable(): bool` — liveness, with the op behind it.** A new optional
Done in #174: the README guard is gone and both docblocks say what they mean.
The `docs/USAGE.md` section is deliberately deferred to land with the renames,
so it is written once against the final names.
- [x] **`isWritable(): bool` — liveness, with the op behind it.** A new optional
`is_alive` in `http_response_stream_ops_t` (`include/php_http_server.h:706`);
every backend already computes it inside `append_chunk` (`peer_closed` for H2,
`stream_credit_is_dead` for the worker). Sound as a predicate because every
Expand All @@ -56,15 +59,28 @@ it and expects a tag within days.
shipped adapter code calls it; `setBodyStream()`/`getBodyStream()`
(`stubs/HttpResponse.php:257,265`) are deleted — one throws "not yet
implemented", the other returns null.
- [ ] **`tryWrite(): bool` and the dialect twins.** The non-blocking half of the
- [~] **`tryWrite(): bool` and the dialect twins.** In #178, without the twins. The non-blocking half of the
pair, matching `WebSocket::trySend()`; `trySseEvent()` and `tryWriteMessage()`
follow, so the idiom is not half-applied. Invariant: false means nothing was
queued and no header was committed, and a dead peer is still the 499 exception.
Blocked by the compressing wrapper — `ws_append_chunk` feeds the encoder and
closes a block before it consults the underlying ops, so a refusal there is not
retryable, and the capacity check has to move ahead of the encoder.
`compressing_stream_ops` (`src/compression/http_compression_response.c:701`) has
no `sendable` slot either, so under compression the answer is a constant true.
Three review passes reshaped it. The refusal moved into `append_chunk` as a
`nonblocking` argument, because a predicate read beforehand cannot be atomic at
the PHP boundary; the wait moved into a `wait_writable` op, because each
transport's own wait carries a deadline, a wake source and a re-pump of the
drain that a wait assembled outside would drop; `awaitWritable()` answers false
rather than true where a transport can be full but offers no wait, since "go
ahead" spins a handler that trusts it. `compressing_stream_ops` and
`h3_stream_ops` gained the missing `sendable` slots — without them a refusal
under compression threw away a block the encoder had already emitted, and the
retry the caller was told to make corrupted the deflate stream.

**HTTP/1 is the open exception**: it keeps no queue of its own, so it never
refuses and an accepted chunk waits for the socket. Two ways to close it were
tried and rejected — see below. The twins (`trySseEvent`, `tryWriteMessage`)
wait for that to settle.
- [ ] **Framing by declared length.** A `Content-Length` set before the first
`write()` reaches the client verbatim on every protocol, and the server becomes
the auditor: excess throws at the offending write, a shortfall aborts the stream
Expand All @@ -78,6 +94,40 @@ it and expects a tag within days.
docblock was corrected ahead of the rename in YanGusik/laravel-spawn#63, so the
wording stops teaching the loop that truncated #60 in the meantime.

## HTTP/1 has no non-blocking write, and the two candidate fixes are both wrong

`tryWrite()` cannot refuse on HTTP/1: the streaming path writes through
`http_connection_send` → `send_raw`, which submits a `uv_write` and awaits it, so
backpressure is the kernel socket buffer and there is no depth to read. Two
designs were worked out and both fail on something mechanical.

- **A second writer for the non-blocking case** (`http_connection_send_batched`,
the one WebSocket uses). It is an unordered channel: a chunk body parked in
`out_pending_buf` waits behind an in-flight write while the headers, a blocking
`send()` and `mark_ended`'s terminal chunk go out through the raw path and reach
the peer first. Chunked framing does not survive that.
- **A queue on the response** (`http1_request_ctx_t`). Dead twice over: on TLS the
drain writer would run in scheduler context, where `tls_wait_space` refuses
outright (`src/core/http_connection_tls.c:97`), so it could not push a byte on
HTTPS; and the context is freed in `http_request_finalize`, while a queue drained
by write completions outlives the handler by definition. The precedents cited for
it — the H2 per-stream ring and the wslay FIFO — both live on connection-lifetime
objects, not on a per-request one.

- [ ] **Answer from the queues the connection already has.** Plaintext:
`out_pending_buf` carries a byte count, a high-water predicate on the same knob,
low-water hysteresis, a drain hook and a destroy defer gate — all implemented and
all exercised by WebSocket. TLS: `BIO_ctrl_get_write_guarantee` on the plaintext
BIO is the exact predicate `tls_wait_space` loops on, so a refusal built from it
is exact by construction. Neither needs a new structure. What it does need is the
out-of-band writers brought under one order first — `send_strv_owned` ignores the
pending tail, and `emit_parse_error` writes with a direct `send(2)` syscall.
Measure before deciding: the case for it is three submits and up to three
suspensions per chunk, and that number has never been taken.
- [ ] **#179 — one serialized outbound path per HTTP/1 connection.** The larger
version of the same idea. Filed, and to be judged against the measurement rather
than against the argument.

## The cmocka suite rots unnoticed

Nothing builds these targets in CI, so production signatures move and the tests keep
Expand Down
26 changes: 26 additions & 0 deletions ide-stubs/true-async-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -2314,6 +2314,32 @@ public function send(string $chunk): static {}
*/
public function sendable(): bool {}

/**
* Offer a chunk without waiting for room: false means the outbound queue
* had no room and nothing was queued, so the same chunk can be offered
* again later. A client that has gone throws HttpException 499 instead of
* answering false, because "wait" and "stop" need opposite reactions.
*
* HTTP/1 keeps no queue of its own, so it never refuses and an accepted
* chunk waits for the socket exactly as send() does.
*/
public function tryWrite(string $chunk): bool {}

/**
* Wait until the outbound queue has room again, and report whether it has.
* True at once on a transport with no queue; false without waiting on one
* that cannot be waited on. A timeout or a cancellation arrives as an
* exception.
*/
public function awaitWritable(?int $timeoutMs = null): bool {}

/**
* True while output is still possible: end() was not called, the response
* is not sealed by sendFile(), and the client has not gone. A false answer
* is final, which is what separates it from sendable().
*/
public function isWritable(): bool {}

// === Server-Sent Events ===

/**
Expand Down
26 changes: 24 additions & 2 deletions include/php_http_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -707,15 +707,28 @@ struct http_response_stream_ops_t {
/* Append a chunk (caller already bumped its refcount). Returns
* one of http_stream_append_result_t. The op itself knows the
* threshold (it lives in the context), so send() doesn't need
* to see server config. */
int (*append_chunk)(void *ctx, zend_string *chunk);
* to see server config.
*
* `nonblocking` forbids suspending the calling coroutine: a transport
* that would have parked returns HTTP_STREAM_APPEND_BACKPRESSURE
* INSTEAD, having queued nothing and committed nothing, so the caller
* may offer the same chunk again. Deciding inside the op is what makes
* that atomic — a predicate consulted beforehand answers about a moment
* that has already passed. */
int (*append_chunk)(void *ctx, zend_string *chunk, bool nonblocking);

/* Advisory, non-blocking: true when append_chunk would accept a
* chunk without suspending the handler (the per-stream staging
* buffer has room). Backs HttpResponse::sendable(). MAY be NULL —
* protocols without a userspace staging ring (HTTP/1, paced by the
* kernel socket buffer) leave it NULL and sendable() reports true. */
bool (*sendable)(void *ctx);
/* REQUIRED of any op whose append_chunk can answer
* HTTP_STREAM_APPEND_BACKPRESSURE: the compressing wrapper reads it to
* decide whether it may feed the encoder, and an encoder cannot be
* un-fed. A NULL slot therefore promises "this transport never refuses",
* and a transport that refuses anyway loses the bytes of a flushed
* block on every refusal. */

/* True while output is still possible: the peer has not gone and the
* transport can still carry bytes. Every input is a one-way latch, so a
Expand All @@ -730,6 +743,15 @@ struct http_response_stream_ops_t {
* Idempotent. */
void (*mark_ended)(void *ctx);

/* Wait until append_chunk would accept a chunk, and report whether it
* would. `timeout_ms` of 0 means the transport's own write deadline.
* Each transport keeps what its internal wait already does — its
* deadline, its re-pump of the drain, its wake source — which a wait
* assembled at the PHP boundary would drop. MAY be NULL: the caller
* then falls back to get_wait_event, and a transport with neither
* cannot be waited on at all. */
bool (*wait_writable)(void *ctx, uint32_t timeout_ms);

/* Lazily-created trigger event the handler awaits on under
* backpressure. Fired by the drain side when the queue drops
* below threshold. Returns NULL only on alloc failure — callers
Expand Down
Loading
Loading