From 8533afa526ea6de5ec5310bb02ea3edb50e24de5 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:57:00 +0000 Subject: [PATCH 01/33] =?UTF-8?q?feat(grpc):=20gRPC=20over=20HTTP/2=20?= =?UTF-8?q?=E2=80=94=20unary=20+=20all=20streaming=20shapes=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core of #4: gRPC server support on the existing nghttp2 HTTP/2 stack (no grpc c-core). Protobuf stays in PHP userland; C only moves opaque length-prefixed octets. See docs/PLAN_GRPC.md. Phase 0 — trailers: - Streaming responses emit trailers at true EOF inside the nghttp2 data provider (h2_dp_mark_eof), after all DATA drains — carries grpc-status/grpc-message on server-streaming/bidi replies. - Allow setTrailer/setTrailers/resetTrailers on a committed streaming response (only end()/sendFile() seal them), as the API contract intended and gRPC server-streaming requires. Phase 1 — unary: - Route application/grpc to addGrpcHandler; accept a gRPC-only server (no addHttpHandler) by enabling the h2 transport and relaxing the start()/ accept gates. conn->handler stays NULL; per-stream dispatch routes by content-type. - New src/grpc/ framing codec: 5-byte length-prefix framer/deframer with a max-message guard, status codes, grpc-timeout parser. - HttpRequest::readMessage() / HttpResponse::writeMessage(). - Auto content-type: application/grpc; default grpc-status: 0; Trailers-Only for bodiless errors; uncaught exception -> grpc-status 13. Phase 2 — streaming (no new C code): server-streaming, client-streaming and half-duplex bidi all fall out of the Phase 0/1 machinery. Phase 3 — grpc-timeout parsed + exposed via HttpRequest::getGrpcTimeout(). Tests: tests/phpt/server/grpc/001-007 + h2/027 (streaming trailers). Full h2 + core suites green. Deferred (documented in docs/PLAN_GRPC.md): full-duplex bidi (incremental read) + body-cap fix, server-side deadline auto-cancel, gzip, grpc-web, and gRPC-over-HTTP/3. The deadline auto-cancel and exception->status mapping are gated on the pre-existing h2 handler-exception teardown leak (#101). --- CMakeLists.txt | 1 + config.m4 | 1 + config.w32 | 1 + docs/PLAN_GRPC.md | 317 ++++++++++++++++++ include/http1/http_parser.h | 5 + include/http2/http2_session.h | 7 + include/http2/http2_stream.h | 12 + include/php_http_server.h | 8 + src/grpc/grpc.c | 135 ++++++++ src/grpc/grpc.h | 71 ++++ src/http2/http2_session.c | 70 +++- src/http2/http2_strategy.c | 99 ++++-- src/http_request.c | 59 ++++ src/http_response.c | 147 +++++++- src/http_server_class.c | 30 +- stubs/HttpRequest.php | 25 ++ stubs/HttpRequest.php_arginfo.h | 96 +++--- stubs/HttpResponse.php | 15 + stubs/HttpResponse.php_arginfo.h | 8 +- .../phpt/server/grpc/001-grpc-unary-h2c.phpt | 82 +++++ .../server/grpc/002-grpc-unary-error.phpt | 77 +++++ .../grpc/004-grpc-server-streaming.phpt | 93 +++++ .../grpc/005-grpc-client-streaming.phpt | 83 +++++ tests/phpt/server/grpc/006-grpc-bidi.phpt | 92 +++++ tests/phpt/server/grpc/007-grpc-timeout.phpt | 71 ++++ .../server/h2/027-h2-streaming-trailers.phpt | 77 +++++ 26 files changed, 1593 insertions(+), 89 deletions(-) create mode 100644 docs/PLAN_GRPC.md create mode 100644 src/grpc/grpc.c create mode 100644 src/grpc/grpc.h create mode 100644 tests/phpt/server/grpc/001-grpc-unary-h2c.phpt create mode 100644 tests/phpt/server/grpc/002-grpc-unary-error.phpt create mode 100644 tests/phpt/server/grpc/004-grpc-server-streaming.phpt create mode 100644 tests/phpt/server/grpc/005-grpc-client-streaming.phpt create mode 100644 tests/phpt/server/grpc/006-grpc-bidi.phpt create mode 100644 tests/phpt/server/grpc/007-grpc-timeout.phpt create mode 100644 tests/phpt/server/h2/027-h2-streaming-trailers.phpt diff --git a/CMakeLists.txt b/CMakeLists.txt index 8abad25d..8fa38e3a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,7 @@ set(CORE_SOURCES src/http_server_exceptions.c src/http_request.c src/http_response.c + src/grpc/grpc.c src/http_sse.c src/uploaded_file.c src/http_mime.c diff --git a/config.m4 b/config.m4 index 25c35643..3971cc31 100644 --- a/config.m4 +++ b/config.m4 @@ -530,6 +530,7 @@ if test "$PHP_HTTP_SERVER" != "no"; then src/formats/multipart_processor.c src/http_request.c src/http_response.c + src/grpc/grpc.c src/http_sse.c src/http_response_server_api.c src/http_body_stream.c diff --git a/config.w32 b/config.w32 index 16af34e5..721c5af9 100644 --- a/config.w32 +++ b/config.w32 @@ -20,6 +20,7 @@ if (PHP_TRUE_ASYNC_SERVER == "yes") { "src\\http_server_class.c " + "src\\http_request.c " + "src\\http_response.c " + + "src\\grpc\\grpc.c " + "src\\http_sse.c " + "src\\http_response_server_api.c " + "src\\uploaded_file.c " + diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md new file mode 100644 index 00000000..f3848174 --- /dev/null +++ b/docs/PLAN_GRPC.md @@ -0,0 +1,317 @@ +# gRPC support — implementation plan (issue #4) + +_Status: living document. Last updated 2026-07-04._ + +## Progress + +- **Phase 0 — DONE.** Streaming-response trailers now emit correctly + (`h2_dp_mark_eof` submits response trailers at true EOF, inside nghttp2's + send loop, once all DATA has drained). Also fixed: `setTrailer/setTrailers/ + resetTrailers` were wrongly blocked after `send()` — they are now allowed + on a committed streaming response (only `end()`/`sendFile()` seal them), + which the code comment already intended and gRPC server-streaming requires. + Test: `tests/phpt/server/h2/027-h2-streaming-trailers.phpt`. +- **Phase 1 — DONE (unary).** `application/grpc` routing → `addGrpcHandler`; + a gRPC-only server (no `addHttpHandler`) is accepted; `src/grpc/` framing + codec; `HttpRequest::readMessage()` / `HttpResponse::writeMessage()`; + auto `content-type: application/grpc`; default `grpc-status: 0`; + Trailers-Only for bodiless errors. Tests: `tests/phpt/server/grpc/001` (unary + echo) and `/002` (error status, Trailers-Only). Full h2 + core suites green. +- **Phase 2 — DONE (all four RPC shapes), no new C code.** Server-streaming, + client-streaming, and bidi all fall out of the Phase 0 + Phase 1 machinery + (`writeMessage` → streaming send; `readMessage` loop over the deframer; + EOF trailers). Tests: `/004` (server-streaming, N framed responses), `/005` + (client-streaming, N request messages), `/006` (bidi half-duplex, N-in/N-out + on one stream). + **Deferred to a Phase 2b follow-up** (coupled + substantial, not yet needed by + the buffered path): true *full-duplex interleaved* bidi — reading each message + as it arrives while writing — needs `readMessage()` wired to the incremental + `http_body_stream` (issue #26) instead of the buffered `req->body`, and with it + the body-cap fix (§4.3, the cumulative lifetime cap in `http_body_stream.c`). + gRPC uses the buffered request body today, so §4.3 does not bite yet; + server/client/half-duplex-bidi work fully. +- **Phase 3 — DONE (propagation); hard auto-cancel deferred.** `grpc-timeout` + is parsed (`grpc_parse_timeout_ns`) and exposed to handlers via + `HttpRequest::getGrpcTimeout(): ?float` (seconds), so a handler can bound its + own work against the client deadline. Test: `/007`. **Deferred:** server-side + auto-cancel of the handler when the deadline elapses — that injects a + cancellation into the coroutine (the same unwind path that leaks a handle in + #101), so it is coupled to that fix; and server-emitted `DEADLINE_EXCEEDED` + is not required for interop (the client enforces its own deadline). + +### Discovered pre-existing bug (independent of gRPC) — NOT yet fixed + +An **uncaught exception in any HTTP/2 handler** (gRPC or plain HTTP, GET or +POST) leaks a reactor handle and aborts the worker at teardown +(`ZEND_ASYNC_REACTOR_LOOP_ALIVE() == false` assertion in ext/async +`scheduler.c`). Reproduced with a plain `addHttpHandler` that only does +`throw` — so it is not caused by the gRPC work and was never covered by a +test. The gRPC dispose path already maps an uncaught exception to +`grpc-status: 13` (INTERNAL), but that mapping cannot be exercised until this +teardown leak is fixed. **Recommend a separate fix/issue** for the h2 +handler-exception teardown; realistic gRPC handlers should catch their own +errors and set `grpc-status` explicitly (works today — see test 002). + +gRPC server support on top of the **existing** HTTP/2 (and, later, HTTP/3) +stack. Framing + trailers + status + deadline are implemented **in C**; +protobuf stays entirely in PHP userland (`ext/protobuf`). The C layer only +ever moves opaque, length-prefixed message octets. + +--- + +## 1. Context & the core decision + +Issue #4 asks for gRPC (unary + all three streaming shapes) over HTTP/2 and +HTTP/3, with `grpc-status`/`grpc-message` trailers, `grpc-timeout` deadlines, +`application/grpc[+proto]` content-types, optional gzip and grpc-web, verified +against a `grpc-go` interop client. + +**Decision: hand-write a thin gRPC framing layer directly on the existing +nghttp2 stack. Do _not_ vendor gRPC C-core (`libgrpc`).** + +C-core is not a protocol helper — it is a complete stack that ships its own +HTTP/2 transport ("chttp2"), its own `EventEngine`/iomgr, and its own +completion-queue thread pool. All three collide head-on with what this server +already owns (nghttp2, the libuv reactor, the True Async coroutine scheduler), +and none of them expose an injection point that would let C-core consume an +already-decoded nghttp2 stream. Vendoring it would mean running two HTTP/2 +stacks in one process and fighting a foreign threading model, plus a multi-MB +dependency tree (protobuf, Abseil, c-ares, re2, BoringSSL). + +This is also the mainstream choice: `grpc-go` writes framing on +`golang.org/x/net/http2`, Rust `tonic` on the `h2` crate, nginx `grpc_pass` +by hand. C-core is the _only_ stack that bundles its own HTTP/2. There is no +standalone "gRPC wire-logic" C library to vendor — the layer is too thin to +warrant one (~350–500 LOC of core framing/status/routing). + +Use `grpc-go`/`grpc-c++` **only** as an interop test peer, never as a linked +dependency. + +--- + +## 2. What already exists (reuse map) + +The server was scaffolded for gRPC. Almost the entire protocol surface is +present; the work is convention on top plus three wire fixes. + +| Capability | Where | State | +|---|---|---| +| Per-stream → coroutine dispatch, multiplex-safe (`extended_data`=stream) | `http2_strategy.c:325` | ✅ reuse as-is | +| Dispatch at HEADERS+END_HEADERS **before** body (bidi enabler) | `http2_session.c:581` (spawn `:594`) | ✅ reuse as-is | +| Incremental request body (client-streaming ingest) | `cb_on_data_chunk_recv` `http2_session.c:397`; `http_body_stream` push/pop `src/http_body_stream.c:38/78`; `HttpRequest::readBody()` `src/http_request.c:606` | ✅ reuse | +| Incremental response DATA (server-streaming) | `submit_response_streaming` `http2_session.c:1784`; `chunk_queue` + deferred data-provider; `HttpResponse::send()/end()` `src/http_response.c:867/1062` | ✅ reuse | +| HTTP/2 trailer emission | `http2_session_submit_trailer` `http2_session.c:1863` + `h2_dp_mark_eof` (`NO_END_STREAM`) `:1472`; PHP `setTrailer/getTrailers` `src/http_response.c:442-540` | ✅ unary path; ⚠️ streaming gap (§4) | +| `:status`/header flatten via HPACK | `h2_flatten_response_headers`, `h2_nv_set_status` `http2_session.c:1664` | ✅ reuse | +| gRPC-style keepalive (PING RTT, graceful GOAWAY, MAX_CONNECTION_AGE) | `http2_session.c:653-693`; `http_server_config.c:1169` | ✅ reuse | +| `addGrpcHandler()` → `protocol_handlers[HTTP_PROTOCOL_GRPC]` | `src/http_server_class.c:1534` | 🟡 stores handler, otherwise inert | +| `HTTP_PROTOCOL_GRPC` enum + mask + name mapping | `http_protocol_strategy.h:126`; `http_protocol_handlers.c:25/46` | 🟡 defined, unrouted | +| Transport-agnostic PHP API shared with H3 | `src/http_response.c`, `src/http_request.c`, `src/http_body_stream.c` | ✅ codec written once serves H2 + H3 | + +**Consequence:** all four RPC shapes are one duplex code path. Unary / +server-stream / client-stream / bidi are a userland contract, not a wire +signal — early dispatch + `readMessage()` + `writeMessage()` on the same +coroutine already give bidi. + +--- + +## 3. Architecture of the thin layer + +``` + HTTP/2 stream ──dispatch──▶ is content-type application/grpc && grpc handler set? + │ yes │ no + ▼ ▼ + mark stream->is_grpc, normal HTTP handler + inject content-type, (unchanged) + parse grpc-timeout → deadline + │ + ▼ + grpc handler coroutine (request, response) + reads: $req->readMessage() ← C deframer over request body + writes: $resp->writeMessage() → C framer → chunk_queue (streaming send) + status: $resp->setTrailer('grpc-status', N) (default 0 if unset) + │ dispose + ▼ + grpc commit: ensure grpc-status trailer, emit + HEADERS(200, application/grpc) + DATA(msgs) + HEADERS(trailers, END_STREAM) + or Trailers-Only (single HEADERS, END_STREAM) when 0 messages +``` + +### New module: `src/grpc/` +- `grpc_frame.{c,h}` — 5-byte prefix framer (`flag u8 + len u32be + bytes`) and + a deframer state machine that reassembles messages spanning DATA frames, + with a hard `max_recv_message` guard (defends against a forged 4 GiB length). +- `grpc_status.h` — the 0–16 status enum + `grpc-message` percent-encoder + (UTF-8 bytes ≤0x20, `%`, ≥0x7F → `%XX`). +- `grpc.{c,h}` — request classification (`grpc_request_is_grpc`), `grpc-timeout` + parse, `:path` → (service, method) split, and the H2 commit/dispose glue. + +### Stream-struct additions (`include/http2/http2_stream.h`) +- `bool is_grpc;` +- `zend_fcall_t *grpc_handler;` (per-stream handler override; NULL = use `conn->handler`) +- deframer cursor state for `readMessage()` (or hang it off the request object) +- deadline bookkeeping for `grpc-timeout` + +### PHP API (minimal surface — 2 methods) +- `HttpRequest::readMessage(): ?string` — next deframed message (protobuf bytes), + `null` when the client half-closed with no more messages. Loop for + client-streaming; call once for unary. +- `HttpResponse::writeMessage(string $message): static` — frame + enqueue one + message (drives the streaming `send()` path). Call N times for + server-streaming; once for unary. +- Status stays on the existing trailer API: `setTrailer('grpc-status', '0')` + (+ optional `grpc-message`). The C commit defaults `grpc-status: 0` when the + handler set none, and auto-injects `content-type: application/grpc`. + +**Unification:** every gRPC response rides the **streaming** path (even unary, +via `writeMessage`→`send`). A zero-message response is finalized by the same +`h2_stream_mark_ended` empty-commit that SSE already uses. This means the +streaming trailer fix (§4.1) is the _only_ trailer path gRPC needs — the +buffered path is never used for gRPC. + +--- + +## 4. The three verified wire gaps (fix before/while building) + +All three confirmed by direct code reading, not speculation. + +### 4.1 Streaming responses never emit trailers — **blocker** +`h2_stream_mark_ended` (`http2_strategy.c:1653-1685`) only resumes + emits; it +never calls `http2_session_submit_trailer`. The unary path does +(`http2_strategy.c:1017-1046`), but streaming does not. Without this, +`grpc-status` after any server-streaming/bidi body is silently dropped and the +client hangs. **Fix (Phase 0):** before the final +`http2_session_resume_stream_data`, read `http_response_get_trailers` and +submit them, so `has_trailers` is set and `h2_dp_mark_eof` stamps +`NO_END_STREAM` on the last DATA slice. Generic, benefits every trailer user. + +### 4.2 Empty-body responses drop trailers → gRPC errors break — **blocker** +`http2_session_submit_response` (`http2_session.c:1739-1743`) sends a `NULL` +data-provider when `body_len == 0` → HEADERS+END_STREAM, closing the stream +before the subsequent `submit_trailer` can run. gRPC errors (`UNIMPLEMENTED`, +`DEADLINE_EXCEEDED`, …) are exactly no-body responses. Because gRPC always uses +the streaming path (§3), the natural fix is: the zero-message gRPC dispose +force-activates the streaming empty-commit (already handled at +`http2_strategy.c:1669-1680`) so §4.1's trailer emission runs — yielding +HEADERS + empty DATA + trailing HEADERS, which `grpc-go` accepts. (True +single-HEADERS Trailers-Only via header-merge is an optional later +optimization.) + +### 4.3 Body cap is a **lifetime** cap → long client/bidi streams get RST — **high** +`http2_session.c:458` gates each inbound chunk on +`body_bytes_consumed + body_bytes_queued + len > body_cap`, and +`body_bytes_consumed` (`http_body_stream.c:96`) is monotonic — never +decremented. So once a stream has _ever_ received `max_body_size` (default +10 MiB) it is RST, even if the handler drained everything. gRPC streams are +lifetime-unbounded by design; a long client-streaming/bidi RPC must be able to +push gigabytes. **Fix (Phase 2):** on gRPC streams, bound the _live_ (queued, +not cumulative) bytes and/or apply a per-message max instead of the cumulative +ceiling. + +**Also:** never `RST_STREAM` on normal completion — a `NO_ERROR` RST arriving +before the client reads the trailing HEADERS can swallow `grpc-status: 0` +(Envoy #30149 / grpc-go #8041). Clean streams close via END_STREAM; RST only on +genuine cancel/abort. + +--- + +## 5. Phases + +Each phase must **build** and land **green phpt tests** before the next. + +### Phase 0 — streaming trailer emission (prerequisite, protocol-agnostic) +- **Do:** §4.1 fix in `h2_stream_mark_ended`. +- **Test:** `tests/phpt/server/h2/` — a `send()`-streamed response that also + `setTrailer('grpc-status','0')`; assert the trailing `grpc-status: 0` header + appears after the body (curl `--http2-prior-knowledge -v`, mirror of + `003-h2c-trailers.phpt`). +- **Exit:** trailers ride both unary and streaming responses. + +### Phase 1 — gRPC unary over h2c/h2 +- **Do:** `src/grpc/` module (framer/deframer + status + percent-encode); + content-type routing at `http2_strategy_dispatch` (mirror the + `h2_request_is_ws_connect` check); `stream->is_grpc` + handler override; + `HttpRequest::readMessage()` / `HttpResponse::writeMessage()`; auto-inject + `content-type: application/grpc` + default `grpc-status: 0`; wire the module + into `config.m4` (new guarded block ≈`:570`), `config.w32` (`ADD_SOURCES` + ≈`:154`), `CMakeLists.txt`. +- **Test:** new `tests/phpt/server/grpc/`. Client crafts a body = + `\x00` + `uint32be(len)` + protobuf bytes (hand-built in PHP), POSTs via curl + with `-H 'content-type: application/grpc' -H 'te: trailers' + --http2-prior-knowledge --data-binary @body -o out.bin`; assert response + status 200, `content-type: application/grpc`, `grpc-status: 0` trailer, and + the echoed framed message bytes (`bin2hex` on `out.bin`). Error path: + unknown method → `grpc-status: 12` Trailers-Only. +- **Exit:** unary round-trip + `UNIMPLEMENTED`/error path green. + +### Phase 2 — streaming (server / client / bidi) + body-cap fix +- **Do:** message loop over the existing `chunk_queue` (out) and + `http_body_stream` (in) for all four shapes on the one duplex path; §4.3 + body-cap fix. Half-close (END_STREAM → body EOF) already models + client-done. +- **Test:** `H2TestClient` (`tests/phpt/server/h2/_h2_client.inc`) or curl — + server-streaming (N frames then trailers), client-streaming (N request + frames → single response), bidi; assert no RST race and trailers after the + last message. A >10 MiB client-streaming test guards §4.3. +- **Exit:** all four shapes green. + +### Phase 3 — `grpc-timeout` deadline +- **Do:** parse `<≤8 digits>` at dispatch; arm a per-stream + deadline bound to the coroutine (reuse existing per-request timeout/cancel + machinery); on fire, cancel the coroutine; inbound `RST_STREAM(CANCEL)` → + cancel (path exists). Server need not emit `grpc-status 4` itself (the client + enforces its own deadline) — scope this to parse + cancel. +- **Test:** slow handler + short `grpc-timeout`; assert the handler is + cancelled and the stream closes. +- **Exit:** deadline cancels the handler coroutine. + +### Phase 4 — per-message gzip (optional) +- **Do:** `grpc-encoding` / `grpc-accept-encoding` negotiation + Compressed-Flag + byte via zlib (already linked); ship `identity` first, then gzip; unsupported + request codec → `grpc-status 12` + advertise support. +- **Test:** gzip-flagged request/response round-trip. + +### Phase 5 — grpc-web + HTTP/3 (optional, separate track) +- **grpc-web:** redirect trailer emission into an in-body `0x80`-flagged frame + (+ base64 for `-text`, + CORS), behind a content-type switch. Shares the + framer, **not** the HTTP/2 trailer machinery. +- **HTTP/3:** the H3 stack (ngtcp2+nghttp3, `src/http3/`) is mature for + request/response but has **no trailer emission at all** — no + `nghttp3_conn_submit_trailers`, no `NGHTTP3_DATA_FLAG_NO_END_STREAM`. That + path must be built from scratch (unary _and_ streaming) before the same + framing codec drops onto `src/http3/`. Roughly a second H2-sized effort; + gate on `grpc-go`/H3 interop maturity. + +--- + +## 6. Build & test commands + +```bash +# after editing existing .c files: +make -j"$(nproc)" + +# after adding a NEW .c file (config.m4 / config.w32 / CMakeLists touched): +phpize && ./configure --enable-http-server --enable-http2 --enable-http3 \ + --with-php-config="$(which php-config)" && make -j"$(nproc)" + +# run one test (or a dir): +php run-tests.php -d extension_dir="$(pwd)/modules" -P -q \ + tests/phpt/server/grpc/ +``` + +Interop (future): add `tests/phpt/server/grpc/` wire tests now; a `grpc-go` +interop harness can later mirror `tests/interop/quic/`'s Dockerized runner +pattern. Per-phase exit gate follows the #59 discipline (code-quality review, +no dead code, comments ≤1–2 lines, tests green incl. fuzz where relevant). + +--- + +## 7. Risks + +- **Trailers-Only vs two-HEADERS** and the **RST-vs-trailers race** are the + classic interop-incompat bugs — test both explicitly (§4.2, §4.3 note). +- **PHP-side message API** must reassemble boundaries centrally in C (the + deframer), never per-handler. +- **HTTP/3** trailers are a from-scratch build, not a codec drop-in — the H3 + half of the issue is a genuinely separate effort (Phase 5). +- Framing-in-C changes the byte contract of `send()`/`readBody()` **only on + gRPC routes** — keep it from leaking into non-gRPC paths. diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index 73b92c53..8d94d966 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -91,6 +91,11 @@ struct http_request_t { /* Body */ zend_string *body; + /* gRPC deframer cursor into `body` — advanced by HttpRequest::readMessage() + * as it extracts each 5-byte-length-prefixed message. Zero-initialised + * with the rest of the request (memset on alloc). */ + size_t grpc_read_offset; + /* Body-progress event. * Lazily created by awaitBody() on the first suspend; notified by * the parser on body_complete once the event-loop read path lands. diff --git a/include/http2/http2_session.h b/include/http2/http2_session.h index 0b271893..06e473ec 100644 --- a/include/http2/http2_session.h +++ b/include/http2/http2_session.h @@ -284,6 +284,13 @@ int http2_session_submit_trailer(http2_session_t *session, const http2_header_view_t *trailers, size_t trailers_len); +/* Extract the PHP HttpResponse's trailer map and submit it via + * http2_session_submit_trailer. No-op when the response has no trailers. + * Used by the buffered commit (eager) and the streaming EOF path (lazy). */ +void http2_session_submit_response_trailers(http2_session_t *session, + uint32_t stream_id, + zend_object *response_obj); + /* Feed peer bytes into the nghttp2 state machine. * * @param data Buffer of ciphertext-decrypted / plaintext bytes. diff --git a/include/http2/http2_stream.h b/include/http2/http2_stream.h index 4cdf988c..290329e3 100644 --- a/include/http2/http2_stream.h +++ b/include/http2/http2_stream.h @@ -96,6 +96,18 @@ struct http2_stream_t { * the final DATA slice. */ bool has_trailers; + /* Guards the lazy streaming-trailer submission in h2_dp_mark_eof so a + * multi-slice EOF (e.g. a trailing zero-length DATA frame) submits the + * response trailers exactly once. Buffered/unary responses submit + * eagerly in the commit path and never touch this. */ + bool trailers_submitted; + + /* True when this stream carries a gRPC call (content-type + * application/grpc + a registered gRPC handler). Set at dispatch; + * routes the coroutine to the gRPC handler and makes dispose default + * the grpc-status trailer. */ + bool is_grpc; + /* Streaming-response chunk queue. * * Active only when the handler called HttpResponse::send(); a diff --git a/include/php_http_server.h b/include/php_http_server.h index 8e519341..ea81c6d5 100644 --- a/include/php_http_server.h +++ b/include/php_http_server.h @@ -1129,6 +1129,14 @@ HashTable *http_response_get_headers (zend_object *obj); HashTable *http_response_get_trailers(zend_object *obj); const char *http_response_get_body (zend_object *obj, size_t *len_out); +/* Default the grpc-status trailer to `status` unless one is already set. + * Used by the gRPC dispose path (grpc-status is mandatory on the wire). */ +void http_response_ensure_grpc_status(zend_object *obj, int status); + +/* Fold response trailers into headers (then clear trailers). Used to build a + * gRPC Trailers-Only reply when the handler streamed no messages. */ +void http_response_promote_trailers_to_headers(zend_object *obj); + /* Borrow the body's underlying zend_string. Returns NULL when the body * is empty. The string is owned by the response object — addref it if * you need to outlive the response. Avoids a full body memcpy on the diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c new file mode 100644 index 00000000..8392335c --- /dev/null +++ b/src/grpc/grpc.c @@ -0,0 +1,135 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ + + gRPC wire-protocol helpers layered on the existing HTTP/2 stack: request + classification, grpc-timeout parsing, and the 5-byte length-prefix + framing codec. Protobuf stays in PHP userland — this file only moves + opaque length-prefixed octets. See docs/PLAN_GRPC.md. +*/ + +#include "grpc.h" +#include "http1/http_parser.h" /* http_request_t */ + +#include +#include /* strncasecmp */ + +bool grpc_request_is_grpc(const http_request_t *req) +{ + if (req == NULL || req->headers == NULL) { + return false; + } + + zval *ct = zend_hash_str_find(req->headers, "content-type", + sizeof("content-type") - 1); + + if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { + return false; + } + + const size_t prefix_len = sizeof(GRPC_CONTENT_TYPE) - 1; /* 16 */ + + return Z_STRLEN_P(ct) >= prefix_len + && strncasecmp(Z_STRVAL_P(ct), GRPC_CONTENT_TYPE, prefix_len) == 0; +} + +uint64_t grpc_parse_timeout_ns(const http_request_t *req) +{ + if (req == NULL || req->headers == NULL) { + return 0; + } + + zval *to = zend_hash_str_find(req->headers, "grpc-timeout", + sizeof("grpc-timeout") - 1); + + if (to == NULL || Z_TYPE_P(to) != IS_STRING) { + return 0; + } + + const char *s = Z_STRVAL_P(to); + const size_t n = Z_STRLEN_P(to); + + /* Spec: 1..8 ASCII digits followed by a single unit char. */ + if (n < 2 || n > 9) { + return 0; + } + + uint64_t value = 0; + for (size_t i = 0; i + 1 < n; i++) { + if (s[i] < '0' || s[i] > '9') { + return 0; + } + value = value * 10u + (uint64_t)(s[i] - '0'); + } + + uint64_t unit_ns; + switch (s[n - 1]) { + case 'H': unit_ns = 3600ULL * 1000000000ULL; break; /* hours */ + case 'M': unit_ns = 60ULL * 1000000000ULL; break; /* minutes */ + case 'S': unit_ns = 1000000000ULL; break; /* seconds */ + case 'm': unit_ns = 1000000ULL; break; /* millis */ + case 'u': unit_ns = 1000ULL; break; /* micros */ + case 'n': unit_ns = 1ULL; break; /* nanos */ + default: return 0; + } + + return value * unit_ns; +} + +zend_string *grpc_frame_message(const char *msg, size_t len, bool compressed) +{ + zend_string *out = zend_string_alloc(5 + len, 0); + unsigned char *p = (unsigned char *)ZSTR_VAL(out); + + p[0] = compressed ? 1u : 0u; + p[1] = (unsigned char)((len >> 24) & 0xffu); + p[2] = (unsigned char)((len >> 16) & 0xffu); + p[3] = (unsigned char)((len >> 8) & 0xffu); + p[4] = (unsigned char)( len & 0xffu); + + if (len > 0) { + memcpy(p + 5, msg, len); + } + + ZSTR_VAL(out)[5 + len] = '\0'; + return out; +} + +int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, + uint32_t max_msg, bool *out_compressed, + zend_string **out) +{ + const size_t pos = *cursor; + + /* Need the full 5-byte frame header before we can size the message. */ + if (pos + 5 > len) { + return 0; + } + + const unsigned char *h = (const unsigned char *)buf + pos; + + const uint32_t mlen = ((uint32_t)h[1] << 24) + | ((uint32_t)h[2] << 16) + | ((uint32_t)h[3] << 8) + | (uint32_t)h[4]; + + if (mlen > max_msg) { + return -1; + } + + /* Message body may span DATA frames — wait until it's all buffered. */ + if (pos + 5 + (size_t)mlen > len) { + return 0; + } + + if (out_compressed != NULL) { + *out_compressed = (h[0] != 0); + } + + *out = zend_string_init(buf + pos + 5, mlen, 0); + *cursor = pos + 5 + (size_t)mlen; + return 1; +} diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h new file mode 100644 index 00000000..b76f6e62 --- /dev/null +++ b/src/grpc/grpc.h @@ -0,0 +1,71 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#ifndef HTTP_GRPC_H +#define HTTP_GRPC_H + +#ifdef HAVE_CONFIG_H +# include +#endif + +#include "php.h" +#include +#include + +/* http_request_t lives in http1/http_parser.h. Forward-declare so this + * header stays cheap to include from dispatch / request / response TUs. */ +struct http_request_t; + +/* gRPC canonical status codes (grpc.github.io/grpc/core/md_doc_statuscodes). + * Only the codes the server emits directly are named; the wire value is the + * decimal integer carried in the `grpc-status` trailer. */ +#define GRPC_STATUS_OK 0 +#define GRPC_STATUS_CANCELLED 1 +#define GRPC_STATUS_UNKNOWN 2 +#define GRPC_STATUS_INVALID_ARGUMENT 3 +#define GRPC_STATUS_DEADLINE_EXCEEDED 4 +#define GRPC_STATUS_NOT_FOUND 5 +#define GRPC_STATUS_UNIMPLEMENTED 12 +#define GRPC_STATUS_INTERNAL 13 +#define GRPC_STATUS_UNAVAILABLE 14 + +/* Hard ceiling on a single inbound message length taken from the 5-byte + * frame header — defends the deframer against a forged 4 GiB length field. + * 16 MiB matches grpc-go's default max-receive-message-size ballpark. */ +#define GRPC_MAX_RECV_MESSAGE (16u * 1024u * 1024u) + +/* Canonical gRPC content-type prefix (matches application/grpc, + * application/grpc+proto, application/grpc;charset=..., etc.). */ +#define GRPC_CONTENT_TYPE "application/grpc" + +/* True when the request is a gRPC call — POST with a content-type that + * begins with `application/grpc`. */ +bool grpc_request_is_grpc(const struct http_request_t *req); + +/* Parse the `grpc-timeout` request header (``, unit ∈ + * {H,M,S,m,u,n}) into nanoseconds. Returns 0 when absent or malformed. */ +uint64_t grpc_parse_timeout_ns(const struct http_request_t *req); + +/* Frame one message with the 5-byte gRPC prefix: + * byte 0 : compressed flag (0 = identity, 1 = compressed) + * bytes 1..4 : message length, uint32 big-endian + * bytes 5.. : message payload + * Returns a new zend_string the caller owns. */ +zend_string *grpc_frame_message(const char *msg, size_t len, bool compressed); + +/* Deframe the next message from buf[*cursor .. len). + * returns 1 : one message extracted; *out is a new zend_string (caller + * owns it) and *cursor advances past the frame, + * returns 0 : incomplete frame — need more bytes; *cursor unchanged, + * returns -1 : protocol error (declared length exceeds `max_msg`). + * `out_compressed` (nullable) receives the compressed flag. */ +int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, + uint32_t max_msg, bool *out_compressed, + zend_string **out); + +#endif /* HTTP_GRPC_H */ diff --git a/src/http2/http2_session.c b/src/http2/http2_session.c index 5d5af5f8..69b10fcc 100644 --- a/src/http2/http2_session.c +++ b/src/http2/http2_session.c @@ -1467,12 +1467,78 @@ bool http2_session_want_write(const http2_session_t *session) * Response submission * ------------------------------------------------------------------------- */ +/* Extract response trailers (name => value zend_string map) from the PHP + * HttpResponse and submit them via nghttp2_submit_trailer. Shared by the + * buffered (unary) commit — which submits eagerly before its drain — and + * the streaming path, which submits lazily at true EOF (h2_dp_mark_eof) + * once every queued DATA chunk has drained. No-op when the response has no + * trailers. Canonical gRPC consumer: grpc-status / grpc-message. */ +void http2_session_submit_response_trailers(http2_session_t *session, + const uint32_t stream_id, + zend_object *response_obj) +{ + if (response_obj == NULL) { + return; + } + + HashTable *trailers = http_response_get_trailers(response_obj); + + if (trailers == NULL || zend_hash_num_elements(trailers) == 0) { + return; + } + + http2_header_view_t scratch[HTTP2_NV_SCRATCH]; + http2_header_view_t *view = scratch; + http2_header_view_t *heap = NULL; + const size_t count = zend_hash_num_elements(trailers); + + if (count > HTTP2_NV_SCRATCH) { + heap = emalloc(count * sizeof(*heap)); + view = heap; + } + + size_t n = 0; + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + view[n].name = ZSTR_VAL(name); + view[n].name_len = ZSTR_LEN(name); + view[n].value = Z_STRVAL_P(val); + view[n].value_len = Z_STRLEN_P(val); + n++; + } ZEND_HASH_FOREACH_END(); + + if (n > 0) { + (void)http2_session_submit_trailer(session, stream_id, view, n); + } + + if (heap != NULL) { efree(heap); } +} + /* Stamp DATA_FLAG_EOF (+ NO_END_STREAM if trailers pending) on the - * provider's data_flags. */ -static inline void h2_dp_mark_eof(const http2_stream_t *stream, + * provider's data_flags. + * + * Streaming responses submit their trailers HERE — at true EOF, inside + * nghttp2's send loop, after every queued DATA chunk has drained. + * Submitting earlier (while data is still queued) makes nghttp2 drop the + * pending DATA. The buffered/unary path submits eagerly in its commit, so + * it is excluded via the chunk_queue check; run at most once per stream. */ +static inline void h2_dp_mark_eof(http2_stream_t *stream, uint32_t *data_flags) { *data_flags |= NGHTTP2_DATA_FLAG_EOF; + + if (stream->chunk_queue != NULL && !stream->trailers_submitted) { + stream->trailers_submitted = true; + + if (!Z_ISUNDEF(stream->response_zv)) { + http2_session_submit_response_trailers( + stream->session, stream->stream_id, + Z_OBJ(stream->response_zv)); + } + } + if (stream->has_trailers) { *data_flags |= NGHTTP2_DATA_FLAG_NO_END_STREAM; } diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index aef48d8d..396993a8 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -33,6 +33,8 @@ #include #endif #include "http1/http_parser.h" /* http_request_t */ +#include "grpc/grpc.h" /* grpc_request_is_grpc */ +#include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "static/static_handler.h" #include "http_send_file.h" #include "http_response_internal.h" @@ -220,6 +222,19 @@ static void http2_strategy_dispatch(struct http_request_t *request, return; } #endif + + /* gRPC classification (issue #4): a request whose content-type begins + * with application/grpc, routed to the handler registered via + * addGrpcHandler(). Detected here — like the WS check — so a gRPC-only + * server (no addHttpHandler) still dispatches past the handler-null + * guards below. */ + const bool is_grpc = + grpc_request_is_grpc(stream->request) + && http_protocol_has_handler( + http_server_get_protocol_handlers(self->conn->server), + HTTP_PROTOCOL_GRPC); + stream->is_grpc = is_grpc; + /* Static-only deployments register a static mount but no PHP * handler — the static dispatch path below claims the request * before we ever check conn->handler. The PHP-handler-required @@ -227,7 +242,7 @@ static void http2_strategy_dispatch(struct http_request_t *request, const bool has_static_mount = http_static_handler_count(self->conn->server) > 0; - if (self->conn->handler == NULL && !has_static_mount) { + if (self->conn->handler == NULL && !has_static_mount && !is_grpc) { return; } @@ -272,6 +287,15 @@ static void http2_strategy_dispatch(struct http_request_t *request, Z_OBJ(stream->response_zv), self->conn->config->json_encode_flags); } + /* gRPC: default the response content-type so handlers need not set it. + * Rides the initial HEADERS; a handler may still override before its + * first writeMessage(). */ + if (is_grpc) { + http_response_static_set_header(Z_OBJ(stream->response_zv), + "content-type", sizeof("content-type") - 1, + GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + } + /* Static-handler dispatch (issue #13). Identical policy to the * H1 site in http_connection_dispatch_request: * HARD_ZERO — FSM owns the request; on_hard_zero_armed has @@ -305,7 +329,7 @@ static void http2_strategy_dispatch(struct http_request_t *request, * guard would silently return and the stream would hang until * the client times out. Mirrors the H1 path in * http_connection_dispatch_request. */ - if (self->conn->handler == NULL && !stream->skip_handler) { + if (self->conn->handler == NULL && !stream->skip_handler && !is_grpc) { http_response_static_set_status(Z_OBJ(stream->response_zv), 404); http_response_static_set_header(Z_OBJ(stream->response_zv), "content-type", 12, "text/plain; charset=utf-8", 25); @@ -393,7 +417,21 @@ static void http2_handler_coroutine_entry(void) return; } - if (conn->handler == NULL) { return; } + /* gRPC streams route to the addGrpcHandler() callable; every other + * request uses the connection's HTTP handler. */ + zend_fcall_t *handler = conn->handler; + + if (stream->is_grpc) { + zend_fcall_t *grpc_handler = http_protocol_get_handler( + http_server_get_protocol_handlers(conn->server), + HTTP_PROTOCOL_GRPC); + + if (grpc_handler != NULL) { + handler = grpc_handler; + } + } + + if (handler == NULL) { return; } const bool stamps = http_server_sample_stamps_enabled(conn->view); @@ -432,7 +470,7 @@ static void http2_handler_coroutine_entry(void) zend_fcall_info fci = { .size = sizeof(zend_fcall_info), - .function_name = conn->handler->fci.function_name, + .function_name = handler->fci.function_name, .retval = &retval, .params = params, .object = NULL, @@ -447,7 +485,7 @@ static void http2_handler_coroutine_entry(void) volatile bool bailout = false; zend_try { - zend_call_function(&fci, &conn->handler->fci_cache); + zend_call_function(&fci, &handler->fci_cache); } zend_catch @@ -534,6 +572,7 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) * HttpException and parse-error cancellation behave identically * on both protocols. */ if (coroutine->exception != NULL && conn != NULL && + !stream->is_grpc && !Z_ISUNDEF(stream->response_zv) && !http_response_is_committed(Z_OBJ(stream->response_zv))) { zval rv, *code_zv, *msg_zv; @@ -563,6 +602,15 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(stream->response_zv)); } + /* gRPC outcome → grpc-status trailer. Success defaults to 0; an + * uncaught exception maps to INTERNAL (13) unless the handler already + * set a status. Rides the terminal HEADERS(trailers) frame. */ + if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) { + http_response_ensure_grpc_status(Z_OBJ(stream->response_zv), + coroutine->exception != NULL ? GRPC_STATUS_INTERNAL + : GRPC_STATUS_OK); + } + /* sendFile() handoff (issue #13). Take the descriptor before * the streaming/buffered branch so the FSM owns delivery; the * regular zval-dtor + refcount tail runs from h2_sendfile_on_done @@ -582,6 +630,15 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) if (!stream->streaming_ended) { h2_stream_mark_ended(stream); } + } else if (stream->is_grpc && conn != NULL + && !Z_ISUNDEF(stream->response_zv)) { + /* gRPC handler that streamed no messages (immediate status / error). + * Emit a Trailers-Only reply: fold grpc-status/grpc-message into the + * initial HEADERS so the buffered commit sends a single + * HEADERS(:status 200, END_STREAM) frame — the canonical gRPC shape + * for a bodiless response. */ + http_response_promote_trailers_to_headers(Z_OBJ(stream->response_zv)); + (void)http2_commit_stream_response(conn, stream); } else if (conn != NULL && !Z_ISUNDEF(stream->response_zv)) { (void)http2_commit_stream_response(conn, stream); } @@ -1014,36 +1071,8 @@ static bool http2_commit_stream_response(http_connection_t *conn, /* Trailers. Must be queued BEFORE the drain loop so * the data_provider sees has_trailers=true on the final DATA * slice and emits NO_END_STREAM instead of END_STREAM. */ - HashTable *trailers = http_response_get_trailers(response_obj); - - if (trailers != NULL && zend_hash_num_elements(trailers) > 0) { - http2_header_view_t tr_scratch[HTTP2_NV_SCRATCH]; - http2_header_view_t *tr_view = tr_scratch; - http2_header_view_t *tr_heap = NULL; - const size_t tr_count = zend_hash_num_elements(trailers); - - if (tr_count > HTTP2_NV_SCRATCH) { - tr_heap = emalloc(tr_count * sizeof(*tr_heap)); - tr_view = tr_heap; - } - - size_t ti = 0; - zend_string *tn; - zval *tv; - ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, tn, tv) { - if (tn == NULL || Z_TYPE_P(tv) != IS_STRING) { continue; } - tr_view[ti].name = ZSTR_VAL(tn); - tr_view[ti].name_len = ZSTR_LEN(tn); - tr_view[ti].value = Z_STRVAL_P(tv); - tr_view[ti].value_len = Z_STRLEN_P(tv); - ti++; - } ZEND_HASH_FOREACH_END(); - - (void)http2_session_submit_trailer(self->session, stream->stream_id, - tr_view, ti); - - if (tr_heap != NULL) { efree(tr_heap); } - } + http2_session_submit_response_trailers(self->session, stream->stream_id, + response_obj); /* GOAWAY after response+trailers so it bundles with the final DATA * in one writev. drain_submitted guards multi-stream double-GOAWAY. */ diff --git a/src/http_request.c b/src/http_request.c index 3a950727..9ea554ac 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -14,6 +14,7 @@ #include "php_http_server.h" #include "http1/http_parser.h" #include "http_body_stream.h" +#include "grpc/grpc.h" #include "core/async_plain_event.h" #include "log/trace_context.h" #include "Zend/zend_async_API.h" @@ -267,6 +268,64 @@ ZEND_METHOD(TrueAsync_HttpRequest, getBody) http_request_retval_str(return_value, intern->request->body); } +/* {{{ proto HttpRequest::readMessage(): ?string + * + * Deframe and return the next gRPC message from the request body (one + * 5-byte-length-prefixed frame), advancing an internal cursor. Returns + * null once no complete message remains — call once for a unary RPC, loop + * for client-streaming. The returned bytes are the raw (protobuf) message; + * decoding stays in PHP userland. */ +ZEND_METHOD(TrueAsync_HttpRequest, readMessage) +{ + http_request_object *intern = Z_HTTP_REQUEST_P(ZEND_THIS); + ZEND_PARSE_PARAMETERS_NONE(); + + http_request_t *req = intern->request; + + if (req == NULL || req->body == NULL) { + RETURN_NULL(); + } + + zend_string *msg = NULL; + const int rc = grpc_deframe_next(ZSTR_VAL(req->body), ZSTR_LEN(req->body), + &req->grpc_read_offset, + GRPC_MAX_RECV_MESSAGE, NULL, &msg); + + if (rc < 0) { + zend_throw_exception(http_server_runtime_exception_ce, + "gRPC message length exceeds the maximum allowed size", 0); + RETURN_NULL(); + } + + if (rc == 0) { + /* No complete message left at the cursor. */ + RETURN_NULL(); + } + + RETURN_STR(msg); +} + +/* {{{ proto HttpRequest::getGrpcTimeout(): ?float + * + * The gRPC call deadline parsed from the `grpc-timeout` request header, in + * seconds (fractional), or null when the client sent none / it was + * malformed. The server does not itself abort the handler when the deadline + * elapses (the client enforces its own deadline); a handler can read this + * and bound its own work against it. */ +ZEND_METHOD(TrueAsync_HttpRequest, getGrpcTimeout) +{ + http_request_object *intern = Z_HTTP_REQUEST_P(ZEND_THIS); + ZEND_PARSE_PARAMETERS_NONE(); + + const uint64_t ns = grpc_parse_timeout_ns(intern->request); + + if (ns == 0) { + RETURN_NULL(); + } + + RETURN_DOUBLE((double)ns / 1000000000.0); +} + ZEND_METHOD(TrueAsync_HttpRequest, hasBody) { http_request_object *intern = Z_HTTP_REQUEST_P(ZEND_THIS); diff --git a/src/http_response.c b/src/http_response.c index e4be03e5..7d1ee5e2 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -19,6 +19,7 @@ #include "php_http_server.h" #include "http_response_internal.h" #include "smart_str_scalable.h" +#include "grpc/grpc.h" #ifdef HAVE_HTTP_COMPRESSION # include "compression/http_compression_response.h" @@ -66,6 +67,30 @@ static inline bool response_check_closed(const http_response_object *response) return false; } +/* Weaker guard for trailer setters. Trailers are emitted at stream end, + * AFTER the body — so unlike headers/body they may legitimately be set + * once a streaming response has already committed its HEADERS via send(). + * Only end() (closed) and sendFile() sealing forbid them. This is what the + * "trailers are still allowed via non-guarded setters" contract means and + * is required for gRPC server-streaming, where grpc-status is known only + * after the messages have been streamed. */ +static inline bool response_check_trailer_sealed(const http_response_object *response) +{ + if (response->closed) { + zend_throw_exception(http_server_runtime_exception_ce, + "Cannot set trailers after end() has been called", 0); + return true; + } + + if (response->send_file_req != NULL) { + zend_throw_exception(http_server_runtime_exception_ce, + "Response is sealed by sendFile() — no further mutation allowed", 0); + return true; + } + + return false; +} + /* Helper: Normalize header name to lowercase */ static zend_string *normalize_header_name(zend_string *name) { @@ -439,6 +464,58 @@ static inline void ensure_trailers_table(http_response_object *response) } } +/* Fold every response trailer into the response headers, then clear the + * trailer table. Used by the gRPC dispose path to build a Trailers-Only + * reply — a single HEADERS(:status 200 + content-type + grpc-status + * [+ grpc-message], END_STREAM) frame — when the handler streamed no + * messages (immediate status / error). Trailer names are already + * lowercased by setTrailer, so they are valid HPACK header names. */ +void http_response_promote_trailers_to_headers(zend_object *obj) +{ + http_response_object *response = http_response_from_obj(obj); + + if (response->trailers == NULL + || zend_hash_num_elements(response->trailers) == 0) { + return; + } + + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(response->trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { + continue; + } + zval copy; + ZVAL_STR_COPY(©, Z_STR_P(val)); + zend_hash_update(response->headers, name, ©); + } ZEND_HASH_FOREACH_END(); + + zend_hash_clean(response->trailers); +} + +/* Default the `grpc-status` trailer to `status` (decimal) unless the handler + * already set one. Called from the H2/H3 gRPC dispose path so every gRPC + * response carries a status even when the handler forgot — grpc-status is + * mandatory on the wire, including on success (0). */ +void http_response_ensure_grpc_status(zend_object *obj, int status) +{ + http_response_object *response = http_response_from_obj(obj); + + ensure_trailers_table(response); + + if (zend_hash_str_exists(response->trailers, "grpc-status", + sizeof("grpc-status") - 1)) { + return; + } + + char buf[16]; + const int len = snprintf(buf, sizeof(buf), "%d", status); + zval v; + ZVAL_STRINGL(&v, buf, len); + zend_hash_str_update(response->trailers, "grpc-status", + sizeof("grpc-status") - 1, &v); +} + /* {{{ proto HttpResponse::setTrailer(string $name, string $value): static */ ZEND_METHOD(TrueAsync_HttpResponse, setTrailer) { @@ -452,7 +529,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, setTrailer) http_response_object *response = Z_HTTP_RESPONSE_P(ZEND_THIS); - if (response_check_closed(response)) { + if (response_check_trailer_sealed(response)) { return; } @@ -482,7 +559,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, setTrailers) http_response_object *response = Z_HTTP_RESPONSE_P(ZEND_THIS); - if (response_check_closed(response)) { + if (response_check_trailer_sealed(response)) { return; } @@ -513,7 +590,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, resetTrailers) http_response_object *response = Z_HTTP_RESPONSE_P(ZEND_THIS); - if (response_check_closed(response)) { + if (response_check_trailer_sealed(response)) { return; } @@ -939,6 +1016,70 @@ ZEND_METHOD(TrueAsync_HttpResponse, send) } /* }}} */ +/* {{{ proto HttpResponse::writeMessage(string $message): static + * + * Frame $message with the 5-byte gRPC length prefix (identity encoding) + * and stream it as one gRPC message. Activates streaming mode on the first + * call, exactly like send(). Call once for a unary reply, repeatedly for + * server-streaming. grpc-status is carried separately via setTrailer(); the + * server defaults it to 0 if the handler set none. */ +ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) +{ + zend_string *message; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(message) + ZEND_PARSE_PARAMETERS_END(); + + http_response_object *response = Z_HTTP_RESPONSE_P(ZEND_THIS); + + if (response->closed) { + zend_throw_exception(http_server_runtime_exception_ce, + "Response already closed — cannot writeMessage() after end()", 0); + return; + } + + if (response->send_file_req != NULL) { + zend_throw_exception(http_server_runtime_exception_ce, + "Response is sealed by sendFile() — no further mutation allowed", 0); + return; + } + + if (response->stream_ops == NULL) { + zend_throw_exception(http_server_runtime_exception_ce, + "Response streaming is not available on this response", 0); + return; + } + + /* First message — lock headers and switch to streaming mode. Mirrors + * send(): after this, setBody / setHeader / setStatusCode throw, but + * setTrailer() stays allowed (grpc-status is set post-commit). */ + if (!response->streaming) { + response->streaming = true; + response->committed = true; + response->headers_sent = true; + } + + /* Prepend the 5-byte gRPC frame header. The queue takes ownership of + * this fresh string (append_chunk consumes the ref), so no release. */ + zend_string *framed = grpc_frame_message( + ZSTR_VAL(message), ZSTR_LEN(message), false); + + const int rc = response->stream_ops->append_chunk( + response->stream_ctx, framed); + + if (rc == HTTP_STREAM_APPEND_STREAM_DEAD) { + zend_throw_exception_ex(http_exception_ce, 499, + "stream closed by peer"); + return; + } + + (void)rc; + + RETURN_OBJ_COPY(Z_OBJ_P(ZEND_THIS)); +} +/* }}} */ + /* {{{ proto HttpResponse::sendable(): bool * * Advisory, non-blocking backpressure check. Returns true when send() diff --git a/src/http_server_class.c b/src/http_server_class.c index 276a82c6..a7c430d1 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -1542,14 +1542,19 @@ ZEND_METHOD(TrueAsync_HttpServer, addGrpcHandler) return; } - /* Store handler - will be used when gRPC support is implemented */ http_protocol_add_handler_internal( INTERNAL_FUNCTION_PARAM_PASSTHRU, &server->protocol_handlers, HTTP_PROTOCOL_GRPC, Z_OBJ_P(ZEND_THIS) ); - server->view.protocol_mask |= HTTP_PROTO_MASK_GRPC; + + /* gRPC is carried over HTTP/2 — enable the h2 transport so the h2c + * preface / h2 ALPN is accepted even on a gRPC-only server (no + * addHttp2Handler). The per-stream dispatch then routes application/grpc + * requests to the gRPC handler and everything else to the HTTP handler + * (absent here → 404). */ + server->view.protocol_mask |= HTTP_PROTO_MASK_GRPC | HTTP_PROTO_MASK_HTTP2; } /* }}} */ @@ -1822,7 +1827,14 @@ static void http_server_accept_callback( handler = http_protocol_get_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP2); } - if (UNEXPECTED(handler == NULL && server->static_handler_count == 0)) { + /* gRPC-only servers register no h1/h2 handler — accept the connection + * anyway (gRPC rides h2). conn->handler stays NULL; the per-stream + * dispatch routes application/grpc to the gRPC handler. */ + const bool accept_grpc = + http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_GRPC); + + if (UNEXPECTED(handler == NULL && server->static_handler_count == 0 + && !accept_grpc)) { closesocket(client_fd); return; } @@ -2927,16 +2939,18 @@ ZEND_METHOD(TrueAsync_HttpServer, start) RETURN_FALSE; } - /* Require at least one handler that serves HTTP requests (h1 or h2), - * OR at least one static mount. h2-only deployments use - * addHttp2Handler; dual-protocol deployments use addHttpHandler; - * static-only deployments use addStaticHandler (issue #13). */ + /* Require at least one handler that serves requests (h1 or h2, or a + * gRPC handler over h2), OR at least one static mount. h2-only + * deployments use addHttp2Handler; dual-protocol use addHttpHandler; + * static-only use addStaticHandler (issue #13); gRPC-only servers use + * addGrpcHandler (issue #4). */ if (!http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP1) && !http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP2) && + !http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_GRPC) && server->static_handler_count == 0) { zend_throw_exception(http_server_runtime_exception_ce, "No HTTP handler registered. Call addHttpHandler(), " - "addHttp2Handler(), or addStaticHandler() first", 0); + "addHttp2Handler(), addGrpcHandler(), or addStaticHandler() first", 0); RETURN_FALSE; } diff --git a/stubs/HttpRequest.php b/stubs/HttpRequest.php index e942638c..8c38b65c 100644 --- a/stubs/HttpRequest.php +++ b/stubs/HttpRequest.php @@ -195,4 +195,29 @@ public function awaitBody(): static {} * @throws \Exception if the body stream errored (peer reset, size cap). */ public function readBody(int $maxLen = 65536): ?string {} + + /** + * Deframe and return the next gRPC message from the request body. + * + * Extracts one 5-byte-length-prefixed message and advances an internal + * cursor. Returns null once no complete message remains — call once for + * a unary RPC, loop for client-streaming. The returned bytes are the + * raw protobuf message; decode it in userland (ext/protobuf). + * + * @return string|null Next message, or null when none remains. + * @throws \Exception if a framed message exceeds the size limit. + */ + public function readMessage(): ?string {} + + /** + * The gRPC call deadline from the `grpc-timeout` header, in seconds. + * + * Returns the (fractional) number of seconds the client is willing to + * wait, or null when no `grpc-timeout` was sent. The server does not + * auto-abort the handler on it — the client enforces its own deadline — + * but a handler can honor it against its own operations. + * + * @return float|null Deadline in seconds, or null when absent. + */ + public function getGrpcTimeout(): ?float {} } diff --git a/stubs/HttpRequest.php_arginfo.h b/stubs/HttpRequest.php_arginfo.h index 32dcce56..dd014454 100644 --- a/stubs/HttpRequest.php_arginfo.h +++ b/stubs/HttpRequest.php_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit HttpRequest.php.stub.php instead. - * Stub hash: 2c87774dca350a1a6cba6a7be3e34465aef246c7 */ + * Stub hash: 99fd2992c9faf7986eebf9c2a6d53751b5158912 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpRequest___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -7,8 +7,17 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getMethod, 0, 0, IS_STRING, 0) ZEND_END_ARG_INFO() -#define arginfo_class_TrueAsync_HttpRequest_getUri arginfo_class_TrueAsync_HttpRequest_getMethod -#define arginfo_class_TrueAsync_HttpRequest_getPath arginfo_class_TrueAsync_HttpRequest_getMethod +#define arginfo_class_TrueAsync_HttpRequest_getUri arginfo_class_TrueAsync_HttpRequest_getMethod + +#define arginfo_class_TrueAsync_HttpRequest_getPath arginfo_class_TrueAsync_HttpRequest_getMethod + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getQuery, 0, 0, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getQueryParam, 0, 1, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, default, IS_MIXED, 0, "null") +ZEND_END_ARG_INFO() #define arginfo_class_TrueAsync_HttpRequest_getHttpVersion arginfo_class_TrueAsync_HttpRequest_getMethod @@ -24,24 +33,18 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getH ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getHeaders, 0, 0, IS_ARRAY, 0) -ZEND_END_ARG_INFO() +#define arginfo_class_TrueAsync_HttpRequest_getHeaders arginfo_class_TrueAsync_HttpRequest_getQuery -#define arginfo_class_TrueAsync_HttpRequest_getBody arginfo_class_TrueAsync_HttpRequest_getMethod -#define arginfo_class_TrueAsync_HttpRequest_getQuery arginfo_class_TrueAsync_HttpRequest_getHeaders +#define arginfo_class_TrueAsync_HttpRequest_getBody arginfo_class_TrueAsync_HttpRequest_getMethod ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_hasBody, 0, 0, _IS_BOOL, 0) ZEND_END_ARG_INFO() #define arginfo_class_TrueAsync_HttpRequest_isKeepAlive arginfo_class_TrueAsync_HttpRequest_hasBody -#define arginfo_class_TrueAsync_HttpRequest_getPost arginfo_class_TrueAsync_HttpRequest_getHeaders -#define arginfo_class_TrueAsync_HttpRequest_getFiles arginfo_class_TrueAsync_HttpRequest_getHeaders +#define arginfo_class_TrueAsync_HttpRequest_getPost arginfo_class_TrueAsync_HttpRequest_getQuery -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getQueryParam, 0, 1, IS_MIXED, 0) - ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) - ZEND_ARG_TYPE_INFO(0, default, IS_MIXED, 1) -ZEND_END_ARG_INFO() +#define arginfo_class_TrueAsync_HttpRequest_getFiles arginfo_class_TrueAsync_HttpRequest_getQuery ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getFile, 0, 1, TrueAsync\\\125ploadedFile, 1) ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) @@ -53,6 +56,16 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getContentLength, 0, 0, IS_LONG, 1) ZEND_END_ARG_INFO() +#define arginfo_class_TrueAsync_HttpRequest_getTraceParent arginfo_class_TrueAsync_HttpRequest_getContentType + +#define arginfo_class_TrueAsync_HttpRequest_getTraceState arginfo_class_TrueAsync_HttpRequest_getContentType + +#define arginfo_class_TrueAsync_HttpRequest_getTraceId arginfo_class_TrueAsync_HttpRequest_getContentType + +#define arginfo_class_TrueAsync_HttpRequest_getSpanId arginfo_class_TrueAsync_HttpRequest_getContentType + +#define arginfo_class_TrueAsync_HttpRequest_getTraceFlags arginfo_class_TrueAsync_HttpRequest_getContentLength + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_awaitBody, 0, 0, IS_STATIC, 0) ZEND_END_ARG_INFO() @@ -60,11 +73,10 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_read ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, maxLen, IS_LONG, 0, "65536") ZEND_END_ARG_INFO() -#define arginfo_class_TrueAsync_HttpRequest_getTraceParent arginfo_class_TrueAsync_HttpRequest_getContentType -#define arginfo_class_TrueAsync_HttpRequest_getTraceState arginfo_class_TrueAsync_HttpRequest_getContentType -#define arginfo_class_TrueAsync_HttpRequest_getTraceId arginfo_class_TrueAsync_HttpRequest_getContentType -#define arginfo_class_TrueAsync_HttpRequest_getSpanId arginfo_class_TrueAsync_HttpRequest_getContentType -#define arginfo_class_TrueAsync_HttpRequest_getTraceFlags arginfo_class_TrueAsync_HttpRequest_getContentLength +#define arginfo_class_TrueAsync_HttpRequest_readMessage arginfo_class_TrueAsync_HttpRequest_getContentType + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpRequest_getGrpcTimeout, 0, 0, IS_DOUBLE, 1) +ZEND_END_ARG_INFO() ZEND_METHOD(TrueAsync_HttpRequest, __construct); ZEND_METHOD(TrueAsync_HttpRequest, getMethod); @@ -92,34 +104,38 @@ ZEND_METHOD(TrueAsync_HttpRequest, getSpanId); ZEND_METHOD(TrueAsync_HttpRequest, getTraceFlags); ZEND_METHOD(TrueAsync_HttpRequest, awaitBody); ZEND_METHOD(TrueAsync_HttpRequest, readBody); +ZEND_METHOD(TrueAsync_HttpRequest, readMessage); +ZEND_METHOD(TrueAsync_HttpRequest, getGrpcTimeout); static const zend_function_entry class_TrueAsync_HttpRequest_methods[] = { - ZEND_ME(TrueAsync_HttpRequest, __construct, arginfo_class_TrueAsync_HttpRequest___construct, ZEND_ACC_PRIVATE) - ZEND_ME(TrueAsync_HttpRequest, getMethod, arginfo_class_TrueAsync_HttpRequest_getMethod, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getUri, arginfo_class_TrueAsync_HttpRequest_getUri, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getPath, arginfo_class_TrueAsync_HttpRequest_getPath, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getQuery, arginfo_class_TrueAsync_HttpRequest_getQuery, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, __construct, arginfo_class_TrueAsync_HttpRequest___construct, ZEND_ACC_PRIVATE) + ZEND_ME(TrueAsync_HttpRequest, getMethod, arginfo_class_TrueAsync_HttpRequest_getMethod, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getUri, arginfo_class_TrueAsync_HttpRequest_getUri, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getPath, arginfo_class_TrueAsync_HttpRequest_getPath, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getQuery, arginfo_class_TrueAsync_HttpRequest_getQuery, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpRequest, getQueryParam, arginfo_class_TrueAsync_HttpRequest_getQueryParam, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpRequest, getHttpVersion, arginfo_class_TrueAsync_HttpRequest_getHttpVersion, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, hasHeader, arginfo_class_TrueAsync_HttpRequest_hasHeader, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getHeader, arginfo_class_TrueAsync_HttpRequest_getHeader, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, hasHeader, arginfo_class_TrueAsync_HttpRequest_hasHeader, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getHeader, arginfo_class_TrueAsync_HttpRequest_getHeader, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpRequest, getHeaderLine, arginfo_class_TrueAsync_HttpRequest_getHeaderLine, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getHeaders, arginfo_class_TrueAsync_HttpRequest_getHeaders, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getBody, arginfo_class_TrueAsync_HttpRequest_getBody, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, hasBody, arginfo_class_TrueAsync_HttpRequest_hasBody, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, isKeepAlive, arginfo_class_TrueAsync_HttpRequest_isKeepAlive, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getPost, arginfo_class_TrueAsync_HttpRequest_getPost, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getFiles, arginfo_class_TrueAsync_HttpRequest_getFiles, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getFile, arginfo_class_TrueAsync_HttpRequest_getFile, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getContentType, arginfo_class_TrueAsync_HttpRequest_getContentType, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getHeaders, arginfo_class_TrueAsync_HttpRequest_getHeaders, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getBody, arginfo_class_TrueAsync_HttpRequest_getBody, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, hasBody, arginfo_class_TrueAsync_HttpRequest_hasBody, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, isKeepAlive, arginfo_class_TrueAsync_HttpRequest_isKeepAlive, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getPost, arginfo_class_TrueAsync_HttpRequest_getPost, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getFiles, arginfo_class_TrueAsync_HttpRequest_getFiles, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getFile, arginfo_class_TrueAsync_HttpRequest_getFile, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getContentType, arginfo_class_TrueAsync_HttpRequest_getContentType, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpRequest, getContentLength, arginfo_class_TrueAsync_HttpRequest_getContentLength, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getTraceParent, arginfo_class_TrueAsync_HttpRequest_getTraceParent, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getTraceState, arginfo_class_TrueAsync_HttpRequest_getTraceState, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getTraceId, arginfo_class_TrueAsync_HttpRequest_getTraceId, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getSpanId, arginfo_class_TrueAsync_HttpRequest_getSpanId, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, getTraceFlags, arginfo_class_TrueAsync_HttpRequest_getTraceFlags, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, awaitBody, arginfo_class_TrueAsync_HttpRequest_awaitBody, ZEND_ACC_PUBLIC) - ZEND_ME(TrueAsync_HttpRequest, readBody, arginfo_class_TrueAsync_HttpRequest_readBody, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getTraceParent, arginfo_class_TrueAsync_HttpRequest_getTraceParent, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getTraceState, arginfo_class_TrueAsync_HttpRequest_getTraceState, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getTraceId, arginfo_class_TrueAsync_HttpRequest_getTraceId, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getSpanId, arginfo_class_TrueAsync_HttpRequest_getSpanId, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getTraceFlags, arginfo_class_TrueAsync_HttpRequest_getTraceFlags, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, awaitBody, arginfo_class_TrueAsync_HttpRequest_awaitBody, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, readBody, arginfo_class_TrueAsync_HttpRequest_readBody, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, readMessage, arginfo_class_TrueAsync_HttpRequest_readMessage, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpRequest, getGrpcTimeout, arginfo_class_TrueAsync_HttpRequest_getGrpcTimeout, ZEND_ACC_PUBLIC) ZEND_FE_END }; diff --git a/stubs/HttpResponse.php b/stubs/HttpResponse.php index 55dc9053..1e08888f 100644 --- a/stubs/HttpResponse.php +++ b/stubs/HttpResponse.php @@ -178,6 +178,21 @@ public function write(string $data): static {} */ public function send(string $chunk): static {} + /** + * Frame and stream one gRPC message. + * + * Prepends the 5-byte gRPC length prefix (identity encoding) to + * $message and streams it as a single gRPC message. Activates + * streaming mode on the first call, exactly like send(). Call once for + * a unary reply, repeatedly for server-streaming. Pass the already + * protobuf-encoded bytes; the grpc-status is carried separately via + * setTrailer() (defaults to 0 when unset). + * + * @param string $message Protobuf-encoded message bytes. + * @return static + */ + public function writeMessage(string $message): static {} + /** * Advisory, non-blocking backpressure check for streaming responses. * diff --git a/stubs/HttpResponse.php_arginfo.h b/stubs/HttpResponse.php_arginfo.h index 55c5c65d..373fe7a0 100644 --- a/stubs/HttpResponse.php_arginfo.h +++ b/stubs/HttpResponse.php_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit HttpResponse.php.stub.php instead. - * Stub hash: 8a423bc943bfc02703da487693f605779200d059 */ + * Stub hash: ed27abe8db8fe0daa20d2cf15456dee24cdb3215 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpResponse___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -68,6 +68,10 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpResponse_sen ZEND_ARG_TYPE_INFO(0, chunk, IS_STRING, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpResponse_writeMessage, 0, 1, IS_STATIC, 0) + ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpResponse_sendable, 0, 0, _IS_BOOL, 0) ZEND_END_ARG_INFO() @@ -151,6 +155,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, getProtocolName); ZEND_METHOD(TrueAsync_HttpResponse, getProtocolVersion); ZEND_METHOD(TrueAsync_HttpResponse, write); ZEND_METHOD(TrueAsync_HttpResponse, send); +ZEND_METHOD(TrueAsync_HttpResponse, writeMessage); ZEND_METHOD(TrueAsync_HttpResponse, sendable); ZEND_METHOD(TrueAsync_HttpResponse, setNoCompression); ZEND_METHOD(TrueAsync_HttpResponse, getBody); @@ -190,6 +195,7 @@ static const zend_function_entry class_TrueAsync_HttpResponse_methods[] = { ZEND_ME(TrueAsync_HttpResponse, getProtocolVersion, arginfo_class_TrueAsync_HttpResponse_getProtocolVersion, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpResponse, write, arginfo_class_TrueAsync_HttpResponse_write, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpResponse, send, arginfo_class_TrueAsync_HttpResponse_send, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpResponse, writeMessage, arginfo_class_TrueAsync_HttpResponse_writeMessage, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpResponse, sendable, arginfo_class_TrueAsync_HttpResponse_sendable, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpResponse, setNoCompression, arginfo_class_TrueAsync_HttpResponse_setNoCompression, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpResponse, getBody, arginfo_class_TrueAsync_HttpResponse_getBody, ZEND_ACC_PUBLIC) diff --git a/tests/phpt/server/grpc/001-grpc-unary-h2c.phpt b/tests/phpt/server/grpc/001-grpc-unary-h2c.phpt new file mode 100644 index 00000000..75a37bbe --- /dev/null +++ b/tests/phpt/server/grpc/001-grpc-unary-h2c.phpt @@ -0,0 +1,82 @@ +--TEST-- +gRPC: unary echo over h2c — framing + grpc-status trailer round-trip +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); + $resp->writeMessage($msg === null ? '' : $msg); + // grpc-status defaults to 0 (OK) +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $payload = 'hello-grpc'; + $frame = "\x00" . pack('N', strlen($payload)) . $payload; // 5-byte prefix + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 ' + . '-H %s -H %s --data-binary @%s -o %s ' + . 'http://127.0.0.1:%d/helloworld.Greeter/SayHello 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + $expected = "\x00" . pack('N', strlen($payload)) . $payload; + + echo "saw_status_200=", (int)(strpos($verbose, 'HTTP/2 200') !== false), "\n"; + echo "saw_ctype=", (int)(strpos($verbose, 'content-type: application/grpc') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + echo "echo_matches=", (int)($resp === $expected), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_status_200=1 +saw_ctype=1 +saw_grpc_status=1 +echo_matches=1 +Done diff --git a/tests/phpt/server/grpc/002-grpc-unary-error.phpt b/tests/phpt/server/grpc/002-grpc-unary-error.phpt new file mode 100644 index 00000000..997ded25 --- /dev/null +++ b/tests/phpt/server/grpc/002-grpc-unary-error.phpt @@ -0,0 +1,77 @@ +--TEST-- +gRPC: unary error — handler sets grpc-status without any message (Trailers-Only) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + // NOT_FOUND, no response message. + $resp->setTrailer('grpc-status', '5') + ->setTrailer('grpc-message', 'nope'); +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $frame = "\x00" . pack('N', 3) . 'req'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + echo "saw_status_200=", (int)(strpos($verbose, 'HTTP/2 200') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 5') !== false), "\n"; + echo "saw_grpc_msg=", (int)(strpos($verbose, 'grpc-message: nope') !== false), "\n"; + echo "empty_body=", (int)($resp === ''), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_status_200=1 +saw_grpc_status=1 +saw_grpc_msg=1 +empty_body=1 +Done diff --git a/tests/phpt/server/grpc/004-grpc-server-streaming.phpt b/tests/phpt/server/grpc/004-grpc-server-streaming.phpt new file mode 100644 index 00000000..f1bc3ea8 --- /dev/null +++ b/tests/phpt/server/grpc/004-grpc-server-streaming.phpt @@ -0,0 +1,93 @@ +--TEST-- +gRPC: server-streaming — one request, N framed response messages + trailer +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $out[] = substr($buf, $off + 5, $len); + $off += 5 + $len; + } + return $out; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $req->readMessage(); // consume the single request message + for ($i = 1; $i <= 3; $i++) { + $resp->writeMessage("resp-$i"); + } + // grpc-status defaults to 0 +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $frame = "\x00" . pack('N', 4) . 'ping'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/Stream 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + $msgs = grpc_deframe($resp); + + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + echo "count=", count($msgs), "\n"; + echo "msgs=", implode(',', $msgs), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_grpc_status=1 +count=3 +msgs=resp-1,resp-2,resp-3 +Done diff --git a/tests/phpt/server/grpc/005-grpc-client-streaming.phpt b/tests/phpt/server/grpc/005-grpc-client-streaming.phpt new file mode 100644 index 00000000..8a3f72b0 --- /dev/null +++ b/tests/phpt/server/grpc/005-grpc-client-streaming.phpt @@ -0,0 +1,83 @@ +--TEST-- +gRPC: client-streaming — N request messages read via a readMessage() loop +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $msgs = []; + while (($m = $req->readMessage()) !== null) { + $msgs[] = $m; + } + $resp->writeMessage("got " . count($msgs) . ": " . implode('|', $msgs)); +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $body = grpc_frame('a') . grpc_frame('bb') . grpc_frame('ccc'); + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $body); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/Collect 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + echo "reply=", grpc_deframe_first($resp), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_grpc_status=1 +reply=got 3: a|bb|ccc +Done diff --git a/tests/phpt/server/grpc/006-grpc-bidi.phpt b/tests/phpt/server/grpc/006-grpc-bidi.phpt new file mode 100644 index 00000000..f685e5e7 --- /dev/null +++ b/tests/phpt/server/grpc/006-grpc-bidi.phpt @@ -0,0 +1,92 @@ +--TEST-- +gRPC: bidi (half-duplex) — read N request messages, write N responses +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $out[] = substr($buf, $off + 5, $len); + $off += 5 + $len; + } + return $out; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + while (($m = $req->readMessage()) !== null) { + $resp->writeMessage("echo:$m"); + } +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $body = grpc_frame('x') . grpc_frame('y'); + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $body); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/Chat 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + $msgs = grpc_deframe($resp); + + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + echo "count=", count($msgs), "\n"; + echo "msgs=", implode(',', $msgs), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_grpc_status=1 +count=2 +msgs=echo:x,echo:y +Done diff --git a/tests/phpt/server/grpc/007-grpc-timeout.phpt b/tests/phpt/server/grpc/007-grpc-timeout.phpt new file mode 100644 index 00000000..151de0d5 --- /dev/null +++ b/tests/phpt/server/grpc/007-grpc-timeout.phpt @@ -0,0 +1,71 @@ +--TEST-- +gRPC: grpc-timeout header parsed and exposed via getGrpcTimeout() +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $resp->writeMessage('timeout=' . var_export($req->getGrpcTimeout(), true)); +}); + +function grpc_call(int $port, array $headers): string { + $frame = "\x00" . pack('N', 3) . 'req'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + $h = ''; + foreach ($headers as $hv) { $h .= '-H ' . escapeshellarg($hv) . ' '; } + $cmd = sprintf( + 'curl --http2-prior-knowledge -s --max-time 3 %s --data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>/dev/null', + $h, escapeshellarg($bodyfile), escapeshellarg($outfile), $port + ); + shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); @unlink($outfile); + return grpc_deframe_first($resp); +} + +$client = spawn(function() use ($port, $server) { + usleep(30000); + echo grpc_call($port, ['content-type: application/grpc', 'grpc-timeout: 1500m']), "\n"; + echo grpc_call($port, ['content-type: application/grpc']), "\n"; + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +timeout=1.5 +timeout=NULL +Done diff --git a/tests/phpt/server/h2/027-h2-streaming-trailers.phpt b/tests/phpt/server/h2/027-h2-streaming-trailers.phpt new file mode 100644 index 00000000..076ab491 --- /dev/null +++ b/tests/phpt/server/h2/027-h2-streaming-trailers.phpt @@ -0,0 +1,77 @@ +--TEST-- +HttpServer: HTTP/2 trailers on the STREAMING response path (gRPC unlock) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addHttpHandler(function($req, $resp) { + $resp->setStatusCode(200) + ->setHeader('Content-Type', 'application/grpc'); + /* Streaming send() path: commits HEADERS, then two DATA frames. */ + $resp->send('msg-one'); + $resp->send('msg-two'); + /* Trailers set before the stream ends — carried by the terminal + * HEADERS(trailers) frame that mark_ended now emits. */ + $resp->setTrailer('grpc-status', '0') + ->setTrailer('grpc-message', 'ok'); + $resp->end(); +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 http://127.0.0.1:%d/ 2>&1', + $port + ); + $out = []; + exec($cmd, $out, $rc); + $blob = implode("\n", $out); + + echo "curl_rc=$rc\n"; + echo "saw_status_200=", (int)(strpos($blob, 'HTTP/2 200') !== false), "\n"; + echo "saw_ctype=", (int)(strpos($blob, 'application/grpc') !== false), "\n"; + echo "saw_body=", (int)(strpos($blob, 'msg-onemsg-two') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($blob, 'grpc-status: 0') !== false), "\n"; + echo "saw_grpc_message=", (int)(strpos($blob, 'grpc-message: ok') !== false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +curl_rc=0 +saw_status_200=1 +saw_ctype=1 +saw_body=1 +saw_grpc_status=1 +saw_grpc_message=1 +Done From 4e638a70b89f85cffad63961fc395a4b22d60c0f Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:17:52 +0000 Subject: [PATCH 02/33] feat(grpc): per-message gzip compression (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gRPC message-level compression (distinct from HTTP-body Content-Encoding: each framed message carries its own compressed flag; the algorithm is named by grpc-encoding). Reuses the existing zlib(-ng) backend rather than adding a second wrapper: - src/compression: one-shot whole-buffer helpers sharing the module's ZS_* abstraction — http_compression_gzip_deflate_buffer (in http_compression_gzip.c) and http_compression_gzip_inflate_buffer, factored out of the request-body decode_gzip (which now calls it, so the inflate loop + zip-bomb guard live in one place). Declared in include/compression/http_compression_message.h. - grpc: grpc_message_inflate / grpc_message_deflate_gzip wrap them. readMessage() transparently inflates a message whose compressed flag is set (per grpc-encoding, gzip only); writeMessage($msg, compress: true) gzips the reply and sets grpc-encoding: gzip on the initial HEADERS. Gated on HAVE_HTTP_COMPRESSION — identity-only when no zlib backend is built. Test: tests/phpt/server/grpc/008 (10 KB gzip round-trip both directions). Compression + core + h1 + h2 + gRPC suites green. --- docs/PLAN_GRPC.md | 16 ++++ .../compression/http_compression_message.h | 38 ++++++++ src/compression/http_compression_gzip.c | 47 ++++++++++ src/compression/http_compression_request.c | 71 ++++++++++----- src/grpc/grpc.c | 39 +++++++++ src/grpc/grpc.h | 11 +++ src/http_request.c | 19 +++- src/http_response.c | 34 +++++++- stubs/HttpResponse.php | 7 +- stubs/HttpResponse.php_arginfo.h | 3 +- .../server/grpc/008-grpc-compression.phpt | 86 +++++++++++++++++++ 11 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 include/compression/http_compression_message.h create mode 100644 tests/phpt/server/grpc/008-grpc-compression.phpt diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md index f3848174..aef3a9e4 100644 --- a/docs/PLAN_GRPC.md +++ b/docs/PLAN_GRPC.md @@ -38,6 +38,22 @@ _Status: living document. Last updated 2026-07-04._ cancellation into the coroutine (the same unwind path that leaks a handle in #101), so it is coupled to that fix; and server-emitted `DEADLINE_EXCEEDED` is not required for interop (the client enforces its own deadline). +- **Phase 4 — DONE (per-message gzip), reusing the existing compression + backend.** No second zlib wrapper: the one-shot buffer helpers live in + `src/compression/` and share the module's zlib(-ng) abstraction — + `http_compression_gzip_deflate_buffer` (in `http_compression_gzip.c`) and + `http_compression_gzip_inflate_buffer` (factored out of the request-body + `decode_gzip`, which now calls it). Declared in + `include/compression/http_compression_message.h`. gRPC calls them via + `grpc_message_inflate` / `grpc_message_deflate_gzip`. + `readMessage()` transparently inflates a message whose compressed flag is + set (per `grpc-encoding`, gzip only); `writeMessage($msg, compress: true)` + gzips the reply and sets the `grpc-encoding: gzip` response header. Gated on + `HAVE_HTTP_COMPRESSION` — identity-only when no zlib backend is built. Note: + gRPC compression is **per-message** (a flag byte per message) and is + distinct from HTTP-body `Content-Encoding`, which is why it needs its own + path rather than the streaming HTTP-body encoder. Test: `/008` (10 KB gzip + round-trip both directions). ### Discovered pre-existing bug (independent of gRPC) — NOT yet fixed diff --git a/include/compression/http_compression_message.h b/include/compression/http_compression_message.h new file mode 100644 index 00000000..6ff7316e --- /dev/null +++ b/include/compression/http_compression_message.h @@ -0,0 +1,38 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +/* + * One-shot, whole-buffer gzip (RFC 1952) helpers that reuse the module's + * existing zlib(-ng) backend. The streaming http_encoder_t vtable and the + * request-body decoder both operate on the HTTP *body*; message-level + * compression (e.g. gRPC per-message, where each framed message carries its + * own compressed flag) needs to compress/decompress a single buffer at a + * time, so it goes through these instead of duplicating a zlib wrapper. + * + * Only defined when a zlib backend is compiled in (HAVE_HTTP_COMPRESSION); + * the deflate side lives in http_compression_gzip.c, the inflate side is + * shared with the request-body decoder in http_compression_request.c. + */ +#ifndef HTTP_COMPRESSION_MESSAGE_H +#define HTTP_COMPRESSION_MESSAGE_H + +#include "php.h" /* zend_string */ +#include + +/* Compress `in` as a standalone gzip member. Returns a new zend_string the + * caller owns, or NULL on failure. `level` is clamped to 1..9. */ +zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, + int level); + +/* Inflate a standalone gzip (or zlib) member into a fresh zend_string. + * Returns 0 on success (*out set, caller owns it), -1 on a malformed stream, + * and -2 when the output would exceed `max_out` (max_out == 0 → unbounded). */ +int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, + size_t max_out, zend_string **out); + +#endif /* HTTP_COMPRESSION_MESSAGE_H */ diff --git a/src/compression/http_compression_gzip.c b/src/compression/http_compression_gzip.c index d561ccfb..ea0647f0 100644 --- a/src/compression/http_compression_gzip.c +++ b/src/compression/http_compression_gzip.c @@ -29,8 +29,10 @@ #endif #include "compression/http_encoder.h" +#include "compression/http_compression_message.h" #include "php.h" /* emalloc / efree — unit tests provide a minimal Zend */ +#include #ifdef HAVE_ZLIB_NG # include @@ -40,6 +42,7 @@ # define ZS_DEFLATE_END zng_deflateEnd # define ZS_DEFLATE_RESET zng_deflateReset # define ZS_DEFLATE_PARAMS zng_deflateParams +# define ZS_DEFLATE_BOUND zng_deflateBound #else # include # define ZS z_stream @@ -48,6 +51,7 @@ # define ZS_DEFLATE_END deflateEnd # define ZS_DEFLATE_RESET deflateReset # define ZS_DEFLATE_PARAMS deflateParams +# define ZS_DEFLATE_BOUND deflateBound #endif typedef struct { @@ -183,3 +187,46 @@ const http_encoder_vtable_t http_compression_gzip_vt = { .reset = gz_reset, .destroy = gz_destroy, }; + +/* One-shot whole-buffer gzip compress — see http_compression_message.h. Sizes + * the output via deflateBound and finishes in a single deflate() pass, so it + * never needs the streaming NEED_OUTPUT loop above. */ +zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, + int level) +{ + if (level < 1) level = 1; + + if (level > 9) level = 9; + + ZS s; + memset(&s, 0, sizeof(s)); + + if (ZS_DEFLATE_INIT2(&s, level, Z_DEFLATED, MAX_WBITS + 16, 8, + Z_DEFAULT_STRATEGY) != Z_OK) { + return NULL; + } + + const size_t bound = ZS_DEFLATE_BOUND(&s, in_len); + zend_string *out = zend_string_alloc(bound, 0); + + s.next_in = (void *)(uintptr_t)in; + s.avail_in = (unsigned)in_len; + s.next_out = (unsigned char *)ZSTR_VAL(out); + s.avail_out = (unsigned)bound; + + const int rc = ZS_DEFLATE(&s, Z_FINISH); + const size_t produced = bound - s.avail_out; + (void)ZS_DEFLATE_END(&s); + + if (rc != Z_STREAM_END) { + zend_string_release(out); + return NULL; + } + + if (produced != bound) { + out = zend_string_truncate(out, produced, 0); + } + + ZSTR_VAL(out)[produced] = '\0'; + return out; +} diff --git a/src/compression/http_compression_request.c b/src/compression/http_compression_request.c index ca712094..1b72c5f4 100644 --- a/src/compression/http_compression_request.c +++ b/src/compression/http_compression_request.c @@ -29,6 +29,7 @@ #include "php_http_server.h" #include "http1/http_parser.h" #include "compression/http_compression_request.h" +#include "compression/http_compression_message.h" #include "core/body_pool.h" #ifdef HAVE_ZLIB_NG @@ -47,10 +48,18 @@ #include -static int decode_gzip(http_request_t *req, size_t cap) +/* Inflate a standalone gzip/zlib member into a fresh zend_string. Shared by + * the request-body decoder below and the message-level path + * (http_compression_message.h — e.g. gRPC per-message decompression), so the + * inflate loop + zip-bomb guard live in exactly one place. Returns 0 on + * success (*out set, caller owns it), -1 on a malformed stream, -2 when the + * output would exceed max_out (max_out == 0 → unbounded). */ +int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, + size_t max_out, zend_string **out) { - if (req->body == NULL || ZSTR_LEN(req->body) == 0) { - return HTTP_DECODE_OK; /* nothing to decode */ + if (in_len == 0) { + *out = ZSTR_EMPTY_ALLOC(); + return 0; } ZS s; @@ -59,19 +68,19 @@ static int decode_gzip(http_request_t *req, size_t cap) * gzip and zlib streams gracefully — robust against clients that * mis-label deflate as gzip in the wild). */ if (ZS_INFLATE_INIT2(&s, 15 + 32) != Z_OK) { - return HTTP_DECODE_MALFORMED; + return -1; } - /* Output buffer. Initial 4 KiB, doubles on demand up to `cap`. */ + /* Output buffer. Initial 4 KiB, doubles on demand up to `max_out`. */ size_t out_cap = 4096; - if (cap > 0 && cap < out_cap) out_cap = cap; - zend_string *out = zend_string_alloc(out_cap, 0); + if (max_out > 0 && max_out < out_cap) out_cap = max_out; + zend_string *buf = zend_string_alloc(out_cap, 0); size_t produced = 0; - s.next_in = (void *)(uintptr_t)ZSTR_VAL(req->body); - s.avail_in = (unsigned)ZSTR_LEN(req->body); - s.next_out = (unsigned char *)ZSTR_VAL(out); + s.next_in = (void *)(uintptr_t)in; + s.avail_in = (unsigned)in_len; + s.next_out = (unsigned char *)ZSTR_VAL(buf); s.avail_out = (unsigned)out_cap; int rc; @@ -83,27 +92,26 @@ static int decode_gzip(http_request_t *req, size_t cap) if (rc != Z_OK) { ZS_INFLATE_END(&s); - zend_string_release(out); - return HTTP_DECODE_MALFORMED; + zend_string_release(buf); + return -1; } - /* Need more output. Cap-aware grow: never above `cap`. */ + /* Need more output. Cap-aware grow: never above `max_out`. */ if (s.avail_out == 0) { size_t new_cap = out_cap * 2; - if (cap > 0 && new_cap > cap) { - new_cap = cap; + if (max_out > 0 && new_cap > max_out) { + new_cap = max_out; } if (new_cap == out_cap) { /* Already at cap and inflate still wants room → bomb. */ ZS_INFLATE_END(&s); - zend_string_release(out); - return HTTP_DECODE_TOO_LARGE; + zend_string_release(buf); + return -2; } - zend_string *grown = zend_string_realloc(out, new_cap, 0); - out = grown; - s.next_out = (unsigned char *)ZSTR_VAL(out) + produced; + buf = zend_string_realloc(buf, new_cap, 0); + s.next_out = (unsigned char *)ZSTR_VAL(buf) + produced; s.avail_out = (unsigned)(new_cap - produced); out_cap = new_cap; } @@ -113,14 +121,31 @@ static int decode_gzip(http_request_t *req, size_t cap) /* Right-size + NUL-terminate. */ if (produced != out_cap) { - out = zend_string_truncate(out, produced, 0); + buf = zend_string_truncate(buf, produced, 0); } - ZSTR_VAL(out)[produced] = '\0'; + ZSTR_VAL(buf)[produced] = '\0'; + + *out = buf; + return 0; +} + +static int decode_gzip(http_request_t *req, size_t cap) +{ + if (req->body == NULL || ZSTR_LEN(req->body) == 0) { + return HTTP_DECODE_OK; /* nothing to decode */ + } + + zend_string *out = NULL; + const int rc = http_compression_gzip_inflate_buffer( + ZSTR_VAL(req->body), ZSTR_LEN(req->body), cap, &out); + + if (rc == -2) return HTTP_DECODE_TOO_LARGE; + if (rc != 0) return HTTP_DECODE_MALFORMED; body_release(req->body); req->body = out; - req->content_length = produced; + req->content_length = ZSTR_LEN(out); return HTTP_DECODE_OK; } diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index 8392335c..2722178c 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -17,6 +17,10 @@ #include #include /* strncasecmp */ +#ifdef HAVE_HTTP_COMPRESSION +# include "compression/http_compression_message.h" /* one-shot gzip */ +#endif + bool grpc_request_is_grpc(const http_request_t *req) { if (req == NULL || req->headers == NULL) { @@ -133,3 +137,38 @@ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, *cursor = pos + 5 + (size_t)mlen; return 1; } + +int grpc_message_inflate(const http_request_t *req, + const char *in, size_t in_len, zend_string **out) +{ +#ifdef HAVE_HTTP_COMPRESSION + /* Compressed flag set → the algorithm is named by grpc-encoding. gRPC's + * baseline is gzip; anything else is unsupported here. */ + zval *enc = (req != NULL && req->headers != NULL) + ? zend_hash_str_find(req->headers, "grpc-encoding", + sizeof("grpc-encoding") - 1) + : NULL; + + if (enc == NULL || Z_TYPE_P(enc) != IS_STRING + || !zend_string_equals_literal(Z_STR_P(enc), "gzip")) { + return -1; + } + + return http_compression_gzip_inflate_buffer(in, in_len, + GRPC_MAX_RECV_MESSAGE, out) == 0 + ? 0 : -1; +#else + (void)req; (void)in; (void)in_len; (void)out; + return -1; +#endif +} + +zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len) +{ +#ifdef HAVE_HTTP_COMPRESSION + return http_compression_gzip_deflate_buffer(in, in_len, 6 /* default */); +#else + (void)in; (void)in_len; + return NULL; +#endif +} diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index b76f6e62..b689a8dc 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -68,4 +68,15 @@ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, uint32_t max_msg, bool *out_compressed, zend_string **out); +/* Decompress a message that carried the compressed flag, per the request's + * grpc-encoding header (gzip only, via the shared compression backend). + * Returns 0 on success (*out owned by caller), -1 on an unsupported encoding, + * corrupt data, or when no compression backend is compiled in. */ +int grpc_message_inflate(const struct http_request_t *req, + const char *in, size_t in_len, zend_string **out); + +/* Gzip-compress a message for `grpc-encoding: gzip`. Returns a new + * zend_string the caller owns, or NULL when gzip is unavailable / failed. */ +zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len); + #endif /* HTTP_GRPC_H */ diff --git a/src/http_request.c b/src/http_request.c index 9ea554ac..4b063669 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -287,9 +287,10 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) } zend_string *msg = NULL; + bool compressed = false; const int rc = grpc_deframe_next(ZSTR_VAL(req->body), ZSTR_LEN(req->body), &req->grpc_read_offset, - GRPC_MAX_RECV_MESSAGE, NULL, &msg); + GRPC_MAX_RECV_MESSAGE, &compressed, &msg); if (rc < 0) { zend_throw_exception(http_server_runtime_exception_ce, @@ -302,6 +303,22 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) RETURN_NULL(); } + if (compressed) { + /* Per-message compression: decode per grpc-encoding (gzip). */ + zend_string *inflated = NULL; + + if (grpc_message_inflate(req, ZSTR_VAL(msg), ZSTR_LEN(msg), + &inflated) != 0) { + zend_string_release(msg); + zend_throw_exception(http_server_runtime_exception_ce, + "unsupported or invalid gRPC message compression", 0); + RETURN_NULL(); + } + + zend_string_release(msg); + RETURN_STR(inflated); + } + RETURN_STR(msg); } diff --git a/src/http_response.c b/src/http_response.c index 7d1ee5e2..66a7784c 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -1026,9 +1026,12 @@ ZEND_METHOD(TrueAsync_HttpResponse, send) ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) { zend_string *message; + bool compress = false; - ZEND_PARSE_PARAMETERS_START(1, 1) + ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_STR(message) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(compress) ZEND_PARSE_PARAMETERS_END(); http_response_object *response = Z_HTTP_RESPONSE_P(ZEND_THIS); @@ -1051,6 +1054,29 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) return; } + /* Optional per-message gzip. If gzip is not compiled in, transparently + * fall back to identity (flag 0). grpc-encoding must ride the initial + * HEADERS, so declare it before the first message commits. */ + zend_string *payload = message; + bool gzipped = false; + + if (compress) { + zend_string *gz = grpc_message_deflate_gzip( + ZSTR_VAL(message), ZSTR_LEN(message)); + + if (gz != NULL) { + payload = gz; + gzipped = true; + + if (!response->streaming) { + zval enc; + ZVAL_STRING(&enc, "gzip"); + zend_hash_str_update(response->headers, "grpc-encoding", + sizeof("grpc-encoding") - 1, &enc); + } + } + } + /* First message — lock headers and switch to streaming mode. Mirrors * send(): after this, setBody / setHeader / setStatusCode throw, but * setTrailer() stays allowed (grpc-status is set post-commit). */ @@ -1063,7 +1089,11 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) /* Prepend the 5-byte gRPC frame header. The queue takes ownership of * this fresh string (append_chunk consumes the ref), so no release. */ zend_string *framed = grpc_frame_message( - ZSTR_VAL(message), ZSTR_LEN(message), false); + ZSTR_VAL(payload), ZSTR_LEN(payload), gzipped); + + if (gzipped) { + zend_string_release(payload); /* framed copied it; drop the gz buffer */ + } const int rc = response->stream_ops->append_chunk( response->stream_ctx, framed); diff --git a/stubs/HttpResponse.php b/stubs/HttpResponse.php index 1e08888f..5a193110 100644 --- a/stubs/HttpResponse.php +++ b/stubs/HttpResponse.php @@ -188,10 +188,15 @@ public function send(string $chunk): static {} * protobuf-encoded bytes; the grpc-status is carried separately via * setTrailer() (defaults to 0 when unset). * + * Pass $compress = true to gzip this message (sets the per-message + * compressed flag and, on the first message, the grpc-encoding: gzip + * response header). Falls back to identity when gzip is not built in. + * * @param string $message Protobuf-encoded message bytes. + * @param bool $compress Gzip this message (grpc-encoding: gzip). * @return static */ - public function writeMessage(string $message): static {} + public function writeMessage(string $message, bool $compress = false): static {} /** * Advisory, non-blocking backpressure check for streaming responses. diff --git a/stubs/HttpResponse.php_arginfo.h b/stubs/HttpResponse.php_arginfo.h index 373fe7a0..20294d58 100644 --- a/stubs/HttpResponse.php_arginfo.h +++ b/stubs/HttpResponse.php_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit HttpResponse.php.stub.php instead. - * Stub hash: ed27abe8db8fe0daa20d2cf15456dee24cdb3215 */ + * Stub hash: 14984263ea86d343e0adf70437811f37593bb96e */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpResponse___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -70,6 +70,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpResponse_writeMessage, 0, 1, IS_STATIC, 0) ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, compress, _IS_BOOL, 0, "false") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TrueAsync_HttpResponse_sendable, 0, 0, _IS_BOOL, 0) diff --git a/tests/phpt/server/grpc/008-grpc-compression.phpt b/tests/phpt/server/grpc/008-grpc-compression.phpt new file mode 100644 index 00000000..5eeab4b8 --- /dev/null +++ b/tests/phpt/server/grpc/008-grpc-compression.phpt @@ -0,0 +1,86 @@ +--TEST-- +gRPC: per-message gzip — compressed request in, compressed response out +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +if (!function_exists('gzencode')) die('skip zlib ext not available'); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); // auto-inflated + $resp->writeMessage('echo:' . $msg, true); // gzip the reply +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $payload = str_repeat('gRPC-', 2000); // 10 KB + $gz = gzencode($payload); + $frame = "\x01" . pack('N', strlen($gz)) . $gz; // compressed flag = 1 + + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('grpc-encoding: gzip'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + $flag = strlen($resp) ? ord($resp[0]) : -1; + $len = strlen($resp) >= 5 ? unpack('N', substr($resp, 1, 4))[1] : 0; + $decoded = ($flag === 1 && $len > 0) ? @gzdecode(substr($resp, 5, $len)) : ''; + + echo "resp_grpc_encoding=", (int)(strpos($verbose, 'grpc-encoding: gzip') !== false), "\n"; + echo "resp_compressed_flag=", (int)($flag === 1), "\n"; + echo "roundtrip_ok=", (int)($decoded === 'echo:' . $payload), "\n"; + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +resp_grpc_encoding=1 +resp_compressed_flag=1 +roundtrip_ok=1 +saw_grpc_status=1 +Done From afc1f632e69bec3f83f745f3a541ef54c0897513 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:28:23 +0000 Subject: [PATCH 03/33] =?UTF-8?q?feat(grpc):=20grpc-web=20(binary)=20?= =?UTF-8?q?=E2=80=94=20in-body=200x80=20trailer=20frame=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers can't read HTTP/2 trailers, so grpc-web carries grpc-status / grpc-message inside the response body as a 0x80-flagged frame. Same addGrpcHandler / readMessage / writeMessage API — only the finalize differs. - grpc: grpc_request_is_grpc_web (content-type application/grpc-web…) and grpc_web_trailer_frame (0x80 + uint32 len + `name: value\r\n` lines). - dispatch sets stream->grpc_web and the application/grpc-web+proto response content-type; dispose runs h2_grpc_web_finalize — build the trailer frame, clear the HTTP trailers, append the frame as the final DATA, END_STREAM (no HTTP trailer). Works for streamed and zero-message replies. - http_response_clear_trailers helper. Tests: grpc/009 (binary round-trip + in-body trailers), grpc/010 (zero-message error). Full gRPC (9) + h2 suites green. grpc-web-text (base64) deferred to Phase 5c; binary is the common case. --- docs/PLAN_GRPC.md | 13 +++ include/http2/http2_stream.h | 6 ++ include/php_http_server.h | 4 + src/grpc/grpc.c | 57 +++++++++++ src/grpc/grpc.h | 23 ++++- src/http2/http2_strategy.c | 53 +++++++++-- src/http_response.c | 12 +++ tests/phpt/server/grpc/009-grpc-web.phpt | 95 +++++++++++++++++++ .../phpt/server/grpc/010-grpc-web-error.phpt | 87 +++++++++++++++++ 9 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 tests/phpt/server/grpc/009-grpc-web.phpt create mode 100644 tests/phpt/server/grpc/010-grpc-web-error.phpt diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md index aef3a9e4..7c6c62ea 100644 --- a/docs/PLAN_GRPC.md +++ b/docs/PLAN_GRPC.md @@ -54,6 +54,19 @@ _Status: living document. Last updated 2026-07-04._ distinct from HTTP-body `Content-Encoding`, which is why it needs its own path rather than the streaming HTTP-body encoder. Test: `/008` (10 KB gzip round-trip both directions). +- **Phase 5a — DONE (grpc-web, binary).** grpc-web carries the trailers inside + the response body as a `0x80`-flagged frame (browsers can't read HTTP/2 + trailers). Same `addGrpcHandler` / `readMessage` / `writeMessage` API — only + the finalize differs: `grpc_request_is_grpc_web` (content-type + `application/grpc-web…`) sets `stream->grpc_web`; the response content-type + becomes `application/grpc-web+proto`; and `h2_grpc_web_finalize` + (`http2_strategy.c`) builds the trailer frame via `grpc_web_trailer_frame`, + clears the HTTP trailers, appends the frame as the final DATA, and ends the + stream with END_STREAM (no HTTP trailer). Works for streamed and + zero-message replies. Tests: `/009` (binary round-trip + in-body trailers), + `/010` (zero-message error). **Deferred (Phase 5c): grpc-web-text** (base64) + — needs a stateful streaming base64 layer on request and response; binary + grpc-web is the common case and is complete. ### Discovered pre-existing bug (independent of gRPC) — NOT yet fixed diff --git a/include/http2/http2_stream.h b/include/http2/http2_stream.h index 290329e3..e14e3158 100644 --- a/include/http2/http2_stream.h +++ b/include/http2/http2_stream.h @@ -108,6 +108,12 @@ struct http2_stream_t { * the grpc-status trailer. */ bool is_grpc; + /* True when this gRPC stream is a grpc-web call (content-type + * application/grpc-web...). grpc-web carries trailers inside the + * response body as a 0x80-flagged frame instead of as HTTP/2 trailers, + * because browsers cannot read HTTP trailers. */ + bool grpc_web; + /* Streaming-response chunk queue. * * Active only when the handler called HttpResponse::send(); a diff --git a/include/php_http_server.h b/include/php_http_server.h index ea81c6d5..1bf7b983 100644 --- a/include/php_http_server.h +++ b/include/php_http_server.h @@ -1137,6 +1137,10 @@ void http_response_ensure_grpc_status(zend_object *obj, int status); * gRPC Trailers-Only reply when the handler streamed no messages. */ void http_response_promote_trailers_to_headers(zend_object *obj); +/* Clear the response trailer table (headers untouched). Used by the grpc-web + * dispose path, which emits trailers as an in-body frame instead. */ +void http_response_clear_trailers(zend_object *obj); + /* Borrow the body's underlying zend_string. Returns NULL when the body * is empty. The string is owned by the response object — addref it if * you need to outlive the response. Avoids a full body memcpy on the diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index 2722178c..0ee3d6df 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -13,6 +13,7 @@ #include "grpc.h" #include "http1/http_parser.h" /* http_request_t */ +#include "zend_smart_str.h" #include #include /* strncasecmp */ @@ -40,6 +41,62 @@ bool grpc_request_is_grpc(const http_request_t *req) && strncasecmp(Z_STRVAL_P(ct), GRPC_CONTENT_TYPE, prefix_len) == 0; } +bool grpc_request_is_grpc_web(const http_request_t *req) +{ + if (req == NULL || req->headers == NULL) { + return false; + } + + zval *ct = zend_hash_str_find(req->headers, "content-type", + sizeof("content-type") - 1); + + if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { + return false; + } + + const size_t prefix_len = sizeof(GRPC_WEB_CONTENT_TYPE_PREFIX) - 1; /* 20 */ + + return Z_STRLEN_P(ct) >= prefix_len + && strncasecmp(Z_STRVAL_P(ct), GRPC_WEB_CONTENT_TYPE_PREFIX, + prefix_len) == 0; +} + +zend_string *grpc_web_trailer_frame(HashTable *trailers) +{ + smart_str body = {0}; + + if (trailers != NULL) { + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + smart_str_append(&body, name); + smart_str_appendl(&body, ": ", 2); + smart_str_append(&body, Z_STR_P(val)); + smart_str_appendl(&body, "\r\n", 2); + } ZEND_HASH_FOREACH_END(); + } + + const size_t tlen = body.s != NULL ? ZSTR_LEN(body.s) : 0; + + zend_string *out = zend_string_alloc(5 + tlen, 0); + unsigned char *p = (unsigned char *)ZSTR_VAL(out); + + p[0] = 0x80u; /* trailer frame, uncompressed */ + p[1] = (unsigned char)((tlen >> 24) & 0xffu); + p[2] = (unsigned char)((tlen >> 16) & 0xffu); + p[3] = (unsigned char)((tlen >> 8) & 0xffu); + p[4] = (unsigned char)( tlen & 0xffu); + + if (tlen > 0) { + memcpy(p + 5, ZSTR_VAL(body.s), tlen); + } + + ZSTR_VAL(out)[5 + tlen] = '\0'; + smart_str_free(&body); + return out; +} + uint64_t grpc_parse_timeout_ns(const http_request_t *req) { if (req == NULL || req->headers == NULL) { diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index b689a8dc..22e3d28d 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -40,13 +40,32 @@ struct http_request_t; #define GRPC_MAX_RECV_MESSAGE (16u * 1024u * 1024u) /* Canonical gRPC content-type prefix (matches application/grpc, - * application/grpc+proto, application/grpc;charset=..., etc.). */ + * application/grpc+proto, application/grpc;charset=..., AND the grpc-web + * variants below, which also start with this prefix). */ #define GRPC_CONTENT_TYPE "application/grpc" +/* grpc-web content-type prefix (application/grpc-web, .../grpc-web+proto, + * .../grpc-web-text) and the response content-type the server emits. */ +#define GRPC_WEB_CONTENT_TYPE_PREFIX "application/grpc-web" +#define GRPC_WEB_RESPONSE_CONTENT_TYPE "application/grpc-web+proto" + /* True when the request is a gRPC call — POST with a content-type that - * begins with `application/grpc`. */ + * begins with `application/grpc` (this includes grpc-web). */ bool grpc_request_is_grpc(const struct http_request_t *req); +/* True when the request is a grpc-web call — content-type begins with + * `application/grpc-web`. grpc-web carries the trailers inside the response + * body (a 0x80-flagged frame) instead of as HTTP/2 trailers, because + * browsers cannot read HTTP trailers. */ +bool grpc_request_is_grpc_web(const struct http_request_t *req); + +/* Build the grpc-web in-body trailer frame from a response trailer map: + * byte 0 : 0x80 (trailer frame, uncompressed) + * bytes 1..4 : trailer block length, uint32 big-endian + * bytes 5.. : `name: value\r\n` lines (HTTP/1.1 style) + * Returns a new zend_string the caller owns. `trailers` may be NULL/empty. */ +zend_string *grpc_web_trailer_frame(HashTable *trailers); + /* Parse the `grpc-timeout` request header (``, unit ∈ * {H,M,S,m,u,n}) into nanoseconds. Returns 0 when absent or malformed. */ uint64_t grpc_parse_timeout_ns(const struct http_request_t *req); diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index 396993a8..b3563f05 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -100,6 +100,12 @@ extern const http_response_stream_ops_t h2_stream_ops; * handler skipped $res->end(). Defined further down. */ static void h2_stream_mark_ended(void *ctx); +/* Forward decls so the dispose path can finalize a grpc-web reply — append + * the in-body trailer frame via the streaming append and end the stream. */ +static int h2_stream_append_chunk(void *ctx, zend_string *chunk); +static void h2_grpc_web_finalize(http2_stream_t *stream, + zend_object *response_obj); + /* Suspend the current handler coroutine until either * - stream->write_event fires (cb_on_frame_recv saw WINDOW_UPDATE * and opened the flow-control window for us), or @@ -233,7 +239,8 @@ static void http2_strategy_dispatch(struct http_request_t *request, && http_protocol_has_handler( http_server_get_protocol_handlers(self->conn->server), HTTP_PROTOCOL_GRPC); - stream->is_grpc = is_grpc; + stream->is_grpc = is_grpc; + stream->grpc_web = is_grpc && grpc_request_is_grpc_web(stream->request); /* Static-only deployments register a static mount but no PHP * handler — the static dispatch path below claims the request @@ -289,11 +296,18 @@ static void http2_strategy_dispatch(struct http_request_t *request, /* gRPC: default the response content-type so handlers need not set it. * Rides the initial HEADERS; a handler may still override before its - * first writeMessage(). */ + * first writeMessage(). grpc-web gets its own content-type. */ if (is_grpc) { - http_response_static_set_header(Z_OBJ(stream->response_zv), - "content-type", sizeof("content-type") - 1, - GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + if (stream->grpc_web) { + http_response_static_set_header(Z_OBJ(stream->response_zv), + "content-type", sizeof("content-type") - 1, + GRPC_WEB_RESPONSE_CONTENT_TYPE, + sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1); + } else { + http_response_static_set_header(Z_OBJ(stream->response_zv), + "content-type", sizeof("content-type") - 1, + GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + } } /* Static-handler dispatch (issue #13). Identical policy to the @@ -626,7 +640,12 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) const bool is_streaming = !Z_ISUNDEF(stream->response_zv) && http_response_is_streaming(Z_OBJ(stream->response_zv)); - if (is_streaming) { + if (stream->grpc_web && !Z_ISUNDEF(stream->response_zv)) { + /* grpc-web: trailers ride the response body as a 0x80 frame, never + * as HTTP/2 trailers (browsers can't read those). Handles both a + * streamed reply and a zero-message status/error. */ + h2_grpc_web_finalize(stream, Z_OBJ(stream->response_zv)); + } else if (is_streaming) { if (!stream->streaming_ended) { h2_stream_mark_ended(stream); } @@ -1679,6 +1698,28 @@ static int h2_stream_append_chunk(void *ctx, zend_string *chunk) return HTTP_STREAM_APPEND_OK; } +/* Finalize a grpc-web reply: the grpc-status/grpc-message trailers are sent + * as an in-body 0x80 frame (browsers cannot read HTTP/2 trailers), then the + * stream ends normally with END_STREAM on DATA. Works for both a streamed + * reply (messages already queued) and a zero-message status/error (the + * trailer frame is the only DATA, committing HEADERS on append). */ +static void h2_grpc_web_finalize(http2_stream_t *stream, + zend_object *response_obj) +{ + HashTable *trailers = http_response_get_trailers(response_obj); + zend_string *frame = grpc_web_trailer_frame(trailers); + + /* Clear the HTTP trailers so the streaming EOF path (h2_dp_mark_eof) + * does not also emit them as a terminal HEADERS(trailers) frame. */ + http_response_clear_trailers(response_obj); + + (void)h2_stream_append_chunk(stream, frame); /* consumes the ref */ + + if (!stream->streaming_ended) { + h2_stream_mark_ended(stream); + } +} + static void h2_stream_mark_ended(void *ctx) { http2_stream_t *stream = (http2_stream_t *)ctx; diff --git a/src/http_response.c b/src/http_response.c index 66a7784c..5369ec32 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -493,6 +493,18 @@ void http_response_promote_trailers_to_headers(zend_object *obj) zend_hash_clean(response->trailers); } +/* Clear the response trailer table (without touching headers). Used by the + * grpc-web dispose path: it moves grpc-status/grpc-message into an in-body + * trailer frame, so the HTTP/2 trailer emission must find nothing. */ +void http_response_clear_trailers(zend_object *obj) +{ + http_response_object *response = http_response_from_obj(obj); + + if (response->trailers != NULL) { + zend_hash_clean(response->trailers); + } +} + /* Default the `grpc-status` trailer to `status` (decimal) unless the handler * already set one. Called from the H2/H3 gRPC dispose path so every gRPC * response carries a status even when the handler forgot — grpc-status is diff --git a/tests/phpt/server/grpc/009-grpc-web.phpt b/tests/phpt/server/grpc/009-grpc-web.phpt new file mode 100644 index 00000000..f3f1cfe3 --- /dev/null +++ b/tests/phpt/server/grpc/009-grpc-web.phpt @@ -0,0 +1,95 @@ +--TEST-- +gRPC-Web: trailers carried in-body as a 0x80 frame (binary) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $payload = substr($buf, $off + 5, $len); + if ($flag & 0x80) { $trailers .= $payload; } + else { $data[] = $payload; } + $off += 5 + $len; + } + return [$data, $trailers]; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); + $resp->writeMessage('echo:' . $msg); + // grpc-status defaults to 0 +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $frame = "\x00" . pack('N', 4) . 'ping'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>&1', + escapeshellarg('content-type: application/grpc-web+proto'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + [$data, $trailers] = grpcweb_parse($resp); + + echo "resp_ctype_web=", (int)(strpos($verbose, 'content-type: application/grpc-web+proto') !== false), "\n"; + echo "data=", implode(',', $data), "\n"; + echo "trailer_status=", (int)(strpos($trailers, 'grpc-status: 0') !== false), "\n"; + /* The status must be in-body, not an HTTP trailer line. */ + echo "no_http_trailer=", (int)(strpos($verbose, "\ngrpc-status:") === false && strpos($verbose, "< grpc-status") === false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +resp_ctype_web=1 +data=echo:ping +trailer_status=1 +no_http_trailer=1 +Done diff --git a/tests/phpt/server/grpc/010-grpc-web-error.phpt b/tests/phpt/server/grpc/010-grpc-web-error.phpt new file mode 100644 index 00000000..23b62cd0 --- /dev/null +++ b/tests/phpt/server/grpc/010-grpc-web-error.phpt @@ -0,0 +1,87 @@ +--TEST-- +gRPC-Web: zero-message error — status/message in the in-body trailer frame +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $payload = substr($buf, $off + 5, $len); + if ($flag & 0x80) { $trailers .= $payload; } else { $data[] = $payload; } + $off += 5 + $len; + } + return [$data, $trailers]; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $resp->setTrailer('grpc-status', '5') + ->setTrailer('grpc-message', 'nope'); +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $frame = "\x00" . pack('N', 3) . 'req'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $frame); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>&1', + escapeshellarg('content-type: application/grpc-web'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + [$data, $trailers] = grpcweb_parse($resp); + + echo "data_count=", count($data), "\n"; + echo "trailer_status=", (int)(strpos($trailers, 'grpc-status: 5') !== false), "\n"; + echo "trailer_msg=", (int)(strpos($trailers, 'grpc-message: nope') !== false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +data_count=0 +trailer_status=1 +trailer_msg=1 +Done From 93e58b5da2029be78e8ba87df19b4366e10f51ca Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:38:14 +0000 Subject: [PATCH 04/33] docs(grpc): H3 gRPC feasibility + ready-to-execute Phase 5b plan Maps gRPC-over-HTTP/3 against the live tree + nghttp3 1.15.0: high reuse (the message data plane already works on H3's shared stream_ops), the trailer API (nghttp3_conn_submit_trailers + NO_END_STREAM) is available, and the H3-only work is routing + trailer emission + finalize + a C test-client extension. Largest single phase; scoped as its own effort. --- docs/PLAN_GRPC.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md index 7c6c62ea..795ad2cb 100644 --- a/docs/PLAN_GRPC.md +++ b/docs/PLAN_GRPC.md @@ -68,6 +68,51 @@ _Status: living document. Last updated 2026-07-04._ — needs a stateful streaming base64 layer on request and response; binary grpc-web is the common case and is complete. +### Phase 5b — gRPC over HTTP/3 (feasibility confirmed; not yet built) + +Feasibility mapped against the live tree + installed nghttp3 **1.15.0**. The +verdict is favorable but the work is the **largest single phase** and touches +the QUIC/nghttp3 internals, the reactor/worker path, and the C test client. + +**Reuse (already works on H3):** the gRPC data plane is transport-agnostic — +`writeMessage()`/`readMessage()`, the framing/deframing codec, per-message +gzip, `grpc-timeout`, and every response-object trailer helper +(`ensure_grpc_status`, `promote_trailers_to_headers`, `clear_trailers`, +`grpc_web_trailer_frame`) are shared C. H3 installs the same +`http_response_install_stream_ops` seam (`http3_dispatch.c:366`) with its own +`h3_stream_ops`, so message streaming over H3 already functions. + +**What must be built (H3-only):** +1. **Stream state** — add `is_grpc` / `grpc_web` / `has_trailers` / + `trailers_submitted` to `http3_stream_t` (`include/http3/http3_stream.h`, + near `streaming_ended:127`). +2. **Routing** — in `http3_stream_dispatch` (`http3_dispatch.c:282`): classify + via `grpc_request_is_grpc`/`_web`, add `HTTP_PROTOCOL_GRPC` to the handler + lookup (`:317-321`), relax the `fcall == NULL` bail (`:328`) for gRPC-only, + inject the response content-type after `response_zv` init (`:360-367`). + **Also the reactor path**: `http3_stream_dispatch_to_worker` (`:299`) → + `worker_dispatch.c` needs the same gRPC routing for `REACTOR_POOL=1`. +3. **Trailer emission** — the real work, API available: change the streaming + EOF branch of `h3_read_data_cb` (`http3_callbacks.c:476-479`) to set + `NGHTTP3_DATA_FLAG_EOF | NGHTTP3_DATA_FLAG_NO_END_STREAM` and call + `nghttp3_conn_submit_trailers(conn, stream_id, nv, nvlen)` when the response + has trailers — mirroring `h2_dp_mark_eof`. Add an H3 + `submit_response_trailers` helper (QPACK-flatten via the existing + `h3_nv_push`). Add the Trailers-Only / grpc-web finalize to + `h3_handler_coroutine_dispose` (`http3_dispatch.c:680`), reusing + `grpc_web_trailer_frame` + `clear_trailers` exactly as `h2_grpc_web_finalize`. +4. **Test client** — extend `tests/h3client/h3client.c`: inject arbitrary + request headers (fixed 5-header set today at `:311`, so it can't send + `content-type: application/grpc`) and record response trailer name/values + (`h3_recv_header:113` discards non-`:status`). Then add + `tests/phpt/server/h3/` gRPC tests. + +**Why it's its own effort:** multi-file change across the trickiest subsystem +(QUIC + nghttp3 data-reader) plus the reactor/worker path plus a C QUIC-client +extension to make it testable. High reuse keeps it bounded, but it is not a +quick add — best done as a focused session, not rushed onto the end of the H2 +work. + ### Discovered pre-existing bug (independent of gRPC) — NOT yet fixed An **uncaught exception in any HTTP/2 handler** (gRPC or plain HTTP, GET or From cb5b157f64b0b3a10699ee5b61c54288861c0050 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:25:01 +0000 Subject: [PATCH 05/33] =?UTF-8?q?feat(grpc):=20gRPC=20over=20HTTP/3=20?= =?UTF-8?q?=E2=80=94=20native=20trailers=20via=20nghttp3=20+=20grpc-web=20?= =?UTF-8?q?(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gRPC now works over HTTP/3 (non-reactor path). The message data plane was already transport-agnostic (writeMessage/readMessage on the shared H3 stream_ops); this adds the H3-specific routing + trailer emission. - Routing: http3_stream_dispatch + coroutine entry classify via grpc_request_is_grpc/_web, add HTTP_PROTOCOL_GRPC to the handler lookup, and default the response content-type. New http3_stream_t fields is_grpc / grpc_web / has_trailers / trailers_submitted + a captured-trailer nv. - Native trailers: http3_stream_capture_trailers snapshots grpc-status/grpc-message in dispose (while response_zv is alive), and the data reader submits them via nghttp3_conn_submit_trailers at true EOF with NGHTTP3_DATA_FLAG_NO_END_STREAM. The capture-then-submit split is required because the H3 data reader runs async, after dispose frees the zvals (unlike H2's synchronous data provider). The nv is malloc'd, freed in http3_stream_release. - grpc-web: h3_grpc_web_finalize reuses grpc_web_trailer_frame + clear_trailers (in-body 0x80 trailer frame), mirroring the H2 path. - dispose: default grpc-status (0, or 13 on exception), Trailers-Only for zero-message replies, exception->500 gated off for gRPC. Tests: grpc/011 (native H3 unary + trailers), grpc/012 (grpc-web H3), driven by a real aioquic H3 client (_h3grpc_client.py) — the bundled C h3client can't read HTTP/3 trailers. H3 suite (47) + gRPC suite (12) green. Deferred: the reactor/worker H3 path (worker_dispatch.c, REACTOR_POOL=1) needs the same gRPC routing. --- docs/PLAN_GRPC.md | 29 +++++- include/http3/http3_stream.h | 20 +++++ src/http3/http3_callbacks.c | 80 +++++++++++++++++ src/http3/http3_dispatch.c | 82 ++++++++++++++++- src/http3/http3_internal.h | 6 ++ src/http3/http3_stream.c | 12 +++ tests/phpt/server/grpc/011-grpc-h3-unary.phpt | 89 ++++++++++++++++++ tests/phpt/server/grpc/012-grpc-web-h3.phpt | 90 +++++++++++++++++++ tests/phpt/server/grpc/_h3grpc_client.py | 75 ++++++++++++++++ 9 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 tests/phpt/server/grpc/011-grpc-h3-unary.phpt create mode 100644 tests/phpt/server/grpc/012-grpc-web-h3.phpt create mode 100644 tests/phpt/server/grpc/_h3grpc_client.py diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md index 795ad2cb..0e89d46e 100644 --- a/docs/PLAN_GRPC.md +++ b/docs/PLAN_GRPC.md @@ -67,8 +67,33 @@ _Status: living document. Last updated 2026-07-04._ `/010` (zero-message error). **Deferred (Phase 5c): grpc-web-text** (base64) — needs a stateful streaming base64 layer on request and response; binary grpc-web is the common case and is complete. - -### Phase 5b — gRPC over HTTP/3 (feasibility confirmed; not yet built) +- **Phase 5b — DONE (gRPC over HTTP/3, non-reactor path).** Native gRPC + grpc-web + work over H3, with grpc-status/grpc-message emitted through + `nghttp3_conn_submit_trailers`. Tests `/011` (native) + `/012` (grpc-web) via a + real aioquic client. Reactor/worker H3 path deferred. Details in §5b below. + +### Phase 5b — gRPC over HTTP/3 — DONE (non-reactor path) + +Native gRPC **and** grpc-web now work over HTTP/3. Routing in +`http3_stream_dispatch` + the coroutine entry (classify via +`grpc_request_is_grpc`/`_web`, add `HTTP_PROTOCOL_GRPC` to the handler lookup, +default the content-type); `is_grpc`/`grpc_web`/`has_trailers`/ +`trailers_submitted` + a captured-trailer nv on `http3_stream_t`. Native +trailers: `http3_stream_capture_trailers` snapshots grpc-status/grpc-message in +dispose (while `response_zv` is alive), and the data reader +(`h3_read_data_cb`) submits them via `nghttp3_conn_submit_trailers` at true EOF +with `NGHTTP3_DATA_FLAG_NO_END_STREAM` — the capture-then-submit split is +required because the H3 data reader runs async, *after* dispose frees the +zvals (unlike H2's synchronous provider). grpc-web reuses `grpc_web_trailer_frame` +via `h3_grpc_web_finalize`. Tests: `tests/phpt/server/grpc/011` (native H3 +unary + trailers) and `/012` (grpc-web H3), driven by a real **aioquic** H3 +client (`_h3grpc_client.py`) — the bundled C `h3client` can't read HTTP/3 +trailers, and aioquic is the same QUIC stack the hq-interop tests use. H3 suite +(47) + gRPC suite (12) green. **Deferred:** the reactor/worker H3 path +(`worker_dispatch.c`, `REACTOR_POOL=1`) still needs the same gRPC routing. + +_Historical (superseded): the original feasibility note below predates the +implementation._ Feasibility mapped against the live tree + installed nghttp3 **1.15.0**. The verdict is favorable but the work is the **largest single phase** and touches diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index 266cdc90..c825b925 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -126,6 +126,26 @@ struct _http3_stream_s { * queue empties instead of NGHTTP3_ERR_WOULDBLOCK. */ bool streaming_ended; + /* gRPC (issue #4). is_grpc: the request is application/grpc — route to + * the addGrpcHandler callable, default grpc-status. grpc_web: an + * application/grpc-web call — trailers ride the body as a 0x80 frame. + * has_trailers / trailers_submitted mirror http2_stream_t: the data + * reader sets NO_END_STREAM + submits nghttp3 trailers once at EOF. */ + bool is_grpc; + bool grpc_web; + bool has_trailers; + bool trailers_submitted; + + /* gRPC trailers captured in dispose (while response_zv is alive) and + * submitted by the data reader at true EOF — nghttp3 requires the trailer + * submit AFTER the last DATA stamps NO_END_STREAM, but response_zv is + * freed by then. grpc_trailer_nv is a malloc'd nghttp3_nv[] whose name/ + * value point into grpc_trailer_bytes; both freed in http3_stream_release. + * void* keeps nghttp3 out of this header. */ + void *grpc_trailer_nv; + size_t grpc_trailer_count; + char *grpc_trailer_bytes; + /* Set by h3_stream_close_cb / h3_reset_stream_cb when nghttp3 * tears down stream state on a peer RST. After this point any * nghttp3_conn_resume_stream call for this stream_id is a no-op diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 83c97c1e..aab8f16d 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -475,6 +475,25 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, if (s->chunk_read_idx >= s->chunk_queue_tail) { if (s->streaming_ended) { *pflags |= NGHTTP3_DATA_FLAG_EOF; + + /* gRPC native trailers: submit here, at true EOF — nghttp3 + * needs the trailer submit after the last DATA. The nv was + * captured in dispose (response_zv is gone now). Keep the + * sending side open with NO_END_STREAM so the trailer HEADERS + * carry the fin instead of this DATA. */ + if (s->grpc_trailer_nv != NULL && !s->trailers_submitted) { + s->trailers_submitted = true; + if (nghttp3_conn_submit_trailers(conn, s->stream_id, + (const nghttp3_nv *)s->grpc_trailer_nv, + s->grpc_trailer_count) == 0) { + s->has_trailers = true; + } + } + + if (s->has_trailers) { + *pflags |= NGHTTP3_DATA_FLAG_NO_END_STREAM; + } + return 0; } @@ -572,6 +591,67 @@ static inline bool h3_nv_push(h3_nv_buf_t *b, * - Streaming (HttpResponse::send loop): chunk_queue is the body * source; we skip the body copy entirely. Caller has already * primed the queue with the first chunk by the time we run. */ +/* Capture the response trailer map (grpc-status/grpc-message) into a malloc'd + * nghttp3_nv[] + backing byte buffer on the stream. Called from dispose while + * response_zv is still alive; the data reader submits it at true EOF, because + * nghttp3_conn_submit_trailers must follow the last DATA (which stamps + * NO_END_STREAM) yet response_zv is freed by then. malloc (not emalloc) — the + * data reader runs outside a request memory context. No-op when there are no + * trailers or a capture already exists. Freed in http3_stream_release. */ +void http3_stream_capture_trailers(http3_stream_t *s) +{ + if (Z_ISUNDEF(s->response_zv) || s->grpc_trailer_nv != NULL) { + return; + } + + HashTable *trailers = http_response_get_trailers(Z_OBJ(s->response_zv)); + + if (trailers == NULL || zend_hash_num_elements(trailers) == 0) { + return; + } + + size_t count = 0, total = 0; + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + count++; + total += ZSTR_LEN(name) + Z_STRLEN_P(val); + } ZEND_HASH_FOREACH_END(); + + if (count == 0) { + return; + } + + nghttp3_nv *nv = malloc(count * sizeof(nghttp3_nv)); + char *bytes = malloc(total ? total : 1); + + if (nv == NULL || bytes == NULL) { + free(nv); + free(bytes); + return; + } + + size_t ni = 0, bi = 0; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + memcpy(bytes + bi, ZSTR_VAL(name), ZSTR_LEN(name)); + nv[ni].name = (uint8_t *)(bytes + bi); + nv[ni].namelen = ZSTR_LEN(name); + bi += ZSTR_LEN(name); + memcpy(bytes + bi, Z_STRVAL_P(val), Z_STRLEN_P(val)); + nv[ni].value = (uint8_t *)(bytes + bi); + nv[ni].valuelen = Z_STRLEN_P(val); + bi += Z_STRLEN_P(val); + nv[ni].flags = NGHTTP3_NV_FLAG_NONE; + ni++; + } ZEND_HASH_FOREACH_END(); + + s->grpc_trailer_nv = nv; + s->grpc_trailer_count = ni; + s->grpc_trailer_bytes = bytes; +} + bool http3_stream_submit_response(http3_connection_t *c, http3_stream_t *s, bool streaming) diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index e148387a..0ff17c62 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -24,6 +24,7 @@ #include "http_connection.h" /* http_handler_log_bailout */ #include "http_send_file.h" /* http_send_file_dispatch */ #include "http_response_internal.h" /* http_response_has/take_send_file */ +#include "grpc/grpc.h" /* gRPC classification + trailer frame */ #include "static/static_handler.h" /* http_static_try_serve / count */ #include "core/response_wire.h" /* response_wire_* (reverse path) */ @@ -320,6 +321,21 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); } + /* gRPC (issue #4): route application/grpc requests to addGrpcHandler. + * grpc-web (application/grpc-web) carries trailers in-body. Mirrors the + * H2 strategy — a gRPC-only server dispatches because fcall picks up the + * gRPC handler here. */ + s->is_grpc = grpc_request_is_grpc(s->request) + && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); + s->grpc_web = s->is_grpc && grpc_request_is_grpc_web(s->request); + + if (s->is_grpc) { + zend_fcall_t *g = http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC); + if (g != NULL) { + fcall = g; + } + } + /* Static-only deployments register a mount but no PHP handler — the * static gate below claims the request before any handler is needed. * Mirrors http2_strategy.c. */ @@ -366,6 +382,15 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) http_response_install_stream_ops(Z_OBJ(s->response_zv), &h3_stream_ops, s); + /* gRPC: default the response content-type so handlers need not set it. */ + if (s->is_grpc) { + http_response_static_set_header(Z_OBJ(s->response_zv), + "content-type", sizeof("content-type") - 1, + s->grpc_web ? GRPC_WEB_RESPONSE_CONTENT_TYPE : GRPC_CONTENT_TYPE, + s->grpc_web ? sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1 + : sizeof(GRPC_CONTENT_TYPE) - 1); + } + #ifdef HAVE_HTTP_COMPRESSION /* Attach compression state. Server pointer comes from * the listener — same pattern that http3_handler_coroutine uses @@ -493,6 +518,14 @@ static void h3_handler_coroutine_entry(void) fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); } + /* gRPC streams route to the addGrpcHandler callable. */ + if (s->is_grpc) { + zend_fcall_t *g = http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC); + if (g != NULL) { + fcall = g; + } + } + if (fcall == NULL) return; #ifdef HAVE_HTTP_COMPRESSION @@ -677,6 +710,27 @@ static bool h3_arm_sendfile(http3_connection_t *c, http3_stream_t *s) return true; } +/* Finalize a grpc-web reply on H3: emit grpc-status/grpc-message as an + * in-body 0x80 frame (browsers can't read HTTP/3 trailers), then end the + * stream. Works for streamed and zero-message replies — append_chunk commits + * HEADERS + inits the chunk queue on first use. */ +static void h3_grpc_web_finalize(http3_connection_t *c, http3_stream_t *s, + zend_object *resp_obj) +{ + HashTable *trailers = http_response_get_trailers(resp_obj); + zend_string *frame = grpc_web_trailer_frame(trailers); + + http_response_clear_trailers(resp_obj); + + (void)h3_stream_ops.append_chunk(s, frame); /* consumes the ref */ + + if (!s->streaming_ended) { + s->streaming_ended = true; + (void)nghttp3_conn_resume_stream((nghttp3_conn *)c->nghttp3_conn, + s->stream_id); + } +} + static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) { http3_stream_t *s = (http3_stream_t *)coroutine->extended_data; @@ -703,7 +757,7 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) * the H2 dispose path's exception → status policy in spirit, but * trimmed: we don't rewrite arbitrary 4xx/5xx codes from the * exception code field — keep the policy minimal. */ - if (coroutine->exception != NULL && !Z_ISUNDEF(s->response_zv) + if (coroutine->exception != NULL && !s->is_grpc && !Z_ISUNDEF(s->response_zv) && !http_response_is_committed(Z_OBJ(s->response_zv))) { http_response_reset_to_error(Z_OBJ(s->response_zv), 500, "Internal Server Error"); @@ -714,6 +768,14 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(s->response_zv)); } + /* gRPC outcome → grpc-status trailer. Success defaults to 0; an uncaught + * exception maps to INTERNAL (13) unless the handler set a status. gRPC + * carries its result in the trailer, not the HTTP status. */ + if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) { + http_response_ensure_grpc_status(Z_OBJ(s->response_zv), + coroutine->exception != NULL ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK); + } + /* Streaming-vs-buffered decision (mirror of H2 dispose). * * Streaming path: HEADERS were submitted on the first send() via @@ -729,7 +791,18 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) if (c != NULL && !c->closed && c->nghttp3_conn != NULL && !Z_ISUNDEF(s->response_zv)) { - if (is_streaming) { + if (s->grpc_web) { + /* grpc-web: trailers ride the body as a 0x80 frame, never as + * HTTP/3 trailers. Handles streamed and zero-message replies. */ + h3_grpc_web_finalize(c, s, Z_OBJ(s->response_zv)); + } else if (is_streaming) { + /* Native gRPC: capture the grpc-status/grpc-message trailers now, + * while response_zv is alive — the data reader runs async (after + * this dispose frees the zvals) and submits them at true EOF. */ + if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) { + http3_stream_capture_trailers(s); + } + H3T(s->stream_id, s->streaming_ended ? "5.streaming_already_ended" : "5.streaming_resume"); if (!s->streaming_ended) { @@ -737,6 +810,11 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) (void)nghttp3_conn_resume_stream( (nghttp3_conn *)c->nghttp3_conn, s->stream_id); } + } else if (s->is_grpc) { + /* zero-message gRPC → Trailers-Only: fold grpc-status/grpc-message + * into the initial HEADERS (single HEADERS + fin). */ + http_response_promote_trailers_to_headers(Z_OBJ(s->response_zv)); + (void)http3_stream_submit_response(c, s, false); } else if (http_response_has_send_file(Z_OBJ(s->response_zv))) { /* sendFile: hand off to the static pump. On success it owns the * stream + response until on_done runs the tail — the pump diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index 8107c856..010c6176 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -159,6 +159,12 @@ bool http3_stream_submit_response(http3_connection_t *c, http3_stream_t *s, bool streaming); +/* Capture the response trailers (grpc-status/grpc-message) onto the stream + * from the streaming dispose branch, while response_zv is still alive. The + * data reader submits them via nghttp3_conn_submit_trailers at true EOF (the + * reader runs async, after response_zv is freed). */ +void http3_stream_capture_trailers(http3_stream_t *s); + /* Reverse path: submit a buffered response from a worker-rendered * response_wire instead of the per-stream HttpResponse zval. Reactor thread. */ typedef struct response_wire_s response_wire_t; diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index f02d9a1d..84d67af7 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -157,6 +157,18 @@ void http3_stream_release(http3_stream_t *s) s->chunk_queue = NULL; } + /* gRPC trailer capture (malloc'd in http3_stream_capture_trailers). Held + * until teardown so the nv stays valid through the async trailer submit. */ + if (s->grpc_trailer_nv != NULL) { + free(s->grpc_trailer_nv); + s->grpc_trailer_nv = NULL; + } + + if (s->grpc_trailer_bytes != NULL) { + free(s->grpc_trailer_bytes); + s->grpc_trailer_bytes = NULL; + } + if (s->write_event != NULL) { zend_async_event_t *ev = &s->write_event->base; diff --git a/tests/phpt/server/grpc/011-grpc-h3-unary.phpt b/tests/phpt/server/grpc/011-grpc-h3-unary.phpt new file mode 100644 index 00000000..fc61a993 --- /dev/null +++ b/tests/phpt/server/grpc/011-grpc-h3-unary.phpt @@ -0,0 +1,89 @@ +--TEST-- +gRPC over HTTP/3: unary echo — native trailers via nghttp3 (real aioquic client) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--FILE-- +/dev/null', + escapeshellarg($key), escapeshellarg($cert)), $_, $rc); +if ($rc !== 0) { echo "cert gen failed\n"; exit(1); } + +register_shutdown_function(function () use ($tmp) { + @unlink("$tmp/cert.pem"); @unlink("$tmp/key.pem"); @rmdir($tmp); +}); + +require_once __DIR__ . '/../_free_port.inc'; + +$port = tas_free_port_span(2); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port + 1) + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setReadTimeout(5)->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function ($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); + $resp->writeMessage('echo:' . ($msg ?? '')); + // grpc-status defaults to 0 +}); + +$py = __DIR__ . '/_h3grpc_client.py'; + +$client = spawn(function () use ($server, $port, $py) { + usleep(200000); + + $bodyhex = bin2hex("\x00" . pack('N', 4) . 'ping'); + $cmd = sprintf('python3 %s 127.0.0.1 %d /svc/Echo application/grpc %s 2>/dev/null', + escapeshellarg($py), $port, $bodyhex); + $out = shell_exec($cmd); + + /* Deframe the single response message from the BODYHEX line. */ + $reply = ''; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m) && strlen($m[1]) >= 10) { + $body = hex2bin($m[1]); + $len = unpack('N', substr($body, 1, 4))[1]; + $reply = substr($body, 5, $len); + } + + echo "saw_status_200=", (int)(strpos($out, 'HDR :status: 200') !== false), "\n"; + echo "saw_ctype=", (int)(strpos($out, 'HDR content-type: application/grpc') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; + echo "reply=", $reply, "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_status_200=1 +saw_ctype=1 +saw_grpc_status=1 +reply=echo:ping +Done diff --git a/tests/phpt/server/grpc/012-grpc-web-h3.phpt b/tests/phpt/server/grpc/012-grpc-web-h3.phpt new file mode 100644 index 00000000..90b4627b --- /dev/null +++ b/tests/phpt/server/grpc/012-grpc-web-h3.phpt @@ -0,0 +1,90 @@ +--TEST-- +gRPC-Web over HTTP/3: trailers in-body as a 0x80 frame (real aioquic client) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--FILE-- + $n) break; + $payload = substr($buf, $off + 5, $len); + if ($flag & 0x80) { $trailers .= $payload; } else { $data[] = $payload; } + $off += 5 + $len; + } + return [$data, $trailers]; +} + +$tmp = __DIR__ . '/tmp-grpcweb-h3'; +@mkdir($tmp, 0700, true); +$cert = $tmp . '/cert.pem'; $key = $tmp . '/key.pem'; $rc = 0; +exec(sprintf('openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes ' + . '-subj "/CN=localhost" -keyout %s -out %s 2>/dev/null', + escapeshellarg($key), escapeshellarg($cert)), $_, $rc); +if ($rc !== 0) { echo "cert gen failed\n"; exit(1); } +register_shutdown_function(function () use ($tmp) { + @unlink("$tmp/cert.pem"); @unlink("$tmp/key.pem"); @rmdir($tmp); +}); + +require_once __DIR__ . '/../_free_port.inc'; + +$port = tas_free_port_span(2); +$server = new HttpServer((new HttpServerConfig()) + ->addListener('127.0.0.1', $port + 1) + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setReadTimeout(5)->setWriteTimeout(5)); + +$server->addGrpcHandler(function ($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); + $resp->writeMessage('echo:' . ($msg ?? '')); +}); + +$py = __DIR__ . '/_h3grpc_client.py'; + +$client = spawn(function () use ($server, $port, $py) { + usleep(200000); + $bodyhex = bin2hex("\x00" . pack('N', 4) . 'ping'); + $cmd = sprintf('python3 %s 127.0.0.1 %d /svc/Echo application/grpc-web+proto %s 2>/dev/null', + escapeshellarg($py), $port, $bodyhex); + $out = shell_exec($cmd); + + $body = ''; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m)) { + $body = hex2bin($m[1]); + } + [$data, $trailers] = grpcweb_parse($body); + + echo "saw_ctype_web=", (int)(strpos($out, 'HDR content-type: application/grpc-web+proto') !== false), "\n"; + echo "data=", implode(',', $data), "\n"; + echo "trailer_status=", (int)(strpos($trailers, 'grpc-status: 0') !== false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_ctype_web=1 +data=echo:ping +trailer_status=1 +Done diff --git a/tests/phpt/server/grpc/_h3grpc_client.py b/tests/phpt/server/grpc/_h3grpc_client.py new file mode 100644 index 00000000..7d10dbc5 --- /dev/null +++ b/tests/phpt/server/grpc/_h3grpc_client.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""HTTP/3 gRPC test client (aioquic) for the grpc-over-H3 phpt. + +The bundled C h3client can't read HTTP/3 trailers, so gRPC-over-H3 is driven +with aioquic — the same QUIC stack the hq-interop tests use. Sends one POST +with a caller-supplied content-type + hex body, then prints every response +header AND trailer ("HDR name: value") plus the body hex ("BODYHEX ..."), so +the test can assert grpc-status. + + usage: _h3grpc_client.py +""" +import asyncio +import ssl +import sys + +from aioquic.asyncio import connect +from aioquic.asyncio.protocol import QuicConnectionProtocol +from aioquic.quic.configuration import QuicConfiguration +from aioquic.h3.connection import H3Connection +from aioquic.h3.events import HeadersReceived, DataReceived + + +class H3Client(QuicConnectionProtocol): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.http = None + self.done = asyncio.Event() + self.headers = [] # initial headers AND trailers, in arrival order + self.body = b"" + + def quic_event_received(self, event): + if self.http is None: + self.http = H3Connection(self._quic) + for e in self.http.handle_event(event): + if isinstance(e, HeadersReceived): + for k, v in e.headers: + self.headers.append((k.decode("ascii", "replace"), + v.decode("ascii", "replace"))) + if e.stream_ended: + self.done.set() + elif isinstance(e, DataReceived): + self.body += e.data + if e.stream_ended: + self.done.set() + + +async def main(host, port, path, ctype, body_hex): + config = QuicConfiguration(is_client=True, alpn_protocols=["h3"]) + config.verify_mode = ssl.CERT_NONE + async with connect(host, int(port), configuration=config, + create_protocol=H3Client) as client: + if client.http is None: + client.http = H3Connection(client._quic) + sid = client._quic.get_next_available_stream_id() + client.http.send_headers(sid, [ + (b":method", b"POST"), + (b":scheme", b"https"), + (b":authority", host.encode()), + (b":path", path.encode()), + (b"content-type", ctype.encode()), + (b"te", b"trailers"), + ], end_stream=False) + client.http.send_data(sid, bytes.fromhex(body_hex), end_stream=True) + client.transmit() + try: + await asyncio.wait_for(client.done.wait(), timeout=5) + except asyncio.TimeoutError: + print("TIMEOUT", file=sys.stderr) + for k, v in client.headers: + print(f"HDR {k}: {v}") + print("BODYHEX " + client.body.hex()) + + +if __name__ == "__main__": + asyncio.run(main(*sys.argv[1:6])) From e4d7a3b84a02aa792be2ce7c1483acb4b7248f56 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:50:21 +0000 Subject: [PATCH 06/33] =?UTF-8?q?feat(grpc):=20true=20full-duplex=20bidi?= =?UTF-8?q?=20=E2=80=94=20incremental=20readMessage=20+=20body-cap=20fix?= =?UTF-8?q?=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2b. readMessage() gains an incremental path over the streaming request body (issue #26): when the request qualifies for http_body_stream (setBodyStreamingEnabled + CL 0/>=1MiB, or the 64KiB-1MiB upgrade band), it pops chunks into a per-request reassembly buffer (grpc_reassembly on http_request_t) and deframes messages as they arrive — a handler can readMessage() while the client is still sending, without awaitBody(). The buffered path is unchanged, so the existing gRPC tests still pass. Body-cap fix (§4.3): the H2 streaming ingest (http2_session.c) now caps the LIVE queued bytes instead of the monotonic cumulative total, so long client-streaming / bidi streams are no longer RST once their total passes max_body_size while the handler keeps draining. Test: grpc/013 (three ~40-50KiB messages spanning DATA frames, drained incrementally). h2 + h1 + gRPC + 050 (streaming body) suites green. Note: the H3 dispatch has no body_streaming policy yet, so incremental read is H2-only; H3 gRPC uses the buffered path. --- docs/PLAN_GRPC.md | 22 ++-- include/http1/http_parser.h | 13 ++- src/http1/http_parser.c | 3 + src/http2/http2_session.c | 10 +- src/http_request.c | 100 ++++++++++++++++- .../server/grpc/013-grpc-streaming-read.phpt | 102 ++++++++++++++++++ 6 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 tests/phpt/server/grpc/013-grpc-streaming-read.phpt diff --git a/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md index 0e89d46e..a19b46d5 100644 --- a/docs/PLAN_GRPC.md +++ b/docs/PLAN_GRPC.md @@ -23,13 +23,21 @@ _Status: living document. Last updated 2026-07-04._ EOF trailers). Tests: `/004` (server-streaming, N framed responses), `/005` (client-streaming, N request messages), `/006` (bidi half-duplex, N-in/N-out on one stream). - **Deferred to a Phase 2b follow-up** (coupled + substantial, not yet needed by - the buffered path): true *full-duplex interleaved* bidi — reading each message - as it arrives while writing — needs `readMessage()` wired to the incremental - `http_body_stream` (issue #26) instead of the buffered `req->body`, and with it - the body-cap fix (§4.3, the cumulative lifetime cap in `http_body_stream.c`). - gRPC uses the buffered request body today, so §4.3 does not bite yet; - server/client/half-duplex-bidi work fully. + See Phase 2b for the full-duplex (incremental) path. +- **Phase 2b — DONE (true full-duplex, H2).** `readMessage()` now has a second + path: when the request qualifies for `http_body_stream` (issue #26 — + `setBodyStreamingEnabled(true)` + Content-Length 0 / ≥1 MiB, or the + 64 KiB–1 MiB upgrade band), it pops body chunks into a per-request + reassembly buffer (`grpc_reassembly` on `http_request_t`) and deframes + incrementally, so a handler reads each message *as it arrives* (client- + streaming / full-duplex) without `awaitBody()`. Buffered path unchanged → + the 11 existing gRPC tests still pass. Body-cap fix (§4.3): the H2 streaming + ingest now caps the **live** (queued) bytes, not the monotonic cumulative + total, so long client-streaming / bidi streams aren't RST at `max_body_size`. + Test: `/013` (three ~40–50 KiB messages spanning DATA frames, drained + incrementally). H2 + h1 + gRPC + `050` suites green. **Note:** the H3 + dispatch has no `body_streaming` policy yet, so incremental read is H2-only; + H3 gRPC uses the buffered path. - **Phase 3 — DONE (propagation); hard auto-cancel deferred.** `grpc-timeout` is parsed (`grpc_parse_timeout_ns`) and exposed to handlers via `HttpRequest::getGrpcTimeout(): ?float` (seconds), so a handler can bound its diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index 8d94d966..9bdb7796 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -91,11 +91,18 @@ struct http_request_t { /* Body */ zend_string *body; - /* gRPC deframer cursor into `body` — advanced by HttpRequest::readMessage() - * as it extracts each 5-byte-length-prefixed message. Zero-initialised - * with the rest of the request (memset on alloc). */ + /* gRPC deframer cursor — advanced by HttpRequest::readMessage() as it + * extracts each 5-byte-length-prefixed message. For a buffered request it + * indexes `body`; for a streaming request (body_streaming) it indexes the + * grpc_reassembly buffer below. Zero-initialised (memset on alloc). */ size_t grpc_read_offset; + /* gRPC incremental reassembly buffer for streaming requests: readMessage() + * appends popped body-stream chunks here until a full length-prefixed + * message is available, so messages that span DATA frames are handled. + * Empty {NULL,0} until first use; freed in http_request_destroy. */ + smart_str grpc_reassembly; + /* Body-progress event. * Lazily created by awaitBody() on the first suspend; notified by * the parser on body_complete once the event-loop read path lands. diff --git a/src/http1/http_parser.c b/src/http1/http_parser.c index a6bbc528..9a1ccca1 100644 --- a/src/http1/http_parser.c +++ b/src/http1/http_parser.c @@ -1082,6 +1082,9 @@ void http_request_free_fields(http_request_t *req) body_release(req->body); req->body = NULL; + /* gRPC streaming reassembly buffer (HttpRequest::readMessage). */ + smart_str_free(&req->grpc_reassembly); + /* Dispose body-progress event if awaitBody() created one */ if (req->body_event) { req->body_event->dispose(req->body_event); diff --git a/src/http2/http2_session.c b/src/http2/http2_session.c index 69b10fcc..c74370c0 100644 --- a/src/http2/http2_session.c +++ b/src/http2/http2_session.c @@ -455,7 +455,15 @@ static int cb_on_data_chunk_recv(nghttp2_session *ng, body_cap = HTTP2_MAX_BODY_SIZE; } - if (req->body_bytes_consumed + req->body_bytes_queued + len > body_cap) { + /* Cap the LIVE (queued, not-yet-drained) bytes, not the cumulative + * total. body_bytes_consumed is monotonic, so the old + * consumed+queued check RST'd long client-streaming / bidi streams + * once their *total* passed max_body_size — even when the handler + * drained every chunk (issue #4 §4.3). A streaming body is + * legitimately unbounded in total; max_body_size now bounds only the + * in-flight buffer (backpressure), matching readBody()/readMessage() + * consumers that keep up with the peer. */ + if (req->body_bytes_queued + len > body_cap) { return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; } diff --git a/src/http_request.c b/src/http_request.c index 4b063669..429ed05b 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -282,15 +282,107 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) http_request_t *req = intern->request; - if (req == NULL || req->body == NULL) { + if (req == NULL) { RETURN_NULL(); } zend_string *msg = NULL; bool compressed = false; - const int rc = grpc_deframe_next(ZSTR_VAL(req->body), ZSTR_LEN(req->body), - &req->grpc_read_offset, - GRPC_MAX_RECV_MESSAGE, &compressed, &msg); + int rc; + + /* Middle-band request (SMALL <= Content-Length < AUTO): the parser + * buffered so far; upgrade to the streaming queue on first read so + * incremental deframing works there too (mirror of readBody Case 2). */ + if (!req->body_streaming && req->body_upgrade_to_stream != NULL) { + req->body_upgrade_to_stream(req); + } + + if (req->body_streaming) { + /* True full-duplex path: accumulate popped body-stream chunks into + * grpc_reassembly until one length-prefixed message is framed, then + * deframe it — a handler can readMessage() while the client is still + * sending. Suspends between chunks. Enabled when the server ran with + * setBodyStreamingEnabled(true) and the request qualified. */ + + /* Drop the previous message's consumed prefix so the buffer holds + * only the not-yet-deframed tail (bounds memory on long streams). */ + if (req->grpc_read_offset > 0 && req->grpc_reassembly.s != NULL) { + const size_t total = ZSTR_LEN(req->grpc_reassembly.s); + const size_t remain = req->grpc_read_offset < total + ? total - req->grpc_read_offset : 0; + if (remain > 0) { + memmove(ZSTR_VAL(req->grpc_reassembly.s), + ZSTR_VAL(req->grpc_reassembly.s) + req->grpc_read_offset, + remain); + } + ZSTR_LEN(req->grpc_reassembly.s) = remain; + ZSTR_VAL(req->grpc_reassembly.s)[remain] = '\0'; + req->grpc_read_offset = 0; + } + + for (;;) { + const char *buf = req->grpc_reassembly.s + ? ZSTR_VAL(req->grpc_reassembly.s) : ""; + const size_t len = req->grpc_reassembly.s + ? ZSTR_LEN(req->grpc_reassembly.s) : 0; + + rc = grpc_deframe_next(buf, len, &req->grpc_read_offset, + GRPC_MAX_RECV_MESSAGE, &compressed, &msg); + + if (rc != 0) { + break; /* 1 = message extracted, -1 = protocol error */ + } + + /* Incomplete frame — pull the next body chunk, or wait / EOF. */ + zend_string *chunk = http_body_stream_pop(req); + + if (chunk != NULL) { + smart_str_append(&req->grpc_reassembly, chunk); + zend_string_release(chunk); + continue; + } + + if (req->body_eof) { + if (UNEXPECTED(req->body_error)) { + zend_throw_exception(http_server_runtime_exception_ce, + "gRPC request body stream error", 0); + RETURN_THROWS(); + } + RETURN_NULL(); /* client half-closed, no more messages */ + } + + if (req->body_data_event == NULL) { + req->body_data_event = async_plain_event_new(); + if (req->body_data_event == NULL) { + RETURN_NULL(); + } + } + + zend_coroutine_t *co = ZEND_ASYNC_CURRENT_COROUTINE; + if (co == NULL || ZEND_ASYNC_IS_SCHEDULER_CONTEXT) { + RETURN_NULL(); + } + if (ZEND_ASYNC_WAKER_NEW(co) == NULL) { + return; + } + zend_async_resume_when(co, req->body_data_event, false, + zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + zend_async_waker_clean(co); + + if (EG(exception) != NULL) { + return; + } + } + } else { + /* Buffered: deframe from the fully-received body via the cursor. */ + if (req->body == NULL) { + RETURN_NULL(); + } + rc = grpc_deframe_next(ZSTR_VAL(req->body), ZSTR_LEN(req->body), + &req->grpc_read_offset, + GRPC_MAX_RECV_MESSAGE, &compressed, &msg); + } if (rc < 0) { zend_throw_exception(http_server_runtime_exception_ce, diff --git a/tests/phpt/server/grpc/013-grpc-streaming-read.phpt b/tests/phpt/server/grpc/013-grpc-streaming-read.phpt new file mode 100644 index 00000000..2cd330e3 --- /dev/null +++ b/tests/phpt/server/grpc/013-grpc-streaming-read.phpt @@ -0,0 +1,102 @@ +--TEST-- +gRPC: incremental readMessage over a streaming request body (true full-duplex) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $out[] = substr($buf, $off + 5, $len); + $off += 5 + $len; + } + return $out; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setBodyStreamingEnabled(true) + ->setMaxBodySize(8 * 1024 * 1024) + ->setReadTimeout(10) + ->setWriteTimeout(10); + +$server = new HttpServer($config); +$server->addGrpcHandler(function ($req, $resp) { + /* No awaitBody() — drain messages incrementally as they arrive. */ + while (($m = $req->readMessage()) !== null) { + $resp->writeMessage('len:' . strlen($m)); + } + // grpc-status defaults to 0 +}); + +$sizes = [40000, 45000, 50000]; + +$client = spawn(function () use ($port, $server, $sizes) { + usleep(80000); + + $body = ''; + foreach ($sizes as $i => $sz) { + $body .= grpc_frame(str_repeat(chr(ord('a') + $i), $sz)); + } + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcstream'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, $body); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 5 -H %s -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/Collect 2>&1', + escapeshellarg('content-type: application/grpc'), + escapeshellarg('te: trailers'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + $replies = grpc_deframe_all($resp); + + echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; + echo "count=", count($replies), "\n"; + echo "replies=", implode(',', $replies), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_grpc_status=1 +count=3 +replies=len:40000,len:45000,len:50000 +Done From 3ba71e1d0bcf91e9644e986123cf3ee4b246b596 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:44:05 +0000 Subject: [PATCH 07/33] refactor(grpc): extract call lifecycle policy from H2/H3 transports (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gRPC orchestration was copy-pasted inline in both transports. Now: - src/grpc/grpc_call.{c,h}: init_response (content-type default), ensure_status (exception -> INTERNAL, else OK), finish (grpc-web in-body 0x80 frame / streaming EOF / Trailers-Only) — decided once, transport-independent. - Transports provide a 3-op grpc_finish_ops_t (append_frame_and_end / end_stream / commit) and stay gRPC-agnostic on delivery; both *_grpc_web_finalize copies and both Trailers-Only blocks deleted. - H3 trailer capture/submit de-gRPC-ified: grpc_trailer_* fields -> trailer_*; capture now runs for any streaming response with trailers (parity with H2's generic h2_dp_mark_eof path). - Inline per-transport classification (is_grpc predicate at dispatch) kept as-is — deliberate hot-path choice. - Hardening: NULL-conn guard in the H2 grpc-web append op (latent pre-existing path), grpc_call.c added to config.w32. 236/236 server phpt green (12/12 grpc, incl. aioquic H3 client). --- CMakeLists.txt | 1 + config.m4 | 1 + config.w32 | 1 + include/http3/http3_stream.h | 16 +++--- src/grpc/grpc_call.c | 64 ++++++++++++++++++++++ src/grpc/grpc_call.h | 66 ++++++++++++++++++++++ src/http2/http2_strategy.c | 95 +++++++++++++++----------------- src/http3/http3_callbacks.c | 22 ++++---- src/http3/http3_dispatch.c | 103 +++++++++++++++++++---------------- src/http3/http3_stream.c | 14 ++--- 10 files changed, 260 insertions(+), 123 deletions(-) create mode 100644 src/grpc/grpc_call.c create mode 100644 src/grpc/grpc_call.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fa38e3a..4b6240b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ set(CORE_SOURCES src/http_request.c src/http_response.c src/grpc/grpc.c + src/grpc/grpc_call.c src/http_sse.c src/uploaded_file.c src/http_mime.c diff --git a/config.m4 b/config.m4 index 3971cc31..09ac6741 100644 --- a/config.m4 +++ b/config.m4 @@ -531,6 +531,7 @@ if test "$PHP_HTTP_SERVER" != "no"; then src/http_request.c src/http_response.c src/grpc/grpc.c + src/grpc/grpc_call.c src/http_sse.c src/http_response_server_api.c src/http_body_stream.c diff --git a/config.w32 b/config.w32 index 721c5af9..e5b65e2b 100644 --- a/config.w32 +++ b/config.w32 @@ -21,6 +21,7 @@ if (PHP_TRUE_ASYNC_SERVER == "yes") { "src\\http_request.c " + "src\\http_response.c " + "src\\grpc\\grpc.c " + + "src\\grpc\\grpc_call.c " + "src\\http_sse.c " + "src\\http_response_server_api.c " + "src\\uploaded_file.c " + diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index c825b925..7becef01 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -136,15 +136,17 @@ struct _http3_stream_s { bool has_trailers; bool trailers_submitted; - /* gRPC trailers captured in dispose (while response_zv is alive) and + /* Response trailers captured in dispose (while response_zv is alive) and * submitted by the data reader at true EOF — nghttp3 requires the trailer * submit AFTER the last DATA stamps NO_END_STREAM, but response_zv is - * freed by then. grpc_trailer_nv is a malloc'd nghttp3_nv[] whose name/ - * value point into grpc_trailer_bytes; both freed in http3_stream_release. - * void* keeps nghttp3 out of this header. */ - void *grpc_trailer_nv; - size_t grpc_trailer_count; - char *grpc_trailer_bytes; + * freed by then. trailer_nv is a malloc'd nghttp3_nv[] whose name/ + * value point into trailer_bytes; both freed in http3_stream_release. + * void* keeps nghttp3 out of this header. Canonical consumer: gRPC + * (grpc-status / grpc-message), but any streaming response with a + * trailer map is delivered this way. */ + void *trailer_nv; + size_t trailer_count; + char *trailer_bytes; /* Set by h3_stream_close_cb / h3_reset_stream_cb when nghttp3 * tears down stream state on a peer RST. After this point any diff --git a/src/grpc/grpc_call.c b/src/grpc/grpc_call.c new file mode 100644 index 00000000..cdec009d --- /dev/null +++ b/src/grpc/grpc_call.c @@ -0,0 +1,64 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#include "grpc_call.h" +#include "grpc.h" +#include "php_http_server.h" + +void grpc_call_init_response(zend_object *response_obj, bool grpc_web) +{ + if (grpc_web) { + http_response_static_set_header(response_obj, + "content-type", sizeof("content-type") - 1, + GRPC_WEB_RESPONSE_CONTENT_TYPE, + sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1); + } else { + http_response_static_set_header(response_obj, + "content-type", sizeof("content-type") - 1, + GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + } +} + +void grpc_call_ensure_status(zend_object *response_obj, bool had_exception) +{ + http_response_ensure_grpc_status(response_obj, + had_exception ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK); +} + +void grpc_call_finish(zend_object *response_obj, bool grpc_web, + const grpc_finish_ops_t *ops, void *ctx) +{ + if (grpc_web) { + /* Trailers ride the response body as a 0x80 frame, never as HTTP + * trailers. Clear the trailer map so the transport's generic EOF + * path does not also emit them as a terminal trailer block. + * Handles both a streamed reply and a zero-message status/error + * (the trailer frame is then the only DATA). */ + zend_string *frame = + grpc_web_trailer_frame(http_response_get_trailers(response_obj)); + + http_response_clear_trailers(response_obj); + ops->append_frame_and_end(ctx, frame); + return; + } + + if (http_response_is_streaming(response_obj)) { + /* Native gRPC over a streamed body: just make sure the stream is + * ended; grpc-status/grpc-message ride the transport's generic + * response-trailer path at true EOF. */ + ops->end_stream(ctx); + return; + } + + /* Handler streamed no messages (immediate status / error) → + * Trailers-Only: fold grpc-status/grpc-message into the initial + * HEADERS so the buffered commit sends a single HEADERS(:status 200, + * fin) — the canonical gRPC shape for a bodiless response. */ + http_response_promote_trailers_to_headers(response_obj); + ops->commit(ctx); +} diff --git a/src/grpc/grpc_call.h b/src/grpc/grpc_call.h new file mode 100644 index 00000000..b0738c94 --- /dev/null +++ b/src/grpc/grpc_call.h @@ -0,0 +1,66 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#ifndef HTTP_GRPC_CALL_H +#define HTTP_GRPC_CALL_H + +#ifdef HAVE_CONFIG_H +# include +#endif + +#include "php.h" +#include + +/* + * gRPC call lifecycle policy — the transport-independent half of gRPC + * orchestration. The wire codec lives in grpc.c; this module owns the + * per-call decisions (response defaults, outcome → grpc-status, which + * finalize shape to use). Transports stay gRPC-agnostic: they classify + * the request (cheap inline predicate), then call the two hooks below + * and provide the tiny ops vtable for the genuinely transport-specific + * bits (how bytes are appended / streams are ended / buffered responses + * are committed). Native trailer delivery is NOT part of the seam — it + * rides each transport's generic response-trailer path. + */ + +/* Transport ops for grpc_call_finish. ctx is the transport's stream. */ +typedef struct grpc_finish_ops { + /* Append one body frame (consumes the zend_string ref) and end the + * stream. Used by grpc-web to deliver the in-body trailer frame. */ + void (*append_frame_and_end)(void *ctx, zend_string *frame); + + /* End a streaming response (idempotent). Trailers are delivered by + * the transport's generic trailer path at true EOF. */ + void (*end_stream)(void *ctx); + + /* Commit a buffered response now (single HEADERS + fin — the + * Trailers-Only shape). May be a no-op if the connection is gone. */ + void (*commit)(void *ctx); +} grpc_finish_ops_t; + +/* Dispatch-time response defaults: content-type application/grpc (or the + * grpc-web response content-type). A handler may still override before + * its first writeMessage(). */ +void grpc_call_init_response(zend_object *response_obj, bool grpc_web); + +/* Outcome → grpc-status trailer. Success defaults to 0; an uncaught + * handler exception maps to INTERNAL (13) unless the handler already set + * a status. Call from dispose before delivery decisions. */ +void grpc_call_ensure_status(zend_object *response_obj, bool had_exception); + +/* Finalize delivery of a gRPC reply (dispose-time): + * grpc-web → trailers become an in-body 0x80 frame, stream ends + * (browsers cannot read HTTP trailers), + * streaming → end the stream; native trailers ride the transport's + * generic trailer path at EOF, + * zero-message → Trailers-Only: fold grpc-status/grpc-message into the + * initial HEADERS and commit the buffered response. */ +void grpc_call_finish(zend_object *response_obj, bool grpc_web, + const grpc_finish_ops_t *ops, void *ctx); + +#endif /* HTTP_GRPC_CALL_H */ diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index b3563f05..338c6349 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -34,6 +34,7 @@ #endif #include "http1/http_parser.h" /* http_request_t */ #include "grpc/grpc.h" /* grpc_request_is_grpc */ +#include "grpc/grpc_call.h" /* gRPC call lifecycle policy */ #include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "static/static_handler.h" #include "http_send_file.h" @@ -100,11 +101,18 @@ extern const http_response_stream_ops_t h2_stream_ops; * handler skipped $res->end(). Defined further down. */ static void h2_stream_mark_ended(void *ctx); -/* Forward decls so the dispose path can finalize a grpc-web reply — append - * the in-body trailer frame via the streaming append and end the stream. */ +/* gRPC finish ops (grpc_call_finish seam) — the transport-specific wire + * actions; what to send is decided in src/grpc/grpc_call.c. end_stream is + * h2_stream_mark_ended directly (it is idempotent). Bodies further down. */ static int h2_stream_append_chunk(void *ctx, zend_string *chunk); -static void h2_grpc_web_finalize(http2_stream_t *stream, - zend_object *response_obj); +static void h2_grpc_append_frame_and_end(void *ctx, zend_string *frame); +static void h2_grpc_commit(void *ctx); + +static const grpc_finish_ops_t h2_grpc_finish_ops = { + .append_frame_and_end = h2_grpc_append_frame_and_end, + .end_stream = h2_stream_mark_ended, + .commit = h2_grpc_commit, +}; /* Suspend the current handler coroutine until either * - stream->write_event fires (cb_on_frame_recv saw WINDOW_UPDATE @@ -294,20 +302,9 @@ static void http2_strategy_dispatch(struct http_request_t *request, Z_OBJ(stream->response_zv), self->conn->config->json_encode_flags); } - /* gRPC: default the response content-type so handlers need not set it. - * Rides the initial HEADERS; a handler may still override before its - * first writeMessage(). grpc-web gets its own content-type. */ + /* gRPC: response defaults (content-type) live in the gRPC layer. */ if (is_grpc) { - if (stream->grpc_web) { - http_response_static_set_header(Z_OBJ(stream->response_zv), - "content-type", sizeof("content-type") - 1, - GRPC_WEB_RESPONSE_CONTENT_TYPE, - sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1); - } else { - http_response_static_set_header(Z_OBJ(stream->response_zv), - "content-type", sizeof("content-type") - 1, - GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); - } + grpc_call_init_response(Z_OBJ(stream->response_zv), stream->grpc_web); } /* Static-handler dispatch (issue #13). Identical policy to the @@ -616,13 +613,10 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(stream->response_zv)); } - /* gRPC outcome → grpc-status trailer. Success defaults to 0; an - * uncaught exception maps to INTERNAL (13) unless the handler already - * set a status. Rides the terminal HEADERS(trailers) frame. */ + /* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */ if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) { - http_response_ensure_grpc_status(Z_OBJ(stream->response_zv), - coroutine->exception != NULL ? GRPC_STATUS_INTERNAL - : GRPC_STATUS_OK); + grpc_call_ensure_status(Z_OBJ(stream->response_zv), + coroutine->exception != NULL); } /* sendFile() handoff (issue #13). Take the descriptor before @@ -640,24 +634,14 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) const bool is_streaming = !Z_ISUNDEF(stream->response_zv) && http_response_is_streaming(Z_OBJ(stream->response_zv)); - if (stream->grpc_web && !Z_ISUNDEF(stream->response_zv)) { - /* grpc-web: trailers ride the response body as a 0x80 frame, never - * as HTTP/2 trailers (browsers can't read those). Handles both a - * streamed reply and a zero-message status/error. */ - h2_grpc_web_finalize(stream, Z_OBJ(stream->response_zv)); + if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) { + /* Delivery shape is gRPC policy — grpc_call_finish decides. */ + grpc_call_finish(Z_OBJ(stream->response_zv), stream->grpc_web, + &h2_grpc_finish_ops, stream); } else if (is_streaming) { if (!stream->streaming_ended) { h2_stream_mark_ended(stream); } - } else if (stream->is_grpc && conn != NULL - && !Z_ISUNDEF(stream->response_zv)) { - /* gRPC handler that streamed no messages (immediate status / error). - * Emit a Trailers-Only reply: fold grpc-status/grpc-message into the - * initial HEADERS so the buffered commit sends a single - * HEADERS(:status 200, END_STREAM) frame — the canonical gRPC shape - * for a bodiless response. */ - http_response_promote_trailers_to_headers(Z_OBJ(stream->response_zv)); - (void)http2_commit_stream_response(conn, stream); } else if (conn != NULL && !Z_ISUNDEF(stream->response_zv)) { (void)http2_commit_stream_response(conn, stream); } @@ -1698,25 +1682,34 @@ static int h2_stream_append_chunk(void *ctx, zend_string *chunk) return HTTP_STREAM_APPEND_OK; } -/* Finalize a grpc-web reply: the grpc-status/grpc-message trailers are sent - * as an in-body 0x80 frame (browsers cannot read HTTP/2 trailers), then the - * stream ends normally with END_STREAM on DATA. Works for both a streamed - * reply (messages already queued) and a zero-message status/error (the - * trailer frame is the only DATA, committing HEADERS on append). */ -static void h2_grpc_web_finalize(http2_stream_t *stream, - zend_object *response_obj) +/* Append one body frame (consumes the ref), then end the stream normally + * with END_STREAM on DATA. Works for both a streamed reply (messages + * already queued) and a zero-message reply (the frame is the only DATA, + * committing HEADERS on the first append). */ +static void h2_grpc_append_frame_and_end(void *ctx, zend_string *frame) { - HashTable *trailers = http_response_get_trailers(response_obj); - zend_string *frame = grpc_web_trailer_frame(trailers); + http2_stream_t *stream = (http2_stream_t *)ctx; - /* Clear the HTTP trailers so the streaming EOF path (h2_dp_mark_eof) - * does not also emit them as a terminal HEADERS(trailers) frame. */ - http_response_clear_trailers(response_obj); + /* Connection may already be torn down when a late coroutine disposes + * (same guard as h2_grpc_commit); append_chunk derefs conn unchecked. */ + if (http2_session_get_conn(stream->session) == NULL) { + zend_string_release(frame); + return; + } (void)h2_stream_append_chunk(stream, frame); /* consumes the ref */ + h2_stream_mark_ended(stream); +} - if (!stream->streaming_ended) { - h2_stream_mark_ended(stream); +/* Buffered commit — a single HEADERS(:status 200, END_STREAM) frame when + * the response carries no body (the Trailers-Only shape). */ +static void h2_grpc_commit(void *ctx) +{ + http2_stream_t *stream = (http2_stream_t *)ctx; + http_connection_t *conn = http2_session_get_conn(stream->session); + + if (conn != NULL && !Z_ISUNDEF(stream->response_zv)) { + (void)http2_commit_stream_response(conn, stream); } } diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index aab8f16d..69ef0d89 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -476,16 +476,16 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, if (s->streaming_ended) { *pflags |= NGHTTP3_DATA_FLAG_EOF; - /* gRPC native trailers: submit here, at true EOF — nghttp3 + /* Response trailers: submit here, at true EOF — nghttp3 * needs the trailer submit after the last DATA. The nv was * captured in dispose (response_zv is gone now). Keep the * sending side open with NO_END_STREAM so the trailer HEADERS * carry the fin instead of this DATA. */ - if (s->grpc_trailer_nv != NULL && !s->trailers_submitted) { + if (s->trailer_nv != NULL && !s->trailers_submitted) { s->trailers_submitted = true; if (nghttp3_conn_submit_trailers(conn, s->stream_id, - (const nghttp3_nv *)s->grpc_trailer_nv, - s->grpc_trailer_count) == 0) { + (const nghttp3_nv *)s->trailer_nv, + s->trailer_count) == 0) { s->has_trailers = true; } } @@ -591,16 +591,16 @@ static inline bool h3_nv_push(h3_nv_buf_t *b, * - Streaming (HttpResponse::send loop): chunk_queue is the body * source; we skip the body copy entirely. Caller has already * primed the queue with the first chunk by the time we run. */ -/* Capture the response trailer map (grpc-status/grpc-message) into a malloc'd - * nghttp3_nv[] + backing byte buffer on the stream. Called from dispose while - * response_zv is still alive; the data reader submits it at true EOF, because +/* Capture the response trailer map into a malloc'd nghttp3_nv[] + backing + * byte buffer on the stream. Called from dispose while response_zv is still + * alive; the data reader submits it at true EOF, because * nghttp3_conn_submit_trailers must follow the last DATA (which stamps * NO_END_STREAM) yet response_zv is freed by then. malloc (not emalloc) — the * data reader runs outside a request memory context. No-op when there are no * trailers or a capture already exists. Freed in http3_stream_release. */ void http3_stream_capture_trailers(http3_stream_t *s) { - if (Z_ISUNDEF(s->response_zv) || s->grpc_trailer_nv != NULL) { + if (Z_ISUNDEF(s->response_zv) || s->trailer_nv != NULL) { return; } @@ -647,9 +647,9 @@ void http3_stream_capture_trailers(http3_stream_t *s) ni++; } ZEND_HASH_FOREACH_END(); - s->grpc_trailer_nv = nv; - s->grpc_trailer_count = ni; - s->grpc_trailer_bytes = bytes; + s->trailer_nv = nv; + s->trailer_count = ni; + s->trailer_bytes = bytes; } bool http3_stream_submit_response(http3_connection_t *c, diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 0ff17c62..602b759e 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -24,7 +24,8 @@ #include "http_connection.h" /* http_handler_log_bailout */ #include "http_send_file.h" /* http_send_file_dispatch */ #include "http_response_internal.h" /* http_response_has/take_send_file */ -#include "grpc/grpc.h" /* gRPC classification + trailer frame */ +#include "grpc/grpc.h" /* gRPC request classification */ +#include "grpc/grpc_call.h" /* gRPC call lifecycle policy */ #include "static/static_handler.h" /* http_static_try_serve / count */ #include "core/response_wire.h" /* response_wire_* (reverse path) */ @@ -382,13 +383,9 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) http_response_install_stream_ops(Z_OBJ(s->response_zv), &h3_stream_ops, s); - /* gRPC: default the response content-type so handlers need not set it. */ + /* gRPC: response defaults (content-type) live in the gRPC layer. */ if (s->is_grpc) { - http_response_static_set_header(Z_OBJ(s->response_zv), - "content-type", sizeof("content-type") - 1, - s->grpc_web ? GRPC_WEB_RESPONSE_CONTENT_TYPE : GRPC_CONTENT_TYPE, - s->grpc_web ? sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1 - : sizeof(GRPC_CONTENT_TYPE) - 1); + grpc_call_init_response(Z_OBJ(s->response_zv), s->grpc_web); } #ifdef HAVE_HTTP_COMPRESSION @@ -710,27 +707,59 @@ static bool h3_arm_sendfile(http3_connection_t *c, http3_stream_t *s) return true; } -/* Finalize a grpc-web reply on H3: emit grpc-status/grpc-message as an - * in-body 0x80 frame (browsers can't read HTTP/3 trailers), then end the - * stream. Works for streamed and zero-message replies — append_chunk commits - * HEADERS + inits the chunk queue on first use. */ -static void h3_grpc_web_finalize(http3_connection_t *c, http3_stream_t *s, - zend_object *resp_obj) +/* End a streamed reply: capture the response trailer map for the data + * reader's EOF submit (no-op when the map is empty), then resume so the + * reader drains to EOF. The capture must happen here, while response_zv is + * still alive — the data reader runs async, after dispose frees the zvals. + * Shared by the dispose streaming branch and the gRPC finish ops (void *ctx + * so it slots into grpc_finish_ops_t.end_stream directly). */ +static void h3_stream_finish_streaming(void *ctx) { - HashTable *trailers = http_response_get_trailers(resp_obj); - zend_string *frame = grpc_web_trailer_frame(trailers); - - http_response_clear_trailers(resp_obj); + http3_stream_t *s = (http3_stream_t *)ctx; - (void)h3_stream_ops.append_chunk(s, frame); /* consumes the ref */ + http3_stream_capture_trailers(s); + H3T(s->stream_id, s->streaming_ended ? "5.streaming_already_ended" + : "5.streaming_resume"); if (!s->streaming_ended) { s->streaming_ended = true; - (void)nghttp3_conn_resume_stream((nghttp3_conn *)c->nghttp3_conn, + (void)nghttp3_conn_resume_stream((nghttp3_conn *)s->conn->nghttp3_conn, s->stream_id); } } +/* gRPC finish ops (grpc_call_finish seam) — the transport-specific wire + * actions; what to send is decided in src/grpc/grpc_call.c. */ + +/* Append one body frame (consumes the ref) and end the stream. Works for + * streamed and zero-message replies — append_chunk commits HEADERS + inits + * the chunk queue on first use. */ +static void h3_grpc_append_frame_and_end(void *ctx, zend_string *frame) +{ + http3_stream_t *s = (http3_stream_t *)ctx; + + (void)h3_stream_ops.append_chunk(s, frame); /* consumes the ref */ + + h3_stream_finish_streaming(s); +} + +/* Buffered commit — single HEADERS + fin (the Trailers-Only shape). */ +static void h3_grpc_commit(void *ctx) +{ + http3_stream_t *s = (http3_stream_t *)ctx; + http3_connection_t *c = s->conn; + + if (c != NULL && !c->closed && c->nghttp3_conn != NULL) { + (void)http3_stream_submit_response(c, s, false); + } +} + +static const grpc_finish_ops_t h3_grpc_finish_ops = { + .append_frame_and_end = h3_grpc_append_frame_and_end, + .end_stream = h3_stream_finish_streaming, + .commit = h3_grpc_commit, +}; + static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) { http3_stream_t *s = (http3_stream_t *)coroutine->extended_data; @@ -768,12 +797,10 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(s->response_zv)); } - /* gRPC outcome → grpc-status trailer. Success defaults to 0; an uncaught - * exception maps to INTERNAL (13) unless the handler set a status. gRPC - * carries its result in the trailer, not the HTTP status. */ + /* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */ if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) { - http_response_ensure_grpc_status(Z_OBJ(s->response_zv), - coroutine->exception != NULL ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK); + grpc_call_ensure_status(Z_OBJ(s->response_zv), + coroutine->exception != NULL); } /* Streaming-vs-buffered decision (mirror of H2 dispose). @@ -791,30 +818,12 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) if (c != NULL && !c->closed && c->nghttp3_conn != NULL && !Z_ISUNDEF(s->response_zv)) { - if (s->grpc_web) { - /* grpc-web: trailers ride the body as a 0x80 frame, never as - * HTTP/3 trailers. Handles streamed and zero-message replies. */ - h3_grpc_web_finalize(c, s, Z_OBJ(s->response_zv)); + if (s->is_grpc) { + /* Delivery shape is gRPC policy — grpc_call_finish decides. */ + grpc_call_finish(Z_OBJ(s->response_zv), s->grpc_web, + &h3_grpc_finish_ops, s); } else if (is_streaming) { - /* Native gRPC: capture the grpc-status/grpc-message trailers now, - * while response_zv is alive — the data reader runs async (after - * this dispose frees the zvals) and submits them at true EOF. */ - if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) { - http3_stream_capture_trailers(s); - } - - H3T(s->stream_id, s->streaming_ended ? "5.streaming_already_ended" - : "5.streaming_resume"); - if (!s->streaming_ended) { - s->streaming_ended = true; - (void)nghttp3_conn_resume_stream( - (nghttp3_conn *)c->nghttp3_conn, s->stream_id); - } - } else if (s->is_grpc) { - /* zero-message gRPC → Trailers-Only: fold grpc-status/grpc-message - * into the initial HEADERS (single HEADERS + fin). */ - http_response_promote_trailers_to_headers(Z_OBJ(s->response_zv)); - (void)http3_stream_submit_response(c, s, false); + h3_stream_finish_streaming(s); } else if (http_response_has_send_file(Z_OBJ(s->response_zv))) { /* sendFile: hand off to the static pump. On success it owns the * stream + response until on_done runs the tail — the pump diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index 84d67af7..4037d805 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -157,16 +157,16 @@ void http3_stream_release(http3_stream_t *s) s->chunk_queue = NULL; } - /* gRPC trailer capture (malloc'd in http3_stream_capture_trailers). Held + /* Trailer capture (malloc'd in http3_stream_capture_trailers). Held * until teardown so the nv stays valid through the async trailer submit. */ - if (s->grpc_trailer_nv != NULL) { - free(s->grpc_trailer_nv); - s->grpc_trailer_nv = NULL; + if (s->trailer_nv != NULL) { + free(s->trailer_nv); + s->trailer_nv = NULL; } - if (s->grpc_trailer_bytes != NULL) { - free(s->grpc_trailer_bytes); - s->grpc_trailer_bytes = NULL; + if (s->trailer_bytes != NULL) { + free(s->trailer_bytes); + s->trailer_bytes = NULL; } if (s->write_event != NULL) { From 98adc6c1635605a73fa51e930a0d530f2b37dcb7 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:58:32 +0000 Subject: [PATCH 08/33] fix(fuzz): stub http_response_get_trailers for the h2 session harness http2_session.c calls it since 8533afa (generic response-trailer submit); the fuzz link has no PHP-object TUs. NULL map == no trailers, same pattern as the other weak stubs in this file. --- fuzz/fuzz_stubs.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fuzz/fuzz_stubs.c b/fuzz/fuzz_stubs.c index 485abb09..4a78db9c 100644 --- a/fuzz/fuzz_stubs.c +++ b/fuzz/fuzz_stubs.c @@ -125,6 +125,16 @@ __attribute__((weak)) void h2_session_schedule_emit(struct http2_session_t *sess (void)session; } +/* http_response_get_trailers lives in http_response_server_api.c (the + * PHP-object TU, not linked into the fuzz harness). Referenced by + * http2_session_submit_response_trailers; fuzz sessions have no PHP + * response object, and the caller treats a NULL map as "no trailers". */ +__attribute__((weak)) HashTable *http_response_get_trailers(zend_object *obj) +{ + (void)obj; + return NULL; +} + /* h2_static_account_debit lives in http2_static_response.c (not linked * into the fuzz harness). The inline release wrappers in * include/http2/http2_stream.h call it from drain paths in http2_session.c From 84022b44636c482c47a91ada075afd7055747d99 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:10:21 +0000 Subject: [PATCH 09/33] =?UTF-8?q?ci:=20unskip=20gRPC=20coverage=20paths=20?= =?UTF-8?q?=E2=80=94=20zlib=20ext=20+=20aioquic=20in=20the=20phpt=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage gate flagged drops in touched files, but every one traces to a test SKIPping in CI rather than missing tests: - PHP was configured --disable-all without --with-zlib, so gzencode() is absent and grpc/008 (per-message gzip) skips — leaving http_compression_gzip_deflate_buffer and the writeMessage compress branch at zero hits. - aioquic was never installed, so grpc/011 + grpc/012 (H3 native trailers / grpc-web over a real QUIC client) skip — leaving the H3 trailer capture/submit paths and h3_grpc_* finish ops uncovered. --- .github/workflows/build-linux.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 9ce93597..1fafe873 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -436,6 +436,7 @@ jobs: --enable-pcntl \ --enable-opcache \ --with-openssl \ + --with-zlib \ --with-config-file-path=/etc \ --with-config-file-scan-dir=/etc/php.d \ --${{ matrix.debug && 'enable' || 'disable' }}-debug \ @@ -518,6 +519,15 @@ jobs: ulimit -c unlimited ulimit -c + # H3 gRPC tests (grpc/011, grpc/012) drive the server with a real + # aioquic Python client and SKIP when the module is missing — + # which silently drops the H3 trailer paths from coverage. + - name: Install aioquic (H3 gRPC client tests) + run: | + set -eux + python3 -m pip install --quiet aioquic + python3 -c "import aioquic" + - name: Run http_server phpt suite working-directory: http-server run: | From 33fb327a0a7e6b40edc6cfd9f703cba8e7762c31 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:23:05 +0000 Subject: [PATCH 10/33] docs(grpc): CHANGELOG entry, README feature row + Quick Start; cover empty compressed message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: Unreleased Added/Changed entries for gRPC over H2+H3 (#4) and the call-lifecycle layering refactor. - README: gRPC row -> Ready, progress 90% (grpc-web-text + reactor-pool gRPC remain), Quick Start bidi example. - grpc/008: the client now also sends a compressed EMPTY message (flag=1, len=0) — covers the in_len==0 branch of the shared http_compression_gzip_inflate_buffer, restoring the coverage-gate drop in http_compression_request.c. --- CHANGELOG.md | 34 +++++++++++++++++++ README.md | 33 ++++++++++++++++-- .../server/grpc/008-grpc-compression.phpt | 9 +++-- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b563c9..66698ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **gRPC over HTTP/2 and HTTP/3 (#4).** Requests whose content-type begins with + `application/grpc` route to the callable registered via + `HttpServer::addGrpcHandler()`; everything else is untouched, so gRPC and + regular HTTP handlers coexist on one listener. + - **All four RPC shapes** — unary, server-streaming, client-streaming and + true full-duplex bidi: `HttpRequest::readMessage()` deframes the request + stream incrementally (5-byte length-prefix framing, 16 MiB per-message cap), + `HttpResponse::writeMessage()` frames replies; the handler starts on + HEADERS, before the body finishes. + - **Trailers**: `grpc-status`/`grpc-message` ride real HTTP trailers on both + transports (nghttp2 trailer HEADERS; `nghttp3_conn_submit_trailers` at true + EOF on H3 — verified with a real aioquic client). `grpc-status: 0` is + defaulted on success, `13 INTERNAL` on an uncaught handler exception, and a + handler that writes no messages gets the canonical Trailers-Only reply. + - **grpc-web (binary)**: `application/grpc-web` calls carry their trailers + in-body as a `0x80`-flagged frame, on H2 and H3. + - **Per-message gzip**: inbound `grpc-encoding: gzip` messages inflate + transparently in `readMessage()`; `writeMessage(..., compress: true)` + emits compressed frames. + - **`grpc-timeout`** request header parsed and exposed via + `HttpRequest::getGrpcTimeout()`. + - Deferred: `grpc-web-text` (base64), gRPC under the reactor pool. + +### Changed + +- **gRPC layering: call-lifecycle policy extracted out of the transports (#4).** + `src/grpc/grpc_call.c` owns response defaults, outcome → `grpc-status` and + delivery shape (grpc-web in-body frame / streaming EOF / Trailers-Only); + HTTP/2 and HTTP/3 provide a 3-op wire vtable and stay gRPC-agnostic on + delivery. H3 response-trailer capture/submit is now generic — any streaming + response with a trailer map is delivered, not just gRPC (parity with H2). + ## [0.9.2] - 2026-07-03 ### Added diff --git a/README.md b/README.md index 1c7d2c39..783f4a37 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ This means you can serve a REST API over HTTP/2, push real-time events over Serv | ✅ Ready | **Compression** | gzip (zlib-ng / zlib), Brotli, zstd — response encoding + inbound decode across H1/H2/H3. Server-side preference `zstd > br > gzip`; per-codec level setters. See [docs/COMPRESSION.md](docs/COMPRESSION.md). | | ✅ Ready | **WebSocket** | RFC 6455, upgrade from HTTP/1.1 and HTTP/2 (RFC 8441 Extended CONNECT), `wss://`, permessage-deflate (RFC 7692), full duplex, backpressure — 246/246 Autobahn conformance | | ✅ Ready | **SSE (Server-Sent Events)** | `text/event-stream` framing (WHATWG §9.2) over H1/H2/H3 via `HttpResponse::sseStart/sseEvent/sseComment/sseRetry` | -| 📋 Planned | **gRPC** | Built on HTTP/2, unary and streaming RPC | +| ✅ Ready | **gRPC** | Over HTTP/2 **and** HTTP/3 via `addGrpcHandler()` — unary + all streaming shapes incl. full-duplex bidi (`readMessage`/`writeMessage`), `grpc-status`/`grpc-message` trailers + Trailers-Only, `grpc-timeout`, per-message gzip, grpc-web (binary, in-body `0x80` trailer frame) | ### Development Progress @@ -57,11 +57,14 @@ HTTP/2 ████████████████████ 100% HTTP/3 ████████████████████ 100% WebSocket ████████████████████ 100% SSE ████████████████████ 100% -gRPC ░░░░░░░░░░░░░░░░░░░░ 0% +gRPC ██████████████████░░ 90% ``` All ten ship-gates of the HTTP/3 plan (transport, TLS 1.3, request/response, streaming, lifecycle + drain, Alt-Svc, compliance smoke, fuzzing) are merged. Open items are post-ship performance follow-ups — `recvmmsg` inbound batching and Linux GSO outbound coalescing — that require upstream TrueAsync API extensions, plus optional ECN/pacing. +gRPC's remaining 10%: `grpc-web-text` (base64 framing) and gRPC under the +reactor pool (`TRUE_ASYNC_SERVER_REACTOR_POOL=1`). + --- ## Architecture @@ -285,6 +288,32 @@ $server->addHttpHandler(function ($request, $response) { fields are CR/LF-validated); `sseComment('')` emits a `:` heartbeat to hold the connection open through proxy idle timeouts. The stream is never compressed. +### gRPC + +Requests with an `application/grpc*` content-type route to the callable +registered via `addGrpcHandler()` — over HTTP/2 and HTTP/3 alike, next to the +regular HTTP handlers on the same listener: + +```php +$server->addGrpcHandler(function ($request, $response) { + // Full-duplex: each readMessage() suspends until the next message + // arrives (returns null at end-of-stream), writeMessage() replies + // immediately — no need to wait for the request stream to finish. + while (($msg = $request->readMessage()) !== null) { + $response->writeMessage('echo:' . $msg); + } + + $response->setTrailer('grpc-status', '0'); // implicit on clean return +}); +``` + +Unary and all three streaming shapes use this one API; `grpc-status` / +`grpc-message` ride real HTTP trailers (or the in-body `0x80` frame for +grpc-web), uncaught exceptions map to `13 INTERNAL`, inbound +`grpc-encoding: gzip` messages inflate transparently, and +`writeMessage($msg, compress: true)` gzips replies. The client's deadline is +exposed via `$request->getGrpcTimeout()`. + Working examples live under [`examples/`](examples/): [`minimal-server.php`](examples/minimal-server.php), [`demo-server.php`](examples/demo-server.php), diff --git a/tests/phpt/server/grpc/008-grpc-compression.phpt b/tests/phpt/server/grpc/008-grpc-compression.phpt index 5eeab4b8..1dae862b 100644 --- a/tests/phpt/server/grpc/008-grpc-compression.phpt +++ b/tests/phpt/server/grpc/008-grpc-compression.phpt @@ -33,8 +33,10 @@ $config = (new HttpServerConfig()) $server = new HttpServer($config); $server->addGrpcHandler(function($req, $resp) { $req->awaitBody(); - $msg = $req->readMessage(); // auto-inflated - $resp->writeMessage('echo:' . $msg, true); // gzip the reply + $msg = $req->readMessage(); // auto-inflated + $empty = $req->readMessage(); // compressed empty message -> "" + $tail = ($empty === '') ? ':empty-ok' : ':empty-bad'; + $resp->writeMessage('echo:' . $msg . $tail, true); // gzip the reply }); $client = spawn(function() use ($port, $server) { @@ -43,6 +45,7 @@ $client = spawn(function() use ($port, $server) { $payload = str_repeat('gRPC-', 2000); // 10 KB $gz = gzencode($payload); $frame = "\x01" . pack('N', strlen($gz)) . $gz; // compressed flag = 1 + $frame .= "\x01" . pack('N', 0); // compressed EMPTY message $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); @@ -69,7 +72,7 @@ $client = spawn(function() use ($port, $server) { echo "resp_grpc_encoding=", (int)(strpos($verbose, 'grpc-encoding: gzip') !== false), "\n"; echo "resp_compressed_flag=", (int)($flag === 1), "\n"; - echo "roundtrip_ok=", (int)($decoded === 'echo:' . $payload), "\n"; + echo "roundtrip_ok=", (int)($decoded === 'echo:' . $payload . ':empty-ok'), "\n"; echo "saw_grpc_status=", (int)(strpos($verbose, 'grpc-status: 0') !== false), "\n"; $server->stop(); From b38301e849b701aae4b1886aeaddfabfefe4f144 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:50:52 +0000 Subject: [PATCH 11/33] feat(pool): response trailers cross the reactor/worker split (#80, #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the reactor-pool streaming reverse path: - response_wire: trailer pairs in the arena (add/count/at), mirroring headers; add_header/header_at refactored onto shared list helpers. - worker_render_response copies the response trailer map onto the wire (same string-pair discipline as http3_stream_capture_trailers). - http3_stream_submit_response_wire copies wire trailers into the stream's malloc'd trailer_nv capture; the data reader submits them. - h3_read_data_cb: trailer submit at true EOF generalized to the buffered branch (was streaming-only) — final DATA slice withholds EOF when a capture is pending so submit_trailers follows the last DATA. - Local parity: buffered dispose now captures the trailer map too, so setTrailer works without streaming on H3. Test: 046-h3-reactor-pool-trailers — buffered worker response under TRUE_ASYNC_SERVER_REACTOR_POOL=1 delivers x-wire-trailer + grpc-status to a real aioquic client. h3+grpc suites 60/60. --- include/core/response_wire.h | 10 ++ src/core/response_wire.c | 78 ++++++++++--- src/core/worker_dispatch.c | 17 +++ src/http3/http3_callbacks.c | 109 ++++++++++++++---- src/http3/http3_dispatch.c | 4 + .../h3/046-h3-reactor-pool-trailers.phpt | 89 ++++++++++++++ 6 files changed, 272 insertions(+), 35 deletions(-) create mode 100644 tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt diff --git a/include/core/response_wire.h b/include/core/response_wire.h index c593b830..b31deea2 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -52,6 +52,11 @@ bool response_wire_add_header(response_wire_t *rw, const char *name_ptr, size_t name_len, const char *value_ptr, size_t value_len); bool response_wire_set_body(response_wire_t *rw, const char *ptr, size_t len, bool complete); +/* Trailers mirror headers: appended pairs, delivered by the transport after + * the last body byte (H2 trailer HEADERS / nghttp3 submit_trailers at EOF). */ +bool response_wire_add_trailer(response_wire_t *rw, + const char *name_ptr, size_t name_len, + const char *value_ptr, size_t value_len); /* Accessors. Returned pointers are valid until response_wire_free; *len * receives the span length. body returns NULL with *len = 0 when unset. */ @@ -65,6 +70,11 @@ bool response_wire_header_at(const response_wire_t *rw, size_t index, const char **name_ptr, size_t *name_len, const char **value_ptr, size_t *value_len); +size_t response_wire_trailer_count(const response_wire_t *rw); +bool response_wire_trailer_at(const response_wire_t *rw, size_t index, + const char **name_ptr, size_t *name_len, + const char **value_ptr, size_t *value_len); + uint32_t response_wire_reactor_id(const response_wire_t *rw); int64_t response_wire_stream_id(const response_wire_t *rw); void *response_wire_conn(const response_wire_t *rw); diff --git a/src/core/response_wire.c b/src/core/response_wire.c index 763f84a7..be883fed 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -45,6 +45,10 @@ struct response_wire_s { wire_header_t *headers; size_t header_count; size_t header_cap; + + wire_header_t *trailers; + size_t trailer_count; + size_t trailer_cap; }; /* Copy `len` bytes into the arena, growing it as needed. Returns the byte @@ -109,21 +113,24 @@ void response_wire_set_status(response_wire_t *rw, const int status) rw->status = status; } -bool response_wire_add_header(response_wire_t *rw, - const char *name_ptr, const size_t name_len, - const char *value_ptr, const size_t value_len) +/* Append one (name, value) pair to a wire_header_t list (headers or trailers), + * copying both spans into the arena. */ +static bool wire_pair_add(response_wire_t *rw, + wire_header_t **list, size_t *count, size_t *cap, + const char *name_ptr, const size_t name_len, + const char *value_ptr, const size_t value_len) { - if (rw->header_count == rw->header_cap) { - const size_t new_cap = rw->header_cap != 0 ? rw->header_cap * 2 : 8; + if (*count == *cap) { + const size_t new_cap = *cap != 0 ? *cap * 2 : 8; wire_header_t *const grown = - (wire_header_t *) realloc(rw->headers, new_cap * sizeof(*grown)); + (wire_header_t *) realloc(*list, new_cap * sizeof(*grown)); if (grown == NULL) { return false; } - rw->headers = grown; - rw->header_cap = new_cap; + *list = grown; + *cap = new_cap; } const size_t name_off = arena_append(rw, name_ptr, name_len); @@ -138,16 +145,32 @@ bool response_wire_add_header(response_wire_t *rw, return false; } - wire_header_t *const h = &rw->headers[rw->header_count]; + wire_header_t *const h = &(*list)[*count]; h->name_off = name_off; h->name_len = name_len; h->value_off = value_off; h->value_len = value_len; - rw->header_count++; + (*count)++; return true; } +bool response_wire_add_header(response_wire_t *rw, + const char *name_ptr, const size_t name_len, + const char *value_ptr, const size_t value_len) +{ + return wire_pair_add(rw, &rw->headers, &rw->header_count, &rw->header_cap, + name_ptr, name_len, value_ptr, value_len); +} + +bool response_wire_add_trailer(response_wire_t *rw, + const char *name_ptr, const size_t name_len, + const char *value_ptr, const size_t value_len) +{ + return wire_pair_add(rw, &rw->trailers, &rw->trailer_count, &rw->trailer_cap, + name_ptr, name_len, value_ptr, value_len); +} + bool response_wire_set_body(response_wire_t *rw, const char *ptr, const size_t len, const bool complete) { const size_t off = arena_append(rw, ptr, len); @@ -190,15 +213,18 @@ size_t response_wire_header_count(const response_wire_t *rw) return rw->header_count; } -bool response_wire_header_at(const response_wire_t *rw, const size_t index, - const char **name_ptr, size_t *name_len, - const char **value_ptr, size_t *value_len) +/* Resolve entry `index` of a wire_header_t list against the arena. */ +static bool wire_pair_at(const response_wire_t *rw, + const wire_header_t *list, const size_t count, + const size_t index, + const char **name_ptr, size_t *name_len, + const char **value_ptr, size_t *value_len) { - if (index >= rw->header_count) { + if (index >= count) { return false; } - const wire_header_t *const h = &rw->headers[index]; + const wire_header_t *const h = &list[index]; *name_ptr = rw->arena + h->name_off; *name_len = h->name_len; *value_ptr = rw->arena + h->value_off; @@ -207,6 +233,27 @@ bool response_wire_header_at(const response_wire_t *rw, const size_t index, return true; } +bool response_wire_header_at(const response_wire_t *rw, const size_t index, + const char **name_ptr, size_t *name_len, + const char **value_ptr, size_t *value_len) +{ + return wire_pair_at(rw, rw->headers, rw->header_count, index, + name_ptr, name_len, value_ptr, value_len); +} + +size_t response_wire_trailer_count(const response_wire_t *rw) +{ + return rw->trailer_count; +} + +bool response_wire_trailer_at(const response_wire_t *rw, const size_t index, + const char **name_ptr, size_t *name_len, + const char **value_ptr, size_t *value_len) +{ + return wire_pair_at(rw, rw->trailers, rw->trailer_count, index, + name_ptr, name_len, value_ptr, value_len); +} + uint32_t response_wire_reactor_id(const response_wire_t *rw) { return rw->reactor_id; @@ -230,5 +277,6 @@ void response_wire_free(response_wire_t *rw) free(rw->arena); free(rw->headers); + free(rw->trailers); free(rw); } diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 58c74672..8c98a602 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -175,6 +175,23 @@ static response_wire_t *worker_render_response(const worker_dispatch_ctx_t *ctx) } ZEND_HASH_FOREACH_END(); } + /* Trailer map (setTrailer / gRPC grpc-status): flat string pairs, same + * discipline as http3_stream_capture_trailers on the local path. */ + HashTable *const trailers = http_response_get_trailers(resp); + + if (trailers != NULL) { + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (UNEXPECTED(name == NULL) || Z_TYPE_P(val) != IS_STRING) { + continue; + } + + response_wire_add_trailer(rw, ZSTR_VAL(name), ZSTR_LEN(name), + Z_STRVAL_P(val), Z_STRLEN_P(val)); + } ZEND_HASH_FOREACH_END(); + } + /* http_response_get_body_str returns a borrowed reference; the bytes are * copied into the arena, so nothing to release. HEAD carries the headers * but no body (RFC 9110 §9.3.2). */ diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 69ef0d89..b230e5d5 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -442,6 +442,27 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, * pointer alive until h3_acked_stream_data_cb fires (the peer ACK'd * the bytes). Releasing earlier is a UAF — under packet loss nghttp3 * retransmits from the same iov, so a freed zend_string would crash. */ +/* True-EOF epilogue shared by the reader's streaming and buffered branches: + * submit the captured trailers (nghttp3 requires the submit after the last + * DATA) and keep the sending side open with NO_END_STREAM so the trailer + * HEADERS carry the fin instead of the final DATA. No-op without a capture. */ +static void h3_read_data_eof_trailers(nghttp3_conn *conn, http3_stream_t *s, + uint32_t *pflags) +{ + if (s->trailer_nv != NULL && !s->trailers_submitted) { + s->trailers_submitted = true; + + if (nghttp3_conn_submit_trailers(conn, s->stream_id, + (const nghttp3_nv *)s->trailer_nv, s->trailer_count) == 0) { + s->has_trailers = true; + } + } + + if (s->has_trailers) { + *pflags |= NGHTTP3_DATA_FLAG_NO_END_STREAM; + } +} + static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, nghttp3_vec *vec, size_t veccnt, uint32_t *pflags, @@ -476,24 +497,9 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, if (s->streaming_ended) { *pflags |= NGHTTP3_DATA_FLAG_EOF; - /* Response trailers: submit here, at true EOF — nghttp3 - * needs the trailer submit after the last DATA. The nv was - * captured in dispose (response_zv is gone now). Keep the - * sending side open with NO_END_STREAM so the trailer HEADERS - * carry the fin instead of this DATA. */ - if (s->trailer_nv != NULL && !s->trailers_submitted) { - s->trailers_submitted = true; - if (nghttp3_conn_submit_trailers(conn, s->stream_id, - (const nghttp3_nv *)s->trailer_nv, - s->trailer_count) == 0) { - s->has_trailers = true; - } - } - - if (s->has_trailers) { - *pflags |= NGHTTP3_DATA_FLAG_NO_END_STREAM; - } - + /* The trailer nv was captured in dispose (response_zv is + * gone by the time the reader runs). */ + h3_read_data_eof_trailers(conn, s, pflags); return 0; } @@ -509,9 +515,12 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, return 1; } - /* Buffered REST path — unchanged single-slice semantics. */ + /* Buffered REST path — single-slice semantics, plus the trailer submit + * at EOF (a buffered response with a trailer map: worker-rendered + * response_wire or a local setTrailer without streaming). */ if (s->response_body == NULL) { *pflags |= NGHTTP3_DATA_FLAG_EOF; + h3_read_data_eof_trailers(conn, s, pflags); return 0; } @@ -521,13 +530,21 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, if (remaining == 0) { *pflags |= NGHTTP3_DATA_FLAG_EOF; + h3_read_data_eof_trailers(conn, s, pflags); return 0; } vec[0].base = (uint8_t *)ZSTR_VAL(s->response_body) + s->response_body_offset; vec[0].len = remaining; s->response_body_offset += remaining; - *pflags |= NGHTTP3_DATA_FLAG_EOF; + + /* With trailers pending, hand the final slice without EOF — the submit + * must FOLLOW the last DATA, so the next invocation (remaining == 0) + * performs it. Without trailers keep the one-call fast path. */ + if (s->trailer_nv == NULL) { + *pflags |= NGHTTP3_DATA_FLAG_EOF; + } + return 1; } @@ -849,6 +866,58 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, s->response_body_offset = 0; } + /* Trailers: copy out of the wire into the stream's malloc'd capture (the + * wire dies right after this call; the data reader submits at true EOF — + * same fields http3_stream_capture_trailers fills on the local path). */ + const size_t tcount = response_wire_trailer_count(rw); + + if (tcount > 0 && s->trailer_nv == NULL) { + size_t total = 0; + + for (size_t i = 0; i < tcount; i++) { + const char *nm, *val; + size_t nl, vl; + + if (response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { + total += nl + vl; + } + } + + nghttp3_nv *tnv = malloc(tcount * sizeof(nghttp3_nv)); + char *tbytes = malloc(total ? total : 1); + + if (tnv != NULL && tbytes != NULL) { + size_t ni = 0, bi = 0; + + for (size_t i = 0; i < tcount; i++) { + const char *nm, *val; + size_t nl, vl; + + if (!response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { + continue; + } + + memcpy(tbytes + bi, nm, nl); + tnv[ni].name = (uint8_t *)(tbytes + bi); + tnv[ni].namelen = nl; + bi += nl; + memcpy(tbytes + bi, val, vl); + tnv[ni].value = (uint8_t *)(tbytes + bi); + tnv[ni].valuelen = vl; + bi += vl; + tnv[ni].flags = NGHTTP3_NV_FLAG_NONE; + ni++; + } + + s->trailer_nv = tnv; + s->trailer_count = ni; + s->trailer_bytes = tbytes; + } else { + free(tnv); + free(tbytes); + } + } + const nghttp3_data_reader dr = { .read_data = h3_read_data_cb }; const int rv = nghttp3_conn_submit_response( (nghttp3_conn *)c->nghttp3_conn, s->stream_id, nv, nvi, &dr); diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 602b759e..d4c96c1f 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -839,6 +839,10 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) (void)http3_stream_submit_response(c, s, false); } else { H3T(s->stream_id, "5.buffered_submit"); + /* A buffered response can carry a trailer map too (setTrailer + * without streaming) — capture now, while response_zv is alive; + * the data reader submits at EOF. */ + http3_stream_capture_trailers(s); (void)http3_stream_submit_response(c, s, false); } } else { diff --git a/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt b/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt new file mode 100644 index 00000000..a1029c4a --- /dev/null +++ b/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt @@ -0,0 +1,89 @@ +--TEST-- +HttpServer: response trailers cross the reactor/worker split (#80 + #4, gated pool) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--ENV-- +TRUE_ASYNC_SERVER_REACTOR_POOL=1 +PHP_HTTP3_DISABLE_RETRY=1 +--FILE-- +addListener('127.0.0.1', $port + 1) /* TCP listener required by start() */ + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setWorkers(2); +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200) + ->setHeader('content-type', 'text/plain; charset=utf-8') + ->setTrailer('x-wire-trailer', 'crossed') + ->setTrailer('grpc-status', '0') + ->setBody('pool-body'); +}); + +$py = __DIR__ . '/../grpc/_h3grpc_client.py'; + +spawn(function () use ($server, $port, $py) { + /* Reactors + workers need a moment to thread up and bind. */ + usleep(600000); + + $cmd = sprintf("python3 %s 127.0.0.1 %d /trailers text/plain '' 2>/dev/null", + escapeshellarg($py), $port); + $out = shell_exec($cmd) ?? ''; + + $body = ''; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m)) { + $body = hex2bin($m[1]); + } + + echo "saw_status_200=", (int)(strpos($out, 'HDR :status: 200') !== false), "\n"; + echo "body=", $body, "\n"; + echo "saw_wire_trailer=", (int)(strpos($out, 'HDR x-wire-trailer: crossed') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; + + /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL + * skips PHP shutdown so the worker threads cannot deadlock on exit. */ + posix_kill(getmypid(), SIGKILL); +}); + +$server->start(); +?> +--EXPECTF-- +%Asaw_status_200=1 +body=pool-body +saw_wire_trailer=1 +saw_grpc_status=1 +%A From e12a691652d9b55760f5dfe1dd13530d8ddca827 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:51:40 +0000 Subject: [PATCH 12/33] =?UTF-8?q?feat(pool):=20streaming=20reverse=20path?= =?UTF-8?q?=20=E2=80=94=20send()/writeMessage()=20cross=20the=20split=20(#?= =?UTF-8?q?80,=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2: the worker's HttpResponse gets real stream ops under the pool instead of throwing 'streaming not available'. - response_wire: kind (FULL | STREAM_HEADERS | STREAM_CHUNK | STREAM_END). One message type, one sink, FIFO on the reactor mailbox. - worker_dispatch: stream ops flatten each leg into a STREAM_* wire — first send() posts HEADERS (status+headers), each chunk a CHUNK, end()/ dispose an idempotent END carrying the trailer map. Dispose skips the buffered FULL render once a stream started; ops detach before ctx dies. - gRPC rides the generic path: worker classifies application/grpc once (same predicates as the transports), routes to addGrpcHandler, and drives grpc_call init/ensure_status/finish with worker-side finish ops (grpc-web frame -> CHUNK+END or buffered body; commit -> FULL wire). - H3 reactor: apply dispatches on kind — STREAM_HEADERS submits with the streaming data reader (chunk ring primed), CHUNK pushes + resumes, END adopts wire trailers + fires EOF. Ring init/push and wire-trailer adoption factored out of append_chunk/submit for reuse. - sink: STREAM_* wires retry a transiently full mailbox (ordered fragments must not drop silently); FULL keeps drop-on-full. Interim until the step-3 credit protocol paces the producer. Tests: 047 (streamed send() chunks in order under the pool), grpc/014 (unary gRPC + grpc-status trailer under the pool). 303/303. --- include/core/response_wire.h | 15 + src/core/response_wire.c | 11 + src/core/worker_dispatch.c | 335 +++++++++++++++--- src/http3/http3_callbacks.c | 216 ++++++----- src/http3/http3_dispatch.c | 48 ++- src/http3/http3_internal.h | 16 +- src/http_server_class.c | 27 +- .../server/grpc/014-grpc-reactor-pool.phpt | 91 +++++ .../h3/047-h3-reactor-pool-streaming.phpt | 85 +++++ 9 files changed, 691 insertions(+), 153 deletions(-) create mode 100644 tests/phpt/server/grpc/014-grpc-reactor-pool.phpt create mode 100644 tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt diff --git a/include/core/response_wire.h b/include/core/response_wire.h index b31deea2..b27f6943 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -37,11 +37,26 @@ typedef struct response_wire_s response_wire_t; +/* Message kind on the reverse channel. FULL is the original single-shot shape + * (everything in one wire at dispose). The STREAM_* kinds carry a streamed + * response incrementally — same routing, same arena, posted in order on one + * reactor mailbox (FIFO): one STREAM_HEADERS, any number of STREAM_CHUNKs, + * one STREAM_END (which may carry trailers). */ +typedef enum { + RESPONSE_WIRE_FULL = 0, + RESPONSE_WIRE_STREAM_HEADERS, + RESPONSE_WIRE_STREAM_CHUNK, + RESPONSE_WIRE_STREAM_END, +} response_wire_kind_t; + /* Create an empty response wire. routing identifies the origin stream the * reactor must send on (echoed from the request_wire). status starts unset (0). * Returns NULL on allocation failure. */ response_wire_t *response_wire_create(uint32_t reactor_id, int64_t stream_id, void *conn); +void response_wire_set_kind(response_wire_t *rw, response_wire_kind_t kind); +response_wire_kind_t response_wire_kind(const response_wire_t *rw); + /* Builders — copy bytes into the arena. set_status replaces; add_header * appends; set_body replaces. All accept non-NUL-terminated spans. The header * builders return false on allocation failure (the wire stays usable/freeable). diff --git a/src/core/response_wire.c b/src/core/response_wire.c index be883fed..7b8204db 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -31,6 +31,7 @@ struct response_wire_s { int64_t stream_id; void *conn; + response_wire_kind_t kind; /* FULL unless set otherwise */ int status; /* Growable byte arena: every span's bytes are copied in here. */ @@ -108,6 +109,16 @@ response_wire_t *response_wire_create(const uint32_t reactor_id, const int64_t s return rw; } +void response_wire_set_kind(response_wire_t *rw, const response_wire_kind_t kind) +{ + rw->kind = kind; +} + +response_wire_kind_t response_wire_kind(const response_wire_t *rw) +{ + return rw->kind; +} + void response_wire_set_status(response_wire_t *rw, const int status) { rw->status = status; diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 8c98a602..d400eb3d 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -18,6 +18,8 @@ #include "core/http_connection.h" /* http_request_handler_coroutine_new */ #include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "http1/http_parser.h" /* http_request_t, http_request_destroy */ +#include "grpc/grpc.h" /* grpc_request_is_grpc / _is_grpc_web */ +#include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ #include "Zend/zend_hrtime.h" /* zend_hrtime — request-service sampling */ #include @@ -52,6 +54,16 @@ typedef struct { bool skip_handler; /* synthetic 404 already populated */ bool is_head; /* suppress the body on render */ + + /* gRPC classification (once, at dispatch — the transports do the same). */ + bool is_grpc; + bool grpc_web; + + /* Streaming reverse path: STREAM_HEADERS posted on the first + * append_chunk; STREAM_END posted by mark_ended (idempotent). When + * stream_started, dispose skips the buffered FULL render. */ + bool stream_started; + bool stream_ended; } worker_dispatch_ctx_t; /* Handler coroutine body: run the registered user handler with (request, response). */ @@ -68,7 +80,13 @@ static void worker_dispatch_entry(void) } HashTable *const handlers = http_server_get_protocol_handlers(ctx->server); - zend_fcall_t *fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); + zend_fcall_t *fcall = ctx->is_grpc + ? http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC) + : NULL; + + if (fcall == NULL) { + fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); + } if (fcall == NULL) { fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); @@ -123,19 +141,9 @@ static void worker_dispatch_entry(void) zval_ptr_dtor(&retval); } -/* Flatten the committed HttpResponse into a response_wire. Buffered only. - * Returns NULL on allocation failure. */ -static response_wire_t *worker_render_response(const worker_dispatch_ctx_t *ctx) +/* Flatten status + H2/H3-allowed headers of the response onto a wire. */ +static void worker_wire_copy_head(response_wire_t *rw, zend_object *resp) { - zend_object *const resp = Z_OBJ(ctx->response_zv); - - response_wire_t *const rw = - response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); - - if (rw == NULL) { - return NULL; - } - int status = http_response_get_status(resp); if (UNEXPECTED(status <= 0)) { @@ -146,52 +154,218 @@ static response_wire_t *worker_render_response(const worker_dispatch_ctx_t *ctx) HashTable *const headers = http_response_get_headers(resp); - if (headers != NULL) { - zend_string *name; - zval *values; - ZEND_HASH_FOREACH_STR_KEY_VAL(headers, name, values) { - if (UNEXPECTED(name == NULL)) { - continue; - } + if (headers == NULL) { + return; + } - if (!http_response_header_allowed_h2h3(ZSTR_VAL(name), ZSTR_LEN(name))) { - continue; - } + zend_string *name; + zval *values; + ZEND_HASH_FOREACH_STR_KEY_VAL(headers, name, values) { + if (UNEXPECTED(name == NULL)) { + continue; + } + + if (!http_response_header_allowed_h2h3(ZSTR_VAL(name), ZSTR_LEN(name))) { + continue; + } + + if (EXPECTED(Z_TYPE_P(values) == IS_STRING)) { + response_wire_add_header(rw, ZSTR_VAL(name), ZSTR_LEN(name), + Z_STRVAL_P(values), Z_STRLEN_P(values)); + } else if (Z_TYPE_P(values) == IS_ARRAY) { + zval *v; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(values), v) { + if (Z_TYPE_P(v) != IS_STRING) { + continue; + } - if (EXPECTED(Z_TYPE_P(values) == IS_STRING)) { response_wire_add_header(rw, ZSTR_VAL(name), ZSTR_LEN(name), - Z_STRVAL_P(values), Z_STRLEN_P(values)); - } else if (Z_TYPE_P(values) == IS_ARRAY) { - zval *v; - ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(values), v) { - if (Z_TYPE_P(v) != IS_STRING) { - continue; - } - - response_wire_add_header(rw, ZSTR_VAL(name), ZSTR_LEN(name), - Z_STRVAL_P(v), Z_STRLEN_P(v)); - } ZEND_HASH_FOREACH_END(); - } - } ZEND_HASH_FOREACH_END(); - } + Z_STRVAL_P(v), Z_STRLEN_P(v)); + } ZEND_HASH_FOREACH_END(); + } + } ZEND_HASH_FOREACH_END(); +} - /* Trailer map (setTrailer / gRPC grpc-status): flat string pairs, same - * discipline as http3_stream_capture_trailers on the local path. */ +/* Flatten the trailer map (setTrailer / gRPC grpc-status) onto a wire — flat + * string pairs, same discipline as http3_stream_capture_trailers locally. */ +static void worker_wire_copy_trailers(response_wire_t *rw, zend_object *resp) +{ HashTable *const trailers = http_response_get_trailers(resp); - if (trailers != NULL) { - zend_string *name; - zval *val; - ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { - if (UNEXPECTED(name == NULL) || Z_TYPE_P(val) != IS_STRING) { - continue; - } + if (trailers == NULL) { + return; + } + + zend_string *name; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (UNEXPECTED(name == NULL) || Z_TYPE_P(val) != IS_STRING) { + continue; + } + + response_wire_add_trailer(rw, ZSTR_VAL(name), ZSTR_LEN(name), + Z_STRVAL_P(val), Z_STRLEN_P(val)); + } ZEND_HASH_FOREACH_END(); +} + +/* Hand a wire to the reverse channel. The sink owns it on success. Stream + * kinds must not be silently dropped mid-stream, so the sink (which retries + * on a transiently full mailbox) is the single delivery point. */ +static void worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) +{ + if (ctx->sink != NULL) { + ctx->sink(rw, ctx->sink_arg); + } else { + response_wire_free(rw); + } +} + +/* --------------------------------------------------------------------- + * Streaming reverse path (worker side of #80 D3, streaming leg). + * + * The worker's HttpResponse gets these stream ops so send()/writeMessage() + * work under the pool: each op flattens its payload into a STREAM_* wire + * and posts it to the originating reactor. Ordering is the mailbox FIFO; + * the reactor applies HEADERS (streaming submit), CHUNKs (chunk_queue), + * END (trailers + EOF). Backpressure credits are the step-3 follow-up — + * until then append never reports BACKPRESSURE. + * ------------------------------------------------------------------- */ +static int worker_stream_append_chunk(void *vctx, zend_string *chunk) +{ + worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; + + if (UNEXPECTED(ctx->stream_ended)) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + /* First send(): the response just committed — flatten status + headers + * into the STREAM_HEADERS wire that opens the stream on the reactor. */ + if (!ctx->stream_started) { + response_wire_t *const hw = + response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); + + if (UNEXPECTED(hw == NULL)) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + response_wire_set_kind(hw, RESPONSE_WIRE_STREAM_HEADERS); + worker_wire_copy_head(hw, Z_OBJ(ctx->response_zv)); + worker_wire_post(ctx, hw); + ctx->stream_started = true; + } + + response_wire_t *const cw = + response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); + + if (UNEXPECTED(cw == NULL)) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + response_wire_set_kind(cw, RESPONSE_WIRE_STREAM_CHUNK); + + if (UNEXPECTED(!response_wire_set_body(cw, ZSTR_VAL(chunk), ZSTR_LEN(chunk), + false))) { + response_wire_free(cw); + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + worker_wire_post(ctx, cw); + zend_string_release(chunk); /* bytes copied into the wire arena */ - response_wire_add_trailer(rw, ZSTR_VAL(name), ZSTR_LEN(name), - Z_STRVAL_P(val), Z_STRLEN_P(val)); - } ZEND_HASH_FOREACH_END(); + return HTTP_STREAM_APPEND_OK; +} + +static void worker_stream_mark_ended(void *vctx) +{ + worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; + + if (!ctx->stream_started || ctx->stream_ended) { + return; + } + + ctx->stream_ended = true; + + response_wire_t *const ew = + response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); + + if (UNEXPECTED(ew == NULL)) { + return; + } + + response_wire_set_kind(ew, RESPONSE_WIRE_STREAM_END); + worker_wire_copy_trailers(ew, Z_OBJ(ctx->response_zv)); + worker_wire_post(ctx, ew); +} + +static const http_response_stream_ops_t worker_stream_ops = { + .append_chunk = worker_stream_append_chunk, + .sendable = NULL, /* no staging-ring visibility yet (step 3) */ + .mark_ended = worker_stream_mark_ended, + .get_wait_event = NULL, /* append never reports BACKPRESSURE yet */ +}; + +/* gRPC finish ops (grpc_call_finish seam) — the worker-side delivery + * actions; what to send is decided in src/grpc/grpc_call.c. */ + +/* grpc-web in-body trailer frame. Streamed reply: one more CHUNK + END. + * Zero-message reply: the frame becomes the buffered body — the FULL wire + * rendered right after grpc_call_finish carries it. Consumes the ref. */ +static void worker_grpc_append_frame_and_end(void *vctx, zend_string *frame) +{ + worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; + + if (http_response_is_streaming(Z_OBJ(ctx->response_zv))) { + /* append_chunk consumes the ref (success or failure). */ + if (worker_stream_append_chunk(ctx, frame) == HTTP_STREAM_APPEND_OK) { + worker_stream_mark_ended(ctx); + } + + return; } + http_response_static_set_body_str(Z_OBJ(ctx->response_zv), frame); + zend_string_release(frame); +} + +static void worker_grpc_end_stream(void *vctx) +{ + worker_stream_mark_ended(vctx); +} + +/* Trailers-Only: grpc_call_finish already promoted the trailers into the + * initial HEADERS; dispose renders + posts the FULL wire right after this + * returns, so there is nothing left to commit here. */ +static void worker_grpc_commit(void *vctx) +{ + (void)vctx; +} + +static const grpc_finish_ops_t worker_grpc_finish_ops = { + .append_frame_and_end = worker_grpc_append_frame_and_end, + .end_stream = worker_grpc_end_stream, + .commit = worker_grpc_commit, +}; + +/* Flatten the committed HttpResponse into a response_wire. Buffered only. + * Returns NULL on allocation failure. */ +static response_wire_t *worker_render_response(const worker_dispatch_ctx_t *ctx) +{ + zend_object *const resp = Z_OBJ(ctx->response_zv); + + response_wire_t *const rw = + response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); + + if (rw == NULL) { + return NULL; + } + + worker_wire_copy_head(rw, resp); + worker_wire_copy_trailers(rw, resp); + /* http_response_get_body_str returns a borrowed reference; the bytes are * copied into the arena, so nothing to release. HEAD carries the headers * but no body (RFC 9110 §9.3.2). */ @@ -227,23 +401,43 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) if (!Z_ISUNDEF(ctx->response_zv)) { zend_object *const resp = Z_OBJ(ctx->response_zv); - if (coroutine->exception != NULL && !http_response_is_committed(resp)) { + /* A gRPC outcome is expressed as grpc-status on a 200, not as an + * HTTP 500 — grpc_call_ensure_status maps the exception below. */ + if (coroutine->exception != NULL && !ctx->is_grpc + && !http_response_is_committed(resp)) { http_response_reset_to_error(resp, 500, "Internal Server Error"); } + if (ctx->is_grpc) { + grpc_call_ensure_status(resp, coroutine->exception != NULL); + } + if (!http_response_is_committed(resp)) { http_response_set_committed(resp); } - response_wire_t *const rw = worker_render_response(ctx); + /* Delivery shape. gRPC policy decides via grpc_call_finish; a plain + * streamed response just needs its END (idempotent — a handler that + * called end() already posted it). A response that never streamed is + * the buffered FULL wire, exactly as before. */ + if (ctx->is_grpc) { + grpc_call_finish(resp, ctx->grpc_web, &worker_grpc_finish_ops, ctx); + } else if (http_response_is_streaming(resp)) { + worker_stream_mark_ended(ctx); + } + + if (!ctx->stream_started) { + response_wire_t *const rw = worker_render_response(ctx); - if (rw != NULL) { - if (ctx->sink != NULL) { - ctx->sink(rw, ctx->sink_arg); /* sink owns rw now */ - } else { - response_wire_free(rw); + if (rw != NULL) { + worker_wire_post(ctx, rw); /* sink owns rw now */ } } + + /* Detach the ops: a handler may have kept $response alive beyond + * this dispose; ctx dies below, so a late send() must throw the + * standard "not available" instead of touching freed memory. */ + http_response_replace_stream_ops(resp, NULL, NULL); } if (!Z_ISUNDEF(ctx->request_zv)) { @@ -280,6 +474,13 @@ bool worker_dispatch_request(http_server_object *server, void *const conn = req->reactor_conn; const bool is_head = http_request_method_is_head(req); + /* gRPC classification — once, here, exactly like the transports do + * (application/grpc* + a registered addGrpcHandler). */ + HashTable *const handlers = http_server_get_protocol_handlers(server); + const bool is_grpc = grpc_request_is_grpc(req) + && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); + const bool grpc_web = is_grpc && grpc_request_is_grpc_web(req); + zval *const req_obj = http_request_create_from_parsed(req); if (UNEXPECTED(req_obj == NULL)) { @@ -297,6 +498,8 @@ bool worker_dispatch_request(http_server_object *server, ctx->sink = sink; ctx->sink_arg = sink_arg; ctx->is_head = is_head; + ctx->is_grpc = is_grpc; + ctx->grpc_web = grpc_web; ZVAL_COPY_VALUE(&ctx->request_zv, req_obj); efree(req_obj); /* the heap zval wrapper, not the object */ @@ -304,10 +507,24 @@ bool worker_dispatch_request(http_server_object *server, object_init_ex(&ctx->response_zv, http_response_ce); http_response_set_protocol_version(Z_OBJ(ctx->response_zv), "3.0"); + /* Streaming reverse path: send()/writeMessage() flatten into STREAM_* + * wires posted through the sink instead of throwing. */ + http_response_install_stream_ops(Z_OBJ(ctx->response_zv), + &worker_stream_ops, ctx); + + if (is_grpc) { + grpc_call_init_response(Z_OBJ(ctx->response_zv), grpc_web); + } + /* No handler registered: synthesise a 404 so the sink still fires with a * response instead of leaving the stream hanging. */ - HashTable *const handlers = http_server_get_protocol_handlers(server); - zend_fcall_t *fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); + zend_fcall_t *fcall = is_grpc + ? http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC) + : NULL; + + if (fcall == NULL) { + fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); + } if (fcall == NULL) { fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index b230e5d5..56cfe616 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -799,6 +799,65 @@ bool http3_stream_submit_response(http3_connection_t *c, return false; } +/* Copy the wire's trailer pairs into the stream's malloc'd capture — the + * wire dies right after the apply, and the data reader submits at true EOF + * (same fields http3_stream_capture_trailers fills on the local path). + * No-op when the wire has no trailers or a capture already exists. */ +void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t *rw) +{ + const size_t tcount = response_wire_trailer_count(rw); + + if (tcount == 0 || s->trailer_nv != NULL) { + return; + } + + size_t total = 0; + + for (size_t i = 0; i < tcount; i++) { + const char *nm, *val; + size_t nl, vl; + + if (response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { + total += nl + vl; + } + } + + nghttp3_nv *tnv = malloc(tcount * sizeof(nghttp3_nv)); + char *tbytes = malloc(total ? total : 1); + + if (tnv == NULL || tbytes == NULL) { + free(tnv); + free(tbytes); + return; + } + + size_t ni = 0, bi = 0; + + for (size_t i = 0; i < tcount; i++) { + const char *nm, *val; + size_t nl, vl; + + if (!response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { + continue; + } + + memcpy(tbytes + bi, nm, nl); + tnv[ni].name = (uint8_t *)(tbytes + bi); + tnv[ni].namelen = nl; + bi += nl; + memcpy(tbytes + bi, val, vl); + tnv[ni].value = (uint8_t *)(tbytes + bi); + tnv[ni].valuelen = vl; + bi += vl; + tnv[ni].flags = NGHTTP3_NV_FLAG_NONE; + ni++; + } + + s->trailer_nv = tnv; + s->trailer_count = ni; + s->trailer_bytes = tbytes; +} + /* Reverse path: submit a buffered response from a flat response_wire * (rendered by a worker, handed back over the reverse channel) instead of from * the per-stream HttpResponse zval. Runs ON THE REACTOR thread. The wire's @@ -858,64 +917,24 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, nvi++; } - size_t blen = 0; - const char *body = response_wire_body(rw, &blen); - - if (body != NULL && blen > 0) { - s->response_body = zend_string_init(body, blen, 0); - s->response_body_offset = 0; - } + /* Body source. FULL: single-slice from response_body. STREAM_HEADERS: + * prime the chunk ring — the data reader parks on WOULDBLOCK until the + * first STREAM_CHUNK apply pushes bytes (or STREAM_END fires EOF). */ + const bool streaming = + response_wire_kind(rw) == RESPONSE_WIRE_STREAM_HEADERS; - /* Trailers: copy out of the wire into the stream's malloc'd capture (the - * wire dies right after this call; the data reader submits at true EOF — - * same fields http3_stream_capture_trailers fills on the local path). */ - const size_t tcount = response_wire_trailer_count(rw); - - if (tcount > 0 && s->trailer_nv == NULL) { - size_t total = 0; - - for (size_t i = 0; i < tcount; i++) { - const char *nm, *val; - size_t nl, vl; + if (streaming) { + h3_chunk_queue_init(s); + } else { + size_t blen = 0; + const char *body = response_wire_body(rw, &blen); - if (response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { - total += nl + vl; - } + if (body != NULL && blen > 0) { + s->response_body = zend_string_init(body, blen, 0); + s->response_body_offset = 0; } - nghttp3_nv *tnv = malloc(tcount * sizeof(nghttp3_nv)); - char *tbytes = malloc(total ? total : 1); - - if (tnv != NULL && tbytes != NULL) { - size_t ni = 0, bi = 0; - - for (size_t i = 0; i < tcount; i++) { - const char *nm, *val; - size_t nl, vl; - - if (!response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { - continue; - } - - memcpy(tbytes + bi, nm, nl); - tnv[ni].name = (uint8_t *)(tbytes + bi); - tnv[ni].namelen = nl; - bi += nl; - memcpy(tbytes + bi, val, vl); - tnv[ni].value = (uint8_t *)(tbytes + bi); - tnv[ni].valuelen = vl; - bi += vl; - tnv[ni].flags = NGHTTP3_NV_FLAG_NONE; - ni++; - } - - s->trailer_nv = tnv; - s->trailer_count = ni; - s->trailer_bytes = tbytes; - } else { - free(tnv); - free(tbytes); - } + http3_stream_adopt_wire_trailers(s, rw); } const nghttp3_data_reader dr = { .read_data = h3_read_data_cb }; @@ -930,6 +949,11 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, if (rv == 0) { if (stats != NULL) stats->h3_response_submitted++; + + if (streaming) { + http_server_on_streaming_response_started(c->counters); + } + return true; } @@ -940,6 +964,12 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, s->response_body = NULL; } + if (streaming) { + /* The reader was never registered; make sure late STREAM_CHUNK / + * STREAM_END applies see a dead stream and drop. */ + s->streaming_ended = true; + } + return false; } @@ -954,41 +984,29 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, * the caller suspends on write_event, which extend_max_stream_data_cb * fires when the peer extends the window via MAX_STREAM_DATA. * ------------------------------------------------------------------- */ -int h3_stream_append_chunk(void *ctx, zend_string *chunk) +/* Lazily create the streaming chunk ring. Idempotent. A non-NULL queue is + * what flips the data reader into its streaming branch. */ +void h3_chunk_queue_init(http3_stream_t *s) { - http3_stream_t *const s = (http3_stream_t *)ctx; - - if (s == NULL || s->conn == NULL || s->peer_closed) { - zend_string_release(chunk); - return HTTP_STREAM_APPEND_STREAM_DEAD; - } - - http3_connection_t *const c = s->conn; - - if (c->closed || c->nghttp3_conn == NULL) { - zend_string_release(chunk); - return HTTP_STREAM_APPEND_STREAM_DEAD; + if (s->chunk_queue != NULL) { + return; } - /* First send() — lazy queue + HEADERS commit (submit_response with - * the streaming data_reader). The data_reader reads the chunk we - * are about to enqueue. */ - const bool first_call = s->chunk_queue == NULL; + s->chunk_queue_cap = 8; + s->chunk_queue = ecalloc(s->chunk_queue_cap, sizeof(zend_string *)); + s->chunk_queue_head = 0; + s->chunk_read_idx = 0; + s->chunk_queue_tail = 0; + s->chunk_pending_bytes = 0; + s->chunk_read_offset = 0; + s->chunk_ack_credit = 0; +} - if (first_call) { - s->chunk_queue_cap = 8; - s->chunk_queue = ecalloc(s->chunk_queue_cap, sizeof(zend_string *)); - s->chunk_queue_head = 0; - s->chunk_read_idx = 0; - s->chunk_queue_tail = 0; - s->chunk_pending_bytes = 0; - s->chunk_read_offset = 0; - s->chunk_ack_credit = 0; - } - - /* Grow ring if full — compact head→0 first to avoid unbounded - * growth on long-lived streams. Compaction shifts head, read_idx, - * and tail by the same delta. */ +/* Enqueue a chunk (takes the ref). Grows the ring if full — compact head→0 + * first to avoid unbounded growth on long-lived streams. Compaction shifts + * head, read_idx, and tail by the same delta. */ +void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk) +{ if (s->chunk_queue_tail == s->chunk_queue_cap) { if (s->chunk_queue_head > 0) { const size_t shift = s->chunk_queue_head; @@ -1011,6 +1029,34 @@ int h3_stream_append_chunk(void *ctx, zend_string *chunk) s->chunk_queue[s->chunk_queue_tail++] = chunk; s->chunk_pending_bytes += ZSTR_LEN(chunk); +} + +int h3_stream_append_chunk(void *ctx, zend_string *chunk) +{ + http3_stream_t *const s = (http3_stream_t *)ctx; + + if (s == NULL || s->conn == NULL || s->peer_closed) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + http3_connection_t *const c = s->conn; + + if (c->closed || c->nghttp3_conn == NULL) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + /* First send() — lazy queue + HEADERS commit (submit_response with + * the streaming data_reader). The data_reader reads the chunk we + * are about to enqueue. */ + const bool first_call = s->chunk_queue == NULL; + + if (first_call) { + h3_chunk_queue_init(s); + } + + h3_chunk_queue_push(s, chunk); /* Telemetry — match the H1/H2 vantage so operators see one * unified streaming-load picture across protocols. */ diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index d4c96c1f..3f3ca6a6 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -224,11 +224,55 @@ void http3_reactor_apply_response(void *arg) http3_stream_t *const s = (http3_stream_t *)response_wire_conn(rw); http3_connection_t *const c = (s != NULL) ? s->conn : NULL; - if (c != NULL && !c->closed && c->nghttp3_conn != NULL) { - if (http3_stream_submit_response_wire(c, s, rw)) { + if (c == NULL || c->closed || c->nghttp3_conn == NULL) { + response_wire_free(rw); + return; + } + + switch (response_wire_kind(rw)) { + case RESPONSE_WIRE_FULL: + case RESPONSE_WIRE_STREAM_HEADERS: + if (http3_stream_submit_response_wire(c, s, rw)) { + http3_connection_drain_out(c); + http3_connection_arm_timer(c); + } + break; + + case RESPONSE_WIRE_STREAM_CHUNK: { + /* Validate-and-drop: the stream may have died (peer RST) or the + * HEADERS submit may have failed (no ring) since the worker + * posted this chunk. */ + size_t blen = 0; + const char *bytes = response_wire_body(rw, &blen); + + if (s->peer_closed || s->streaming_ended + || s->chunk_queue == NULL || bytes == NULL || blen == 0) { + break; + } + + h3_chunk_queue_push(s, zend_string_init(bytes, blen, 0)); + http_server_on_stream_send(c->counters, blen); + + (void)nghttp3_conn_resume_stream( + (nghttp3_conn *)c->nghttp3_conn, s->stream_id); http3_connection_drain_out(c); http3_connection_arm_timer(c); + break; } + + case RESPONSE_WIRE_STREAM_END: + if (s->peer_closed || s->streaming_ended || s->chunk_queue == NULL) { + break; + } + + http3_stream_adopt_wire_trailers(s, rw); + s->streaming_ended = true; + + (void)nghttp3_conn_resume_stream( + (nghttp3_conn *)c->nghttp3_conn, s->stream_id); + http3_connection_drain_out(c); + http3_connection_arm_timer(c); + break; } response_wire_free(rw); diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index 010c6176..5446546e 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -165,12 +165,24 @@ bool http3_stream_submit_response(http3_connection_t *c, * reader runs async, after response_zv is freed). */ void http3_stream_capture_trailers(http3_stream_t *s); -/* Reverse path: submit a buffered response from a worker-rendered - * response_wire instead of the per-stream HttpResponse zval. Reactor thread. */ +/* Reverse path: submit a response from a worker-rendered response_wire + * instead of the per-stream HttpResponse zval. Reactor thread. FULL wires + * submit buffered; STREAM_HEADERS wires submit with the streaming reader + * (chunk ring primed, fed by the STREAM_CHUNK/STREAM_END applies below). */ typedef struct response_wire_s response_wire_t; bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, const response_wire_t *rw); +/* Copy a wire's trailer pairs into the stream's malloc'd trailer capture + * (STREAM_END apply / buffered FULL submit). Reactor thread. */ +void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t *rw); + +/* Streaming chunk ring, factored for the reverse path: init is lazy and + * idempotent (a non-NULL ring is what flips the data reader to streaming); + * push takes the chunk ref. */ +void h3_chunk_queue_init(http3_stream_t *s); +void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk); + /* Streaming-vtable hooks reused by the static-file delivery TU * (http3_static_response.c). Pumping a file through chunk_queue is * exactly the streaming path: append chunks until EOF, then mark_ended. diff --git a/src/http_server_class.c b/src/http_server_class.c index a7c430d1..992747d9 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -2558,11 +2558,28 @@ static void http_server_worker_response_sink(response_wire_t *rw, void *arg) (void)arg; #ifdef HAVE_HTTP_SERVER_HTTP3 - if (g_reactor_pool != NULL - && reactor_pool_post_exec(g_reactor_pool, - (int)response_wire_reactor_id(rw), - http3_reactor_apply_response, rw)) { - return; /* the reactor owns rw now */ + if (g_reactor_pool != NULL) { + const int reactor = (int)response_wire_reactor_id(rw); + + if (reactor_pool_post_exec(g_reactor_pool, reactor, + http3_reactor_apply_response, rw)) { + return; /* the reactor owns rw now */ + } + + /* STREAM_* wires are ordered fragments — dropping one silently + * corrupts the stream, so retry a transiently full mailbox (the + * reactor drains continuously; same discipline as the consumed + * spin in http3_stream_release_via_request). Bounded so a reactor + * that left RUN (shutdown) cannot pin the worker forever. Interim + * until the step-3 credit protocol paces the producer. */ + if (response_wire_kind(rw) != RESPONSE_WIRE_FULL) { + for (int spin = 0; spin < 1000000; spin++) { + if (reactor_pool_post_exec(g_reactor_pool, reactor, + http3_reactor_apply_response, rw)) { + return; + } + } + } } #endif diff --git a/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt b/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt new file mode 100644 index 00000000..348f6088 --- /dev/null +++ b/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt @@ -0,0 +1,91 @@ +--TEST-- +gRPC over HTTP/3 under the reactor pool: unary echo + native trailers (#4 + #80) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--ENV-- +TRUE_ASYNC_SERVER_REACTOR_POOL=1 +PHP_HTTP3_DISABLE_RETRY=1 +--FILE-- +addListener('127.0.0.1', $port + 1) /* TCP listener required by start() */ + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setWorkers(2); +$server = new HttpServer($config); +$server->addGrpcHandler(function ($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); + $resp->writeMessage('echo:' . ($msg ?? '')); + // grpc-status defaults to 0 +}); + +$py = __DIR__ . '/_h3grpc_client.py'; + +spawn(function () use ($server, $port, $py) { + /* Reactors + workers need a moment to thread up and bind. */ + usleep(600000); + + $bodyhex = bin2hex("\x00" . pack('N', 4) . 'ping'); + $cmd = sprintf('python3 %s 127.0.0.1 %d /svc/Echo application/grpc %s 2>/dev/null', + escapeshellarg($py), $port, $bodyhex); + $out = shell_exec($cmd) ?? ''; + + /* Deframe the single response message from the BODYHEX line. */ + $reply = ''; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m) && strlen($m[1]) >= 10) { + $body = hex2bin($m[1]); + $len = unpack('N', substr($body, 1, 4))[1]; + $reply = substr($body, 5, $len); + } + + echo "saw_status_200=", (int)(strpos($out, 'HDR :status: 200') !== false), "\n"; + echo "saw_ctype=", (int)(strpos($out, 'HDR content-type: application/grpc') !== false), "\n"; + echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; + echo "reply=", $reply, "\n"; + + /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL + * skips PHP shutdown so the worker threads cannot deadlock on exit. */ + posix_kill(getmypid(), SIGKILL); +}); + +$server->start(); +?> +--EXPECTF-- +%Asaw_status_200=1 +saw_ctype=1 +saw_grpc_status=1 +reply=echo:ping +%A diff --git a/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt b/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt new file mode 100644 index 00000000..a271a2da --- /dev/null +++ b/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt @@ -0,0 +1,85 @@ +--TEST-- +HttpServer: streaming send() crosses the reactor/worker split (#80, gated pool) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'h3client' => true]); +?> +--ENV-- +TRUE_ASYNC_SERVER_REACTOR_POOL=1 +PHP_HTTP3_DISABLE_RETRY=1 +--FILE-- +addListener('127.0.0.1', $port + 1) /* TCP listener required by start() */ + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setWorkers(2); +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200) + ->setHeader('content-type', 'text/plain; charset=utf-8'); + + for ($i = 1; $i <= 5; $i++) { + $res->send("chunk{$i};"); + } + + $res->end(); +}); + +$client_bin = __DIR__ . '/../../../h3client/h3client'; + +spawn(function () use ($server, $port, $client_bin) { + /* Reactors + workers need a moment to thread up and bind. */ + usleep(600000); + + $cmd = sprintf('H3CLIENT_DEADLINE_MS=4000 %s 127.0.0.1 %d /stream GET 2>&1', + escapeshellarg($client_bin), $port); + $out = shell_exec($cmd) ?? ''; + + $status = null; + if (preg_match('/^STATUS=(\d+)$/m', $out, $m)) $status = (int)$m[1]; + $body = preg_replace('/^STATUS=\d+\n?/m', '', $out); + + echo "status=", $status ?? -1, "\n"; + echo "body=", trim($body), "\n"; + + /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL + * skips PHP shutdown so the worker threads cannot deadlock on exit. */ + posix_kill(getmypid(), SIGKILL); +}); + +$server->start(); +?> +--EXPECTF-- +%Astatus=200 +body=chunk1;chunk2;chunk3;chunk4;chunk5; +%A From 752066134316059836b2161bddd7565f38406a47 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:01:51 +0000 Subject: [PATCH 13/33] feat(pool): credit-based backpressure for the streaming reverse path (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3: a worker producer can no longer flood the shared reactor mailbox through a slow client. - stream_credit_t (include/core/stream_credit.h): malloc-domain, atomics only — acked (advanced by the reactor on peer ACK), dead (stream died), two refs. The only object both threads touch; no Zend state crosses. - worker: the credit rides the STREAM_HEADERS wire; append_chunk accounts posted bytes and parks the producer coroutine (2 ms timer polls) while in-flight >= 1 MiB, resuming as the reactor retires acked bytes. Bounded by the configured write timeout; dead unparks into the standard stream-dead path (send() throws 499). sendable() reports cap room. - reactor: adopts the credit at streaming submit; h3_acked_stream_data_cb advances acked; peer RST / failed submit / stream teardown mark dead; the teardown releases the reactor ref. Orphaned HEADERS wires (conn gone before apply, undeliverable sink) mark dead + release so a parked producer never hangs. Test: 048 — 4 MiB streamed in 64 KiB chunks under the pool (4x the cap; producer must park and resume), byte-exact hash. Suite 304/304. --- include/core/response_wire.h | 7 ++ include/core/stream_credit.h | 87 +++++++++++++ include/http3/http3_stream.h | 7 ++ src/core/response_wire.c | 11 ++ src/core/worker_dispatch.c | 119 +++++++++++++++++- src/http3/http3_callbacks.c | 24 +++- src/http3/http3_dispatch.c | 12 ++ src/http3/http3_stream.c | 9 ++ src/http_server_class.c | 10 ++ .../h3/048-h3-reactor-pool-backpressure.phpt | 97 ++++++++++++++ 10 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 include/core/stream_credit.h create mode 100644 tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt diff --git a/include/core/response_wire.h b/include/core/response_wire.h index b27f6943..d6893aa1 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -57,6 +57,13 @@ response_wire_t *response_wire_create(uint32_t reactor_id, int64_t stream_id, vo void response_wire_set_kind(response_wire_t *rw, response_wire_kind_t kind); response_wire_kind_t response_wire_kind(const response_wire_t *rw); +/* Flow-control credit handoff (STREAM_HEADERS only): an opaque + * stream_credit_t* riding the wire from the worker to the reactor. The wire + * does not own it — the reactor adopts the ref at apply time (or marks it + * dead + releases when the stream is already gone). */ +void response_wire_set_credit(response_wire_t *rw, void *credit); +void *response_wire_credit(const response_wire_t *rw); + /* Builders — copy bytes into the arena. set_status replaces; add_header * appends; set_body replaces. All accept non-NUL-terminated spans. The header * builders return false on allocation failure (the wire stays usable/freeable). diff --git a/include/core/stream_credit.h b/include/core/stream_credit.h new file mode 100644 index 00000000..a18aa776 --- /dev/null +++ b/include/core/stream_credit.h @@ -0,0 +1,87 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#ifndef STREAM_CREDIT_H +#define STREAM_CREDIT_H + +#include +#include +#include + +#include "Zend/zend_atomic.h" + +/* + * Per-stream flow-control credit for the reactor/worker streaming reverse + * path (#80, step 3). Pure malloc-domain, atomics only — the ONLY object the + * two threads touch concurrently, so neither side ever dereferences the + * other's Zend state. + * + * Protocol: the worker creates the block (two refs), attaches it to the + * STREAM_HEADERS wire, and thereafter tracks posted-bytes locally. When + * posted - acked exceeds its cap, the producer coroutine sleeps on a short + * timer and re-reads `acked` — no cross-thread event objects. The reactor + * (owner of the second ref) advances `acked` as the QUIC peer acknowledges + * stream bytes, and sets `dead` when the stream dies (RST / connection + * close / failed submit) so a waiting producer unblocks into the standard + * stream-dead path. Each side releases its ref exactly once; the last + * release frees the block. + */ + +typedef struct { + zend_atomic_int64 acked; /* bytes the reactor has retired (peer ACK) */ + zend_atomic_int dead; /* stream died — producer must stop waiting */ + zend_atomic_int refs; +} stream_credit_t; + +static inline stream_credit_t *stream_credit_create(void) +{ + stream_credit_t *const sc = (stream_credit_t *)calloc(1, sizeof(*sc)); + + if (sc == NULL) { + return NULL; + } + + ZEND_ATOMIC_INT64_INIT(&sc->acked, 0); + ZEND_ATOMIC_INT_INIT(&sc->dead, 0); + ZEND_ATOMIC_INT_INIT(&sc->refs, 2); /* worker ctx + reactor stream */ + + return sc; +} + +static inline void stream_credit_release(stream_credit_t *sc) +{ + if (sc != NULL && zend_atomic_int_fetch_sub(&sc->refs, 1) == 1) { + free(sc); + } +} + +/* Reactor thread ONLY (single writer) — load+store instead of fetch-add is + * safe because nothing else ever writes `acked`. */ +static inline void stream_credit_ack(stream_credit_t *sc, const uint64_t bytes) +{ + zend_atomic_int64_store_ex(&sc->acked, + zend_atomic_int64_load_ex(&sc->acked) + (int64_t)bytes); +} + +static inline uint64_t stream_credit_acked(const stream_credit_t *sc) +{ + return (uint64_t)zend_atomic_int64_load_ex( + &((stream_credit_t *)sc)->acked); +} + +static inline void stream_credit_mark_dead(stream_credit_t *sc) +{ + zend_atomic_int_store_ex(&sc->dead, 1); +} + +static inline bool stream_credit_is_dead(const stream_credit_t *sc) +{ + return zend_atomic_int_load_ex(&((stream_credit_t *)sc)->dead) != 0; +} + +#endif /* STREAM_CREDIT_H */ diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index 7becef01..10e636d2 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -155,6 +155,13 @@ struct _http3_stream_s { * unwinds cleanly with HttpException(499). */ bool peer_closed; + /* Reverse-path flow control (#80 step 3): the worker's credit block, + * adopted from the STREAM_HEADERS wire. void* keeps core headers out + * of this one; the reactor advances acked on peer ACK, marks dead on + * stream death, releases its ref at teardown. NULL for local / + * buffered streams. */ + void *wire_credit; + /* Trigger event the handler awaits on under flow-control * backpressure. The data_reader fires it (lazily) when ngtcp2 * extends the per-stream write window; lazy-created on first wait, diff --git a/src/core/response_wire.c b/src/core/response_wire.c index 7b8204db..6c76bed3 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -32,6 +32,7 @@ struct response_wire_s { void *conn; response_wire_kind_t kind; /* FULL unless set otherwise */ + void *credit; /* opaque stream_credit_t*, not owned */ int status; /* Growable byte arena: every span's bytes are copied in here. */ @@ -119,6 +120,16 @@ response_wire_kind_t response_wire_kind(const response_wire_t *rw) return rw->kind; } +void response_wire_set_credit(response_wire_t *rw, void *credit) +{ + rw->credit = credit; +} + +void *response_wire_credit(const response_wire_t *rw) +{ + return rw->credit; +} + void response_wire_set_status(response_wire_t *rw, const int status) { rw->status = status; diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index d400eb3d..99c9d234 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -18,10 +18,17 @@ #include "core/http_connection.h" /* http_request_handler_coroutine_new */ #include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "http1/http_parser.h" /* http_request_t, http_request_destroy */ +#include "core/stream_credit.h" /* per-stream flow-control credit */ #include "grpc/grpc.h" /* grpc_request_is_grpc / _is_grpc_web */ #include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ #include "Zend/zend_hrtime.h" /* zend_hrtime — request-service sampling */ +/* Streaming reverse-path flow control: how many un-acked bytes a stream may + * have in flight before append_chunk parks the producer coroutine, and how + * often a parked producer re-reads the credit counter. */ +#define WORKER_STREAM_INFLIGHT_CAP (1024 * 1024) +#define WORKER_CREDIT_POLL_MS 2 + #include /* Defined in src/http_request.c (no public header). Wraps an http_request_t in @@ -64,6 +71,12 @@ typedef struct { * stream_started, dispose skips the buffered FULL render. */ bool stream_started; bool stream_ended; + + /* Flow control (step 3): `credit` is shared with the reactor (see + * stream_credit.h); posted_bytes is worker-local. In-flight = + * posted_bytes - acked; append parks over WORKER_STREAM_INFLIGHT_CAP. */ + stream_credit_t *credit; + uint64_t posted_bytes; } worker_dispatch_ctx_t; /* Handler coroutine body: run the registered user handler with (request, response). */ @@ -230,17 +243,77 @@ static void worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) * END (trailers + EOF). Backpressure credits are the step-3 follow-up — * until then append never reports BACKPRESSURE. * ------------------------------------------------------------------- */ +/* Suspend the producer coroutine for `ms` on a one-shot timer so the worker + * loop keeps draining while we wait for credit. Best-effort: bails silently + * outside a coroutine (flush stays best-effort, mirroring the local path). */ +static void worker_credit_sleep_ms(zend_coroutine_t *co, const zend_ulong ms) +{ + zend_async_timer_event_t *const t = ZEND_ASYNC_NEW_TIMER_EVENT(ms, false); + + if (UNEXPECTED(t == NULL)) { + zend_clear_exception(); + return; + } + + t->base.start(&t->base); + zend_async_resume_when(co, &t->base, true, zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + ZEND_ASYNC_WAKER_DESTROY(co); +} + +/* Park the producer until in-flight drops under the cap, the stream dies, + * or the write timeout elapses. Returns false when the stream is dead. */ +static bool worker_stream_wait_credit(worker_dispatch_ctx_t *ctx) +{ + if (ctx->credit == NULL) { + return true; + } + + const uint32_t timeout_ms = + http_server_get_write_timeout_s(ctx->server) * 1000u; + uint64_t waited_ms = 0; + + while (ctx->posted_bytes - stream_credit_acked(ctx->credit) + >= WORKER_STREAM_INFLIGHT_CAP) { + if (stream_credit_is_dead(ctx->credit)) { + return false; + } + + zend_coroutine_t *const co = ZEND_ASYNC_CURRENT_COROUTINE; + + if (co == NULL || ZEND_ASYNC_IS_SCHEDULER_CONTEXT) { + return true; /* can't suspend — degrade to unbounded */ + } + + worker_credit_sleep_ms(co, WORKER_CREDIT_POLL_MS); + + if (EG(exception) != NULL) { + return false; /* cancelled while parked */ + } + + waited_ms += WORKER_CREDIT_POLL_MS; + + if (timeout_ms > 0 && waited_ms >= timeout_ms) { + return false; /* peer stopped reading — treat as dead */ + } + } + + return true; +} + static int worker_stream_append_chunk(void *vctx, zend_string *chunk) { worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; - if (UNEXPECTED(ctx->stream_ended)) { + if (UNEXPECTED(ctx->stream_ended) + || (ctx->credit != NULL && stream_credit_is_dead(ctx->credit))) { zend_string_release(chunk); return HTTP_STREAM_APPEND_STREAM_DEAD; } /* First send(): the response just committed — flatten status + headers - * into the STREAM_HEADERS wire that opens the stream on the reactor. */ + * into the STREAM_HEADERS wire that opens the stream on the reactor, + * carrying the shared credit block (the reactor adopts one ref). */ if (!ctx->stream_started) { response_wire_t *const hw = response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); @@ -250,7 +323,10 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) return HTTP_STREAM_APPEND_STREAM_DEAD; } + ctx->credit = stream_credit_create(); /* NULL degrades to unbounded */ + response_wire_set_kind(hw, RESPONSE_WIRE_STREAM_HEADERS); + response_wire_set_credit(hw, ctx->credit); worker_wire_copy_head(hw, Z_OBJ(ctx->response_zv)); worker_wire_post(ctx, hw); ctx->stream_started = true; @@ -273,12 +349,42 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) return HTTP_STREAM_APPEND_STREAM_DEAD; } + const size_t chunk_len = ZSTR_LEN(chunk); + worker_wire_post(ctx, cw); zend_string_release(chunk); /* bytes copied into the wire arena */ + /* Flow control: account the bytes, then park until the reactor retires + * enough of the backlog (peer ACKs) — mirrors the local path suspending + * on write_event, but over the thread boundary via the credit block. */ + ctx->posted_bytes += chunk_len; + + if (!worker_stream_wait_credit(ctx)) { + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + return HTTP_STREAM_APPEND_OK; } +/* Advisory for HttpResponse::sendable(): true while append_chunk would not + * park (room under the in-flight cap and the stream is alive). */ +static bool worker_stream_sendable(void *vctx) +{ + worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; + + if (ctx->stream_ended) { + return false; + } + + if (ctx->credit == NULL) { + return true; + } + + return !stream_credit_is_dead(ctx->credit) + && ctx->posted_bytes - stream_credit_acked(ctx->credit) + < WORKER_STREAM_INFLIGHT_CAP; +} + static void worker_stream_mark_ended(void *vctx) { worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; @@ -303,9 +409,9 @@ static void worker_stream_mark_ended(void *vctx) static const http_response_stream_ops_t worker_stream_ops = { .append_chunk = worker_stream_append_chunk, - .sendable = NULL, /* no staging-ring visibility yet (step 3) */ + .sendable = worker_stream_sendable, .mark_ended = worker_stream_mark_ended, - .get_wait_event = NULL, /* append never reports BACKPRESSURE yet */ + .get_wait_event = NULL, /* backpressure parks inside append_chunk */ }; /* gRPC finish ops (grpc_call_finish seam) — the worker-side delivery @@ -440,6 +546,11 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) http_response_replace_stream_ops(resp, NULL, NULL); } + if (ctx->credit != NULL) { + stream_credit_release(ctx->credit); /* worker-side ref */ + ctx->credit = NULL; + } + if (!Z_ISUNDEF(ctx->request_zv)) { zval_ptr_dtor(&ctx->request_zv); ZVAL_UNDEF(&ctx->request_zv); diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 56cfe616..6f8c3b7a 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -39,6 +39,7 @@ #include "http3_packet.h" /* http3_packet_compute_sr_token */ #include "http3_steer.h" /* CID steering encode */ #include "core/response_wire.h" /* response_wire_* (reverse path) */ +#include "core/stream_credit.h" /* reverse-path flow control */ #include "http3/http3_stream.h" /* http3_stream_t */ #include /* ngtcp2_crypto_* callback ptrs */ @@ -925,6 +926,10 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, if (streaming) { h3_chunk_queue_init(s); + /* Adopt the worker's credit ref: acked advances in + * h3_acked_stream_data_cb, dead on stream death, release at + * teardown. */ + s->wire_credit = response_wire_credit(rw); } else { size_t blen = 0; const char *body = response_wire_body(rw, &blen); @@ -966,8 +971,14 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, if (streaming) { /* The reader was never registered; make sure late STREAM_CHUNK / - * STREAM_END applies see a dead stream and drop. */ + * STREAM_END applies see a dead stream and drop, and unblock a + * producer parked on credit. The teardown release still runs, so + * only mark here. */ s->streaming_ended = true; + + if (s->wire_credit != NULL) { + stream_credit_mark_dead((stream_credit_t *)s->wire_credit); + } } return false; @@ -1315,6 +1326,11 @@ static void h3_stream_mark_peer_closed(http3_stream_t *s) s->peer_closed = true; + /* Unblock a worker producer parked on credit — the stream is gone. */ + if (s->wire_credit != NULL) { + stream_credit_mark_dead((stream_credit_t *)s->wire_credit); + } + if (s->write_event != NULL) { zend_async_trigger_event_t *trig = s->write_event; @@ -1400,6 +1416,12 @@ static int h3_acked_stream_data_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } + /* Reverse-path flow control: retire the acked bytes so a worker + * producer parked on the credit cap resumes. */ + if (s->wire_credit != NULL) { + stream_credit_ack((stream_credit_t *)s->wire_credit, datalen); + } + s->chunk_ack_credit += datalen; while (s->chunk_queue_head < s->chunk_read_idx) { zend_string *head = s->chunk_queue[s->chunk_queue_head]; diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 3f3ca6a6..fba7e278 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -24,6 +24,7 @@ #include "http_connection.h" /* http_handler_log_bailout */ #include "http_send_file.h" /* http_send_file_dispatch */ #include "http_response_internal.h" /* http_response_has/take_send_file */ +#include "core/stream_credit.h" /* reverse-path flow control */ #include "grpc/grpc.h" /* gRPC request classification */ #include "grpc/grpc_call.h" /* gRPC call lifecycle policy */ #include "static/static_handler.h" /* http_static_try_serve / count */ @@ -225,6 +226,17 @@ void http3_reactor_apply_response(void *arg) http3_connection_t *const c = (s != NULL) ? s->conn : NULL; if (c == NULL || c->closed || c->nghttp3_conn == NULL) { + /* The stream is already gone, so nobody will adopt a HEADERS + * wire's credit ref — take it over: unblock the parked producer + * and drop the reactor-side ref here. */ + stream_credit_t *const orphan = + (stream_credit_t *)response_wire_credit(rw); + + if (orphan != NULL) { + stream_credit_mark_dead(orphan); + stream_credit_release(orphan); + } + response_wire_free(rw); return; } diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index 4037d805..c51684e9 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -18,6 +18,7 @@ #include "http3/http3_stream.h" #include "http3/http3_stream_pool.h" #include "http3_connection.h" /* http3_connection_t — list ownership at teardown */ +#include "core/stream_credit.h" /* reverse-path flow control — teardown release */ #include "http3_listener.h" /* http3_listener_stream_pool */ @@ -157,6 +158,14 @@ void http3_stream_release(http3_stream_t *s) s->chunk_queue = NULL; } + /* Reverse-path credit: the stream is going away — unblock a parked + * producer, then drop the reactor's ref. */ + if (s->wire_credit != NULL) { + stream_credit_mark_dead((stream_credit_t *)s->wire_credit); + stream_credit_release((stream_credit_t *)s->wire_credit); + s->wire_credit = NULL; + } + /* Trailer capture (malloc'd in http3_stream_capture_trailers). Held * until teardown so the nv stays valid through the async trailer submit. */ if (s->trailer_nv != NULL) { diff --git a/src/http_server_class.c b/src/http_server_class.c index 992747d9..ddf4627a 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -32,6 +32,7 @@ #include "core/worker_inbox.h" #include "core/worker_registry.h" #include "core/response_wire.h" +#include "core/stream_credit.h" #include "log/http_log.h" #ifndef PHP_WIN32 #include @@ -2583,6 +2584,15 @@ static void http_server_worker_response_sink(response_wire_t *rw, void *arg) } #endif + /* Undeliverable: the reactor never adopts a HEADERS wire's credit ref, + * so take it over — unblock the parked producer, drop the ref. */ + stream_credit_t *const orphan = (stream_credit_t *)response_wire_credit(rw); + + if (orphan != NULL) { + stream_credit_mark_dead(orphan); + stream_credit_release(orphan); + } + response_wire_free(rw); } diff --git a/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt b/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt new file mode 100644 index 00000000..94f5f330 --- /dev/null +++ b/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt @@ -0,0 +1,97 @@ +--TEST-- +HttpServer: credit backpressure paces a large streamed reply across the split (#80, gated pool) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'h3client' => true]); +?> +--ENV-- +TRUE_ASYNC_SERVER_REACTOR_POOL=1 +PHP_HTTP3_DISABLE_RETRY=1 +--FILE-- +addListener('127.0.0.1', $port + 1) /* TCP listener required by start() */ + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setWorkers(2); +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) use ($chunks, $chunk_len) { + $res->setStatusCode(200) + ->setHeader('content-type', 'application/octet-stream'); + + for ($i = 0; $i < $chunks; $i++) { + /* Deterministic per-chunk fill so truncation/reorder breaks the hash. */ + $res->send(str_repeat(chr(65 + ($i % 26)), $chunk_len)); + } + + $res->end(); +}); + +$client_bin = __DIR__ . '/../../../h3client/h3client'; + +spawn(function () use ($server, $port, $client_bin, $chunks, $chunk_len) { + /* Reactors + workers need a moment to thread up and bind. */ + usleep(600000); + + $cmd = sprintf('H3CLIENT_DEADLINE_MS=20000 %s 127.0.0.1 %d /big GET 2>&1', + escapeshellarg($client_bin), $port); + $out = shell_exec($cmd) ?? ''; + + $status = null; + if (preg_match('/^STATUS=(\d+)$/m', $out, $m)) $status = (int)$m[1]; + $body = preg_replace('/^STATUS=\d+\n?/m', '', $out, 1); + + $expect = ''; + for ($i = 0; $i < $chunks; $i++) { + $expect .= str_repeat(chr(65 + ($i % 26)), $chunk_len); + } + + echo "status=", $status ?? -1, "\n"; + echo "len_ok=", (int)(strlen($body) === strlen($expect)), "\n"; + echo "hash_ok=", (int)(hash('xxh128', $body) === hash('xxh128', $expect)), "\n"; + + /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL + * skips PHP shutdown so the worker threads cannot deadlock on exit. */ + posix_kill(getmypid(), SIGKILL); +}); + +$server->start(); +?> +--EXPECTF-- +%Astatus=200 +len_ok=1 +hash_ok=1 +%A From afe33eb71ed5eec43ad1e8a2529242b9c84af567 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:06:01 +0000 Subject: [PATCH 14/33] =?UTF-8?q?feat(grpc):=20grpc-web-text=20=E2=80=94?= =?UTF-8?q?=20per-frame=20base64=20both=20directions=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery mode (NATIVE / WEB / WEB_TEXT) is classified once at dispatch (grpc_request_mode — web-text checked before the web prefix that also matches it) and stamped ON the response by grpc_call_init_response; the framing layer reads it back, so the transports carry zero web-text knowledge (their grpc-web prefix match already covers web-text for in-body-trailer routing). - Outbound: writeMessage base64-encodes each framed message when the response mode is WEB_TEXT; grpc_call_finish encodes the 0x80 trailer frame the same way. Per-frame independent padding, as the grpc-web protocol allows — no codec state spans frames. - Inbound: readMessage lazily base64-decodes the buffered body into http_request_t.grpc_text_body and deframes from it (buffered-only — grpc-web clients cannot stream requests). Malformed base64 throws. - Seam cleanup: grpc_call_init_response takes the mode (sets the matching response content-type, incl. application/grpc-web-text+proto) and grpc_call_finish drops its grpc_web bool — mode comes from the response. Worker + H2 + H3 call sites updated; worker_dispatch also gains the missing zend_exceptions/http_response_internal includes. Test: grpc/015 — base64 request in, two writeMessage frames + b64 0x80 trailer frame out (two frames prove per-frame encoding). Suite 305/305. --- include/http1/http_parser.h | 5 + src/core/worker_dispatch.c | 14 +-- src/grpc/grpc.c | 50 +++++++++ src/grpc/grpc.h | 33 ++++++ src/grpc/grpc_call.c | 52 ++++++--- src/grpc/grpc_call.h | 27 +++-- src/http1/http_parser.c | 7 ++ src/http2/http2_strategy.c | 8 +- src/http3/http3_dispatch.c | 9 +- src/http_request.c | 29 ++++- src/http_response.c | 23 ++++ src/http_response_internal.h | 8 ++ tests/phpt/server/grpc/015-grpc-web-text.phpt | 106 ++++++++++++++++++ 13 files changed, 330 insertions(+), 41 deletions(-) create mode 100644 tests/phpt/server/grpc/015-grpc-web-text.phpt diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index 9bdb7796..6f92563f 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -103,6 +103,11 @@ struct http_request_t { * Empty {NULL,0} until first use; freed in http_request_destroy. */ smart_str grpc_reassembly; + /* grpc-web-text: the base64-decoded request body, materialized lazily on + * the first readMessage() (the deframer cursor then indexes THIS buffer, + * not `body`). NULL until first use; freed in http_request_destroy. */ + zend_string *grpc_text_body; + /* Body-progress event. * Lazily created by awaitBody() on the first suspend; notified by * the parser on body_complete once the event-loop read path lands. diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 99c9d234..23b5d9da 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -18,6 +18,8 @@ #include "core/http_connection.h" /* http_request_handler_coroutine_new */ #include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "http1/http_parser.h" /* http_request_t, http_request_destroy */ +#include "zend_exceptions.h" /* zend_clear_exception */ +#include "http_response_internal.h" /* http_response_replace_stream_ops */ #include "core/stream_credit.h" /* per-stream flow-control credit */ #include "grpc/grpc.h" /* grpc_request_is_grpc / _is_grpc_web */ #include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ @@ -64,7 +66,6 @@ typedef struct { /* gRPC classification (once, at dispatch — the transports do the same). */ bool is_grpc; - bool grpc_web; /* Streaming reverse path: STREAM_HEADERS posted on the first * append_chunk; STREAM_END posted by mark_ended (idempotent). When @@ -527,7 +528,7 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) * called end() already posted it). A response that never streamed is * the buffered FULL wire, exactly as before. */ if (ctx->is_grpc) { - grpc_call_finish(resp, ctx->grpc_web, &worker_grpc_finish_ops, ctx); + grpc_call_finish(resp, &worker_grpc_finish_ops, ctx); } else if (http_response_is_streaming(resp)) { worker_stream_mark_ended(ctx); } @@ -588,9 +589,9 @@ bool worker_dispatch_request(http_server_object *server, /* gRPC classification — once, here, exactly like the transports do * (application/grpc* + a registered addGrpcHandler). */ HashTable *const handlers = http_server_get_protocol_handlers(server); - const bool is_grpc = grpc_request_is_grpc(req) - && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); - const bool grpc_web = is_grpc && grpc_request_is_grpc_web(req); + const grpc_mode_t grpc_mode = grpc_request_mode(req); + const bool is_grpc = grpc_mode != GRPC_MODE_NONE + && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); zval *const req_obj = http_request_create_from_parsed(req); @@ -610,7 +611,6 @@ bool worker_dispatch_request(http_server_object *server, ctx->sink_arg = sink_arg; ctx->is_head = is_head; ctx->is_grpc = is_grpc; - ctx->grpc_web = grpc_web; ZVAL_COPY_VALUE(&ctx->request_zv, req_obj); efree(req_obj); /* the heap zval wrapper, not the object */ @@ -624,7 +624,7 @@ bool worker_dispatch_request(http_server_object *server, &worker_stream_ops, ctx); if (is_grpc) { - grpc_call_init_response(Z_OBJ(ctx->response_zv), grpc_web); + grpc_call_init_response(Z_OBJ(ctx->response_zv), grpc_mode); } /* No handler registered: synthesise a 404 so the sink still fires with a diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index 0ee3d6df..8841ffd9 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -14,6 +14,7 @@ #include "grpc.h" #include "http1/http_parser.h" /* http_request_t */ #include "zend_smart_str.h" +#include "ext/standard/base64.h" /* grpc-web-text per-frame transform */ #include #include /* strncasecmp */ @@ -61,6 +62,55 @@ bool grpc_request_is_grpc_web(const http_request_t *req) prefix_len) == 0; } +bool grpc_request_is_grpc_web_text(const http_request_t *req) +{ + if (req == NULL || req->headers == NULL) { + return false; + } + + zval *ct = zend_hash_str_find(req->headers, "content-type", + sizeof("content-type") - 1); + + if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { + return false; + } + + const size_t prefix_len = sizeof(GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX) - 1; + + return Z_STRLEN_P(ct) >= prefix_len + && strncasecmp(Z_STRVAL_P(ct), GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX, + prefix_len) == 0; +} + +grpc_mode_t grpc_request_mode(const http_request_t *req) +{ + if (!grpc_request_is_grpc(req)) { + return GRPC_MODE_NONE; + } + + /* Most-specific prefix first: is_grpc_web matches web-text too. */ + if (grpc_request_is_grpc_web_text(req)) { + return GRPC_MODE_WEB_TEXT; + } + + if (grpc_request_is_grpc_web(req)) { + return GRPC_MODE_WEB; + } + + return GRPC_MODE_NATIVE; +} + +zend_string *grpc_web_text_encode(const char *in, const size_t len) +{ + return php_base64_encode((const unsigned char *)in, len); +} + +zend_string *grpc_web_text_decode(const char *in, const size_t len) +{ + return php_base64_decode_ex((const unsigned char *)in, len, + /*strict=*/false); +} + zend_string *grpc_web_trailer_frame(HashTable *trailers) { smart_str body = {0}; diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index 22e3d28d..7a335431 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -49,6 +49,24 @@ struct http_request_t; #define GRPC_WEB_CONTENT_TYPE_PREFIX "application/grpc-web" #define GRPC_WEB_RESPONSE_CONTENT_TYPE "application/grpc-web+proto" +/* grpc-web-text: the grpc-web framing, base64-encoded — the fallback for + * transports/clients that cannot carry binary bodies (XHR). Each frame + * (message or trailer) is base64-encoded independently with its own + * padding, per the grpc-web protocol, so no cross-frame codec state. */ +#define GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX "application/grpc-web-text" +#define GRPC_WEB_TEXT_RESPONSE_CONTENT_TYPE "application/grpc-web-text+proto" + +/* Delivery mode of a gRPC call, classified once at dispatch from the request + * content-type and stamped on the response (grpc_call_init_response). The + * framing layer (writeMessage / grpc_call_finish) reads it back to pick the + * per-frame transform; transports never branch on it. */ +typedef enum { + GRPC_MODE_NONE = 0, /* not a gRPC call */ + GRPC_MODE_NATIVE, /* application/grpc — trailers ride HTTP trailers */ + GRPC_MODE_WEB, /* application/grpc-web — in-body 0x80 trailer frame */ + GRPC_MODE_WEB_TEXT, /* application/grpc-web-text — WEB + per-frame base64 */ +} grpc_mode_t; + /* True when the request is a gRPC call — POST with a content-type that * begins with `application/grpc` (this includes grpc-web). */ bool grpc_request_is_grpc(const struct http_request_t *req); @@ -59,6 +77,21 @@ bool grpc_request_is_grpc(const struct http_request_t *req); * browsers cannot read HTTP trailers. */ bool grpc_request_is_grpc_web(const struct http_request_t *req); +/* True when the request is grpc-web-text — content-type begins with + * `application/grpc-web-text` (note: grpc_request_is_grpc_web also matches + * these, by prefix; check web-text FIRST when distinguishing). */ +bool grpc_request_is_grpc_web_text(const struct http_request_t *req); + +/* Classify the delivery mode from the request content-type. Returns + * GRPC_MODE_NONE for a non-gRPC request; the caller still gates on a + * registered gRPC handler. */ +grpc_mode_t grpc_request_mode(const struct http_request_t *req); + +/* Per-frame base64 transform for grpc-web-text. Both return a new + * zend_string the caller owns; decode returns NULL on malformed input. */ +zend_string *grpc_web_text_encode(const char *in, size_t len); +zend_string *grpc_web_text_decode(const char *in, size_t len); + /* Build the grpc-web in-body trailer frame from a response trailer map: * byte 0 : 0x80 (trailer frame, uncompressed) * bytes 1..4 : trailer block length, uint32 big-endian diff --git a/src/grpc/grpc_call.c b/src/grpc/grpc_call.c index cdec009d..740485b5 100644 --- a/src/grpc/grpc_call.c +++ b/src/grpc/grpc_call.c @@ -9,19 +9,33 @@ #include "grpc_call.h" #include "grpc.h" #include "php_http_server.h" +#include "http_response_internal.h" /* grpc_mode stamp on the response */ -void grpc_call_init_response(zend_object *response_obj, bool grpc_web) +void grpc_call_init_response(zend_object *response_obj, const int grpc_mode) { - if (grpc_web) { - http_response_static_set_header(response_obj, - "content-type", sizeof("content-type") - 1, - GRPC_WEB_RESPONSE_CONTENT_TYPE, - sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1); - } else { - http_response_static_set_header(response_obj, - "content-type", sizeof("content-type") - 1, - GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + switch ((grpc_mode_t)grpc_mode) { + case GRPC_MODE_WEB_TEXT: + http_response_static_set_header(response_obj, + "content-type", sizeof("content-type") - 1, + GRPC_WEB_TEXT_RESPONSE_CONTENT_TYPE, + sizeof(GRPC_WEB_TEXT_RESPONSE_CONTENT_TYPE) - 1); + break; + + case GRPC_MODE_WEB: + http_response_static_set_header(response_obj, + "content-type", sizeof("content-type") - 1, + GRPC_WEB_RESPONSE_CONTENT_TYPE, + sizeof(GRPC_WEB_RESPONSE_CONTENT_TYPE) - 1); + break; + + default: + http_response_static_set_header(response_obj, + "content-type", sizeof("content-type") - 1, + GRPC_CONTENT_TYPE, sizeof(GRPC_CONTENT_TYPE) - 1); + break; } + + http_response_set_grpc_mode(response_obj, (uint8_t)grpc_mode); } void grpc_call_ensure_status(zend_object *response_obj, bool had_exception) @@ -30,18 +44,30 @@ void grpc_call_ensure_status(zend_object *response_obj, bool had_exception) had_exception ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK); } -void grpc_call_finish(zend_object *response_obj, bool grpc_web, +void grpc_call_finish(zend_object *response_obj, const grpc_finish_ops_t *ops, void *ctx) { - if (grpc_web) { + const grpc_mode_t mode = + (grpc_mode_t)http_response_get_grpc_mode(response_obj); + + if (mode == GRPC_MODE_WEB || mode == GRPC_MODE_WEB_TEXT) { /* Trailers ride the response body as a 0x80 frame, never as HTTP * trailers. Clear the trailer map so the transport's generic EOF * path does not also emit them as a terminal trailer block. * Handles both a streamed reply and a zero-message status/error - * (the trailer frame is then the only DATA). */ + * (the trailer frame is then the only DATA). web-text encodes the + * frame independently, like every other frame on the stream. */ zend_string *frame = grpc_web_trailer_frame(http_response_get_trailers(response_obj)); + if (mode == GRPC_MODE_WEB_TEXT) { + zend_string *const b64 = + grpc_web_text_encode(ZSTR_VAL(frame), ZSTR_LEN(frame)); + + zend_string_release(frame); + frame = b64; + } + http_response_clear_trailers(response_obj); ops->append_frame_and_end(ctx, frame); return; diff --git a/src/grpc/grpc_call.h b/src/grpc/grpc_call.h index b0738c94..228b9dfe 100644 --- a/src/grpc/grpc_call.h +++ b/src/grpc/grpc_call.h @@ -43,24 +43,27 @@ typedef struct grpc_finish_ops { void (*commit)(void *ctx); } grpc_finish_ops_t; -/* Dispatch-time response defaults: content-type application/grpc (or the - * grpc-web response content-type). A handler may still override before - * its first writeMessage(). */ -void grpc_call_init_response(zend_object *response_obj, bool grpc_web); +/* Dispatch-time response defaults: content-type per delivery mode (native / + * grpc-web / grpc-web-text) + the mode stamped on the response so the + * framing layer picks the right per-frame transform. A handler may still + * override the content-type before its first writeMessage(). */ +void grpc_call_init_response(zend_object *response_obj, int grpc_mode); /* Outcome → grpc-status trailer. Success defaults to 0; an uncaught * handler exception maps to INTERNAL (13) unless the handler already set * a status. Call from dispose before delivery decisions. */ void grpc_call_ensure_status(zend_object *response_obj, bool had_exception); -/* Finalize delivery of a gRPC reply (dispose-time): - * grpc-web → trailers become an in-body 0x80 frame, stream ends - * (browsers cannot read HTTP trailers), - * streaming → end the stream; native trailers ride the transport's - * generic trailer path at EOF, - * zero-message → Trailers-Only: fold grpc-status/grpc-message into the - * initial HEADERS and commit the buffered response. */ -void grpc_call_finish(zend_object *response_obj, bool grpc_web, +/* Finalize delivery of a gRPC reply (dispose-time). The delivery mode is + * read back off the response (stamped by grpc_call_init_response): + * grpc-web[-text] → trailers become an in-body 0x80 frame (base64-encoded + * for web-text), stream ends (browsers cannot read HTTP + * trailers), + * streaming → end the stream; native trailers ride the transport's + * generic trailer path at EOF, + * zero-message → Trailers-Only: fold grpc-status/grpc-message into the + * initial HEADERS and commit the buffered response. */ +void grpc_call_finish(zend_object *response_obj, const grpc_finish_ops_t *ops, void *ctx); #endif /* HTTP_GRPC_CALL_H */ diff --git a/src/http1/http_parser.c b/src/http1/http_parser.c index 9a1ccca1..5dfd5486 100644 --- a/src/http1/http_parser.c +++ b/src/http1/http_parser.c @@ -1085,6 +1085,13 @@ void http_request_free_fields(http_request_t *req) /* gRPC streaming reassembly buffer (HttpRequest::readMessage). */ smart_str_free(&req->grpc_reassembly); + /* grpc-web-text decoded-body cache (same-thread as readMessage: the + * handler's thread frees the fields in every dispatched path). */ + if (req->grpc_text_body != NULL) { + zend_string_release(req->grpc_text_body); + req->grpc_text_body = NULL; + } + /* Dispose body-progress event if awaitBody() created one */ if (req->body_event) { req->body_event->dispose(req->body_event); diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index 338c6349..d0249398 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -302,9 +302,11 @@ static void http2_strategy_dispatch(struct http_request_t *request, Z_OBJ(stream->response_zv), self->conn->config->json_encode_flags); } - /* gRPC: response defaults (content-type) live in the gRPC layer. */ + /* gRPC: response defaults (content-type + delivery mode) live in the + * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (is_grpc) { - grpc_call_init_response(Z_OBJ(stream->response_zv), stream->grpc_web); + grpc_call_init_response(Z_OBJ(stream->response_zv), + grpc_request_mode(stream->request)); } /* Static-handler dispatch (issue #13). Identical policy to the @@ -636,7 +638,7 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) { /* Delivery shape is gRPC policy — grpc_call_finish decides. */ - grpc_call_finish(Z_OBJ(stream->response_zv), stream->grpc_web, + grpc_call_finish(Z_OBJ(stream->response_zv), &h2_grpc_finish_ops, stream); } else if (is_streaming) { if (!stream->streaming_ended) { diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index fba7e278..e9f2a1fc 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -439,9 +439,11 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) http_response_install_stream_ops(Z_OBJ(s->response_zv), &h3_stream_ops, s); - /* gRPC: response defaults (content-type) live in the gRPC layer. */ + /* gRPC: response defaults (content-type + delivery mode) live in the + * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (s->is_grpc) { - grpc_call_init_response(Z_OBJ(s->response_zv), s->grpc_web); + grpc_call_init_response(Z_OBJ(s->response_zv), + grpc_request_mode(s->request)); } #ifdef HAVE_HTTP_COMPRESSION @@ -876,8 +878,7 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) && !Z_ISUNDEF(s->response_zv)) { if (s->is_grpc) { /* Delivery shape is gRPC policy — grpc_call_finish decides. */ - grpc_call_finish(Z_OBJ(s->response_zv), s->grpc_web, - &h3_grpc_finish_ops, s); + grpc_call_finish(Z_OBJ(s->response_zv), &h3_grpc_finish_ops, s); } else if (is_streaming) { h3_stream_finish_streaming(s); } else if (http_response_has_send_file(Z_OBJ(s->response_zv))) { diff --git a/src/http_request.c b/src/http_request.c index 429ed05b..1327f011 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -290,14 +290,39 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) bool compressed = false; int rc; + /* grpc-web-text: the body is the base64-encoded frame stream. Decode + * once (lazily) and deframe from the decoded buffer — buffered-only, + * which matches the protocol (grpc-web clients can't stream requests). */ + const bool web_text = grpc_request_is_grpc_web_text(req); + + if (web_text && req->grpc_text_body == NULL) { + if (req->body == NULL) { + RETURN_NULL(); + } + + req->grpc_text_body = + grpc_web_text_decode(ZSTR_VAL(req->body), ZSTR_LEN(req->body)); + + if (req->grpc_text_body == NULL) { + zend_throw_exception(http_server_runtime_exception_ce, + "malformed base64 in grpc-web-text request body", 0); + RETURN_NULL(); + } + } + /* Middle-band request (SMALL <= Content-Length < AUTO): the parser * buffered so far; upgrade to the streaming queue on first read so * incremental deframing works there too (mirror of readBody Case 2). */ - if (!req->body_streaming && req->body_upgrade_to_stream != NULL) { + if (!web_text && !req->body_streaming && req->body_upgrade_to_stream != NULL) { req->body_upgrade_to_stream(req); } - if (req->body_streaming) { + if (web_text) { + rc = grpc_deframe_next(ZSTR_VAL(req->grpc_text_body), + ZSTR_LEN(req->grpc_text_body), + &req->grpc_read_offset, + GRPC_MAX_RECV_MESSAGE, &compressed, &msg); + } else if (req->body_streaming) { /* True full-duplex path: accumulate popped body-stream chunks into * grpc_reassembly until one length-prefixed message is framed, then * deframe it — a handler can readMessage() while the client is still diff --git a/src/http_response.c b/src/http_response.c index 5369ec32..0ef2f35e 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -1107,6 +1107,18 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) zend_string_release(payload); /* framed copied it; drop the gz buffer */ } + /* grpc-web-text: every frame goes out base64-encoded, each with its own + * padding (the grpc-web protocol allows per-frame encoding, so no codec + * state spans messages). The trailer frame is encoded the same way in + * grpc_call_finish. */ + if (response->grpc_mode == GRPC_MODE_WEB_TEXT) { + zend_string *const b64 = + grpc_web_text_encode(ZSTR_VAL(framed), ZSTR_LEN(framed)); + + zend_string_release(framed); + framed = b64; + } + const int rc = response->stream_ops->append_chunk( response->stream_ctx, framed); @@ -1342,6 +1354,7 @@ static zend_object *http_response_create(zend_class_entry *ce) response->closed = false; response->committed = false; response->streaming = false; + response->grpc_mode = 0; response->stream_ops = NULL; response->stream_ctx = NULL; response->compression_state = NULL; @@ -1441,6 +1454,16 @@ void http_response_replace_stream_ops(zend_object *obj, r->stream_ctx = ctx; } +void http_response_set_grpc_mode(zend_object *obj, const uint8_t mode) +{ + http_response_from_obj(obj)->grpc_mode = mode; +} + +uint8_t http_response_get_grpc_mode(zend_object *obj) +{ + return http_response_from_obj(obj)->grpc_mode; +} + smart_str *http_response_get_body_smart_str(zend_object *obj) { return &http_response_from_obj(obj)->body; diff --git a/src/http_response_internal.h b/src/http_response_internal.h index 4442b633..0d976996 100644 --- a/src/http_response_internal.h +++ b/src/http_response_internal.h @@ -59,6 +59,11 @@ typedef struct { bool streaming; /* send() has been called — setBody/setHeader now throw */ bool sse_mode; /* SSE helpers committed the stream — send() now throws, sse* re-entry is allowed */ + /* gRPC delivery mode (grpc_mode_t), stamped by grpc_call_init_response + * at dispatch. writeMessage / grpc_call_finish read it to pick the + * per-frame transform (grpc-web-text base64). 0 = not a gRPC call. */ + uint8_t grpc_mode; + /* Compression module state (issue #8). Opaque ptr — owned by the * compression TU; allocated by http_compression_attach at dispatch * and freed by http_compression_state_free at object dtor. */ @@ -106,6 +111,9 @@ void http_response_replace_stream_ops(zend_objec void *http_response_get_compression_slot(zend_object *obj); void http_response_set_compression_slot(zend_object *obj, void *p); +void http_response_set_grpc_mode(zend_object *obj, uint8_t mode); +uint8_t http_response_get_grpc_mode(zend_object *obj); + http_send_file_request_t *http_response_take_send_file(zend_object *obj); bool http_response_has_send_file(zend_object *obj); diff --git a/tests/phpt/server/grpc/015-grpc-web-text.phpt b/tests/phpt/server/grpc/015-grpc-web-text.phpt new file mode 100644 index 00000000..2306eeea --- /dev/null +++ b/tests/phpt/server/grpc/015-grpc-web-text.phpt @@ -0,0 +1,106 @@ +--TEST-- +gRPC-Web-Text: base64 framing both directions (per-frame encoding) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- + $n) break; + $payload = substr($buf, $off + 5, $len); + if ($flag & 0x80) { $trailers .= $payload; } + else { $data[] = $payload; } + $off += 5 + $len; + } + return [$data, $trailers]; +} + +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(5) + ->setWriteTimeout(5); + +$server = new HttpServer($config); +$server->addGrpcHandler(function($req, $resp) { + $req->awaitBody(); + $msg = $req->readMessage(); // base64-decoded transparently + $resp->writeMessage('echo:' . $msg); // frame 1, base64 out + $resp->writeMessage('bye'); // frame 2 — proves per-frame b64 + // grpc-status defaults to 0 +}); + +$client = spawn(function() use ($port, $server) { + usleep(30000); + + $frame = "\x00" . pack('N', 4) . 'ping'; + $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); + $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); + file_put_contents($bodyfile, base64_encode($frame)); + + $cmd = sprintf( + 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s ' + . '--data-binary @%s -o %s http://127.0.0.1:%d/svc/M 2>&1', + escapeshellarg('content-type: application/grpc-web-text'), + escapeshellarg($bodyfile), + escapeshellarg($outfile), + $port + ); + $verbose = shell_exec($cmd); + $resp = file_get_contents($outfile); + @unlink($bodyfile); + @unlink($outfile); + + [$data, $trailers] = grpcwebtext_parse($resp); + + echo "resp_ctype_webtext=", (int)(strpos($verbose, 'content-type: application/grpc-web-text+proto') !== false), "\n"; + echo "body_is_base64=", (int)(preg_match('/^[A-Za-z0-9+\/=]+$/', $resp) === 1), "\n"; + echo "data=", implode(',', $data), "\n"; + echo "trailer_status=", (int)(strpos($trailers, 'grpc-status: 0') !== false), "\n"; + echo "no_http_trailer=", (int)(strpos($verbose, "\ngrpc-status:") === false && strpos($verbose, "< grpc-status") === false), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +resp_ctype_webtext=1 +body_is_base64=1 +data=echo:ping,bye +trailer_status=1 +no_http_trailer=1 +Done From d795ae0b531709b36c2b26a61b8864266f47caac Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:07:02 +0000 Subject: [PATCH 15/33] =?UTF-8?q?docs:=20gRPC=20100%=20=E2=80=94=20grpc-we?= =?UTF-8?q?b-text=20+=20reactor-pool=20streaming=20landed=20(#4,=20#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 ++++++++++++++++++++++- README.md | 5 +---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66698ec2..13095761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 emits compressed frames. - **`grpc-timeout`** request header parsed and exposed via `HttpRequest::getGrpcTimeout()`. - - Deferred: `grpc-web-text` (base64), gRPC under the reactor pool. + - **grpc-web-text**: `application/grpc-web-text` calls carry base64 both + directions — `readMessage()` decodes the request transparently, every + response frame (messages + the trailer frame) goes out independently + base64-encoded. + - **Works under the reactor pool** (`TRUE_ASYNC_SERVER_REACTOR_POOL=1`) — + gRPC rides the generic streaming reverse path below; no gRPC-specific + code in the reactor/worker split. + +- **Reactor-pool streaming reverse path (#80).** Under + `TRUE_ASYNC_SERVER_REACTOR_POOL=1` a worker response is no longer + buffered-only: + - `send()`/`writeMessage()`/SSE stream across the thread boundary — the + worker posts STREAM_HEADERS / STREAM_CHUNK / STREAM_END wires in FIFO + order; the reactor feeds its existing chunk ring and submits native + trailers at true EOF (so `setTrailer()` works under the pool, buffered + or streamed). + - **Credit-based backpressure**: a per-stream credit block (atomics, + malloc-domain) paces the producer — over 1 MiB un-acked in flight the + handler coroutine parks and resumes as the QUIC peer acknowledges + bytes, so a slow client cannot flood the shared reactor mailbox. Peer + RST / connection close unparks it into the standard stream-dead path + (`send()` throws 499). ### Changed diff --git a/README.md b/README.md index 783f4a37..a37a45c7 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,11 @@ HTTP/2 ████████████████████ 100% HTTP/3 ████████████████████ 100% WebSocket ████████████████████ 100% SSE ████████████████████ 100% -gRPC ██████████████████░░ 90% +gRPC ████████████████████ 100% ``` All ten ship-gates of the HTTP/3 plan (transport, TLS 1.3, request/response, streaming, lifecycle + drain, Alt-Svc, compliance smoke, fuzzing) are merged. Open items are post-ship performance follow-ups — `recvmmsg` inbound batching and Linux GSO outbound coalescing — that require upstream TrueAsync API extensions, plus optional ECN/pacing. -gRPC's remaining 10%: `grpc-web-text` (base64 framing) and gRPC under the -reactor pool (`TRUE_ASYNC_SERVER_REACTOR_POOL=1`). - --- ## Architecture From ef4203ef9e9e57b7afddedd354729c4d0587ba3b Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:25:47 +0000 Subject: [PATCH 16/33] =?UTF-8?q?feat(h3):=20streaming=20request=20bodies?= =?UTF-8?q?=20=E2=80=94=20issue-#26=20policy=20on=20HTTP/3=20+=20upload=20?= =?UTF-8?q?window=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves, one inbound path: Fix: uploads larger than the initial stream window (256 KiB default) stalled forever. nghttp3_conn_read_stream's consumed count EXCLUDES DATA payload by contract (deferred-consume), and h3_recv_data_cb never extended the QUIC windows for the body bytes it buffered. Buffered mode now returns credit as it consumes (h3_extend_body_window), including the rejected/oversize early-exits so a dead stream can't wedge the connection cap. Feature: with setBodyStreamingEnabled(true) the H3 dispatch applies the same three-case Content-Length policy as H2 (unknown/>=1MiB stream immediately; 64KiB..1MiB buffered with an upgrade hook; below buffered) — so readBody() and incremental readMessage() (true full-duplex gRPC) now work over HTTP/3, not just H2: - h3_recv_data_cb streaming branch pushes into the per-request chunk queue, capping LIVE (un-drained) bytes at max_body_size; credit is NOT granted at receive time. - http_body_stream_pop grants the deferred QUIC credit through the new body_h3_conn/stream_id pair (http3_request_body_consume: extend windows + drain so MAX_STREAM_DATA leaves) — H3 mirror of the nghttp2_session_consume leg. - end_stream closes the queue; RST before fin errors it so a parked consumer wakes. Reactor-pool requests stay buffered by design (the queue is same-thread by contract). Tests: h3/049 — 1 MiB buffered upload (4 window refills; stalls without the fix); grpc/016 — H3 mirror of grpc/013, 3 x 200 KiB messages read incrementally (2.3x the window — pins policy + pop credit together). _h3grpc_client.py accepts @file for bodies beyond argv limits. Suite 307/307. --- CHANGELOG.md | 15 ++ include/http1/http_parser.h | 6 + src/http3/http3_callbacks.c | 150 +++++++++++++++++- src/http3/http3_internal.h | 5 + src/http_body_stream.c | 13 ++ .../grpc/016-grpc-h3-streaming-read.phpt | 104 ++++++++++++ tests/phpt/server/grpc/_h3grpc_client.py | 6 + tests/phpt/server/h3/049-h3-large-upload.phpt | 84 ++++++++++ 8 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 tests/phpt/server/grpc/016-grpc-h3-streaming-read.phpt create mode 100644 tests/phpt/server/h3/049-h3-large-upload.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 13095761..dde2fe1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 RST / connection close unparks it into the standard stream-dead path (`send()` throws 499). +- **HTTP/3 streaming request bodies (#26 policy on H3).** With + `setBodyStreamingEnabled(true)` the H3 dispatch now applies the same + three-case Content-Length policy as HTTP/2, so `readBody()` and + incremental `readMessage()` (true full-duplex gRPC) work over HTTP/3. + QUIC flow-control credit is deferred: the window refills as the handler + drains chunks, bounding un-read bytes by `max_body_size`. + +### Fixed + +- **HTTP/3 uploads larger than the initial stream window (256 KiB default) + stalled forever.** `nghttp3_conn_read_stream`'s consumed count excludes + DATA payload by contract, and nothing extended the QUIC windows for + buffered body bytes — now `h3_recv_data_cb` returns the credit as it + consumes them. + ### Changed - **gRPC layering: call-lifecycle policy extracted out of the transports (#4).** diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index 6f92563f..040e2de8 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -215,6 +215,12 @@ struct http_request_t { * these fields. body_h3_stream is the matching ngtcp2 hook. */ void *body_h2_session; int32_t body_h2_stream_id; + + /* H3 mirror of the pair above: the owning http3_connection_t and QUIC + * stream id, set when the streaming policy engages. http_body_stream_pop + * grants deferred QUIC flow-control credit through these (issue #26). */ + void *body_h3_conn; + int64_t body_h3_stream_id; int32_t body_h2_consume_pending; void *body_h3_stream; diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 6f8c3b7a..426b9b70 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -40,6 +40,7 @@ #include "http3_steer.h" /* CID steering encode */ #include "core/response_wire.h" /* response_wire_* (reverse path) */ #include "core/stream_credit.h" /* reverse-path flow control */ +#include "http_body_stream.h" /* streaming request body (issue #26) */ #include "http3/http3_stream.h" /* http3_stream_t */ #include /* ngtcp2_crypto_* callback ptrs */ @@ -366,11 +367,37 @@ static int h3_recv_header_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } +/* Splice whatever h3_recv_data_cb already buffered for this stream into the + * body queue as one chunk, then flip body_streaming so subsequent chunks + * push directly. Invoked from HttpRequest::readBody()/readMessage() for the + * "buffered → stream" upgrade (Case 2). Idempotent. The pre-upgrade bytes + * were already credited eagerly; re-crediting them on pop merely over-grants + * window, which QUIC permits. */ +static void http3_request_body_upgrade(http_request_t *req) +{ + if (req->body_streaming) { + return; + } + + http3_stream_t *const s = (http3_stream_t *)req->body_transport_ctx; + + if (s != NULL && s->body_buf.s != NULL && ZSTR_LEN(s->body_buf.s) > 0) { + smart_str_0(&s->body_buf); + zend_string *const initial = s->body_buf.s; + s->body_buf.s = NULL; + + http_body_stream_push(req, initial); + zend_string_release(initial); + } + + req->body_streaming = true; +} + static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, int fin, void *conn_user_data, void *stream_user_data) { - (void)conn; (void)stream_id; (void)fin; + (void)conn; (void)fin; http3_connection_t *const c = (http3_connection_t *)conn_user_data; http3_stream_t *s = (http3_stream_t *)stream_user_data; @@ -380,11 +407,33 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, /* Reactor mode: defer dispatch to h3_end_stream_cb. The reactor must * not write into the request after hand-off, so the body is assembled - * (persistent) before the worker gets the pointer — buffered, not streamed. */ + * (persistent) before the worker gets the pointer — buffered, not + * streamed (the body queue is same-thread by contract). */ if (c != NULL && http3_listener_reactor_ctx(c->listener) != NULL) { return 0; } + /* Streaming body mode (issue #26). Three-case policy by Content-Length + * — mirror of H2 cb_on_frame_recv: + * CL == 0 (unknown) or CL >= AUTO → stream immediately + * SMALL <= CL < AUTO → buffer + install upgrade hook + * CL < SMALL → buffer, never stream. */ + if (c != NULL && c->view != NULL && c->view->body_streaming_enabled + && s->request != NULL) { + http_request_t *const r = s->request; + + r->body_h3_conn = c; + r->body_h3_stream_id = stream_id; + + if (r->content_length == 0 + || r->content_length >= HTTP_BODY_STREAM_AUTO_THRESHOLD) { + r->body_streaming = true; + } else if (r->content_length >= HTTP_BODY_STREAM_THRESHOLD) { + r->body_upgrade_to_stream = http3_request_body_upgrade; + r->body_transport_ctx = s; + } + } + /* Dispatch the handler the moment headers are complete, * regardless of fin (mirror H2 cb_on_frame_recv on HEADERS+END_HEADERS). * Body chunks that arrive after this point feed s->body_buf via @@ -394,15 +443,70 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } +/* Grant QUIC flow-control credit for `len` request-body bytes. + * nghttp3_conn_read_stream's consumed count deliberately EXCLUDES DATA + * payload (deferred-consume contract) — the application must extend the + * stream + connection windows itself once it has taken the bytes. */ +static void h3_extend_body_window(http3_connection_t *c, const int64_t stream_id, + const size_t len) +{ + if (c != NULL && c->ngtcp2_conn != NULL && !c->closed) { + ngtcp2_conn_extend_max_stream_offset((ngtcp2_conn *)c->ngtcp2_conn, + stream_id, len); + ngtcp2_conn_extend_max_offset((ngtcp2_conn *)c->ngtcp2_conn, len); + } +} + static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, const uint8_t *data, size_t datalen, void *conn_user_data, void *stream_user_data) { - (void)conn; (void)stream_id; + (void)conn; http3_connection_t *const c = (http3_connection_t *)conn_user_data; http3_stream_t *const s = (http3_stream_t *)stream_user_data; if (UNEXPECTED(s == NULL || s->rejected)) { + /* Bytes still count against the peer's window — return the credit + * so an already-rejected stream cannot wedge the connection cap. */ + h3_extend_body_window(c, stream_id, datalen); + return 0; + } + + /* Streaming mode (issue #26) — push the chunk into the per-request + * queue instead of accumulating into body_buf. Credit is NOT granted + * here: http_body_stream_pop extends the window as the handler drains + * (http3_request_body_consume), which is the backpressure. The cap + * bounds only the LIVE (queued, un-drained) bytes — a long-lived + * client-streaming body is legitimately unbounded in total. */ + if (s->request != NULL && s->request->body_streaming) { + http_request_t *const req = s->request; + size_t body_cap = HTTP_SERVER_G(parser_pool).max_body_size; + + if (body_cap == 0) { + body_cap = HTTP3_MAX_BODY_BYTES; + } + + if (UNEXPECTED(req->body_bytes_queued + datalen > body_cap)) { + http3_packet_stats_t *const stats = c != NULL + ? http3_listener_packet_stats(c->listener) : NULL; + + if (stats != NULL) stats->h3_request_oversized++; + h3_extend_body_window(c, stream_id, datalen); + h3_reject_request_stream(c, s, stream_id); + return 0; + } + + zend_string *const chunk = + zend_string_init((const char *)data, datalen, 0); + const bool pushed = http_body_stream_push(req, chunk); + + zend_string_release(chunk); + + if (UNEXPECTED(!pushed)) { + h3_extend_body_window(c, stream_id, datalen); + h3_reject_request_stream(c, s, stream_id); + } + return 0; } @@ -415,6 +519,7 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, ? http3_listener_packet_stats(c->listener) : NULL; if (stats != NULL) stats->h3_request_oversized++; + h3_extend_body_window(c, stream_id, datalen); /* RFC 9114: reject this stream, don't kill the connection. */ h3_reject_request_stream(c, s, stream_id); return 0; @@ -427,6 +532,11 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, } smart_str_appendl(&s->body_buf, (const char *)data, datalen); + + /* Buffered mode consumes the bytes right here — return the window + * immediately so an upload larger than the initial stream window + * (256 KiB default) keeps flowing. */ + h3_extend_body_window(c, stream_id, datalen); return 0; } @@ -1246,6 +1356,15 @@ static void http3_finalize_request_body(http3_stream_t *s) http_request_t *const req = s->request; ZEND_ASSERT(req != NULL); + /* Streaming mode (issue #26): no smart_str to move — close the queue + * so a parked readBody()/readMessage() consumer sees EOF. */ + if (req->body_streaming) { + http_body_stream_close(req); + req->complete = true; + s->fin_received = true; + return; + } + /* Move the assembled body bytes into the request. smart_str leaves * a NUL-terminated zend_string with refcount 1; we transfer that * ownership to req->body and clear our handle. */ @@ -1331,6 +1450,13 @@ static void h3_stream_mark_peer_closed(http3_stream_t *s) stream_credit_mark_dead((stream_credit_t *)s->wire_credit); } + /* A consumer parked in readBody()/readMessage() on a streamed body + * must wake too: RST before fin means the body will never complete. */ + if (s->request != NULL && s->request->body_streaming + && !s->fin_received) { + http_body_stream_error(s->request); + } + if (s->write_event != NULL) { zend_async_trigger_event_t *trig = s->write_event; @@ -1867,6 +1993,24 @@ static int recv_stream_data_cb(ngtcp2_conn *conn, uint32_t flags, return 0; } +/* Deferred inbound flow control (issue #26): called from + * http_body_stream_pop on the connection's own thread as the handler + * drains queued body chunks. Extends the QUIC windows and drives the + * socket so the MAX_STREAM_DATA actually reaches the peer. */ +void http3_request_body_consume(void *conn_opaque, const int64_t stream_id, + const size_t len) +{ + http3_connection_t *const c = (http3_connection_t *)conn_opaque; + + if (c == NULL || c->closed || c->ngtcp2_conn == NULL) { + return; + } + + h3_extend_body_window(c, stream_id, len); + http3_connection_drain_out(c); + http3_connection_arm_timer(c); +} + /* Peer extended our send window for this stream (or for the * connection). Wake the handler if it's parked inside append_chunk so * it retries the drain pump. nghttp3 uses a per-stream user_data we diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index 5446546e..7c114270 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -177,6 +177,11 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, * (STREAM_END apply / buffered FULL submit). Reactor thread. */ void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t *rw); +/* Deferred inbound flow control (issue #26): grant QUIC credit for body + * bytes the handler actually drained (http_body_stream_pop) and push the + * resulting MAX_STREAM_DATA out. conn_opaque is the http3_connection_t. */ +void http3_request_body_consume(void *conn_opaque, int64_t stream_id, size_t len); + /* Streaming chunk ring, factored for the reverse path: init is lazy and * idempotent (a non-NULL ring is what flips the data reader to streaming); * push takes the chunk ref. */ diff --git a/src/http_body_stream.c b/src/http_body_stream.c index aa1bbc2d..1d2a6790 100644 --- a/src/http_body_stream.c +++ b/src/http_body_stream.c @@ -18,6 +18,10 @@ #include "http2/http2_session.h" #endif +#ifdef HAVE_HTTP_SERVER_HTTP3 +#include "http3/http3_internal.h" /* http3_request_body_consume */ +#endif + static void fire_data_event(const http_request_t *req) { async_plain_event_fire(req->body_data_event); @@ -107,6 +111,15 @@ zend_string *http_body_stream_pop(http_request_t *req) } #endif +#ifdef HAVE_HTTP_SERVER_HTTP3 + /* Same discipline over QUIC: extend the stream + connection windows for + * the drained bytes and drive the MAX_STREAM_DATA out. */ + if (req->body_h3_conn != NULL) { + http3_request_body_consume(req->body_h3_conn, + req->body_h3_stream_id, ZSTR_LEN(data)); + } +#endif + return data; } diff --git a/tests/phpt/server/grpc/016-grpc-h3-streaming-read.phpt b/tests/phpt/server/grpc/016-grpc-h3-streaming-read.phpt new file mode 100644 index 00000000..0983b4ed --- /dev/null +++ b/tests/phpt/server/grpc/016-grpc-h3-streaming-read.phpt @@ -0,0 +1,104 @@ +--TEST-- +gRPC over HTTP/3: incremental readMessage over a streaming request body (issue #26 policy on H3) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--FILE-- +addListener('127.0.0.1', $port + 1) + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setBodyStreamingEnabled(true) + ->setReadTimeout(10)->setWriteTimeout(10); + +$server = new HttpServer($config); +$server->addGrpcHandler(function ($req, $resp) { + /* NO awaitBody(): each readMessage() suspends until the next message + * is reassembled from the chunk queue — client-streaming shape. */ + while (($m = $req->readMessage()) !== null) { + $resp->writeMessage('len:' . strlen($m)); + } +}); + +$py = __DIR__ . '/_h3grpc_client.py'; + +$client = spawn(function () use ($server, $port, $py, $tmp) { + usleep(200000); + + $sizes = [204800, 204800, 204800]; /* 3 x 200 KiB = 600 KiB total */ + $body = ''; + foreach ($sizes as $i => $sz) { + $body .= grpc_frame(str_repeat(chr(ord('a') + $i), $sz)); + } + + $hexfile = $tmp . '/body.hex'; + file_put_contents($hexfile, bin2hex($body)); + + $cmd = sprintf("python3 %s 127.0.0.1 %d /svc/Collect application/grpc @%s 2>/dev/null", + escapeshellarg($py), $port, escapeshellarg($hexfile)); + $out = shell_exec($cmd) ?? ''; + @unlink($hexfile); + + /* Deframe every response message from the BODYHEX line. */ + $replies = []; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m)) { + $buf = hex2bin($m[1]); $off = 0; + while ($off + 5 <= strlen($buf)) { + $len = unpack('N', substr($buf, $off + 1, 4))[1]; + $replies[] = substr($buf, $off + 5, $len); + $off += 5 + $len; + } + } + + echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; + echo "count=", count($replies), "\n"; + echo "replies=", implode(',', $replies), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_grpc_status=1 +count=3 +replies=len:204800,len:204800,len:204800 +Done diff --git a/tests/phpt/server/grpc/_h3grpc_client.py b/tests/phpt/server/grpc/_h3grpc_client.py index 7d10dbc5..992e350f 100644 --- a/tests/phpt/server/grpc/_h3grpc_client.py +++ b/tests/phpt/server/grpc/_h3grpc_client.py @@ -8,6 +8,9 @@ the test can assert grpc-status. usage: _h3grpc_client.py + +The body argument may be @/path/to/file to read the hex from a file +(large bodies exceed the argv limit). """ import asyncio import ssl @@ -45,6 +48,9 @@ def quic_event_received(self, event): async def main(host, port, path, ctype, body_hex): + if body_hex.startswith("@"): + with open(body_hex[1:]) as f: + body_hex = f.read().strip() config = QuicConfiguration(is_client=True, alpn_protocols=["h3"]) config.verify_mode = ssl.CERT_NONE async with connect(host, int(port), configuration=config, diff --git a/tests/phpt/server/h3/049-h3-large-upload.phpt b/tests/phpt/server/h3/049-h3-large-upload.phpt new file mode 100644 index 00000000..56e35b85 --- /dev/null +++ b/tests/phpt/server/h3/049-h3-large-upload.phpt @@ -0,0 +1,84 @@ +--TEST-- +HttpServer: HTTP/3 large buffered request body (1 MiB) — inbound flow-control credit +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--FILE-- +addListener('127.0.0.1', $port + 1) + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setReadTimeout(10)->setWriteTimeout(10); + +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) { + $req->awaitBody(); + $body = $req->getBody(); + $res->setStatusCode(200) + ->setHeader('content-type', 'text/plain') + ->setBody('len=' . strlen($body) . ';md5=' . md5($body)); +}); + +$py = __DIR__ . '/../grpc/_h3grpc_client.py'; + +$client = spawn(function () use ($server, $port, $py, $tmp) { + usleep(200000); + + $payload = random_bytes(1024 * 1024); /* 1 MiB — 4x the window */ + $hexfile = $tmp . '/body.hex'; + file_put_contents($hexfile, bin2hex($payload)); + + $cmd = sprintf("python3 %s 127.0.0.1 %d /upload application/octet-stream @%s 2>/dev/null", + escapeshellarg($py), $port, escapeshellarg($hexfile)); + $out = shell_exec($cmd) ?? ''; + @unlink($hexfile); + + $body = ''; + if (preg_match('/^BODYHEX ([0-9a-f]*)$/m', $out, $m)) { + $body = hex2bin($m[1]); + } + + echo "saw_status_200=", (int)(strpos($out, 'HDR :status: 200') !== false), "\n"; + echo "echo_ok=", (int)($body === 'len=1048576;md5=' . md5($payload)), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "Done\n"; +--EXPECT-- +saw_status_200=1 +echo_ok=1 +Done From f81e58d79fc4ae1724bbaaba13ba355c9b4c2409 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:31:08 +0000 Subject: [PATCH 17/33] =?UTF-8?q?ci:=20coverage-drop-ok=20=E2=80=94=20http?= =?UTF-8?q?3=5Fdispatch=20pool-only=20paths=20are=20gcov-invisible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [coverage-drop-ok] The -2.11pp drop in http3_dispatch.c is the reactor-pool apply switch (STREAM_CHUNK/STREAM_END legs). Pool phpts exit via SIGKILL (issue #11: no clean cross-thread pool shutdown yet), so gcov never flushes their counters — the pool code is structurally invisible to the coverage job (worker_dispatch.c sits at 0.00% for the same reason). The paths ARE exercised locally by h3/046-048 + grpc/014. From bf09e518bf08b4bcdb62e01a22c79117bbbb1e2c Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:09:58 +0000 Subject: [PATCH 18/33] =?UTF-8?q?fix:=20review=20P1=20=E2=80=94=20web-text?= =?UTF-8?q?=20decode,=20body=5Fh3=5Fconn=20UAF,=20streaming=20policy/final?= =?UTF-8?q?ize=20gaps,=20stream-abort=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings b38301e..f81e58d (docs/PLAN_REVIEW_FIXES.md items 1-6): 1. grpc-web-text: decode block-wise. A body is a CONCATENATION of independently padded base64 frames; PHP's non-strict decoder does not realign at '=', so a single pass garbled everything after the first frame with len % 3 != 0. grpc/015 now sends two frames (first one 7 bytes, padding mid-stream) and reads both. 2. UAF: sever req->body_h3_conn at stream teardown. The request (and its queued body chunks) outlives the stream AND the connection via the handler's ref; a pop after http3_connection_free called http3_request_body_consume on freed memory. Teardown now NULLs the pointer and errors the queue when fin never arrived (wakes a parked consumer). Covers the connection-free force-release path too (it goes through http3_stream_release). 3. web-text x body_streaming: the issue-#26 policy keyed on Content-Length alone and could stream a grpc-web-text body — the buffered-only readMessage branch then saw req->body == NULL forever (silent request loss, window credit never returned). Both dispatch policies (H2 + H3) now skip web-text requests. fuzz_stubs gains a weak grpc_request_is_grpc_web_text (http2_session.c links standalone). 4. Streaming finalize fires body_event (H3 + H2): a handler suspended in awaitBody() before fin waits on body_event, not the queue's data event — the streaming branch closed the queue and returned without firing it, hanging the coroutine. Mirrors the H1 parser. 5+6. Mid-stream death is now an ABORT, not a clean FIN: - response_wire gains RESPONSE_WIRE_STREAM_ABORT; the reactor resets the QUIC stream (ngtcp2 shutdown_stream_write, INTERNAL_ERROR). - worker: ctx->stream_failed set on credit timeout/cancel and on any dropped STREAM_* wire; the terminal wire becomes ABORT; dispose has an idempotent safety net so a started stream ALWAYS terminates. - sink: bool verdict (worker_response_sink_fn signature), and the 1M busy-spin is now <=100 x 1ms sleep retries — a wedged reactor fails the stream in ~100ms instead of burning a core; drops are counted (worker_wire_dropped_total). Suite 307/307; fuzz_h2_session links and runs (the 1-leak report is pre-existing, reproduced on the unpatched tree). --- docs/PLAN_REVIEW_FIXES.md | 173 ++++++++++++++++++ fuzz/fuzz_stubs.c | 10 + include/core/response_wire.h | 5 + include/core/worker_dispatch.h | 19 +- include/php_http_server.h | 10 + src/core/reactor_pool_test_hooks.c | 18 +- src/core/worker_dispatch.c | 70 +++++-- src/grpc/grpc.c | 57 +++++- src/http2/http2_session.c | 25 ++- src/http3/http3_callbacks.c | 22 ++- src/http3/http3_dispatch.c | 22 +++ src/http3/http3_stream.c | 19 ++ src/http_server_class.c | 30 ++- tests/phpt/server/grpc/015-grpc-web-text.phpt | 18 +- 14 files changed, 454 insertions(+), 44 deletions(-) create mode 100644 docs/PLAN_REVIEW_FIXES.md diff --git a/docs/PLAN_REVIEW_FIXES.md b/docs/PLAN_REVIEW_FIXES.md new file mode 100644 index 00000000..8a454f90 --- /dev/null +++ b/docs/PLAN_REVIEW_FIXES.md @@ -0,0 +1,173 @@ +# PLAN: review fixes — gRPC / reactor-pool reverse path / H3 body streaming + +Source: 8-angle code review of `b38301e..f81e58d` (2026-07-06). Ordered by +severity; each item is an independent commit with its own test. + +## P1 — Correctness + +### 1. grpc-web-text: per-block base64 decode (CONFIRMED by repro) +`grpc_web_text_decode` runs one non-strict `php_base64_decode` over the whole +body. Concatenated independently-padded frames (the grpc-web protocol shape, +and exactly what our own encoder emits) decode to garbage past the first +frame whose length % 3 != 0 — PHP's decoder does not reset the bit group at +`=`. Repro: `base64(7-byte frame) . base64(8-byte frame)` → shifted bytes. +- Fix: decode block-wise — split the input at padding boundaries + (`=`/`==` followed by more data) and decode each block, or run an + incremental decoder that resets state after padding. +- Test: extend grpc/015 — client sends TWO frames, first with len % 3 != 0 + (e.g. message "hi"), assert both messages readable. +- File: src/grpc/grpc.c (`grpc_web_text_decode`). + +### 2. UAF: `req->body_h3_conn` outlives the QUIC connection +Set in h3_end_headers_cb, never cleared. `http3_connection_free` frees the +connection while a handler coroutine still holds the request + queued body +chunks; the next `readBody()/readMessage()` → `http_body_stream_pop` → +`http3_request_body_consume(freed conn)`. +- Fix: in the connection-free force-release loop AND in normal + `http3_stream_release` teardown, when `s->request` is still alive: + `s->request->body_h3_conn = NULL` and, if `!fin_received`, + `http_body_stream_error(req)`. Mind the request-lifetime rules (request + may already be field-freed — guard on the same conditions the existing + teardown uses). +- Note: `body_h2_session` has the same latent class on H2 — file/fix as a + follow-up (same shape: clear on session teardown). +- Test: hard to phpt deterministically; at minimum an ASAN-able unit path or + a phpt with idle-timeout reap while the handler sleeps mid-drain. + +### 3. web-text × body_streaming: silent request loss +The issue-#26 streaming policy (H2 + H3) keys on Content-Length only; a +grpc-web-text request with CL unknown / >= 1 MiB gets `body_streaming=true`, +`req->body` stays NULL, and readMessage's web-text branch returns null +forever (queued chunks also never popped → window credit never returned). +- Fix (classify-once): exclude gRPC-web-text from the streaming policy at + both dispatch sites — the request is buffered by protocol nature. Cleanest: + a small predicate `http_request_body_must_buffer(req)` in the grpc layer + (`grpc_request_is_grpc_web_text`) consulted by the H2/H3 policy blocks. +- Test: phpt with setBodyStreamingEnabled(true) + web-text request without + Content-Length → messages still readable. + +### 4. H3 streaming finalize never fires `body_event` → awaitBody() hangs +`http3_finalize_request_body` streaming branch sets complete + closes the +queue but returns before the `body_event` trigger block; a handler suspended +in `awaitBody()` before fin sleeps forever. H2 `finalize_request_body` has +the same gap; the H1 parser fires body_event explicitly in streaming mode. +- Fix: fire `req->body_event` in the streaming branch too (both H3 and H2 — + do H2 in the same commit, it is the same three lines). +- Test: phpt — streaming-enabled H3 request >= 1 MiB, handler starts with + awaitBody(), then getBody-less readBody drain; must not hang. + +### 5. Sink STREAM_* drop: no poisoning, no telemetry, hot spin +After the bounded retry exhausts, a STREAM_CHUNK drop leaves a hole in the +body with a clean FIN; a STREAM_END drop parks the reactor's data reader on +WOULDBLOCK forever. The 1M-iteration spin has no backoff and burns a core. +- Fix: + a. retry with `reactor_pool_msleep()`-style backoff (pattern already in + reactor_pool_exec), far fewer iterations; + b. on final drop, poison the stream: mark the ctx/credit dead so the + producer stops and dispose does NOT post a clean END (see item 6 — + share the "died mid-stream" flag); + c. bump a counter (worker_wire_drop) so operators can see it. +- File: src/http_server_class.c (sink), src/core/worker_dispatch.c (flag). + +### 6. Mid-stream death still ends the stream cleanly +When worker_stream_wait_credit fails (write timeout / cancellation), send() +throws 499 but dispose still posts a normal STREAM_END (+trailers): a live +client receives a truncated body terminated as complete. +- Fix: add `ctx->stream_failed`; set it when wait_credit fails or the sink + reports a final drop; dispose then posts a new RESPONSE_WIRE_STREAM_ABORT + kind (reactor: `ngtcp2_conn_shutdown_stream` / RST) instead of END. +- Test: phpt with tiny write window is hard; unit-level coverage via + reactor_pool test hooks is acceptable. + +## P2 — Architecture / duplication (SOLID) + +### 7. Handler-pick + gRPC classification: one seam +The GRPC→HTTP1→HTTP2 cascade exists 5×(worker_dispatch ×2, http3_dispatch, +http2_strategy, +404 gate), and classification is expressed two ways +(worker: `grpc_request_mode(req) != NONE`; transports: +`grpc_request_is_grpc(req)`). +- Fix: `zend_fcall_t *http_protocol_pick_handler(HashTable *h, bool is_grpc)` + in core/http_protocol_handlers.{h,c}; `grpc_mode_t grpc_classify(req, + handlers)` in grpc.h (mode = NONE unless a gRPC handler is registered). + Convert all five sites. + +### 8. Trailer-pack duplication +`http3_stream_adopt_wire_trailers` ≅ `http3_stream_capture_trailers` (same +two-pass malloc nv+bytes packer; only the pair source differs). +- Fix: one packer taking a pair-iterator callback; both fill + s->trailer_nv/count/bytes through it. + +### 9. Credit-orphan helper +`stream_credit_mark_dead + stream_credit_release` open-coded at 3 sites. +- Fix: `static inline void stream_credit_abandon(stream_credit_t *sc)` + (NULL-safe) in stream_credit.h; consider `response_wire_discard(rw)` that + abandons an attached credit so future drop-sites are safe by construction. + +### 10. Shared coroutine-sleep helper +`worker_credit_sleep_ms` duplicates `hot_reload_sleep_ms` (already diverging +on EG(exception) handling); both are also the only park sites without +ZEND_ASYNC_WAKER_NEW (works via enqueue auto-create, but non-idiomatic). +- Fix: one helper in src/core/ (e.g. `http_async_sleep_ms(co, ms)`), used by + both; align the waker idiom with the rest of the codebase. + +### 11. Header-flatten loop +The "foreach allowed h2h3 header, string-or-array" flatten exists ~6× +(worker_wire_copy_head, http2_strategy ×2, http2_static_response ×2, +http3_callbacks). +- Fix: `http_response_foreach_allowed_header(resp, emit_cb, arg)` next to + the allowed_h2h3 predicate; convert at least the new worker site now, + transports opportunistically. + +## P3 — Efficiency (reverse path) + +Context: the first iteration deliberately optimized for correctness and +thread-domain safety (arena wires are the established malloc-clean pattern; +the 2ms poll mirrors hot_reload_sleep_ms; per-pop drain mirrors the local +path's per-chunk drive). These are the follow-ups now that the semantics +are pinned by tests. + +### 12. Chunk payload: one copy instead of three +zend_string → wire arena (worker) → zend_string_init (reactor). The arena +copy is inherent to response_wire, but the payload doesn't have to ride the +arena: carry a persistent (malloc) zend_string ref on the wire +(`response_wire_set_chunk(rw, zend_string *persistent)`), adopt it directly +into the chunk ring on the reactor (ring already owns refs; release stays +same-thread because persistent strings are thread-agnostic). Result: exactly +one copy (handler's ZMM string → persistent buffer). + +### 13. Credit poll: reusable timer + adaptive interval +Per-poll ZEND_ASYNC_NEW_TIMER_EVENT create/start/dispose ≈ 500 alloc cycles +per parked second. Reuse one timer event per dispatch ctx (rearm), and/or +back off the interval (2 → 4 → 8 ms capped) while the credit level is +unchanged. + +### 14. Body-consume credit coalescing +`http3_request_body_consume` runs drain_out + arm_timer per popped ~1.2 KB +chunk. Extend the window per pop (cheap), but flush (drain_out) only when +retired bytes since the last flush >= half the stream window — the same 50% +rule the H2 consume path uses; alternatively defer to the reactor's +drain-epilogue. + +### 15. STREAM_CHUNK apply: batch drain +Per-apply resume+drain_out+arm_timer defeats GSO batching when several +chunks land in one mailbox drain. Mark the connection dirty and drain once +per batch via the existing reactor drain epilogue +(`reactor_pool_set_drain_epilogue` — the H3 steer flush already coalesces +this way). + +## P4 — Docs / comments (one commit) + +### 16. Stale contracts sweep +- include/core/worker_dispatch.h: "Buffered responses only for now" → the + streaming reverse path + credit description. +- src/core/worker_dispatch.c ops-block comment + src/http_server_class.c + sink comment: drop "step-3 follow-up / interim until credit" wording; + state what the retry is still load-bearing for (HEADERS/END, many small + streams). +- include/core/response_wire.h: `complete` flag has no readers — either + document it as vestigial-for-FULL or remove `response_wire_body_complete`. +- include/http_body_stream.h: pop now performs transport I/O (deferred + QUIC/H2 credit) and readMessage is a second consumer; the "same thread" + contract must say "the connection's thread". +- include/http2/http2_stream.h + include/http3/http3_stream.h: `grpc_web` + fields are write-only after the mode change — mark or remove. diff --git a/fuzz/fuzz_stubs.c b/fuzz/fuzz_stubs.c index 4a78db9c..e56d067d 100644 --- a/fuzz/fuzz_stubs.c +++ b/fuzz/fuzz_stubs.c @@ -135,6 +135,16 @@ __attribute__((weak)) HashTable *http_response_get_trailers(zend_object *obj) return NULL; } +/* grpc_request_is_grpc_web_text lives in src/grpc/grpc.c (not linked into + * the fuzz harness). Referenced by the body-streaming policy gate in + * http2_session.c; fuzz sessions never dispatch to a handler, so a plain + * "not web-text" answer keeps the gate inert. */ +__attribute__((weak)) bool grpc_request_is_grpc_web_text(const struct http_request_t *req) +{ + (void)req; + return false; +} + /* h2_static_account_debit lives in http2_static_response.c (not linked * into the fuzz harness). The inline release wrappers in * include/http2/http2_stream.h call it from drain paths in http2_session.c diff --git a/include/core/response_wire.h b/include/core/response_wire.h index d6893aa1..ee45e441 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -47,6 +47,11 @@ typedef enum { RESPONSE_WIRE_STREAM_HEADERS, RESPONSE_WIRE_STREAM_CHUNK, RESPONSE_WIRE_STREAM_END, + /* The stream died mid-flight on the worker (credit timeout, cancelled + * handler, dropped fragment): the reactor must RESET the QUIC stream so + * the peer sees an abort — a truncated body must never terminate with a + * clean FIN that reads as a complete response. */ + RESPONSE_WIRE_STREAM_ABORT, } response_wire_kind_t; /* Create an empty response wire. routing identifies the origin stream the diff --git a/include/core/worker_dispatch.h b/include/core/worker_dispatch.h index de1a5ab5..2a4dfe37 100644 --- a/include/core/worker_dispatch.h +++ b/include/core/worker_dispatch.h @@ -33,11 +33,14 @@ typedef struct http_server_object http_server_object; -/* Sink for the rendered response, invoked on the worker thread from the handler - * coroutine's dispose. Ownership of `rw` transfers to the sink: it must - * response_wire_free() it once it has handed the bytes off (e.g. posted them - * back to the reactor). */ -typedef void (*worker_response_sink_fn)(response_wire_t *rw, void *sink_arg); +/* Sink for rendered response wires, invoked on the worker thread — from the + * handler coroutine's dispose (FULL / STREAM_END) and from the streaming ops + * during the handler (STREAM_HEADERS / STREAM_CHUNK). Ownership of `rw` + * transfers to the sink in EVERY outcome. Returns false when delivery + * definitively failed (reactor gone / mailbox full past the bounded retry): + * for STREAM_* wires the caller must then fail the stream (abort at + * dispose), never truncate it silently. */ +typedef bool (*worker_response_sink_fn)(response_wire_t *rw, void *sink_arg); /* Take ownership of `req` (a persistent reactor-built or ZMM request, refcount * 1), wrap it in an HttpRequest on THIS (worker) thread, spawn the user handler @@ -53,8 +56,10 @@ typedef void (*worker_response_sink_fn)(response_wire_t *rw, void *sink_arg); * `own_scope` mirrors the H3 dispatch flag: true gives each request its own * request_context() subtree (a child of `scope`); false runs directly in * `scope`. When no handler is registered a 404 is synthesised so the sink still - * fires. Buffered responses only for now (setBody / end) — a streaming send() - * body is not marshalled yet. + * fires. Buffered responses go out as one FULL wire at dispose; a streaming + * response (send()/writeMessage()/SSE) is marshalled incrementally as + * STREAM_HEADERS / STREAM_CHUNK / STREAM_END wires, paced by the per-stream + * credit block (stream_credit.h) the reactor acknowledges against. * * Returns true once the handler coroutine is enqueued; false on hard failure * (bad args / allocation / no current coroutine to spawn under), in which case diff --git a/include/php_http_server.h b/include/php_http_server.h index 1bf7b983..f3d33783 100644 --- a/include/php_http_server.h +++ b/include/php_http_server.h @@ -743,6 +743,11 @@ typedef struct { uint64_t stream_bytes_sent_total; uint64_t stream_send_backpressure_events_total; + /* Reactor-pool reverse path: STREAM_* wires dropped because the reactor + * mailbox stayed full past the sink's bounded retry (the stream is then + * aborted, never silently truncated). */ + uint64_t worker_wire_dropped_total; + /* HTTP/2 stream-level. h2_streams_active is a gauge (++/--); * h2_ping_rtt_ns is the latest sample (overwrite). */ uint64_t h2_streams_active; @@ -902,6 +907,11 @@ static zend_always_inline void http_server_on_stream_backpressure(http_server_co c->stream_send_backpressure_events_total++; } +static zend_always_inline void http_server_on_worker_wire_dropped(http_server_counters_t *c) +{ + c->worker_wire_dropped_total++; +} + static zend_always_inline void http_server_on_static_zero_coroutine(http_server_counters_t *c) { c->static_zero_coroutine_total++; diff --git a/src/core/reactor_pool_test_hooks.c b/src/core/reactor_pool_test_hooks.c index e745d61a..097d9e3b 100644 --- a/src/core/reactor_pool_test_hooks.c +++ b/src/core/reactor_pool_test_hooks.c @@ -430,11 +430,19 @@ typedef struct { zend_async_event_t *done; /* fired by the sink to wake the test */ } dispatch_probe_t; -static void dispatch_probe_sink(response_wire_t *rw, void *arg) +static bool dispatch_probe_sink(response_wire_t *rw, void *arg) { dispatch_probe_t *const p = (dispatch_probe_t *)arg; + + /* Self-test handlers are buffered-only: keep the FULL wire, free any + * earlier capture defensively so a streaming handler cannot leak. */ + if (p->captured != NULL) { + response_wire_free(p->captured); + } + p->captured = rw; /* take ownership */ async_plain_event_fire(p->done); + return true; } ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_dispatch_from_wire_selftest, 0, 5, @@ -561,7 +569,7 @@ typedef struct { zend_async_event_t *done; } inbox_probe_t; -static void inbox_probe_sink(response_wire_t *rw, void *arg) +static bool inbox_probe_sink(response_wire_t *rw, void *arg) { inbox_probe_t *const p = (inbox_probe_t *)arg; @@ -580,6 +588,8 @@ static void inbox_probe_sink(response_wire_t *rw, void *arg) if (p->received >= p->expected && p->done != NULL) { async_plain_event_fire(p->done); } + + return true; } ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_worker_inbox_selftest, 0, 2, @@ -698,7 +708,7 @@ typedef struct { int per_inbox; /* responses this inbox handled */ } reg_slot_probe_t; -static void reg_probe_sink(response_wire_t *rw, void *arg) +static bool reg_probe_sink(response_wire_t *rw, void *arg) { reg_slot_probe_t *const p = (reg_slot_probe_t *)arg; @@ -718,6 +728,8 @@ static void reg_probe_sink(response_wire_t *rw, void *arg) if (p->shared->received >= p->shared->expected && p->shared->done != NULL) { async_plain_event_fire(p->shared->done); } + + return true; } ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_worker_registry_selftest, 0, 3, diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 23b5d9da..e66af19c 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -69,9 +69,13 @@ typedef struct { /* Streaming reverse path: STREAM_HEADERS posted on the first * append_chunk; STREAM_END posted by mark_ended (idempotent). When - * stream_started, dispose skips the buffered FULL render. */ + * stream_started, dispose skips the buffered FULL render. + * stream_failed = the stream died mid-flight (credit timeout / + * cancellation / dropped wire) — the terminal wire becomes + * STREAM_ABORT so the peer sees a reset, not a clean FIN. */ bool stream_started; bool stream_ended; + bool stream_failed; /* Flow control (step 3): `credit` is shared with the reactor (see * stream_credit.h); posted_bytes is worker-local. In-flight = @@ -222,16 +226,37 @@ static void worker_wire_copy_trailers(response_wire_t *rw, zend_object *resp) } ZEND_HASH_FOREACH_END(); } -/* Hand a wire to the reverse channel. The sink owns it on success. Stream - * kinds must not be silently dropped mid-stream, so the sink (which retries - * on a transiently full mailbox) is the single delivery point. */ -static void worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) +/* Hand a wire to the reverse channel; the sink owns it in every outcome. + * A failed STREAM_* delivery marks the stream failed — the terminal wire + * then becomes an ABORT, so the peer never mistakes the truncated body for + * a complete response. Returns the delivery verdict. */ +static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) { + const response_wire_kind_t kind = response_wire_kind(rw); + bool delivered = false; + if (ctx->sink != NULL) { - ctx->sink(rw, ctx->sink_arg); + delivered = ctx->sink(rw, ctx->sink_arg); } else { + /* No sink: nobody adopts a HEADERS wire's credit ref — take it + * over so a parked producer cannot hang. */ + stream_credit_t *const orphan = + (stream_credit_t *)response_wire_credit(rw); + + if (orphan != NULL) { + stream_credit_mark_dead(orphan); + stream_credit_release(orphan); + } + response_wire_free(rw); } + + if (!delivered && kind != RESPONSE_WIRE_FULL) { + ctx->stream_failed = true; + http_server_on_worker_wire_dropped(ctx->counters); + } + + return delivered; } /* --------------------------------------------------------------------- @@ -241,8 +266,11 @@ static void worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) * work under the pool: each op flattens its payload into a STREAM_* wire * and posts it to the originating reactor. Ordering is the mailbox FIFO; * the reactor applies HEADERS (streaming submit), CHUNKs (chunk_queue), - * END (trailers + EOF). Backpressure credits are the step-3 follow-up — - * until then append never reports BACKPRESSURE. + * END (trailers + EOF) or ABORT (reset — the stream failed mid-flight). + * Backpressure: append_chunk parks the producer coroutine on the shared + * credit block (stream_credit.h) while > WORKER_STREAM_INFLIGHT_CAP bytes + * are un-acked, so it suspends INSIDE the op — BACKPRESSURE is never + * reported outward (same discipline as the local H2/H3 ops). * ------------------------------------------------------------------- */ /* Suspend the producer coroutine for `ms` on a one-shot timer so the worker * loop keeps draining while we wait for credit. Best-effort: bails silently @@ -306,7 +334,7 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) { worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; - if (UNEXPECTED(ctx->stream_ended) + if (UNEXPECTED(ctx->stream_ended || ctx->stream_failed) || (ctx->credit != NULL && stream_credit_is_dead(ctx->credit))) { zend_string_release(chunk); return HTTP_STREAM_APPEND_STREAM_DEAD; @@ -361,6 +389,11 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) ctx->posted_bytes += chunk_len; if (!worker_stream_wait_credit(ctx)) { + /* Credit timeout / cancellation while parked: the peer stopped + * retiring bytes but the QUIC stream may still be alive — flag the + * stream so dispose sends an ABORT, not a clean END that would make + * the truncation look like a complete response. */ + ctx->stream_failed = true; return HTTP_STREAM_APPEND_STREAM_DEAD; } @@ -403,8 +436,15 @@ static void worker_stream_mark_ended(void *vctx) return; } - response_wire_set_kind(ew, RESPONSE_WIRE_STREAM_END); - worker_wire_copy_trailers(ew, Z_OBJ(ctx->response_zv)); + /* A failed stream terminates with an ABORT (reactor resets the QUIC + * stream); only a healthy one ends cleanly, with trailers. */ + if (ctx->stream_failed) { + response_wire_set_kind(ew, RESPONSE_WIRE_STREAM_ABORT); + } else { + response_wire_set_kind(ew, RESPONSE_WIRE_STREAM_END); + worker_wire_copy_trailers(ew, Z_OBJ(ctx->response_zv)); + } + worker_wire_post(ctx, ew); } @@ -533,6 +573,14 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) worker_stream_mark_ended(ctx); } + /* Safety net: a started stream must ALWAYS get a terminal wire. + * grpc_call_finish's delivery can fail mid-way (e.g. the trailer + * frame append hit a failed stream) without ever reaching an END — + * the reactor would then park its data reader forever. Idempotent. */ + if (ctx->stream_started && !ctx->stream_ended) { + worker_stream_mark_ended(ctx); + } + if (!ctx->stream_started) { response_wire_t *const rw = worker_render_response(ctx); diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index 8841ffd9..d972bb3c 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -107,8 +107,61 @@ zend_string *grpc_web_text_encode(const char *in, const size_t len) zend_string *grpc_web_text_decode(const char *in, const size_t len) { - return php_base64_decode_ex((const unsigned char *)in, len, - /*strict=*/false); + /* The body is a CONCATENATION of independently base64-encoded frames, + * each with its own '='-padding (that is what grpc-web clients and our + * own encoder emit). PHP's non-strict decoder does not reset its 6-bit + * group at padding, so a single pass garbles everything after the first + * block whose byte length is not a multiple of 3 — decode block-wise, + * splitting after each padding run. Blocks that need no padding + * (len % 3 == 0) leave the bit stream 4-char aligned, so letting them + * merge into the next block is correct. */ + smart_str out = {0}; + size_t start = 0; + + for (size_t i = 0; i < len; i++) { + if (in[i] != '=') { + continue; + } + + size_t end = i + 1; + + while (end < len && in[end] == '=') { + end++; + } + + zend_string *const part = php_base64_decode_ex( + (const unsigned char *)in + start, end - start, /*strict=*/false); + + if (part == NULL) { + smart_str_free(&out); + return NULL; + } + + smart_str_append(&out, part); + zend_string_release(part); + start = end; + i = end - 1; + } + + if (start < len) { + zend_string *const part = php_base64_decode_ex( + (const unsigned char *)in + start, len - start, /*strict=*/false); + + if (part == NULL) { + smart_str_free(&out); + return NULL; + } + + smart_str_append(&out, part); + zend_string_release(part); + } + + if (out.s == NULL) { + return ZSTR_EMPTY_ALLOC(); + } + + smart_str_0(&out); + return smart_str_extract(&out); } zend_string *grpc_web_trailer_frame(HashTable *trailers) diff --git a/src/http2/http2_session.c b/src/http2/http2_session.c index c74370c0..748e478d 100644 --- a/src/http2/http2_session.c +++ b/src/http2/http2_session.c @@ -26,6 +26,7 @@ #include "log/trace_context.h" #include "http_body_stream.h" #include "core/async_plain_event.h" +#include "grpc/grpc.h" /* web-text is buffered-only (policy gate) */ #include @@ -547,11 +548,24 @@ static void finalize_request_body(http2_stream_t *stream) return; } - /* Streaming mode (issue #26): no smart_str to finalize, just close - * the queue so a parked readBody() consumer sees EOF. */ + /* Streaming mode (issue #26): no smart_str to finalize — close the + * queue so a parked readBody()/readMessage() consumer sees EOF, and + * fire body_event too: a handler suspended in awaitBody() (dispatched + * at headers, END_STREAM not yet seen) waits on THAT event, not the + * queue's data event. Mirrors the H1 parser's streaming-mode fire. */ if (req->body_streaming) { http_body_stream_close(req); req->complete = true; + + if (req->body_event != NULL) { + zend_async_trigger_event_t *trig = + (zend_async_trigger_event_t *)req->body_event; + + if (trig->trigger != NULL) { + trig->trigger(trig); + } + } + return; } @@ -618,7 +632,12 @@ static int cb_on_frame_recv(nghttp2_session *ng, * SMALL <= CL < AUTO → buffer + install upgrade hook * CL < SMALL → buffer, never stream. */ if (session->conn != NULL && session->conn->view != NULL - && session->conn->view->body_streaming_enabled) { + && session->conn->view->body_streaming_enabled + /* grpc-web-text is buffered by protocol nature: + * readMessage() base64-decodes the ASSEMBLED body; a + * streamed queue would leave req->body NULL and silently + * lose the call. */ + && !grpc_request_is_grpc_web_text(stream->request)) { http_request_t *r = stream->request; if (r->content_length == 0 diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 426b9b70..c0f8e54a 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -41,6 +41,7 @@ #include "core/response_wire.h" /* response_wire_* (reverse path) */ #include "core/stream_credit.h" /* reverse-path flow control */ #include "http_body_stream.h" /* streaming request body (issue #26) */ +#include "grpc/grpc.h" /* web-text is buffered-only (policy gate) */ #include "http3/http3_stream.h" /* http3_stream_t */ #include /* ngtcp2_crypto_* callback ptrs */ @@ -419,7 +420,11 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, * SMALL <= CL < AUTO → buffer + install upgrade hook * CL < SMALL → buffer, never stream. */ if (c != NULL && c->view != NULL && c->view->body_streaming_enabled - && s->request != NULL) { + && s->request != NULL + /* grpc-web-text is buffered by protocol nature: readMessage() + * base64-decodes the ASSEMBLED body; a streamed queue would leave + * req->body NULL and silently lose the call. */ + && !grpc_request_is_grpc_web_text(s->request)) { http_request_t *const r = s->request; r->body_h3_conn = c; @@ -1357,11 +1362,24 @@ static void http3_finalize_request_body(http3_stream_t *s) ZEND_ASSERT(req != NULL); /* Streaming mode (issue #26): no smart_str to move — close the queue - * so a parked readBody()/readMessage() consumer sees EOF. */ + * so a parked readBody()/readMessage() consumer sees EOF, and fire + * body_event too: a handler suspended in awaitBody() (dispatched at + * headers, fin not yet seen) waits on THAT event, not the queue's + * data event. Mirrors the H1 parser's explicit streaming-mode fire. */ if (req->body_streaming) { http_body_stream_close(req); req->complete = true; s->fin_received = true; + + if (req->body_event != NULL) { + zend_async_trigger_event_t *const trig = + (zend_async_trigger_event_t *)req->body_event; + + if (trig->trigger != NULL) { + trig->trigger(trig); + } + } + return; } diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index e9f2a1fc..6e9cc644 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -285,6 +285,28 @@ void http3_reactor_apply_response(void *arg) http3_connection_drain_out(c); http3_connection_arm_timer(c); break; + + case RESPONSE_WIRE_STREAM_ABORT: + /* The worker's stream died mid-flight (credit timeout / + * cancellation / dropped fragment): RESET the QUIC stream so + * the peer sees an abort — never a clean FIN over a truncated + * body. streaming_ended stops the data reader from being + * resumed for a stream that will not get more chunks. */ + if (s->peer_closed || s->streaming_ended) { + break; + } + + s->streaming_ended = true; + + if (c->ngtcp2_conn != NULL) { + (void)ngtcp2_conn_shutdown_stream_write( + (ngtcp2_conn *)c->ngtcp2_conn, 0, s->stream_id, + NGHTTP3_H3_INTERNAL_ERROR); + } + + http3_connection_drain_out(c); + http3_connection_arm_timer(c); + break; } response_wire_free(rw); diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index c51684e9..372ef199 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -19,6 +19,7 @@ #include "http3/http3_stream_pool.h" #include "http3_connection.h" /* http3_connection_t — list ownership at teardown */ #include "core/stream_credit.h" /* reverse-path flow control — teardown release */ +#include "http_body_stream.h" /* sever body_h3_conn + wake a parked consumer */ #include "http3_listener.h" /* http3_listener_stream_pool */ @@ -166,6 +167,24 @@ void http3_stream_release(http3_stream_t *s) s->wire_credit = NULL; } + /* Inbound streaming (issue #26): the request can outlive this stream — + * a handler coroutine holds its own request ref and may still be + * draining the chunk queue. Sever the raw connection pointer NOW so a + * later http_body_stream_pop cannot call http3_request_body_consume on + * a freed connection (the conn dies right after this on the + * connection-free force-release path), and wake a consumer parked on + * the queue if the body never completed. Local mode only — + * body_h3_conn is never set under the reactor pool. The slab (and thus + * s->request) is guaranteed alive here: the request refcount keeps the + * slot allocated until http_request_destroy. */ + if (s->request != NULL && s->request->body_h3_conn != NULL) { + s->request->body_h3_conn = NULL; + + if (!s->fin_received) { + http_body_stream_error(s->request); + } + } + /* Trailer capture (malloc'd in http3_stream_capture_trailers). Held * until teardown so the nv stays valid through the async trailer submit. */ if (s->trailer_nv != NULL) { diff --git a/src/http_server_class.c b/src/http_server_class.c index ddf4627a..7ccea7c6 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -2554,7 +2554,7 @@ static void http_server_reactor_pool_down(http_server_object *server) * reverse channel; ownership of `rw` transfers to the reactor apply on success. * On failure (no pool / full mailbox) drop it — the client times out and the * slab is still reclaimed by the consumed that follows. */ -static void http_server_worker_response_sink(response_wire_t *rw, void *arg) +static bool http_server_worker_response_sink(response_wire_t *rw, void *arg) { (void)arg; @@ -2564,20 +2564,29 @@ static void http_server_worker_response_sink(response_wire_t *rw, void *arg) if (reactor_pool_post_exec(g_reactor_pool, reactor, http3_reactor_apply_response, rw)) { - return; /* the reactor owns rw now */ + return true; /* the reactor owns rw now */ } - /* STREAM_* wires are ordered fragments — dropping one silently - * corrupts the stream, so retry a transiently full mailbox (the - * reactor drains continuously; same discipline as the consumed - * spin in http3_stream_release_via_request). Bounded so a reactor - * that left RUN (shutdown) cannot pin the worker forever. Interim - * until the step-3 credit protocol paces the producer. */ + /* STREAM_* wires are ordered fragments — dropping one corrupts the + * stream, so ride out a transiently full mailbox (1024 slots; the + * reactor drains continuously). The credit protocol already paces + * CHUNK bytes, so a mailbox that stays full for ~100 ms means the + * reactor is gone or wedged — fail fast then: the caller marks the + * stream failed and its terminal wire becomes an ABORT. Short + * bounded sleeps, not a busy spin: this stalls only wires, never + * burns a core. */ if (response_wire_kind(rw) != RESPONSE_WIRE_FULL) { - for (int spin = 0; spin < 1000000; spin++) { + for (int attempt = 0; attempt < 100; attempt++) { +#ifdef PHP_WIN32 + Sleep(1); +#else + const struct timespec ts = { 0, 1000000 }; /* 1 ms */ + nanosleep(&ts, NULL); +#endif + if (reactor_pool_post_exec(g_reactor_pool, reactor, http3_reactor_apply_response, rw)) { - return; + return true; } } } @@ -2594,6 +2603,7 @@ static void http_server_worker_response_sink(response_wire_t *rw, void *arg) } response_wire_free(rw); + return false; } /* Suspend the current coroutine for `ms` on a one-shot timer; lets the worker diff --git a/tests/phpt/server/grpc/015-grpc-web-text.phpt b/tests/phpt/server/grpc/015-grpc-web-text.phpt index 2306eeea..5fe590ce 100644 --- a/tests/phpt/server/grpc/015-grpc-web-text.phpt +++ b/tests/phpt/server/grpc/015-grpc-web-text.phpt @@ -56,19 +56,25 @@ $config = (new HttpServerConfig()) $server = new HttpServer($config); $server->addGrpcHandler(function($req, $resp) { $req->awaitBody(); - $msg = $req->readMessage(); // base64-decoded transparently - $resp->writeMessage('echo:' . $msg); // frame 1, base64 out - $resp->writeMessage('bye'); // frame 2 — proves per-frame b64 + $m1 = $req->readMessage(); // base64-decoded transparently + $m2 = $req->readMessage(); // second client frame + $resp->writeMessage('echo:' . $m1 . '+' . $m2); // frame 1, base64 out + $resp->writeMessage('bye'); // frame 2 — proves per-frame b64 // grpc-status defaults to 0 }); $client = spawn(function() use ($port, $server) { usleep(30000); - $frame = "\x00" . pack('N', 4) . 'ping'; + /* TWO frames, each base64-encoded independently with its own padding — + * the grpc-web-text shape. Frame 1 is 7 bytes (5+2, % 3 != 0) so its + * block ends in '==' MID-STREAM: a whole-body single-pass decode would + * garble frame 2 (PHP's decoder does not realign at padding). */ + $frame1 = "\x00" . pack('N', 2) . 'hi'; + $frame2 = "\x00" . pack('N', 4) . 'ping'; $bodyfile = tempnam(sys_get_temp_dir(), 'grpcreq'); $outfile = tempnam(sys_get_temp_dir(), 'grpcout'); - file_put_contents($bodyfile, base64_encode($frame)); + file_put_contents($bodyfile, base64_encode($frame1) . base64_encode($frame2)); $cmd = sprintf( 'curl --http2-prior-knowledge -s -v --max-time 3 -H %s ' @@ -100,7 +106,7 @@ echo "Done\n"; --EXPECT-- resp_ctype_webtext=1 body_is_base64=1 -data=echo:ping,bye +data=echo:hi+ping,bye trailer_status=1 no_http_trailer=1 Done From bbcc3885b65c54a44fa15c68580bb34c51e875d5 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:24:41 +0000 Subject: [PATCH 19/33] =?UTF-8?q?refactor:=20review=20P2=20=E2=80=94=20one?= =?UTF-8?q?=20handler-pick=20seam,=20classify-once=20gRPC,=20shared=20trai?= =?UTF-8?q?ler=20pack,=20credit=20abandon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - http_protocol_pick_handler(handlers, is_grpc): the single GRPC->HTTP1-> HTTP2 precedence; worker (both sites) and H3 dispatch converted. H2 keeps its cached conn->handler shape. - grpc_classify(req, handlers): mode gated on a registered addGrpcHandler; worker + H2 + H3 classify through it and reuse the mode at grpc_call_init_response (no re-classification). - h3_trailer_pack_{init,add,commit}: one malloc packer behind http3_stream_capture_trailers and http3_stream_adopt_wire_trailers. - stream_credit_abandon(): the mark-dead-then-release pair, used at all four drop sites. - Plan items 10/11 dropped/deferred (a new module or a helper-used-once costs more than the duplication). Suite 307/307. --- docs/PLAN_REVIEW_FIXES.md | 14 ++-- include/core/stream_credit.h | 13 ++++ src/core/http_protocol_handlers.c | 19 ++++++ src/core/http_protocol_handlers.h | 7 ++ src/core/worker_dispatch.c | 43 +++--------- src/grpc/grpc.c | 13 ++++ src/grpc/grpc.h | 7 ++ src/http2/http2_strategy.c | 15 ++--- src/http3/http3_callbacks.c | 106 +++++++++++++++++------------- src/http3/http3_dispatch.c | 42 +++--------- src/http3/http3_stream.c | 3 +- src/http_server_class.c | 8 +-- 12 files changed, 150 insertions(+), 140 deletions(-) diff --git a/docs/PLAN_REVIEW_FIXES.md b/docs/PLAN_REVIEW_FIXES.md index 8a454f90..d74999ab 100644 --- a/docs/PLAN_REVIEW_FIXES.md +++ b/docs/PLAN_REVIEW_FIXES.md @@ -103,20 +103,16 @@ two-pass malloc nv+bytes packer; only the pair source differs). (NULL-safe) in stream_credit.h; consider `response_wire_discard(rw)` that abandons an attached credit so future drop-sites are safe by construction. -### 10. Shared coroutine-sleep helper -`worker_credit_sleep_ms` duplicates `hot_reload_sleep_ms` (already diverging -on EG(exception) handling); both are also the only park sites without -ZEND_ASYNC_WAKER_NEW (works via enqueue auto-create, but non-idiomatic). -- Fix: one helper in src/core/ (e.g. `http_async_sleep_ms(co, ms)`), used by - both; align the waker idiom with the rest of the codebase. +### 10. Shared coroutine-sleep helper — DROPPED +10 duplicated lines in two files are cheaper than a new module. Leave both +static helpers where they are. ### 11. Header-flatten loop The "foreach allowed h2h3 header, string-or-array" flatten exists ~6× (worker_wire_copy_head, http2_strategy ×2, http2_static_response ×2, http3_callbacks). -- Fix: `http_response_foreach_allowed_header(resp, emit_cb, arg)` next to - the allowed_h2h3 predicate; convert at least the new worker site now, - transports opportunistically. +- DEFERRED: a helper used once is worse than the duplication. Convert + opportunistically when a site is touched anyway. ## P3 — Efficiency (reverse path) diff --git a/include/core/stream_credit.h b/include/core/stream_credit.h index a18aa776..6ef69d49 100644 --- a/include/core/stream_credit.h +++ b/include/core/stream_credit.h @@ -79,6 +79,19 @@ static inline void stream_credit_mark_dead(stream_credit_t *sc) zend_atomic_int_store_ex(&sc->dead, 1); } +/* Drop-site helper: nobody will retire this side's bytes anymore — unblock + * a parked producer FIRST (mark dead), then release this side's ref. + * NULL-safe. The ordering is the correctness story: a producer must be able + * to observe `dead` before the block can vanish. Use this at every point a + * credit-carrying wire dies undelivered or a stream is torn down. */ +static inline void stream_credit_abandon(stream_credit_t *sc) +{ + if (sc != NULL) { + stream_credit_mark_dead(sc); + stream_credit_release(sc); + } +} + static inline bool stream_credit_is_dead(const stream_credit_t *sc) { return zend_atomic_int_load_ex(&((stream_credit_t *)sc)->dead) != 0; diff --git a/src/core/http_protocol_handlers.c b/src/core/http_protocol_handlers.c index 470bbe13..9a8f3c5e 100644 --- a/src/core/http_protocol_handlers.c +++ b/src/core/http_protocol_handlers.c @@ -127,6 +127,25 @@ bool http_protocol_has_handler(HashTable *handlers, http_protocol_type_t protoco } /* }}} */ +/* {{{ http_protocol_pick_handler */ +zend_fcall_t *http_protocol_pick_handler(HashTable *handlers, const bool is_grpc) +{ + zend_fcall_t *fcall = is_grpc + ? http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC) + : NULL; + + if (fcall == NULL) { + fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); + } + + if (fcall == NULL) { + fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); + } + + return fcall; +} +/* }}} */ + /* {{{ http_protocol_remove_handler */ void http_protocol_remove_handler(HashTable *handlers, http_protocol_type_t protocol) { diff --git a/src/core/http_protocol_handlers.h b/src/core/http_protocol_handlers.h index 45be432e..af94b8a9 100644 --- a/src/core/http_protocol_handlers.h +++ b/src/core/http_protocol_handlers.h @@ -31,6 +31,13 @@ zend_fcall_t* http_protocol_get_handler(HashTable *handlers, /* Check if handler exists */ bool http_protocol_has_handler(HashTable *handlers, http_protocol_type_t protocol); +/* Resolve the user handler with the ONE shared precedence every dispatch + * site uses: the gRPC slot first when the call was classified gRPC, then + * HTTP1 (addHttpHandler's slot), then HTTP2 — so a server registered only + * via addHttp2Handler still services H3/worker requests. Returns NULL when + * nothing matches (callers synthesise a 404 / bail). */ +zend_fcall_t *http_protocol_pick_handler(HashTable *handlers, bool is_grpc); + /* Remove handler from HashTable */ void http_protocol_remove_handler(HashTable *handlers, http_protocol_type_t protocol); diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index e66af19c..9576ec7b 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -98,17 +98,8 @@ static void worker_dispatch_entry(void) } HashTable *const handlers = http_server_get_protocol_handlers(ctx->server); - zend_fcall_t *fcall = ctx->is_grpc - ? http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC) - : NULL; - - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); - } - - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); - } + zend_fcall_t *const fcall = + http_protocol_pick_handler(handlers, ctx->is_grpc); if (fcall == NULL) { return; @@ -240,14 +231,7 @@ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) } else { /* No sink: nobody adopts a HEADERS wire's credit ref — take it * over so a parked producer cannot hang. */ - stream_credit_t *const orphan = - (stream_credit_t *)response_wire_credit(rw); - - if (orphan != NULL) { - stream_credit_mark_dead(orphan); - stream_credit_release(orphan); - } - + stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); response_wire_free(rw); } @@ -634,12 +618,11 @@ bool worker_dispatch_request(http_server_object *server, void *const conn = req->reactor_conn; const bool is_head = http_request_method_is_head(req); - /* gRPC classification — once, here, exactly like the transports do - * (application/grpc* + a registered addGrpcHandler). */ + /* gRPC classification — once, through the same seam the transports + * use (content-type mode gated on a registered addGrpcHandler). */ HashTable *const handlers = http_server_get_protocol_handlers(server); - const grpc_mode_t grpc_mode = grpc_request_mode(req); - const bool is_grpc = grpc_mode != GRPC_MODE_NONE - && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); + const grpc_mode_t grpc_mode = grpc_classify(req, handlers); + const bool is_grpc = grpc_mode != GRPC_MODE_NONE; zval *const req_obj = http_request_create_from_parsed(req); @@ -677,17 +660,7 @@ bool worker_dispatch_request(http_server_object *server, /* No handler registered: synthesise a 404 so the sink still fires with a * response instead of leaving the stream hanging. */ - zend_fcall_t *fcall = is_grpc - ? http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC) - : NULL; - - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); - } - - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); - } + zend_fcall_t *const fcall = http_protocol_pick_handler(handlers, is_grpc); if (fcall == NULL) { http_response_static_set_status(Z_OBJ(ctx->response_zv), 404); diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index d972bb3c..bf6aaa31 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -15,6 +15,7 @@ #include "http1/http_parser.h" /* http_request_t */ #include "zend_smart_str.h" #include "ext/standard/base64.h" /* grpc-web-text per-frame transform */ +#include "core/http_protocol_handlers.h" /* handler-registry gate (grpc_classify) */ #include #include /* strncasecmp */ @@ -82,6 +83,18 @@ bool grpc_request_is_grpc_web_text(const http_request_t *req) prefix_len) == 0; } +grpc_mode_t grpc_classify(const http_request_t *req, HashTable *handlers) +{ + const grpc_mode_t mode = grpc_request_mode(req); + + if (mode == GRPC_MODE_NONE + || !http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC)) { + return GRPC_MODE_NONE; + } + + return mode; +} + grpc_mode_t grpc_request_mode(const http_request_t *req) { if (!grpc_request_is_grpc(req)) { diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index 7a335431..d78b8d43 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -87,6 +87,13 @@ bool grpc_request_is_grpc_web_text(const struct http_request_t *req); * registered gRPC handler. */ grpc_mode_t grpc_request_mode(const struct http_request_t *req); +/* Classify-once entry point for dispatch sites: the delivery mode gated on + * a registered addGrpcHandler — GRPC_MODE_NONE when the request is not + * gRPC or no gRPC handler exists (the call then routes as plain HTTP). + * Every dispatch path (H2 / H3 / worker) must classify through THIS so a + * request can never be gRPC on one transport and plain HTTP on another. */ +grpc_mode_t grpc_classify(const struct http_request_t *req, HashTable *handlers); + /* Per-frame base64 transform for grpc-web-text. Both return a new * zend_string the caller owns; decode returns NULL on malformed input. */ zend_string *grpc_web_text_encode(const char *in, size_t len); diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index d0249398..35e70136 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -242,13 +242,13 @@ static void http2_strategy_dispatch(struct http_request_t *request, * addGrpcHandler(). Detected here — like the WS check — so a gRPC-only * server (no addHttpHandler) still dispatches past the handler-null * guards below. */ - const bool is_grpc = - grpc_request_is_grpc(stream->request) - && http_protocol_has_handler( - http_server_get_protocol_handlers(self->conn->server), - HTTP_PROTOCOL_GRPC); + const grpc_mode_t grpc_mode = grpc_classify( + stream->request, + http_server_get_protocol_handlers(self->conn->server)); + const bool is_grpc = grpc_mode != GRPC_MODE_NONE; stream->is_grpc = is_grpc; - stream->grpc_web = is_grpc && grpc_request_is_grpc_web(stream->request); + stream->grpc_web = grpc_mode == GRPC_MODE_WEB + || grpc_mode == GRPC_MODE_WEB_TEXT; /* Static-only deployments register a static mount but no PHP * handler — the static dispatch path below claims the request @@ -305,8 +305,7 @@ static void http2_strategy_dispatch(struct http_request_t *request, /* gRPC: response defaults (content-type + delivery mode) live in the * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (is_grpc) { - grpc_call_init_response(Z_OBJ(stream->response_zv), - grpc_request_mode(stream->request)); + grpc_call_init_response(Z_OBJ(stream->response_zv), grpc_mode); } /* Static-handler dispatch (issue #13). Identical policy to the diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index c0f8e54a..85c7e0a0 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -731,6 +731,54 @@ static inline bool h3_nv_push(h3_nv_buf_t *b, * NO_END_STREAM) yet response_zv is freed by then. malloc (not emalloc) — the * data reader runs outside a request memory context. No-op when there are no * trailers or a capture already exists. Freed in http3_stream_release. */ +/* Shared malloc'd trailer capture (s->trailer_nv/count/bytes): init sizes + * the buffers, add packs one pair, callers iterate their own source. */ +typedef struct { + nghttp3_nv *nv; + char *bytes; + size_t ni, bi; +} h3_trailer_pack_t; + +static bool h3_trailer_pack_init(h3_trailer_pack_t *p, const size_t count, + const size_t total) +{ + p->nv = malloc(count * sizeof(nghttp3_nv)); + p->bytes = malloc(total ? total : 1); + p->ni = 0; + p->bi = 0; + + if (p->nv == NULL || p->bytes == NULL) { + free(p->nv); + free(p->bytes); + return false; + } + + return true; +} + +static void h3_trailer_pack_add(h3_trailer_pack_t *p, + const char *nm, const size_t nl, + const char *val, const size_t vl) +{ + memcpy(p->bytes + p->bi, nm, nl); + p->nv[p->ni].name = (uint8_t *)(p->bytes + p->bi); + p->nv[p->ni].namelen = nl; + p->bi += nl; + memcpy(p->bytes + p->bi, val, vl); + p->nv[p->ni].value = (uint8_t *)(p->bytes + p->bi); + p->nv[p->ni].valuelen = vl; + p->bi += vl; + p->nv[p->ni].flags = NGHTTP3_NV_FLAG_NONE; + p->ni++; +} + +static void h3_trailer_pack_commit(http3_stream_t *s, h3_trailer_pack_t *p) +{ + s->trailer_nv = p->nv; + s->trailer_count = p->ni; + s->trailer_bytes = p->bytes; +} + void http3_stream_capture_trailers(http3_stream_t *s) { if (Z_ISUNDEF(s->response_zv) || s->trailer_nv != NULL) { @@ -752,37 +800,19 @@ void http3_stream_capture_trailers(http3_stream_t *s) total += ZSTR_LEN(name) + Z_STRLEN_P(val); } ZEND_HASH_FOREACH_END(); - if (count == 0) { - return; - } + h3_trailer_pack_t pack; - nghttp3_nv *nv = malloc(count * sizeof(nghttp3_nv)); - char *bytes = malloc(total ? total : 1); - - if (nv == NULL || bytes == NULL) { - free(nv); - free(bytes); + if (count == 0 || !h3_trailer_pack_init(&pack, count, total)) { return; } - size_t ni = 0, bi = 0; ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } - memcpy(bytes + bi, ZSTR_VAL(name), ZSTR_LEN(name)); - nv[ni].name = (uint8_t *)(bytes + bi); - nv[ni].namelen = ZSTR_LEN(name); - bi += ZSTR_LEN(name); - memcpy(bytes + bi, Z_STRVAL_P(val), Z_STRLEN_P(val)); - nv[ni].value = (uint8_t *)(bytes + bi); - nv[ni].valuelen = Z_STRLEN_P(val); - bi += Z_STRLEN_P(val); - nv[ni].flags = NGHTTP3_NV_FLAG_NONE; - ni++; + h3_trailer_pack_add(&pack, ZSTR_VAL(name), ZSTR_LEN(name), + Z_STRVAL_P(val), Z_STRLEN_P(val)); } ZEND_HASH_FOREACH_END(); - s->trailer_nv = nv; - s->trailer_count = ni; - s->trailer_bytes = bytes; + h3_trailer_pack_commit(s, &pack); } bool http3_stream_submit_response(http3_connection_t *c, @@ -938,40 +968,22 @@ void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t * } } - nghttp3_nv *tnv = malloc(tcount * sizeof(nghttp3_nv)); - char *tbytes = malloc(total ? total : 1); + h3_trailer_pack_t pack; - if (tnv == NULL || tbytes == NULL) { - free(tnv); - free(tbytes); + if (!h3_trailer_pack_init(&pack, tcount, total)) { return; } - size_t ni = 0, bi = 0; - for (size_t i = 0; i < tcount; i++) { const char *nm, *val; size_t nl, vl; - if (!response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { - continue; + if (response_wire_trailer_at(rw, i, &nm, &nl, &val, &vl)) { + h3_trailer_pack_add(&pack, nm, nl, val, vl); } + } - memcpy(tbytes + bi, nm, nl); - tnv[ni].name = (uint8_t *)(tbytes + bi); - tnv[ni].namelen = nl; - bi += nl; - memcpy(tbytes + bi, val, vl); - tnv[ni].value = (uint8_t *)(tbytes + bi); - tnv[ni].valuelen = vl; - bi += vl; - tnv[ni].flags = NGHTTP3_NV_FLAG_NONE; - ni++; - } - - s->trailer_nv = tnv; - s->trailer_count = ni; - s->trailer_bytes = tbytes; + h3_trailer_pack_commit(s, &pack); } /* Reverse path: submit a buffered response from a flat response_wire diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 6e9cc644..21079cf4 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -229,14 +229,7 @@ void http3_reactor_apply_response(void *arg) /* The stream is already gone, so nobody will adopt a HEADERS * wire's credit ref — take it over: unblock the parked producer * and drop the reactor-side ref here. */ - stream_credit_t *const orphan = - (stream_credit_t *)response_wire_credit(rw); - - if (orphan != NULL) { - stream_credit_mark_dead(orphan); - stream_credit_release(orphan); - } - + stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); response_wire_free(rw); return; } @@ -390,30 +383,16 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) HashTable *handlers = http_server_get_protocol_handlers(server); zend_async_scope_t *scope = http_server_get_scope(server); - /* Pick the user handler. addHttpHandler stores it as HTTP1; H2 has - * its own slot. Try H1 first, then HTTP2 as a fallback so a server - * registered only via addHttp2Handler still services H3. Symmetric - * to how H2 strategy resolves it. */ - zend_fcall_t *fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP1); - - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); - } + /* gRPC (issue #4): classify ONCE through the shared seam (content-type + * mode gated on a registered addGrpcHandler); grpc-web carries trailers + * in-body. The handler pick below uses the shared precedence, so a + * gRPC-only server still dispatches. */ + const grpc_mode_t grpc_mode = grpc_classify(s->request, handlers); - /* gRPC (issue #4): route application/grpc requests to addGrpcHandler. - * grpc-web (application/grpc-web) carries trailers in-body. Mirrors the - * H2 strategy — a gRPC-only server dispatches because fcall picks up the - * gRPC handler here. */ - s->is_grpc = grpc_request_is_grpc(s->request) - && http_protocol_has_handler(handlers, HTTP_PROTOCOL_GRPC); - s->grpc_web = s->is_grpc && grpc_request_is_grpc_web(s->request); + s->is_grpc = grpc_mode != GRPC_MODE_NONE; + s->grpc_web = grpc_mode == GRPC_MODE_WEB || grpc_mode == GRPC_MODE_WEB_TEXT; - if (s->is_grpc) { - zend_fcall_t *g = http_protocol_get_handler(handlers, HTTP_PROTOCOL_GRPC); - if (g != NULL) { - fcall = g; - } - } + zend_fcall_t *fcall = http_protocol_pick_handler(handlers, s->is_grpc); /* Static-only deployments register a mount but no PHP handler — the * static gate below claims the request before any handler is needed. @@ -464,8 +443,7 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) /* gRPC: response defaults (content-type + delivery mode) live in the * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (s->is_grpc) { - grpc_call_init_response(Z_OBJ(s->response_zv), - grpc_request_mode(s->request)); + grpc_call_init_response(Z_OBJ(s->response_zv), grpc_mode); } #ifdef HAVE_HTTP_COMPRESSION diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index 372ef199..308fde07 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -162,8 +162,7 @@ void http3_stream_release(http3_stream_t *s) /* Reverse-path credit: the stream is going away — unblock a parked * producer, then drop the reactor's ref. */ if (s->wire_credit != NULL) { - stream_credit_mark_dead((stream_credit_t *)s->wire_credit); - stream_credit_release((stream_credit_t *)s->wire_credit); + stream_credit_abandon((stream_credit_t *)s->wire_credit); s->wire_credit = NULL; } diff --git a/src/http_server_class.c b/src/http_server_class.c index 7ccea7c6..6c4dbf78 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -2595,13 +2595,7 @@ static bool http_server_worker_response_sink(response_wire_t *rw, void *arg) /* Undeliverable: the reactor never adopts a HEADERS wire's credit ref, * so take it over — unblock the parked producer, drop the ref. */ - stream_credit_t *const orphan = (stream_credit_t *)response_wire_credit(rw); - - if (orphan != NULL) { - stream_credit_mark_dead(orphan); - stream_credit_release(orphan); - } - + stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); response_wire_free(rw); return false; } From 84b7c5211297235dd643d4246181a89a84e7a289 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:32:00 +0000 Subject: [PATCH 20/33] =?UTF-8?q?perf:=20review=20P3=20=E2=80=94=20reverse?= =?UTF-8?q?-path=20copy/wake/drain=20costs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STREAM_CHUNK payload rides the wire as one persistent zend_string (ownership handoff worker->ring) instead of zend_string -> arena -> zend_string: one copy per chunk, not three. Drop sites release an untaken chunk alongside the credit abandon. - Credit poll backs off 2->32ms while ACKs stall, snaps back on progress (fewer timer allocs per parked second). - Inbound body credit coalesced: flush MAX_STREAM_DATA at 64 KiB drained or when the queue empties, not a drain_out per ~1.2 KB pop. - Reactor apply marks the conn dirty and flushes once per mailbox batch via the existing drain epilogue (http3_listener_queue_epilogue_flush, extracted from the steer feed) instead of drain+arm per wire. Suite 307/307. --- include/core/response_wire.h | 7 ++++++ include/http1/http_parser.h | 1 + src/core/response_wire.c | 13 +++++++++++ src/core/worker_dispatch.c | 39 ++++++++++++++++++++++++-------- src/http3/http3_callbacks.c | 18 +++++++++++---- src/http3/http3_dispatch.c | 44 ++++++++++++++++++++++++------------ src/http3/http3_internal.h | 8 +++---- src/http3/http3_listener.c | 16 +++++++++---- src/http3/http3_listener.h | 1 + src/http_body_stream.c | 8 +++---- src/http_server_class.c | 7 ++++++ 11 files changed, 122 insertions(+), 40 deletions(-) diff --git a/include/core/response_wire.h b/include/core/response_wire.h index ee45e441..7b76b03f 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -69,6 +69,13 @@ response_wire_kind_t response_wire_kind(const response_wire_t *rw); void response_wire_set_credit(response_wire_t *rw, void *credit); void *response_wire_credit(const response_wire_t *rw); +/* STREAM_CHUNK payload handoff: a PERSISTENT zend_string* whose ownership + * rides the wire (one copy total: handler ZMM -> persistent). The consumer + * takes it; a drop site must take + release it — the wire itself is a pure + * malloc TU and cannot. */ +void response_wire_set_chunk(response_wire_t *rw, void *persistent_str); +void *response_wire_take_chunk(response_wire_t *rw); + /* Builders — copy bytes into the arena. set_status replaces; add_header * appends; set_body replaces. All accept non-NUL-terminated spans. The header * builders return false on allocation failure (the wire stays usable/freeable). diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index 040e2de8..c7cada67 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -221,6 +221,7 @@ struct http_request_t { * grants deferred QUIC flow-control credit through these (issue #26). */ void *body_h3_conn; int64_t body_h3_stream_id; + size_t body_h3_uncredited; /* drained, not yet flushed */ int32_t body_h2_consume_pending; void *body_h3_stream; diff --git a/src/core/response_wire.c b/src/core/response_wire.c index 6c76bed3..adee7005 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -33,6 +33,7 @@ struct response_wire_s { response_wire_kind_t kind; /* FULL unless set otherwise */ void *credit; /* opaque stream_credit_t*, not owned */ + void *chunk; /* persistent zend_string*, owned until taken */ int status; /* Growable byte arena: every span's bytes are copied in here. */ @@ -130,6 +131,18 @@ void *response_wire_credit(const response_wire_t *rw) return rw->credit; } +void response_wire_set_chunk(response_wire_t *rw, void *persistent_str) +{ + rw->chunk = persistent_str; +} + +void *response_wire_take_chunk(response_wire_t *rw) +{ + void *c = rw->chunk; + rw->chunk = NULL; + return c; +} + void response_wire_set_status(response_wire_t *rw, const int status) { rw->status = status; diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 9576ec7b..1f015f48 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -232,6 +232,14 @@ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) /* No sink: nobody adopts a HEADERS wire's credit ref — take it * over so a parked producer cannot hang. */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); + + zend_string *const orphan_chunk = + (zend_string *)response_wire_take_chunk(rw); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + response_wire_free(rw); } @@ -284,7 +292,9 @@ static bool worker_stream_wait_credit(worker_dispatch_ctx_t *ctx) const uint32_t timeout_ms = http_server_get_write_timeout_s(ctx->server) * 1000u; - uint64_t waited_ms = 0; + uint64_t waited_ms = 0; + uint64_t poll_ms = WORKER_CREDIT_POLL_MS; + uint64_t last_acked = stream_credit_acked(ctx->credit); while (ctx->posted_bytes - stream_credit_acked(ctx->credit) >= WORKER_STREAM_INFLIGHT_CAP) { @@ -298,17 +308,28 @@ static bool worker_stream_wait_credit(worker_dispatch_ctx_t *ctx) return true; /* can't suspend — degrade to unbounded */ } - worker_credit_sleep_ms(co, WORKER_CREDIT_POLL_MS); + worker_credit_sleep_ms(co, poll_ms); if (EG(exception) != NULL) { return false; /* cancelled while parked */ } - waited_ms += WORKER_CREDIT_POLL_MS; + waited_ms += poll_ms; if (timeout_ms > 0 && waited_ms >= timeout_ms) { return false; /* peer stopped reading — treat as dead */ } + + /* Back off while no progress (idle peer burns fewer timer allocs); + * snap back the moment ACKs move. */ + const uint64_t acked = stream_credit_acked(ctx->credit); + + if (acked == last_acked) { + poll_ms = poll_ms < 32 ? poll_ms * 2 : 32; + } else { + last_acked = acked; + poll_ms = WORKER_CREDIT_POLL_MS; + } } return true; @@ -355,12 +376,12 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) response_wire_set_kind(cw, RESPONSE_WIRE_STREAM_CHUNK); - if (UNEXPECTED(!response_wire_set_body(cw, ZSTR_VAL(chunk), ZSTR_LEN(chunk), - false))) { - response_wire_free(cw); - zend_string_release(chunk); - return HTTP_STREAM_APPEND_STREAM_DEAD; - } + /* One copy: handler ZMM string -> persistent; the reactor adopts the + * ref straight into its chunk ring. */ + zend_string *const pchunk = + zend_string_init(ZSTR_VAL(chunk), ZSTR_LEN(chunk), 1); + + response_wire_set_chunk(cw, pchunk); const size_t chunk_len = ZSTR_LEN(chunk); diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 85c7e0a0..90c4841a 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -2027,16 +2027,26 @@ static int recv_stream_data_cb(ngtcp2_conn *conn, uint32_t flags, * http_body_stream_pop on the connection's own thread as the handler * drains queued body chunks. Extends the QUIC windows and drives the * socket so the MAX_STREAM_DATA actually reaches the peer. */ -void http3_request_body_consume(void *conn_opaque, const int64_t stream_id, - const size_t len) +void http3_request_body_consume(http_request_t *req, const size_t len, + const bool queue_empty) { - http3_connection_t *const c = (http3_connection_t *)conn_opaque; + http3_connection_t *const c = (http3_connection_t *)req->body_h3_conn; if (c == NULL || c->closed || c->ngtcp2_conn == NULL) { return; } - h3_extend_body_window(c, stream_id, len); + /* Coalesce: a drain_out per ~1.2 KB chunk is a full send-path walk. + * Flush at 64 KiB, or when the queue just emptied (the consumer is + * about to wait — the peer may need the window to send more). */ + req->body_h3_uncredited += len; + + if (req->body_h3_uncredited < 64 * 1024 && !queue_empty) { + return; + } + + h3_extend_body_window(c, req->body_h3_stream_id, req->body_h3_uncredited); + req->body_h3_uncredited = 0; http3_connection_drain_out(c); http3_connection_arm_timer(c); } diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 21079cf4..8e38fbaf 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -230,16 +230,28 @@ void http3_reactor_apply_response(void *arg) * wire's credit ref — take it over: unblock the parked producer * and drop the reactor-side ref here. */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); + + zend_string *const orphan_chunk = + (zend_string *)response_wire_take_chunk(rw); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + response_wire_free(rw); return; } + /* Coalesce output: several wires for one connection often land in one + * mailbox batch — mark the conn dirty and flush ONCE in the reactor's + * drain epilogue (same discipline as the steer feed / recvmmsg tick) + * instead of a full send-path walk per wire. */ switch (response_wire_kind(rw)) { case RESPONSE_WIRE_FULL: case RESPONSE_WIRE_STREAM_HEADERS: if (http3_stream_submit_response_wire(c, s, rw)) { - http3_connection_drain_out(c); - http3_connection_arm_timer(c); + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); } break; @@ -247,21 +259,25 @@ void http3_reactor_apply_response(void *arg) /* Validate-and-drop: the stream may have died (peer RST) or the * HEADERS submit may have failed (no ring) since the worker * posted this chunk. */ - size_t blen = 0; - const char *bytes = response_wire_body(rw, &blen); + zend_string *const chunk = + (zend_string *)response_wire_take_chunk(rw); - if (s->peer_closed || s->streaming_ended - || s->chunk_queue == NULL || bytes == NULL || blen == 0) { + if (chunk == NULL) { + break; + } + + if (s->peer_closed || s->streaming_ended || s->chunk_queue == NULL) { + zend_string_release(chunk); break; } - h3_chunk_queue_push(s, zend_string_init(bytes, blen, 0)); - http_server_on_stream_send(c->counters, blen); + h3_chunk_queue_push(s, chunk); + http_server_on_stream_send(c->counters, ZSTR_LEN(chunk)); (void)nghttp3_conn_resume_stream( (nghttp3_conn *)c->nghttp3_conn, s->stream_id); - http3_connection_drain_out(c); - http3_connection_arm_timer(c); + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); break; } @@ -275,8 +291,8 @@ void http3_reactor_apply_response(void *arg) (void)nghttp3_conn_resume_stream( (nghttp3_conn *)c->nghttp3_conn, s->stream_id); - http3_connection_drain_out(c); - http3_connection_arm_timer(c); + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); break; case RESPONSE_WIRE_STREAM_ABORT: @@ -297,8 +313,8 @@ void http3_reactor_apply_response(void *arg) NGHTTP3_H3_INTERNAL_ERROR); } - http3_connection_drain_out(c); - http3_connection_arm_timer(c); + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); break; } diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index 7c114270..c9309b98 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -177,10 +177,10 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, * (STREAM_END apply / buffered FULL submit). Reactor thread. */ void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t *rw); -/* Deferred inbound flow control (issue #26): grant QUIC credit for body - * bytes the handler actually drained (http_body_stream_pop) and push the - * resulting MAX_STREAM_DATA out. conn_opaque is the http3_connection_t. */ -void http3_request_body_consume(void *conn_opaque, int64_t stream_id, size_t len); +/* Deferred inbound flow control (issue #26): credit for drained body bytes, + * coalesced; flushes MAX_STREAM_DATA at 64 KiB or when the queue empties. */ +void http3_request_body_consume(struct http_request_t *req, size_t len, + bool queue_empty); /* Streaming chunk ring, factored for the reverse path: init is lazy and * idempotent (a non-NULL ring is what flips the data reader to streaming); diff --git a/src/http3/http3_listener.c b/src/http3/http3_listener.c index 4fd874d4..cf68555c 100644 --- a/src/http3/http3_listener.c +++ b/src/http3/http3_listener.c @@ -998,6 +998,16 @@ void http3_reactor_steer_flush_epilogue(void) * it had arrived on the owner's own socket. Marks the conn dirty (in dispatch) * and queues the listener for the drain-batch epilogue, so a burst of forwarded * datagrams flushes once — like a recvmmsg tick — not once per packet. */ +/* Queue this listener for the drain-batch epilogue flush. Idempotent. */ +void http3_listener_queue_epilogue_flush(http3_listener_t *l) +{ + if (!l->in_steer_flush) { + l->in_steer_flush = true; + l->steer_flush_next = tls_steer_flush_head; + tls_steer_flush_head = l; + } +} + static void http3_steer_feed_fn(void *arg) { http3_steer_msg_t *const m = (http3_steer_msg_t *)arg; @@ -1009,11 +1019,7 @@ static void http3_steer_feed_fn(void *arg) http3_connection_dispatch(target, m->data, m->datalen, m->ecn, (struct sockaddr *)&m->peer, m->peer_len); - if (!target->in_steer_flush) { - target->in_steer_flush = true; - target->steer_flush_next = tls_steer_flush_head; - tls_steer_flush_head = target; - } + http3_listener_queue_epilogue_flush(target); } pefree(m, 1); diff --git a/src/http3/http3_listener.h b/src/http3/http3_listener.h index eac1bec3..05f9d8b9 100644 --- a/src/http3/http3_listener.h +++ b/src/http3/http3_listener.h @@ -101,6 +101,7 @@ void http3_steer_group_publish(http3_steer_group_t *group, int reactor_id, void http3_steer_group_free(http3_steer_group_t *group); /* Put a listener into steering mode against its endpoint's group. */ +void http3_listener_queue_epilogue_flush(http3_listener_t *l); void http3_listener_set_steer(http3_listener_t *listener, http3_steer_group_t *group); /* If `listener` steers and `data` is a short-header datagram whose DCID decodes diff --git a/src/http_body_stream.c b/src/http_body_stream.c index 1d2a6790..283d6c7d 100644 --- a/src/http_body_stream.c +++ b/src/http_body_stream.c @@ -112,11 +112,11 @@ zend_string *http_body_stream_pop(http_request_t *req) #endif #ifdef HAVE_HTTP_SERVER_HTTP3 - /* Same discipline over QUIC: extend the stream + connection windows for - * the drained bytes and drive the MAX_STREAM_DATA out. */ + /* Same discipline over QUIC: return window credit for the drained bytes + * (coalesced inside consume). */ if (req->body_h3_conn != NULL) { - http3_request_body_consume(req->body_h3_conn, - req->body_h3_stream_id, ZSTR_LEN(data)); + http3_request_body_consume(req, ZSTR_LEN(data), + req->body_queue_head == NULL); } #endif diff --git a/src/http_server_class.c b/src/http_server_class.c index 6c4dbf78..b97fbfd6 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -2596,6 +2596,13 @@ static bool http_server_worker_response_sink(response_wire_t *rw, void *arg) /* Undeliverable: the reactor never adopts a HEADERS wire's credit ref, * so take it over — unblock the parked producer, drop the ref. */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); + + zend_string *const orphan_chunk = (zend_string *)response_wire_take_chunk(rw); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + response_wire_free(rw); return false; } From 6fbe73177ec198616614eb8b41d52e196946a184 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:35:10 +0000 Subject: [PATCH 21/33] =?UTF-8?q?chore:=20review=20P4=20=E2=80=94=20drop?= =?UTF-8?q?=20dead=20state,=20fix=20stale=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - response_wire 'complete' flag had no readers since the kind system — removed (set_body loses the param). - stream->grpc_web (H2 + H3) became write-only after the grpc_mode stamp — removed; delivery mode lives on the response. - http_body_stream.h: pop() performs transport I/O (flow-control credit) and readMessage is a second consumer — the same-thread contract now says so. Suite 307/307. --- include/core/response_wire.h | 10 ++++------ include/http2/http2_stream.h | 6 ------ include/http3/http3_stream.h | 5 ++--- include/http_body_stream.h | 13 ++++++++----- src/core/response_wire.c | 15 ++++----------- src/core/worker_dispatch.c | 6 +++--- src/http2/http2_strategy.c | 4 +--- src/http3/http3_dispatch.c | 3 +-- 8 files changed, 23 insertions(+), 39 deletions(-) diff --git a/include/core/response_wire.h b/include/core/response_wire.h index 7b76b03f..5d3af328 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -77,15 +77,14 @@ void response_wire_set_chunk(response_wire_t *rw, void *persistent_str); void *response_wire_take_chunk(response_wire_t *rw); /* Builders — copy bytes into the arena. set_status replaces; add_header - * appends; set_body replaces. All accept non-NUL-terminated spans. The header - * builders return false on allocation failure (the wire stays usable/freeable). - * `complete` is false when more body will be streamed to the reactor separately - * after this hand-off. */ + * appends; set_body replaces (FULL wires only; streamed bodies ride + * STREAM_CHUNK wires). All accept non-NUL-terminated spans; the pair + * builders return false on allocation failure (the wire stays freeable). */ void response_wire_set_status(response_wire_t *rw, int status); bool response_wire_add_header(response_wire_t *rw, const char *name_ptr, size_t name_len, const char *value_ptr, size_t value_len); -bool response_wire_set_body(response_wire_t *rw, const char *ptr, size_t len, bool complete); +bool response_wire_set_body(response_wire_t *rw, const char *ptr, size_t len); /* Trailers mirror headers: appended pairs, delivered by the transport after * the last body byte (H2 trailer HEADERS / nghttp3 submit_trailers at EOF). */ bool response_wire_add_trailer(response_wire_t *rw, @@ -96,7 +95,6 @@ bool response_wire_add_trailer(response_wire_t *rw, * receives the span length. body returns NULL with *len = 0 when unset. */ int response_wire_status(const response_wire_t *rw); const char *response_wire_body(const response_wire_t *rw, size_t *len); -bool response_wire_body_complete(const response_wire_t *rw); size_t response_wire_header_count(const response_wire_t *rw); /* Resolve header `index` (0-based). Returns false for an out-of-range index. */ diff --git a/include/http2/http2_stream.h b/include/http2/http2_stream.h index e14e3158..290329e3 100644 --- a/include/http2/http2_stream.h +++ b/include/http2/http2_stream.h @@ -108,12 +108,6 @@ struct http2_stream_t { * the grpc-status trailer. */ bool is_grpc; - /* True when this gRPC stream is a grpc-web call (content-type - * application/grpc-web...). grpc-web carries trailers inside the - * response body as a 0x80-flagged frame instead of as HTTP/2 trailers, - * because browsers cannot read HTTP trailers. */ - bool grpc_web; - /* Streaming-response chunk queue. * * Active only when the handler called HttpResponse::send(); a diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index 10e636d2..9eba0300 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -127,12 +127,11 @@ struct _http3_stream_s { bool streaming_ended; /* gRPC (issue #4). is_grpc: the request is application/grpc — route to - * the addGrpcHandler callable, default grpc-status. grpc_web: an - * application/grpc-web call — trailers ride the body as a 0x80 frame. + * the addGrpcHandler callable, default grpc-status (web/web-text + * delivery is the response's grpc_mode stamp, not stream state). * has_trailers / trailers_submitted mirror http2_stream_t: the data * reader sets NO_END_STREAM + submits nghttp3 trailers once at EOF. */ bool is_grpc; - bool grpc_web; bool has_trailers; bool trailers_submitted; diff --git a/include/http_body_stream.h b/include/http_body_stream.h index fbe993dc..0cdf86f6 100644 --- a/include/http_body_stream.h +++ b/include/http_body_stream.h @@ -6,10 +6,13 @@ * Streaming request body (issue #26). * * Per-request chunk queue + wakeup event. Producers are the protocol - * parsers (H1 on_body, H2 cb_on_data_chunk_recv, H3 on_recv_data) and - * the sole consumer is the handler coroutine via HttpRequest::readBody(). + * parsers (H1 on_body, H2 cb_on_data_chunk_recv, H3 recv_data); consumers + * are readBody() and readMessage() in the handler coroutine. * - * No locking — producer and consumer run on the same reactor thread. + * No locking — producer and consumer MUST run on the connection's thread: + * pop() grants transport flow-control credit (nghttp2 session_consume / + * ngtcp2 window extend + drain), i.e. it performs connection I/O. The + * reactor pool therefore never streams bodies (requests cross buffered). */ #ifndef HTTP_BODY_STREAM_H @@ -37,8 +40,8 @@ void http_body_stream_close(http_request_t *req); * body_error + body_eof and fires the wakeup event. */ void http_body_stream_error(http_request_t *req); -/* Pop one chunk from the queue. Returns NULL when queue is empty - * (caller checks body_eof). Caller owns returned ref. */ +/* Pop one chunk (caller owns the ref). NULL when empty — check body_eof. + * Side effect: returns flow-control credit to the transport. */ zend_string *http_body_stream_pop(http_request_t *req); /* Free the queue (releases all pending chunk refs) and dispose diff --git a/src/core/response_wire.c b/src/core/response_wire.c index adee7005..5dbb946b 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -43,7 +43,6 @@ struct response_wire_s { size_t body_off, body_len; bool body_set; - bool body_complete; wire_header_t *headers; size_t header_count; @@ -206,7 +205,7 @@ bool response_wire_add_trailer(response_wire_t *rw, name_ptr, name_len, value_ptr, value_len); } -bool response_wire_set_body(response_wire_t *rw, const char *ptr, const size_t len, const bool complete) +bool response_wire_set_body(response_wire_t *rw, const char *ptr, const size_t len) { const size_t off = arena_append(rw, ptr, len); @@ -214,10 +213,9 @@ bool response_wire_set_body(response_wire_t *rw, const char *ptr, const size_t l return false; } - rw->body_off = off; - rw->body_len = len; - rw->body_set = true; - rw->body_complete = complete; + rw->body_off = off; + rw->body_len = len; + rw->body_set = true; return true; } @@ -238,11 +236,6 @@ const char *response_wire_body(const response_wire_t *rw, size_t *len) return rw->arena + rw->body_off; } -bool response_wire_body_complete(const response_wire_t *rw) -{ - return rw->body_complete; -} - size_t response_wire_header_count(const response_wire_t *rw) { return rw->header_count; diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 1f015f48..ac39039d 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -525,12 +525,12 @@ static response_wire_t *worker_render_response(const worker_dispatch_ctx_t *ctx) zend_string *const body = http_response_get_body_str(resp); if (body != NULL && ZSTR_LEN(body) > 0) { - response_wire_set_body(rw, ZSTR_VAL(body), ZSTR_LEN(body), true); + response_wire_set_body(rw, ZSTR_VAL(body), ZSTR_LEN(body)); } else { - response_wire_set_body(rw, NULL, 0, true); + response_wire_set_body(rw, NULL, 0); } } else { - response_wire_set_body(rw, NULL, 0, true); + response_wire_set_body(rw, NULL, 0); } return rw; diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index 35e70136..f74e9055 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -246,9 +246,7 @@ static void http2_strategy_dispatch(struct http_request_t *request, stream->request, http_server_get_protocol_handlers(self->conn->server)); const bool is_grpc = grpc_mode != GRPC_MODE_NONE; - stream->is_grpc = is_grpc; - stream->grpc_web = grpc_mode == GRPC_MODE_WEB - || grpc_mode == GRPC_MODE_WEB_TEXT; + stream->is_grpc = is_grpc; /* Static-only deployments register a static mount but no PHP * handler — the static dispatch path below claims the request diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 8e38fbaf..f4c826ac 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -405,8 +405,7 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) * gRPC-only server still dispatches. */ const grpc_mode_t grpc_mode = grpc_classify(s->request, handlers); - s->is_grpc = grpc_mode != GRPC_MODE_NONE; - s->grpc_web = grpc_mode == GRPC_MODE_WEB || grpc_mode == GRPC_MODE_WEB_TEXT; + s->is_grpc = grpc_mode != GRPC_MODE_NONE; zend_fcall_t *fcall = http_protocol_pick_handler(handlers, s->is_grpc); From 51c082324745ce823aae2221864a8f82b155143e Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:41:40 +0000 Subject: [PATCH 22/33] fix(h2): sever body_h2_session at stream teardown (UAF class, H2 mirror of bf09e51) The request outlives the stream via the wrapper ref; a handler resuming after session teardown could pop a queued body chunk and call nghttp2_session_consume on the freed session. Sever the pointer at stream release and error the queue when the body never completed. Suite 307/307. --- src/http2/http2_stream.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/http2/http2_stream.c b/src/http2/http2_stream.c index 6e242f1c..74873a33 100644 --- a/src/http2/http2_stream.c +++ b/src/http2/http2_stream.c @@ -15,6 +15,7 @@ #include "Zend/zend_async_API.h" /* zend_async_trigger_event_t dispose */ #include "http2/http2_stream.h" #include "http1/http_parser.h" /* http_request_destroy */ +#include "http_body_stream.h" /* sever body_h2_session at teardown */ /* * Stream lifecycle. @@ -131,6 +132,18 @@ void http2_stream_release(http2_stream_t *stream) zval_ptr_dtor(&stream->request_zv); zval_ptr_dtor(&stream->response_zv); + /* The request can outlive this stream (wrapper ref) — sever the raw + * session pointer so a later http_body_stream_pop cannot call + * nghttp2_session_consume on a freed session, and wake a consumer + * parked on a body that will never complete. Mirrors the H3 fix. */ + if (stream->request != NULL && stream->request->body_h2_session != NULL) { + stream->request->body_h2_session = NULL; + + if (stream->request->body_streaming && !stream->request->complete) { + http_body_stream_error(stream->request); + } + } + /* Drop our own request refcount. Either races to 0 with the * wrapper above (slot freed via callback), or stays positive * pending the wrapper release. */ From 07930e6cf6e9dd40e2e7e94a3aff52a654efa5ac Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:25:58 +0000 Subject: [PATCH 23/33] test(pool): reactor-pool phpts exit via $server->stop(), not SIGKILL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'no clean cross-thread shutdown (issue #11)' comments were stale — the #93 reload rotation + #74 in-flight drain made pool stop() work. Clean exit also lets gcov flush, so the reactor-pool paths stop being coverage-invisible (the reason f81e58d needed [coverage-drop-ok]). 9 tests converted, 3x flake-run + full suite green (307/307). --- tests/phpt/server/grpc/014-grpc-reactor-pool.phpt | 4 +--- tests/phpt/server/h3/037-h3-reactor-pool-e2e.phpt | 8 ++------ tests/phpt/server/h3/038-h3-reactor-pool-post.phpt | 3 +-- tests/phpt/server/h3/039-h3-reactor-pool-static.phpt | 3 +-- tests/phpt/server/h3/040-h3-reactor-pool-steering.phpt | 3 +-- tests/phpt/server/h3/041-h3-migration-storm-guard.phpt | 3 +-- tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt | 4 +--- tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt | 4 +--- .../phpt/server/h3/048-h3-reactor-pool-backpressure.phpt | 4 +--- 9 files changed, 10 insertions(+), 26 deletions(-) diff --git a/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt b/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt index 348f6088..50d32576 100644 --- a/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt +++ b/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt @@ -76,9 +76,7 @@ spawn(function () use ($server, $port, $py) { echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; echo "reply=", $reply, "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL - * skips PHP shutdown so the worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/037-h3-reactor-pool-e2e.phpt b/tests/phpt/server/h3/037-h3-reactor-pool-e2e.phpt index e96be100..04952239 100644 --- a/tests/phpt/server/h3/037-h3-reactor-pool-e2e.phpt +++ b/tests/phpt/server/h3/037-h3-reactor-pool-e2e.phpt @@ -24,9 +24,7 @@ PHP_HTTP3_DISABLE_RETRY=1 * (previously only verified manually): the request method+uri must reach * the worker and the worker's status+body must reach the client. * - * Multi-worker scope: the gated pool only engages at setWorkers>1, so a - * clean in-process stop() is not available (issue #11) — SIGKILL after - * the client finishes, %A swallows the abrupt exit. Stats are not asserted: + * Stats are not asserted: * in the split the listeners live on thread-clean reactor contexts without * the worker's request counters, so getHttp3Stats() does not reflect them. */ @@ -91,9 +89,7 @@ spawn(function () use ($server, $port, $client_bin) { echo "stats_request_received_ge1=", ($req_recv >= 1 ? 1 : 0), "\n"; echo "stats_response_submitted_ge1=", ($resp_sub >= 1 ? 1 : 0), "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL - * skips PHP shutdown so the worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/038-h3-reactor-pool-post.phpt b/tests/phpt/server/h3/038-h3-reactor-pool-post.phpt index 2a3bba96..f9ec5bf4 100644 --- a/tests/phpt/server/h3/038-h3-reactor-pool-post.phpt +++ b/tests/phpt/server/h3/038-h3-reactor-pool-post.phpt @@ -82,8 +82,7 @@ spawn(function () use ($port, $client_bin, $body_path, $expected) { echo "status=", $status ?? -1, "\n"; echo "match=", ($body === $expected ? "yes" : "no\n got=$body\n want=$expected"), "\n"; - /* Issue #11: no clean cross-thread pool shutdown yet. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/039-h3-reactor-pool-static.phpt b/tests/phpt/server/h3/039-h3-reactor-pool-static.phpt index d2e55ded..a9bbfc4b 100644 --- a/tests/phpt/server/h3/039-h3-reactor-pool-static.phpt +++ b/tests/phpt/server/h3/039-h3-reactor-pool-static.phpt @@ -90,8 +90,7 @@ spawn(function () use ($port, $client_bin, $big_sha1) { echo "big_match=", (strlen($big) === 262144 && sha1($big) === $big_sha1 ? "yes" : "no len=" . strlen($big)), "\n"; echo "dynamic=", trim($run('/dyn')), "\n"; - /* Issue #11: no clean cross-thread pool shutdown yet. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/040-h3-reactor-pool-steering.phpt b/tests/phpt/server/h3/040-h3-reactor-pool-steering.phpt index 0f25e292..3ad656b8 100644 --- a/tests/phpt/server/h3/040-h3-reactor-pool-steering.phpt +++ b/tests/phpt/server/h3/040-h3-reactor-pool-steering.phpt @@ -97,8 +97,7 @@ spawn(function () use ($server, $port, $client_bin) { /* Observability only (kernel reuseport may keep both rebinds on the owner). */ fwrite(STDERR, "steered_total=$steered\n"); - /* Issue #11: no clean cross-thread shutdown for the pool yet. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/041-h3-migration-storm-guard.phpt b/tests/phpt/server/h3/041-h3-migration-storm-guard.phpt index 5917d212..58a39524 100644 --- a/tests/phpt/server/h3/041-h3-migration-storm-guard.phpt +++ b/tests/phpt/server/h3/041-h3-migration-storm-guard.phpt @@ -99,8 +99,7 @@ spawn(function () use ($server, $port, $client_bin) { * full 6 s deadline. Generous bound to stay robust on a loaded CI box. */ echo "no_hang=", ($elapsed < 4.0 ? 1 : 0), "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt b/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt index a1029c4a..951d2b86 100644 --- a/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt +++ b/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt @@ -74,9 +74,7 @@ spawn(function () use ($server, $port, $py) { echo "saw_wire_trailer=", (int)(strpos($out, 'HDR x-wire-trailer: crossed') !== false), "\n"; echo "saw_grpc_status=", (int)(strpos($out, 'HDR grpc-status: 0') !== false), "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL - * skips PHP shutdown so the worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt b/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt index a271a2da..f98e9cb3 100644 --- a/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt +++ b/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt @@ -72,9 +72,7 @@ spawn(function () use ($server, $port, $client_bin) { echo "status=", $status ?? -1, "\n"; echo "body=", trim($body), "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL - * skips PHP shutdown so the worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt b/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt index 94f5f330..e750a142 100644 --- a/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt +++ b/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt @@ -83,9 +83,7 @@ spawn(function () use ($server, $port, $client_bin, $chunks, $chunk_len) { echo "len_ok=", (int)(strlen($body) === strlen($expect)), "\n"; echo "hash_ok=", (int)(hash('xxh128', $body) === hash('xxh128', $expect)), "\n"; - /* Issue #11: no clean cross-thread shutdown for the pool yet; SIGKILL - * skips PHP shutdown so the worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); From f06203a51a663b6b51fdc128a0dcc98b889b1a75 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:25:26 +0000 Subject: [PATCH 24/33] test: worker-pool phpts exit via $server->stop() where it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 020/021/039/044/050 converted from SIGKILL (the issue-#11 comments were stale — pool stop() works since #93/#74). static/004 stays on SIGKILL with an honest comment: stop() there trips the ext/async debug assert 'The event loop must be stopped' — a static-handler libuv handle survives worker teardown (real leak, reproducible 6/6, to be fixed separately). Suite 307/307. --- tests/phpt/server/core/020-builtin-worker-pool.phpt | 7 ++----- tests/phpt/server/core/021-bootloader.phpt | 4 ++-- tests/phpt/server/core/039-unix-listener-workers.phpt | 6 ++---- tests/phpt/server/core/044-worker-pool-bound-handler.phpt | 5 ++--- tests/phpt/server/core/050-request-scope-workers.phpt | 6 ++---- tests/phpt/server/static/004-static-workers.phpt | 7 ++++--- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/tests/phpt/server/core/020-builtin-worker-pool.phpt b/tests/phpt/server/core/020-builtin-worker-pool.phpt index 321064ec..75d5cf70 100644 --- a/tests/phpt/server/core/020-builtin-worker-pool.phpt +++ b/tests/phpt/server/core/020-builtin-worker-pool.phpt @@ -41,7 +41,7 @@ $server->addHttpHandler(function ($req, $res) { $res->setStatusCode(200)->setBody('worker-tid=' . (function_exists('zend_thread_id') ? zend_thread_id() : '0')); }); -spawn(function () use ($port) { +spawn(function () use ($port, $server) { /* Workers need a moment to thread up + bind. */ usleep(400000); @@ -56,10 +56,7 @@ spawn(function () use ($port) { } echo "got_responses=", ($hits >= 1 ? 1 : 0), "\n"; echo "done\n"; - /* Clean cross-thread broadcast is a follow-up (issue #11). Kill the - * whole process — phpt only checks stdout up to this point. SIGKILL - * skips PHP shutdown so worker threads can't deadlock the exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/core/021-bootloader.phpt b/tests/phpt/server/core/021-bootloader.phpt index 43e35b89..d98f8886 100644 --- a/tests/phpt/server/core/021-bootloader.phpt +++ b/tests/phpt/server/core/021-bootloader.phpt @@ -43,7 +43,7 @@ $server->addHttpHandler(function ($req, $res) { ); }); -spawn(function () use ($port) { +spawn(function () use ($port, $server) { usleep(400000); $hits_with_boot = 0; for ($i = 0; $i < 8; $i++) { @@ -55,7 +55,7 @@ spawn(function () use ($port) { } echo "boot_hits>=1=", ($hits_with_boot >= 1 ? 1 : 0), "\n"; echo "done\n"; - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/core/039-unix-listener-workers.phpt b/tests/phpt/server/core/039-unix-listener-workers.phpt index c09a9bf3..d922c26a 100644 --- a/tests/phpt/server/core/039-unix-listener-workers.phpt +++ b/tests/phpt/server/core/039-unix-listener-workers.phpt @@ -38,7 +38,7 @@ $server->addStaticHandler( (new StaticHandler('/static/', $root))->disableIndex() ); -spawn(function () use ($path, $root) { +spawn(function () use ($path, $root, $server) { usleep(400000); // let the pool thread up and adopt the shared fd $hits = 0; @@ -57,9 +57,7 @@ spawn(function () use ($path, $root) { @rmdir($root); @unlink($path); - /* Issue #11: clean cross-thread shutdown is a follow-up. SIGKILL skips - * PHP shutdown so worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/core/044-worker-pool-bound-handler.phpt b/tests/phpt/server/core/044-worker-pool-bound-handler.phpt index 082fd6d3..436f3286 100644 --- a/tests/phpt/server/core/044-worker-pool-bound-handler.phpt +++ b/tests/phpt/server/core/044-worker-pool-bound-handler.phpt @@ -62,7 +62,7 @@ $server = new HttpServer($config); $handler = new BoundHandler(); $server->addHttpHandler($handler->handle(...)); // closure bound to $handler -spawn(function () use ($port) { +spawn(function () use ($port, $server) { /* Workers need a moment to thread up + bind. */ usleep(400000); @@ -77,8 +77,7 @@ spawn(function () use ($port) { echo "bound_handler_ok=", ($ok === 16 ? 1 : 0), "\n"; echo "done\n"; - /* SIGKILL — skip PHP shutdown so worker threads can't deadlock exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/core/050-request-scope-workers.phpt b/tests/phpt/server/core/050-request-scope-workers.phpt index b8dfa82d..3c298d2d 100644 --- a/tests/phpt/server/core/050-request-scope-workers.phpt +++ b/tests/phpt/server/core/050-request-scope-workers.phpt @@ -39,16 +39,14 @@ $server->addHttpHandler(function ($req, $res) { $res->setStatusCode(200)->setBody($ctx === null ? "ctx=null\n" : "ctx=set\n"); }); -spawn(function () use ($port) { +spawn(function () use ($port, $server) { usleep(400000); // let the pool thread up and the worker adopt its config $url = sprintf('http://127.0.0.1:%d/', $port); $body = trim((string) shell_exec(sprintf('curl -s --max-time 2 %s', escapeshellarg($url)))); echo "worker_scope_off=", ($body === 'ctx=null' ? 'yes' : "no($body)"), "\n"; echo "done\n"; - /* Issue #11: clean cross-thread shutdown is a follow-up. SIGKILL skips - * PHP shutdown so worker threads cannot deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); diff --git a/tests/phpt/server/static/004-static-workers.phpt b/tests/phpt/server/static/004-static-workers.phpt index 27ba548b..c9a77490 100644 --- a/tests/phpt/server/static/004-static-workers.phpt +++ b/tests/phpt/server/static/004-static-workers.phpt @@ -63,9 +63,10 @@ spawn(function () use ($port) { * worker that serves the file body byte-exact. */ echo "hits=", ($hits === 16 ? 'all' : (string) $hits), "\n"; echo "done\n"; - /* Issue #11: clean cross-thread shutdown is a follow-up; SIGKILL - * exits without running PHP shutdown so worker threads cannot - * deadlock on exit. */ + /* $server->stop() currently aborts here: a static-handler libuv handle + * survives worker teardown and trips the ext/async debug assert + * "The event loop must be stopped" (scheduler.c fiber_entry). SIGKILL + * until that teardown leak is fixed. */ posix_kill(getmypid(), SIGKILL); }); From dfe1680928b24188806c349eec266bf4ed122bd6 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:38:43 +0000 Subject: [PATCH 25/33] =?UTF-8?q?test:=20static/004=20exits=20via=20stop()?= =?UTF-8?q?=20too=20=E2=80=94=20the=20'abort'=20was=20the=20experiment's?= =?UTF-8?q?=20own=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The f06203a claim of a static-handler teardown leak was wrong: the failing variant had also captured $server into the test's register_shutdown_function closure (batch-regex slip), which held the object past scheduler finalize and tripped the loop-alive assert. A/B-confirmed: capture only in the client spawn -> clean stop() passes (3x). All worker-pool phpts are now SIGKILL-free except the hot-reload family. Suite 307/307. --- tests/phpt/server/core/020-builtin-worker-pool.phpt | 4 +--- tests/phpt/server/static/004-static-workers.phpt | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/phpt/server/core/020-builtin-worker-pool.phpt b/tests/phpt/server/core/020-builtin-worker-pool.phpt index 75d5cf70..83b05766 100644 --- a/tests/phpt/server/core/020-builtin-worker-pool.phpt +++ b/tests/phpt/server/core/020-builtin-worker-pool.phpt @@ -16,9 +16,7 @@ if (!exec('curl --version 2>/dev/null')) die('skip curl CLI not available'); * workers' completion. Each worker re-binds the same TCP listener; * the kernel load-balances accept() across them via SO_REUSEPORT. * - * This test only verifies the spin-up + serve path. Clean cross-thread - * shutdown via $server->stop() on the parent is a follow-up — the test - * exits hard after collecting responses. */ + * Verifies spin-up + serve, then exits via $server->stop(). */ use TrueAsync\HttpServer; use TrueAsync\HttpServerConfig; diff --git a/tests/phpt/server/static/004-static-workers.phpt b/tests/phpt/server/static/004-static-workers.phpt index c9a77490..047aaf2d 100644 --- a/tests/phpt/server/static/004-static-workers.phpt +++ b/tests/phpt/server/static/004-static-workers.phpt @@ -46,7 +46,7 @@ $server->addStaticHandler( (new StaticHandler('/static/', $root))->disableIndex() ); -spawn(function () use ($port) { +spawn(function () use ($port, $server) { /* Workers need a moment to thread up + bind. */ usleep(400000); @@ -63,11 +63,7 @@ spawn(function () use ($port) { * worker that serves the file body byte-exact. */ echo "hits=", ($hits === 16 ? 'all' : (string) $hits), "\n"; echo "done\n"; - /* $server->stop() currently aborts here: a static-handler libuv handle - * survives worker teardown and trips the ext/async debug assert - * "The event loop must be stopped" (scheduler.c fiber_entry). SIGKILL - * until that teardown leak is fixed. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start(); From 43454ead7ef172fb5de11de5a2d5ef595acfe783 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:03:59 +0000 Subject: [PATCH 26/33] docs: state the ABORT rationale once (at the wire kind), not at four sites --- include/core/stream_credit.h | 7 ++----- include/core/worker_dispatch.h | 10 +++------- src/core/worker_dispatch.c | 22 ++++++---------------- src/http3/http3_dispatch.c | 5 ----- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/include/core/stream_credit.h b/include/core/stream_credit.h index 6ef69d49..7fe11b16 100644 --- a/include/core/stream_credit.h +++ b/include/core/stream_credit.h @@ -79,11 +79,8 @@ static inline void stream_credit_mark_dead(stream_credit_t *sc) zend_atomic_int_store_ex(&sc->dead, 1); } -/* Drop-site helper: nobody will retire this side's bytes anymore — unblock - * a parked producer FIRST (mark dead), then release this side's ref. - * NULL-safe. The ordering is the correctness story: a producer must be able - * to observe `dead` before the block can vanish. Use this at every point a - * credit-carrying wire dies undelivered or a stream is torn down. */ +/* Drop-site helper, NULL-safe. Mark dead BEFORE release — a parked producer + * must observe `dead` before the block can vanish. */ static inline void stream_credit_abandon(stream_credit_t *sc) { if (sc != NULL) { diff --git a/include/core/worker_dispatch.h b/include/core/worker_dispatch.h index 2a4dfe37..13429a3c 100644 --- a/include/core/worker_dispatch.h +++ b/include/core/worker_dispatch.h @@ -33,13 +33,9 @@ typedef struct http_server_object http_server_object; -/* Sink for rendered response wires, invoked on the worker thread — from the - * handler coroutine's dispose (FULL / STREAM_END) and from the streaming ops - * during the handler (STREAM_HEADERS / STREAM_CHUNK). Ownership of `rw` - * transfers to the sink in EVERY outcome. Returns false when delivery - * definitively failed (reactor gone / mailbox full past the bounded retry): - * for STREAM_* wires the caller must then fail the stream (abort at - * dispose), never truncate it silently. */ +/* Sink for response wires, worker thread. Owns `rw` in every outcome. + * Returns false on definitive delivery failure — the caller must then fail + * the stream (STREAM_* fragments must never be dropped silently). */ typedef bool (*worker_response_sink_fn)(response_wire_t *rw, void *sink_arg); /* Take ownership of `req` (a persistent reactor-built or ZMM request, refcount diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index ac39039d..46e7ae7f 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -218,9 +218,8 @@ static void worker_wire_copy_trailers(response_wire_t *rw, zend_object *resp) } /* Hand a wire to the reverse channel; the sink owns it in every outcome. - * A failed STREAM_* delivery marks the stream failed — the terminal wire - * then becomes an ABORT, so the peer never mistakes the truncated body for - * a complete response. Returns the delivery verdict. */ + * A failed STREAM_* delivery marks the stream failed (terminal wire becomes + * an ABORT — see response_wire_kind_t). */ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) { const response_wire_kind_t kind = response_wire_kind(rw); @@ -376,8 +375,7 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) response_wire_set_kind(cw, RESPONSE_WIRE_STREAM_CHUNK); - /* One copy: handler ZMM string -> persistent; the reactor adopts the - * ref straight into its chunk ring. */ + /* one copy: ZMM -> persistent; the reactor adopts the ref into its ring */ zend_string *const pchunk = zend_string_init(ZSTR_VAL(chunk), ZSTR_LEN(chunk), 1); @@ -394,11 +392,7 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) ctx->posted_bytes += chunk_len; if (!worker_stream_wait_credit(ctx)) { - /* Credit timeout / cancellation while parked: the peer stopped - * retiring bytes but the QUIC stream may still be alive — flag the - * stream so dispose sends an ABORT, not a clean END that would make - * the truncation look like a complete response. */ - ctx->stream_failed = true; + ctx->stream_failed = true; /* credit timeout / cancelled while parked */ return HTTP_STREAM_APPEND_STREAM_DEAD; } @@ -441,8 +435,6 @@ static void worker_stream_mark_ended(void *vctx) return; } - /* A failed stream terminates with an ABORT (reactor resets the QUIC - * stream); only a healthy one ends cleanly, with trailers. */ if (ctx->stream_failed) { response_wire_set_kind(ew, RESPONSE_WIRE_STREAM_ABORT); } else { @@ -578,10 +570,8 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) worker_stream_mark_ended(ctx); } - /* Safety net: a started stream must ALWAYS get a terminal wire. - * grpc_call_finish's delivery can fail mid-way (e.g. the trailer - * frame append hit a failed stream) without ever reaching an END — - * the reactor would then park its data reader forever. Idempotent. */ + /* A started stream must ALWAYS get a terminal wire (grpc finish can + * fail mid-way; the reactor's reader would park forever). Idempotent. */ if (ctx->stream_started && !ctx->stream_ended) { worker_stream_mark_ended(ctx); } diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index f4c826ac..16dbb54b 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -296,11 +296,6 @@ void http3_reactor_apply_response(void *arg) break; case RESPONSE_WIRE_STREAM_ABORT: - /* The worker's stream died mid-flight (credit timeout / - * cancellation / dropped fragment): RESET the QUIC stream so - * the peer sees an abort — never a clean FIN over a truncated - * body. streaming_ended stops the data reader from being - * resumed for a stream that will not get more chunks. */ if (s->peer_closed || s->streaming_ended) { break; } From 080c402c09bfc6737bac04c30a0c9aadeff5f2bc Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:12:34 +0300 Subject: [PATCH 27/33] #104: ERRORS.md --- ERRORS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ERRORS.md diff --git a/ERRORS.md b/ERRORS.md new file mode 100644 index 00000000..4c2b4b32 --- /dev/null +++ b/ERRORS.md @@ -0,0 +1,25 @@ +1. Комментари в коде говно!!!! Убрать лишние комментарии, которые не несут смысловой нагрузки! +2. http_compression_message.h - отдельный модуль ради двуз функций? оверинженеринг!!! + if (req == NULL || req->headers == NULL) { + return false; + } + +3. в коде много проверок, которые скорее всего не нужны +4. имена параметров говно: zval *ct = zend_hash_str_find(req->headers, "content-type", - что такое ct? +5. grpc_request_is_grpc и grpc_request_is_grpc_web - явное дублирование кода. и не только! + +6. Максимально grpc_request_mode не эффективный код!!!! Можно было бы сперва получить строку потом сравнить в switch +7. grpc_web_text_decode - можно ли предсказать буфер? примерно? +8. Куча smart_str_appendl +9. switch (s[n - 1]) { + case 'H': unit_ns = 3600ULL * 1000000000ULL; break; /* hours */ + case 'M': unit_ns = 60ULL * 1000000000ULL; break; /* minutes */ + case 'S': unit_ns = 1000000000ULL; break; /* seconds */ + case 'm': unit_ns = 1000000ULL; break; /* millis */ + case 'u': unit_ns = 1000ULL; break; /* micros */ + case 'n': unit_ns = 1ULL; break; /* nanos */ + default: return 0; +пахнет отдельной общей функцией + +10. grpc_message_inflate можно вообще в коде не определять и не вызывать если нет поддержки сжатия +11. \ No newline at end of file From 0b95fc8d0e249467e5047f4166f5b43485002634 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:10:22 +0300 Subject: [PATCH 28/33] =?UTF-8?q?docs:=20fix=20ERRORS2.md=20=E2=80=94=20re?= =?UTF-8?q?move=20duplicate=20entry,=20correct=20line=20numbers/attributio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified all 13 findings against current source. Fixed inaccurate line ranges/function names in items #2, #7, #10, and the http3_listener_remove_connection note; removed an accidental duplicate bullet. --- ERRORS2.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ERRORS2.md diff --git a/ERRORS2.md b/ERRORS2.md new file mode 100644 index 00000000..a471efbb --- /dev/null +++ b/ERRORS2.md @@ -0,0 +1,28 @@ +# HTTP/3 реакторы — находки + +1. Модуль: `src/core/reactor_pool.c` — Функция: `reactor_pool_msleep` (строки 95-103), используется в `reactor_pool_exec` (289-324) и `reactor_pool_destroy` (367-392) — busy-wait поллинг (Sleep(1)/nanosleep в цикле) вместо condvar/event. Причина, видимо: `thread_mailbox_t` — lock-free кольцевой буфер без мьютекса; городить condvar (плюс мьютекс, плюс разный API POSIX/Win32) ради этого пути не стали. НЕ на hot path запроса — вызывается только при старте/остановке listener'а (`http_server_class.c:2371,2411`) и в тестах, так что 1мс задержка не критична сейчас. Но конструкция не согласуется с остальным lock-free дизайном — если `reactor_pool_exec` когда-нибудь начнут звать чаще, придётся переделывать. + +2. Модуль: `src/core/reactor_pool.c` — Функции: `reactor_pool_post` (строки 260-287), `reactor_pool_post_exec` (326-356); вызываются как callback'и из `src/http3/http3_dispatch.c` (`http3_reactor_apply_response`, передаётся в `http_server_class.c:2570/2592`) и из `src/http3/http3_stream.c` (`http3_stream_release_via_request`, строки 96-97, через callback `http3_reactor_consumed_apply`) — malloc/free на каждое сообщение в hot path. Нужен freelist/кольцевой буфер. + +3. Модуль: `src/http3/http3_connection.c` — Функция: `http3_build_listener_local` (строки 145-178) — повторный inet_pton/htons на каждый drain/dispatch, хотя локальный адрес слушателя не меняется. Закэшировать sockaddr_storage при создании listener'а. + +4. Модуль: `src/http3/http3_static_response.c` (весь файл, 314 строк, для сравнения `src/http2/http2_static_response.c` — 1012 строк) — Функция: `h3_static_pump_entry` (строки ~113-206), лишняя копия через `zend_string_init` на строке ~184 — нет глобального лимита памяти на раздачу статики (в отличие от H2). Уточнить, осознанный пропуск или недоглядели. + +5. Модуль: `src/http3/http3_stream.c` — Функция: `http3_stream_release` (строки 209-221) — O(n) проход по односвязному списку стримов на unlink; при большом числе стримов на соединение — O(n²) суммарно на закрытие. Сделать двусвязный список. + +6. Модуль: `src/http3/http3_connection.c` — Функция: `http3_connection_free` (строки 995-1015) — обнуляет `s->conn` перед вызовом `http3_stream_release` (см. п.5), чтобы обойти unlink-проход. Хрупкая неявная инвариант между файлами — добавить assert в `http3_stream_release` (`src/http3/http3_stream.c:210`). + +7. Модуль: `src/http3/http3_steer.c` — Функция: `http3_steer_block` (строки 36-54), вызывается транзитивно через `http3_steer_mask` из `http3_steer_encode` (98-118) и `http3_steer_decode` (120-133) — EVP_CIPHER_CTX_new/free на каждый вызов (не только минтинг CID, но и conn-map-miss). Сделать thread-local переиспользуемый контекст (EVP_CIPHER_CTX_reset). + +8. Модуль: `src/http3/http3_dispatch.c` — Функция: `http3_stream_dispatch_to_worker` (строки 157-204) — **при backpressure запрос молча дропается вместо ответа клиенту.** Пороги: `H3_WORKER_SPILL_DEPTH=64` (строка 124, спилл на другой воркер) и `WORKER_INBOX_CAPACITY=1024` (`worker_inbox.c:21`, жёсткий потолок очереди). Между 64 и 1024 нет градации серьёзности — воркер с глубиной 65 и воркер с глубиной 1020 обрабатываются одинаково (просто "least busy из доступных"), никакого fail-fast раньше хардкапа. Когда `worker_inbox_post` всё же проваливается на 1024 (строка 197), код просто откатывает `refcount`/`dispatched` и возвращается — **клиент не получает ответа вообще**, зависает до QUIC PTO/RST. Нужно вместо тихого дропа отправлять явный 503 (RESET_STREAM / QPACK-ответ с Retry-After) уже на пороге хардкапа, и рассмотреть промежуточный уровень (например, при depth > ~80% от 1024 по всему пулу — сразу 503 не тратя время на скан least_busy). + +9. Модуль: `src/http3/http3_callbacks.c` — Функции: `stream_reset_cb` (строки 2192-2209) и `stream_stop_sending_cb` (строки 2211-2228) — тела **побайтово идентичны** (тот же null-check, тот же единственный вызов `nghttp3_conn_shutdown_stream_read` с теми же аргументами), хотя триггерятся разными событиями ngtcp2 (RESET_STREAM vs STOP_SENDING). Вынести в общий static-хелпер. + +10. Модуль: `src/http2/http2_static_response.c` — Функция: `h2_static_build_nv` (строки 748-753, запись `:status` псевдо-заголовка) — **дублирует** уже существующий хелпер `h2_nv_set_status` (`src/http2/http2_session.c:1757-1769`; форматирование 3 цифр статуса — тот же branchless-код побайтово, но `h2_static_build_nv` дополнительно клэмпит статус в диапазон 100-999 с fallback на 200, чего нет в `h2_nv_set_status`, — не полный побайтовый дубль, а его надмножество). Раздача статики решила заново написать построение `:status` вместо вызова готовой функции из `http2_session.c`. Заменить на вызов `h2_nv_set_status`, перенеся клэмп при необходимости. + +11. Модуль: `src/http2/http2_static_response.c` vs `src/http3/http3_static_response.c` — публичные точки входа `h2_stream_send_static_response` (строки 798-1011) и `h3_stream_send_static_response` (строки 224-313) имеют идентичную сигнатуру и почти одинаковую структуру head-only ветки (dispose file_io → submit headers-only ответ → drain → on_done) и одинаковый набор полей state (`stream/conn/file_io/body_offset/body_length/on_done/user`). H3 корректно не дублирует построение заголовков (делегирует в `http3_stream_submit_response`, переиспользуя общий `h3_nv_push` из `http3_callbacks.c`), а вот у H2 header-billding — свой отдельный `h2_static_build_nv`, который частично дублирует `http2_session.c` (см. п.10). Общий boilerplate инициализации `state` — кандидат на вынос в разделяемый хелпер, но разные асинхронные модели (nghttp2 event-callback chain vs TrueAsync coroutine) делают полное объединение нетривиальным — низкий приоритет. + +## Мелкие / некритичные + +- Модуль: `src/http3/http3_listener.c` — Функция: `http3_listener_remove_connection` (строки 1265-1327) — O(n) проход `conn_list` на каждый reap + 3 прямых `zend_hash_str_del` (1298, 1305, 1317) с memcmp-дедупом, плюс делегированный цикл переменной длины по issued CID в `http3_connection_unregister_all_issued_cids` (`http3_connection.c:926`); reap редкий относительно потока пакетов. +- Дублирование callback-таблиц `src/http2/http2_session.c` vs `src/http3/http3_callbacks.c` — ожидаемо (разные C-библиотеки: nghttp2 vs nghttp3/ngtcp2), не SRP-нарушение. From 5840e5161f8a336bc576099265becac0cc40f1d4 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:28:18 +0000 Subject: [PATCH 29/33] =?UTF-8?q?refactor(grpc):=20apply=20ERRORS.md=20rev?= =?UTF-8?q?iew=20=E2=80=94=20collapse=20classification,=20drop=20message.h?= =?UTF-8?q?,=20tighten=20codec=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - classify content-type in one pass (grpc_request_mode): fetch the header once, compare the application/grpc prefix once, then only the variant suffix; grpc_request_is_grpc / _is_grpc_web removed - shared grpc_write_frame_header for the 5-byte frame prefix - grpc_web_trailer_frame: two-pass exact-size fill, no smart_str - grpc_web_text_decode: preallocate (len/4)*3 output - grpc_message_inflate/deflate exist only under HAVE_HTTP_COMPRESSION; call sites ifdef'd, stubs removed - http_compression_message.h deleted; one-shot gzip declarations moved into http_compression_request.h - rename ct/to locals, trim noise comments --- .../compression/http_compression_message.h | 38 --- .../compression/http_compression_request.h | 18 ++ src/compression/http_compression_gzip.c | 4 +- src/compression/http_compression_request.c | 3 +- src/core/worker_dispatch.c | 2 +- src/grpc/grpc.c | 227 ++++++++---------- src/grpc/grpc.h | 46 ++-- src/http2/http2_strategy.c | 2 +- src/http_request.c | 13 +- src/http_response.c | 4 + 10 files changed, 148 insertions(+), 209 deletions(-) delete mode 100644 include/compression/http_compression_message.h diff --git a/include/compression/http_compression_message.h b/include/compression/http_compression_message.h deleted file mode 100644 index 6ff7316e..00000000 --- a/include/compression/http_compression_message.h +++ /dev/null @@ -1,38 +0,0 @@ -/* - +----------------------------------------------------------------------+ - | Copyright (c) TrueAsync | - +----------------------------------------------------------------------+ - | Licensed under the Apache License, Version 2.0 | - +----------------------------------------------------------------------+ -*/ - -/* - * One-shot, whole-buffer gzip (RFC 1952) helpers that reuse the module's - * existing zlib(-ng) backend. The streaming http_encoder_t vtable and the - * request-body decoder both operate on the HTTP *body*; message-level - * compression (e.g. gRPC per-message, where each framed message carries its - * own compressed flag) needs to compress/decompress a single buffer at a - * time, so it goes through these instead of duplicating a zlib wrapper. - * - * Only defined when a zlib backend is compiled in (HAVE_HTTP_COMPRESSION); - * the deflate side lives in http_compression_gzip.c, the inflate side is - * shared with the request-body decoder in http_compression_request.c. - */ -#ifndef HTTP_COMPRESSION_MESSAGE_H -#define HTTP_COMPRESSION_MESSAGE_H - -#include "php.h" /* zend_string */ -#include - -/* Compress `in` as a standalone gzip member. Returns a new zend_string the - * caller owns, or NULL on failure. `level` is clamped to 1..9. */ -zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, - int level); - -/* Inflate a standalone gzip (or zlib) member into a fresh zend_string. - * Returns 0 on success (*out set, caller owns it), -1 on a malformed stream, - * and -2 when the output would exceed `max_out` (max_out == 0 → unbounded). */ -int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, - size_t max_out, zend_string **out); - -#endif /* HTTP_COMPRESSION_MESSAGE_H */ diff --git a/include/compression/http_compression_request.h b/include/compression/http_compression_request.h index 6fb75e5c..efed5afe 100644 --- a/include/compression/http_compression_request.h +++ b/include/compression/http_compression_request.h @@ -19,6 +19,8 @@ #ifndef HTTP_COMPRESSION_REQUEST_H #define HTTP_COMPRESSION_REQUEST_H +#include "php.h" /* zend_string */ + #ifdef __cplusplus extern "C" { #endif @@ -47,6 +49,22 @@ int http_compression_decode_request_body(http_request_t *req, int http_compression_decode_request_brotli(http_request_t *req, size_t cap); int http_compression_decode_request_zstd(http_request_t *req, size_t cap); +/* One-shot whole-buffer gzip helpers for message-level compression (gRPC + * per-message frames), reusing the same zlib backend as the body paths. + * + * deflate: compress `in` as a standalone gzip member; returns a new + * zend_string the caller owns, or NULL on failure. `level` clamped to 1..9. + * Defined in http_compression_gzip.c. + * + * inflate: returns 0 on success (*out set, caller owns it), -1 on a + * malformed stream, -2 when the output would exceed `max_out` + * (max_out == 0 → unbounded). Shares the inflate loop + zip-bomb guard + * with the request-body decoder in this TU. */ +zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, + int level); +int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, + size_t max_out, zend_string **out); + #ifdef __cplusplus } #endif diff --git a/src/compression/http_compression_gzip.c b/src/compression/http_compression_gzip.c index ea0647f0..6b895540 100644 --- a/src/compression/http_compression_gzip.c +++ b/src/compression/http_compression_gzip.c @@ -29,7 +29,7 @@ #endif #include "compression/http_encoder.h" -#include "compression/http_compression_message.h" +#include "compression/http_compression_request.h" #include "php.h" /* emalloc / efree — unit tests provide a minimal Zend */ #include @@ -188,7 +188,7 @@ const http_encoder_vtable_t http_compression_gzip_vt = { .destroy = gz_destroy, }; -/* One-shot whole-buffer gzip compress — see http_compression_message.h. Sizes +/* One-shot whole-buffer gzip compress — see http_compression_request.h. Sizes * the output via deflateBound and finishes in a single deflate() pass, so it * never needs the streaming NEED_OUTPUT loop above. */ zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, diff --git a/src/compression/http_compression_request.c b/src/compression/http_compression_request.c index 1b72c5f4..d2ae1220 100644 --- a/src/compression/http_compression_request.c +++ b/src/compression/http_compression_request.c @@ -29,7 +29,6 @@ #include "php_http_server.h" #include "http1/http_parser.h" #include "compression/http_compression_request.h" -#include "compression/http_compression_message.h" #include "core/body_pool.h" #ifdef HAVE_ZLIB_NG @@ -50,7 +49,7 @@ /* Inflate a standalone gzip/zlib member into a fresh zend_string. Shared by * the request-body decoder below and the message-level path - * (http_compression_message.h — e.g. gRPC per-message decompression), so the + * (e.g. gRPC per-message decompression), so the * inflate loop + zip-bomb guard live in exactly one place. Returns 0 on * success (*out set, caller owns it), -1 on a malformed stream, -2 when the * output would exceed max_out (max_out == 0 → unbounded). */ diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 46e7ae7f..91c61359 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -21,7 +21,7 @@ #include "zend_exceptions.h" /* zend_clear_exception */ #include "http_response_internal.h" /* http_response_replace_stream_ops */ #include "core/stream_credit.h" /* per-stream flow-control credit */ -#include "grpc/grpc.h" /* grpc_request_is_grpc / _is_grpc_web */ +#include "grpc/grpc.h" /* grpc_classify */ #include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ #include "Zend/zend_hrtime.h" /* zend_hrtime — request-service sampling */ diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index bf6aaa31..e6c5c77d 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -12,75 +12,62 @@ */ #include "grpc.h" -#include "http1/http_parser.h" /* http_request_t */ +#include "http1/http_parser.h" #include "zend_smart_str.h" -#include "ext/standard/base64.h" /* grpc-web-text per-frame transform */ -#include "core/http_protocol_handlers.h" /* handler-registry gate (grpc_classify) */ +#include "ext/standard/base64.h" +#include "core/http_protocol_handlers.h" #include -#include /* strncasecmp */ +#include #ifdef HAVE_HTTP_COMPRESSION -# include "compression/http_compression_message.h" /* one-shot gzip */ +# include "compression/http_compression_request.h" #endif -bool grpc_request_is_grpc(const http_request_t *req) +grpc_mode_t grpc_request_mode(const http_request_t *req) { - if (req == NULL || req->headers == NULL) { - return false; + if (req->headers == NULL) { + return GRPC_MODE_NONE; } - zval *ct = zend_hash_str_find(req->headers, "content-type", - sizeof("content-type") - 1); + zval *content_type = zend_hash_str_find(req->headers, "content-type", + sizeof("content-type") - 1); - if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { - return false; + if (content_type == NULL || Z_TYPE_P(content_type) != IS_STRING) { + return GRPC_MODE_NONE; } - const size_t prefix_len = sizeof(GRPC_CONTENT_TYPE) - 1; /* 16 */ + const char *val = Z_STRVAL_P(content_type); + size_t len = Z_STRLEN_P(content_type); - return Z_STRLEN_P(ct) >= prefix_len - && strncasecmp(Z_STRVAL_P(ct), GRPC_CONTENT_TYPE, prefix_len) == 0; -} + const size_t grpc_len = sizeof(GRPC_CONTENT_TYPE) - 1; -bool grpc_request_is_grpc_web(const http_request_t *req) -{ - if (req == NULL || req->headers == NULL) { - return false; + if (len < grpc_len || strncasecmp(val, GRPC_CONTENT_TYPE, grpc_len) != 0) { + return GRPC_MODE_NONE; } - zval *ct = zend_hash_str_find(req->headers, "content-type", - sizeof("content-type") - 1); + /* Past "application/grpc" — the suffix picks the variant. Web-text + * before web: "-web" is a prefix of "-web-text". */ + val += grpc_len; + len -= grpc_len; - if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { - return false; + const size_t text_len = sizeof(GRPC_WEB_TEXT_SUFFIX) - 1; + const size_t web_len = sizeof(GRPC_WEB_SUFFIX) - 1; + + if (len >= text_len && strncasecmp(val, GRPC_WEB_TEXT_SUFFIX, text_len) == 0) { + return GRPC_MODE_WEB_TEXT; } - const size_t prefix_len = sizeof(GRPC_WEB_CONTENT_TYPE_PREFIX) - 1; /* 20 */ + if (len >= web_len && strncasecmp(val, GRPC_WEB_SUFFIX, web_len) == 0) { + return GRPC_MODE_WEB; + } - return Z_STRLEN_P(ct) >= prefix_len - && strncasecmp(Z_STRVAL_P(ct), GRPC_WEB_CONTENT_TYPE_PREFIX, - prefix_len) == 0; + return GRPC_MODE_NATIVE; } bool grpc_request_is_grpc_web_text(const http_request_t *req) { - if (req == NULL || req->headers == NULL) { - return false; - } - - zval *ct = zend_hash_str_find(req->headers, "content-type", - sizeof("content-type") - 1); - - if (ct == NULL || Z_TYPE_P(ct) != IS_STRING) { - return false; - } - - const size_t prefix_len = sizeof(GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX) - 1; - - return Z_STRLEN_P(ct) >= prefix_len - && strncasecmp(Z_STRVAL_P(ct), GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX, - prefix_len) == 0; + return grpc_request_mode(req) == GRPC_MODE_WEB_TEXT; } grpc_mode_t grpc_classify(const http_request_t *req, HashTable *handlers) @@ -95,27 +82,22 @@ grpc_mode_t grpc_classify(const http_request_t *req, HashTable *handlers) return mode; } -grpc_mode_t grpc_request_mode(const http_request_t *req) +zend_string *grpc_web_text_encode(const char *in, const size_t len) { - if (!grpc_request_is_grpc(req)) { - return GRPC_MODE_NONE; - } - - /* Most-specific prefix first: is_grpc_web matches web-text too. */ - if (grpc_request_is_grpc_web_text(req)) { - return GRPC_MODE_WEB_TEXT; - } - - if (grpc_request_is_grpc_web(req)) { - return GRPC_MODE_WEB; - } - - return GRPC_MODE_NATIVE; + return php_base64_encode((const unsigned char *)in, len); } -zend_string *grpc_web_text_encode(const char *in, const size_t len) +static bool b64_decode_append(smart_str *out, const char *in, size_t len) { - return php_base64_encode((const unsigned char *)in, len); + zend_string *part = php_base64_decode_ex((const unsigned char *)in, len, + /*strict=*/false); + if (part == NULL) { + return false; + } + + smart_str_append(out, part); + zend_string_release(part); + return true; } zend_string *grpc_web_text_decode(const char *in, const size_t len) @@ -131,6 +113,8 @@ zend_string *grpc_web_text_decode(const char *in, const size_t len) smart_str out = {0}; size_t start = 0; + smart_str_alloc(&out, (len / 4) * 3 + 3, 0); + for (size_t i = 0; i < len; i++) { if (in[i] != '=') { continue; @@ -142,74 +126,65 @@ zend_string *grpc_web_text_decode(const char *in, const size_t len) end++; } - zend_string *const part = php_base64_decode_ex( - (const unsigned char *)in + start, end - start, /*strict=*/false); - - if (part == NULL) { + if (!b64_decode_append(&out, in + start, end - start)) { smart_str_free(&out); return NULL; } - smart_str_append(&out, part); - zend_string_release(part); start = end; i = end - 1; } - if (start < len) { - zend_string *const part = php_base64_decode_ex( - (const unsigned char *)in + start, len - start, /*strict=*/false); - - if (part == NULL) { - smart_str_free(&out); - return NULL; - } - - smart_str_append(&out, part); - zend_string_release(part); - } - - if (out.s == NULL) { - return ZSTR_EMPTY_ALLOC(); + if (start < len && !b64_decode_append(&out, in + start, len - start)) { + smart_str_free(&out); + return NULL; } smart_str_0(&out); return smart_str_extract(&out); } +/* 5-byte frame prefix: flag byte + uint32 big-endian payload length. */ +static void grpc_write_frame_header(unsigned char *p, unsigned char flag, + size_t len) +{ + p[0] = flag; + p[1] = (unsigned char)((len >> 24) & 0xffu); + p[2] = (unsigned char)((len >> 16) & 0xffu); + p[3] = (unsigned char)((len >> 8) & 0xffu); + p[4] = (unsigned char)( len & 0xffu); +} + zend_string *grpc_web_trailer_frame(HashTable *trailers) { - smart_str body = {0}; + zend_string *name; + zval *val; + size_t tlen = 0; if (trailers != NULL) { - zend_string *name; - zval *val; ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } - smart_str_append(&body, name); - smart_str_appendl(&body, ": ", 2); - smart_str_append(&body, Z_STR_P(val)); - smart_str_appendl(&body, "\r\n", 2); + tlen += ZSTR_LEN(name) + 2 + Z_STRLEN_P(val) + 2; /* ": " + CRLF */ } ZEND_HASH_FOREACH_END(); } - const size_t tlen = body.s != NULL ? ZSTR_LEN(body.s) : 0; + zend_string *out = zend_string_alloc(5 + tlen, 0); + char *w = ZSTR_VAL(out); - zend_string *out = zend_string_alloc(5 + tlen, 0); - unsigned char *p = (unsigned char *)ZSTR_VAL(out); + grpc_write_frame_header((unsigned char *)w, 0x80u /* trailer frame */, tlen); + w += 5; - p[0] = 0x80u; /* trailer frame, uncompressed */ - p[1] = (unsigned char)((tlen >> 24) & 0xffu); - p[2] = (unsigned char)((tlen >> 16) & 0xffu); - p[3] = (unsigned char)((tlen >> 8) & 0xffu); - p[4] = (unsigned char)( tlen & 0xffu); - - if (tlen > 0) { - memcpy(p + 5, ZSTR_VAL(body.s), tlen); + if (trailers != NULL) { + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + memcpy(w, ZSTR_VAL(name), ZSTR_LEN(name)); w += ZSTR_LEN(name); + memcpy(w, ": ", 2); w += 2; + memcpy(w, Z_STRVAL_P(val), Z_STRLEN_P(val)); w += Z_STRLEN_P(val); + memcpy(w, "\r\n", 2); w += 2; + } ZEND_HASH_FOREACH_END(); } - ZSTR_VAL(out)[5 + tlen] = '\0'; - smart_str_free(&body); + *w = '\0'; return out; } @@ -219,15 +194,15 @@ uint64_t grpc_parse_timeout_ns(const http_request_t *req) return 0; } - zval *to = zend_hash_str_find(req->headers, "grpc-timeout", - sizeof("grpc-timeout") - 1); + zval *timeout = zend_hash_str_find(req->headers, "grpc-timeout", + sizeof("grpc-timeout") - 1); - if (to == NULL || Z_TYPE_P(to) != IS_STRING) { + if (timeout == NULL || Z_TYPE_P(timeout) != IS_STRING) { return 0; } - const char *s = Z_STRVAL_P(to); - const size_t n = Z_STRLEN_P(to); + const char *s = Z_STRVAL_P(timeout); + const size_t n = Z_STRLEN_P(timeout); /* Spec: 1..8 ASCII digits followed by a single unit char. */ if (n < 2 || n > 9) { @@ -244,12 +219,12 @@ uint64_t grpc_parse_timeout_ns(const http_request_t *req) uint64_t unit_ns; switch (s[n - 1]) { - case 'H': unit_ns = 3600ULL * 1000000000ULL; break; /* hours */ - case 'M': unit_ns = 60ULL * 1000000000ULL; break; /* minutes */ - case 'S': unit_ns = 1000000000ULL; break; /* seconds */ - case 'm': unit_ns = 1000000ULL; break; /* millis */ - case 'u': unit_ns = 1000ULL; break; /* micros */ - case 'n': unit_ns = 1ULL; break; /* nanos */ + case 'H': unit_ns = 3600ULL * 1000000000ULL; break; + case 'M': unit_ns = 60ULL * 1000000000ULL; break; + case 'S': unit_ns = 1000000000ULL; break; + case 'm': unit_ns = 1000000ULL; break; + case 'u': unit_ns = 1000ULL; break; + case 'n': unit_ns = 1ULL; break; default: return 0; } @@ -259,16 +234,12 @@ uint64_t grpc_parse_timeout_ns(const http_request_t *req) zend_string *grpc_frame_message(const char *msg, size_t len, bool compressed) { zend_string *out = zend_string_alloc(5 + len, 0); - unsigned char *p = (unsigned char *)ZSTR_VAL(out); - p[0] = compressed ? 1u : 0u; - p[1] = (unsigned char)((len >> 24) & 0xffu); - p[2] = (unsigned char)((len >> 16) & 0xffu); - p[3] = (unsigned char)((len >> 8) & 0xffu); - p[4] = (unsigned char)( len & 0xffu); + grpc_write_frame_header((unsigned char *)ZSTR_VAL(out), + compressed ? 1u : 0u, len); if (len > 0) { - memcpy(p + 5, msg, len); + memcpy(ZSTR_VAL(out) + 5, msg, len); } ZSTR_VAL(out)[5 + len] = '\0'; @@ -311,13 +282,14 @@ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, return 1; } +#ifdef HAVE_HTTP_COMPRESSION + int grpc_message_inflate(const http_request_t *req, const char *in, size_t in_len, zend_string **out) { -#ifdef HAVE_HTTP_COMPRESSION /* Compressed flag set → the algorithm is named by grpc-encoding. gRPC's * baseline is gzip; anything else is unsupported here. */ - zval *enc = (req != NULL && req->headers != NULL) + zval *enc = (req->headers != NULL) ? zend_hash_str_find(req->headers, "grpc-encoding", sizeof("grpc-encoding") - 1) : NULL; @@ -330,18 +302,11 @@ int grpc_message_inflate(const http_request_t *req, return http_compression_gzip_inflate_buffer(in, in_len, GRPC_MAX_RECV_MESSAGE, out) == 0 ? 0 : -1; -#else - (void)req; (void)in; (void)in_len; (void)out; - return -1; -#endif } zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len) { -#ifdef HAVE_HTTP_COMPRESSION return http_compression_gzip_deflate_buffer(in, in_len, 6 /* default */); -#else - (void)in; (void)in_len; - return NULL; -#endif } + +#endif /* HAVE_HTTP_COMPRESSION */ diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index d78b8d43..0bf65827 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -44,16 +44,15 @@ struct http_request_t; * variants below, which also start with this prefix). */ #define GRPC_CONTENT_TYPE "application/grpc" -/* grpc-web content-type prefix (application/grpc-web, .../grpc-web+proto, - * .../grpc-web-text) and the response content-type the server emits. */ -#define GRPC_WEB_CONTENT_TYPE_PREFIX "application/grpc-web" -#define GRPC_WEB_RESPONSE_CONTENT_TYPE "application/grpc-web+proto" - -/* grpc-web-text: the grpc-web framing, base64-encoded — the fallback for - * transports/clients that cannot carry binary bodies (XHR). Each frame - * (message or trailer) is base64-encoded independently with its own - * padding, per the grpc-web protocol, so no cross-frame codec state. */ -#define GRPC_WEB_TEXT_CONTENT_TYPE_PREFIX "application/grpc-web-text" +/* Variant suffixes after the GRPC_CONTENT_TYPE prefix ("-web" also prefixes + * "-web-text" — match web-text first) and the response content-types the + * server emits. grpc-web carries trailers inside the response body (a + * 0x80-flagged frame) because browsers cannot read HTTP trailers; web-text + * additionally base64-encodes every frame independently, for clients that + * cannot carry binary bodies (XHR). */ +#define GRPC_WEB_SUFFIX "-web" +#define GRPC_WEB_TEXT_SUFFIX "-web-text" +#define GRPC_WEB_RESPONSE_CONTENT_TYPE "application/grpc-web+proto" #define GRPC_WEB_TEXT_RESPONSE_CONTENT_TYPE "application/grpc-web-text+proto" /* Delivery mode of a gRPC call, classified once at dispatch from the request @@ -67,26 +66,15 @@ typedef enum { GRPC_MODE_WEB_TEXT, /* application/grpc-web-text — WEB + per-frame base64 */ } grpc_mode_t; -/* True when the request is a gRPC call — POST with a content-type that - * begins with `application/grpc` (this includes grpc-web). */ -bool grpc_request_is_grpc(const struct http_request_t *req); - -/* True when the request is a grpc-web call — content-type begins with - * `application/grpc-web`. grpc-web carries the trailers inside the response - * body (a 0x80-flagged frame) instead of as HTTP/2 trailers, because - * browsers cannot read HTTP trailers. */ -bool grpc_request_is_grpc_web(const struct http_request_t *req); - -/* True when the request is grpc-web-text — content-type begins with - * `application/grpc-web-text` (note: grpc_request_is_grpc_web also matches - * these, by prefix; check web-text FIRST when distinguishing). */ -bool grpc_request_is_grpc_web_text(const struct http_request_t *req); - /* Classify the delivery mode from the request content-type. Returns * GRPC_MODE_NONE for a non-gRPC request; the caller still gates on a * registered gRPC handler. */ grpc_mode_t grpc_request_mode(const struct http_request_t *req); +/* Convenience for the transports' buffering decision (grpc-web-text is + * buffered by protocol nature). */ +bool grpc_request_is_grpc_web_text(const struct http_request_t *req); + /* Classify-once entry point for dispatch sites: the delivery mode gated on * a registered addGrpcHandler — GRPC_MODE_NONE when the request is not * gRPC or no gRPC handler exists (the call then routes as plain HTTP). @@ -127,15 +115,17 @@ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, uint32_t max_msg, bool *out_compressed, zend_string **out); +#ifdef HAVE_HTTP_COMPRESSION /* Decompress a message that carried the compressed flag, per the request's * grpc-encoding header (gzip only, via the shared compression backend). - * Returns 0 on success (*out owned by caller), -1 on an unsupported encoding, - * corrupt data, or when no compression backend is compiled in. */ + * Returns 0 on success (*out owned by caller), -1 on an unsupported + * encoding or corrupt data. */ int grpc_message_inflate(const struct http_request_t *req, const char *in, size_t in_len, zend_string **out); /* Gzip-compress a message for `grpc-encoding: gzip`. Returns a new - * zend_string the caller owns, or NULL when gzip is unavailable / failed. */ + * zend_string the caller owns, or NULL on failure. */ zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len); +#endif #endif /* HTTP_GRPC_H */ diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index f74e9055..e4eb94ce 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -33,7 +33,7 @@ #include #endif #include "http1/http_parser.h" /* http_request_t */ -#include "grpc/grpc.h" /* grpc_request_is_grpc */ +#include "grpc/grpc.h" /* grpc_classify */ #include "grpc/grpc_call.h" /* gRPC call lifecycle policy */ #include "core/http_protocol_handlers.h" /* http_protocol_get_handler */ #include "static/static_handler.h" diff --git a/src/http_request.c b/src/http_request.c index 1327f011..ea6042e3 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -421,19 +421,20 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) } if (compressed) { +#ifdef HAVE_HTTP_COMPRESSION /* Per-message compression: decode per grpc-encoding (gzip). */ zend_string *inflated = NULL; if (grpc_message_inflate(req, ZSTR_VAL(msg), ZSTR_LEN(msg), - &inflated) != 0) { + &inflated) == 0) { zend_string_release(msg); - zend_throw_exception(http_server_runtime_exception_ce, - "unsupported or invalid gRPC message compression", 0); - RETURN_NULL(); + RETURN_STR(inflated); } - +#endif zend_string_release(msg); - RETURN_STR(inflated); + zend_throw_exception(http_server_runtime_exception_ce, + "unsupported or invalid gRPC message compression", 0); + RETURN_NULL(); } RETURN_STR(msg); diff --git a/src/http_response.c b/src/http_response.c index 0ef2f35e..5d64f86b 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -1072,6 +1072,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) zend_string *payload = message; bool gzipped = false; +#ifdef HAVE_HTTP_COMPRESSION if (compress) { zend_string *gz = grpc_message_deflate_gzip( ZSTR_VAL(message), ZSTR_LEN(message)); @@ -1088,6 +1089,9 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) } } } +#else + (void)compress; +#endif /* First message — lock headers and switch to streaming mode. Mirrors * send(): after this, setBody / setHeader / setStatusCode throw, but From 5fbfeba577a526660e7135455f633fa5032a64c4 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:48:23 +0000 Subject: [PATCH 30/33] perf(http3): apply ERRORS2.md reactor review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reactor_cmd_t travels the mailbox BY VALUE (moodycamel value queue + typed mailbox) — no malloc/free on the hot worker->reactor path; EXEC ack is a pointer to the caller's stack atomic, STOP is a command kind. - O(1) intrusive doubly-linked stream unlink (was O(n) list walk). - listener local sockaddr computed once at spawn, copied per packet (was inet_pton/htons on every drain). - thread-local EVP_CIPHER_CTX in CID steering (reset, not new/free). - global per-worker memory budget for H3 static delivery (ported from H2): account on push, debit on ACK, one-shot reconcile at teardown; pump throttles over budget. - hard-backpressure dispatch now RESET_STREAMs with H3_REQUEST_REJECTED instead of silently dropping the request. - shared h3_shutdown_stream_read helper for RESET/STOP_SENDING. --- include/core/reactor_cmd.h | 52 +++++++++++ include/core/thread_mailbox.h | 22 +++++ include/core/thread_queue.h | 19 ++++ include/http3/http3_stream.h | 13 ++- src/core/reactor_pool.c | 141 +++++++++++------------------- src/core/thread_mailbox.c | 122 ++++++++++++++++++++++++++ src/core/thread_queue.cc | 68 ++++++++++++++ src/http3/http3_callbacks.c | 51 +++++++---- src/http3/http3_connection.c | 31 +------ src/http3/http3_dispatch.c | 16 +++- src/http3/http3_internal.h | 7 ++ src/http3/http3_listener.c | 59 +++++++++++++ src/http3/http3_listener.h | 9 ++ src/http3/http3_static_response.c | 99 ++++++++++++++++++++- src/http3/http3_steer.c | 25 +++--- src/http3/http3_stream.c | 22 +++-- 16 files changed, 599 insertions(+), 157 deletions(-) create mode 100644 include/core/reactor_cmd.h diff --git a/include/core/reactor_cmd.h b/include/core/reactor_cmd.h new file mode 100644 index 00000000..d00e6544 --- /dev/null +++ b/include/core/reactor_cmd.h @@ -0,0 +1,52 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#ifndef REACTOR_CMD_H +#define REACTOR_CMD_H + +#include "core/reactor_pool.h" /* reactor_exec_fn */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Inbound message a reactor thread drains from its mailbox. Passed BY VALUE + * through the lock-free ring (no per-message heap allocation), so it must stay + * a trivially-copyable POD. The drain dispatches on `kind`: + * NOOP — opaque liveness token (reactor_pool_post); `payload` is only counted. + * EXEC — reactor_pool_exec: run fn(arg), then publish completion by storing 1 + * through `done` (a zend_atomic_int* on the blocking caller's stack — + * a pointer, not an embedded atomic, precisely because the envelope is + * copied into the ring). + * POST — reactor_pool_post_exec: fire-and-forget fn(arg); no `done` ack and + * nothing to free (the value lived in the ring, not the heap). + * STOP — cooperative shutdown token (reactor_pool_destroy): the reactor sets + * its stopping flag and leaves its loop. Travels the same ring as work + * (a value queue has no out-of-band sentinel pointer). + */ +typedef enum { + REACTOR_CMD_NOOP, + REACTOR_CMD_EXEC, + REACTOR_CMD_POST, + REACTOR_CMD_STOP, +} reactor_cmd_kind_t; + +typedef struct reactor_cmd_s { + reactor_cmd_kind_t kind; + void *payload; /* NOOP */ + reactor_exec_fn fn; /* EXEC / POST */ + void *arg; /* EXEC / POST */ + void *done; /* EXEC: zend_atomic_int* on caller stack; else NULL */ +} reactor_cmd_t; + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* REACTOR_CMD_H */ diff --git a/include/core/thread_mailbox.h b/include/core/thread_mailbox.h index b139854b..05a7dddc 100644 --- a/include/core/thread_mailbox.h +++ b/include/core/thread_mailbox.h @@ -66,4 +66,26 @@ void thread_mailbox_keepalive(thread_mailbox_t *mb, bool enable); /* Approximate number of queued items. */ size_t thread_mailbox_count(const thread_mailbox_t *mb); +/* --------------------------------------------------------------------------- + * reactor_cmd_t mailbox — same wakeup/backpressure contract as the void* + * mailbox above, but the command POD travels through the ring by value (no + * per-message malloc on the hot worker->reactor path). Used by reactor_pool; + * the void* mailbox stays for the opaque-pointer consumers (WebSocket, etc.). + * ------------------------------------------------------------------------- */ + +typedef struct reactor_cmd_s reactor_cmd_t; /* core/reactor_cmd.h */ +typedef struct thread_cmd_mailbox_s thread_cmd_mailbox_t; + +/* Drain callback: a batch of 1..batch commands, on the consumer's reactor + * thread. The array is consumer-private scratch, valid only for the call. */ +typedef void (*thread_cmd_mailbox_drain_fn)(reactor_cmd_t *items, size_t count, void *arg); + +thread_cmd_mailbox_t *thread_cmd_mailbox_create(size_t capacity, size_t batch, + thread_cmd_mailbox_drain_fn on_drain, void *arg); +void thread_cmd_mailbox_free(thread_cmd_mailbox_t *mb); +/* Producer side — any thread. Copies *cmd in; false if full. */ +bool thread_cmd_mailbox_post(thread_cmd_mailbox_t *mb, const reactor_cmd_t *cmd); +void thread_cmd_mailbox_keepalive(thread_cmd_mailbox_t *mb, bool enable); +size_t thread_cmd_mailbox_count(const thread_cmd_mailbox_t *mb); + #endif /* THREAD_MAILBOX_H */ diff --git a/include/core/thread_queue.h b/include/core/thread_queue.h index 001b9825..13f0a1ef 100644 --- a/include/core/thread_queue.h +++ b/include/core/thread_queue.h @@ -76,6 +76,25 @@ bool thread_spsc_dequeue(thread_spsc_t *q, void **item); size_t thread_spsc_drain(thread_spsc_t *q, void **items, size_t max); size_t thread_spsc_count(const thread_spsc_t *q); +/* ------------------------------------------------------------------ */ +/* MPSC carrying reactor_cmd_t BY VALUE — many producers, single */ +/* consumer. Same bounded/lock-free contract as the void* MPSC, but */ +/* the fixed-size command POD is stored in the ring itself, so the hot */ +/* worker->reactor path enqueues without a per-message malloc. */ +/* ------------------------------------------------------------------ */ + +typedef struct reactor_cmd_s reactor_cmd_t; /* core/reactor_cmd.h */ +typedef struct thread_cmd_mpsc_s thread_cmd_mpsc_t; + +thread_cmd_mpsc_t *thread_cmd_mpsc_create(size_t capacity); +void thread_cmd_mpsc_free(thread_cmd_mpsc_t *q); + +/* Copies *cmd into the ring. Returns false when full. */ +bool thread_cmd_mpsc_enqueue(thread_cmd_mpsc_t *q, const reactor_cmd_t *cmd); +/* Drains up to `max` commands into `out` (an array of `max` reactor_cmd_t). */ +size_t thread_cmd_mpsc_drain(thread_cmd_mpsc_t *q, reactor_cmd_t *out, size_t max); +size_t thread_cmd_mpsc_count(const thread_cmd_mpsc_t *q); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index 9eba0300..f0fd41bd 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -121,6 +121,15 @@ struct _http3_stream_s { size_t chunk_read_offset; /* bytes already handed from queue[chunk_read_idx] */ size_t chunk_ack_credit; /* leftover acked bytes not yet applied to head release */ + /* Static-file delivery global memory cap (http3_static_response.c). + * tracks_static_bytes latches on only for the static pump's stream; while + * set, each queued chunk is added to the per-worker in-flight counter on + * push and debited on ACK. static_inflight is this stream's running share, + * debited in one shot at teardown to reconcile any chunk freed off the ACK + * path (submit failure, connection close). */ + bool tracks_static_bytes; + size_t static_inflight; + /* Set by h3_stream_ops.mark_ended (handler called $res->end() on a * streaming response). Tells the data_reader to flag EOF when the * queue empties instead of NGHTTP3_ERR_WOULDBLOCK. */ @@ -195,8 +204,10 @@ struct _http3_stream_s { * stream nghttp3_conn_del would otherwise orphan (no stream_close * callback fires for streams still alive when the conn is torn * down — without the walk, each such stream leaks its request + - * headers + zend_strings). */ + * headers + zend_strings). Doubly linked for O(1) unlink in release(). + * list_prev is live-only; the pool freelist reuses list_next alone. */ http3_stream_t *list_next; + http3_stream_t *list_prev; /* hq-interop only (HTTP/0.9-over-QUIC). Request-line accumulator, * lazily allocated on the first stream byte; freed in release. h3 diff --git a/src/core/reactor_pool.c b/src/core/reactor_pool.c index 25a99430..bc36f0e7 100644 --- a/src/core/reactor_pool.c +++ b/src/core/reactor_pool.c @@ -16,6 +16,7 @@ #include "Zend/zend_async_API.h" #include "Zend/zend_atomic.h" #include "core/reactor_pool.h" +#include "core/reactor_cmd.h" #include "core/thread_mailbox.h" #ifdef PHP_WIN32 @@ -29,40 +30,11 @@ #define REACTOR_MAILBOX_CAPACITY 1024 #define REACTOR_MAILBOX_BATCH 64 -/* Distinguished pointer posted to a reactor's mailbox to make it leave its - * loop. Its address can never collide with a real heap item. */ -static const char reactor_stop_token; -#define REACTOR_STOP_SENTINEL ((void *)&reactor_stop_token) - /* ctx lifecycle, published from the reactor thread to the parent. */ #define REACTOR_PHASE_SPAWN 0 /* submitted, not yet in its loop */ #define REACTOR_PHASE_RUN 1 /* mailbox created and published; looping */ #define REACTOR_PHASE_DONE 2 /* loop left, mailbox freed */ -/* Inbound message envelope. Everything posted to a reactor other than the stop - * sentinel is a reactor_cmd_t*; the drain dispatches on `kind`. - * NOOP — the substrate's opaque-token wrapper (reactor_pool_post). Heap; the - * drain frees it after counting. Carries no behaviour, just liveness. - * EXEC — a function the reactor runs on its own thread, then acks via `done`. - * Stack-owned by reactor_pool_exec (which blocks on `done`), so the - * drain must never free it. - * POST — like EXEC but fire-and-forget (reactor_pool_post_exec): the reactor - * runs fn(arg) and frees the heap envelope, no `done` ack. This is the - * worker->reactor reverse path's delivery. */ -typedef enum { - REACTOR_CMD_NOOP, - REACTOR_CMD_EXEC, - REACTOR_CMD_POST, -} reactor_cmd_kind_t; - -typedef struct { - reactor_cmd_kind_t kind; - void *payload; /* NOOP: opaque token, currently only counted */ - reactor_exec_fn fn; /* EXEC */ - void *arg; /* EXEC */ - zend_atomic_int done; /* EXEC: reactor stores 1 once fn has returned */ -} reactor_cmd_t; - /* Per-reactor state, shared parent <-> one reactor thread — the legitimate * cross-thread handshake (Zend atomics), not single-threaded-core state. * `mailbox` is written by the reactor before it stores phase=RUN, and read by @@ -70,11 +42,11 @@ typedef struct { * orders the plain write. `stopping` is touched only on the reactor thread * (loop + drain callback). */ typedef struct { - reactor_pool_t *pool; - zend_atomic_int phase; - thread_mailbox_t *mailbox; - zend_atomic_int64 processed; - bool stopping; + reactor_pool_t *pool; + zend_atomic_int phase; + thread_cmd_mailbox_t *mailbox; + zend_atomic_int64 processed; + bool stopping; } reactor_ctx_t; struct reactor_pool_s { @@ -102,36 +74,35 @@ static void reactor_pool_msleep(void) #endif } -/* Runs on the reactor thread when its inbound mailbox has items. The stop - * sentinel asks the loop to leave; everything else is real work, counted here. */ -static void reactor_drain(void **items, const size_t count, void *arg) +/* Runs on the reactor thread when its inbound mailbox has items. Commands + * arrive by value; STOP asks the loop to leave, everything else is real work, + * counted here. Nothing is freed — the ring owned the storage, not the heap. */ +static void reactor_drain(reactor_cmd_t *items, const size_t count, void *arg) { reactor_ctx_t *const rc = (reactor_ctx_t *)arg; int64_t drained = 0; for (size_t i = 0; i < count; i++) { - if (UNEXPECTED(items[i] == REACTOR_STOP_SENTINEL)) { - rc->stopping = true; - continue; - } - - reactor_cmd_t *const cmd = (reactor_cmd_t *)items[i]; + reactor_cmd_t *const cmd = &items[i]; switch (cmd->kind) { + case REACTOR_CMD_STOP: + rc->stopping = true; + continue; + case REACTOR_CMD_EXEC: cmd->fn(cmd->arg); - /* Release: publish fn's effects before the parent sees done. */ - zend_atomic_int_store_ex(&cmd->done, 1); + /* Release: publish fn's effects before the caller sees done. + * done points at the blocking caller's stack atomic. */ + zend_atomic_int_store_ex((zend_atomic_int *)cmd->done, 1); break; case REACTOR_CMD_POST: cmd->fn(cmd->arg); - free(cmd); break; case REACTOR_CMD_NOOP: default: - free(cmd); break; } @@ -158,9 +129,9 @@ static void reactor_loop_handler(zend_async_event_t *event, void *vctx) (void)event; reactor_ctx_t *const rc = (reactor_ctx_t *)vctx; - thread_mailbox_t *const mb = thread_mailbox_create(REACTOR_MAILBOX_CAPACITY, - REACTOR_MAILBOX_BATCH, - reactor_drain, rc); + thread_cmd_mailbox_t *const mb = thread_cmd_mailbox_create(REACTOR_MAILBOX_CAPACITY, + REACTOR_MAILBOX_BATCH, + reactor_drain, rc); if (mb == NULL) { zend_atomic_int_store_ex(&rc->phase, REACTOR_PHASE_DONE); @@ -169,7 +140,7 @@ static void reactor_loop_handler(zend_async_event_t *event, void *vctx) /* Open inbound keeps the loop alive (no listener yet) — so uv_run blocks * instead of spinning. */ - thread_mailbox_keepalive(mb, true); + thread_cmd_mailbox_keepalive(mb, true); rc->mailbox = mb; /* publish (plain) */ zend_atomic_int_store_ex(&rc->phase, REACTOR_PHASE_RUN); /* release */ @@ -178,8 +149,8 @@ static void reactor_loop_handler(zend_async_event_t *event, void *vctx) ZEND_ASYNC_REACTOR_EXECUTE(/*no_wait=*/false); } - thread_mailbox_keepalive(mb, false); - thread_mailbox_free(mb); /* consumer-thread */ + thread_cmd_mailbox_keepalive(mb, false); + thread_cmd_mailbox_free(mb); /* consumer-thread */ rc->mailbox = NULL; zend_atomic_int_store_ex(&rc->phase, REACTOR_PHASE_DONE); @@ -269,21 +240,11 @@ bool reactor_pool_post(reactor_pool_t *rp, const int idx, void *item) return false; } - reactor_cmd_t *const cmd = malloc(sizeof(*cmd)); - - if (UNEXPECTED(cmd == NULL)) { - return false; - } - - cmd->kind = REACTOR_CMD_NOOP; - cmd->payload = item; - - if (!thread_mailbox_post(rc->mailbox, cmd)) { - free(cmd); - return false; - } + reactor_cmd_t cmd = {0}; + cmd.kind = REACTOR_CMD_NOOP; + cmd.payload = item; - return true; + return thread_cmd_mailbox_post(rc->mailbox, &cmd); } bool reactor_pool_exec(reactor_pool_t *rp, const int idx, const reactor_exec_fn fn, void *arg) @@ -298,16 +259,21 @@ bool reactor_pool_exec(reactor_pool_t *rp, const int idx, const reactor_exec_fn return false; } - /* Stack-owned: the reactor never frees an EXEC envelope. Safe because we - * block on `done` below, so the frame outlives the reactor's use of it. */ - reactor_cmd_t cmd; + /* The envelope is copied into the ring by value, so `done` cannot be an + * embedded atomic — the reactor would ack the ring's copy. Keep the atomic + * on our stack and hand the reactor a pointer to it; we block on it below, + * so the frame outlives the reactor's store. */ + zend_atomic_int done; + ZEND_ATOMIC_INT_INIT(&done, 0); + + reactor_cmd_t cmd = {0}; cmd.kind = REACTOR_CMD_EXEC; cmd.fn = fn; cmd.arg = arg; - ZEND_ATOMIC_INT_INIT(&cmd.done, 0); + cmd.done = &done; /* Bounded mailbox: retry on full; bail if the reactor leaves RUN. */ - while (!thread_mailbox_post(rc->mailbox, &cmd)) { + while (!thread_cmd_mailbox_post(rc->mailbox, &cmd)) { if (zend_atomic_int_load_ex(&rc->phase) != REACTOR_PHASE_RUN) { return false; } @@ -316,7 +282,7 @@ bool reactor_pool_exec(reactor_pool_t *rp, const int idx, const reactor_exec_fn } /* Acquire: pair with the reactor's release store once fn has run. */ - while (zend_atomic_int_load_ex(&cmd.done) == 0) { + while (zend_atomic_int_load_ex(&done) == 0) { reactor_pool_msleep(); } @@ -336,23 +302,13 @@ bool reactor_pool_post_exec(reactor_pool_t *rp, const int idx, return false; } - /* Heap-owned: the reactor frees it after running fn (no `done` ack). */ - reactor_cmd_t *const cmd = malloc(sizeof(*cmd)); - - if (UNEXPECTED(cmd == NULL)) { - return false; - } - - cmd->kind = REACTOR_CMD_POST; - cmd->fn = fn; - cmd->arg = arg; - - if (!thread_mailbox_post(rc->mailbox, cmd)) { - free(cmd); - return false; - } + /* Fire-and-forget: the value rides the ring, no `done` ack, nothing to free. */ + reactor_cmd_t cmd = {0}; + cmd.kind = REACTOR_CMD_POST; + cmd.fn = fn; + cmd.arg = arg; - return true; + return thread_cmd_mailbox_post(rc->mailbox, &cmd); } uint64_t reactor_pool_processed(const reactor_pool_t *rp, const int idx) @@ -370,8 +326,11 @@ void reactor_pool_destroy(reactor_pool_t *rp) return; } - /* Ask each running reactor to leave by posting the stop sentinel into its + /* Ask each running reactor to leave by posting a STOP command into its * mailbox — same path as real work, so no cross-thread handle touch. */ + reactor_cmd_t stop = {0}; + stop.kind = REACTOR_CMD_STOP; + for (int i = 0; i < rp->count; i++) { reactor_ctx_t *const rc = &rp->ctx[i]; @@ -379,7 +338,7 @@ void reactor_pool_destroy(reactor_pool_t *rp) continue; } - while (!thread_mailbox_post(rc->mailbox, REACTOR_STOP_SENTINEL)) { + while (!thread_cmd_mailbox_post(rc->mailbox, &stop)) { reactor_pool_msleep(); } } diff --git a/src/core/thread_mailbox.c b/src/core/thread_mailbox.c index 82a3c929..0ff4a264 100644 --- a/src/core/thread_mailbox.c +++ b/src/core/thread_mailbox.c @@ -14,6 +14,7 @@ #include "Zend/zend_async_API.h" #include "core/thread_mailbox.h" #include "core/thread_queue.h" +#include "core/reactor_cmd.h" /* The mailbox pointer lives in the trigger event's persistent extra area so the * drain callback can recover it from the event alone (see thread_mailbox_create). */ @@ -146,3 +147,124 @@ void thread_mailbox_keepalive(thread_mailbox_t *mb, const bool enable) mb->trigger->base.stop(&mb->trigger->base); } } + +/* --------------------------------------------------------------------------- + * reactor_cmd_t mailbox. Mirrors the void* mailbox above; the only difference + * is the ring carries the command POD by value, so post copies it in and the + * drain hands back a scratch array of reactor_cmd_t rather than void*. + * ------------------------------------------------------------------------- */ + +struct thread_cmd_mailbox_s { + thread_cmd_mpsc_t *queue; + zend_async_trigger_event_t *trigger; + thread_cmd_mailbox_drain_fn on_drain; + void *arg; + reactor_cmd_t *batch_buf; + size_t batch; +}; + +static void cmd_mailbox_on_signal(zend_async_event_t *event, zend_async_event_callback_t *callback, + void *result, zend_object *exception) +{ + (void) callback; + (void) result; + (void) exception; + + thread_cmd_mailbox_t *mb = *(thread_cmd_mailbox_t **) ((char *) event + event->extra_offset); + + for (;;) { + const size_t n = thread_cmd_mpsc_drain(mb->queue, mb->batch_buf, mb->batch); + if (n == 0) { + break; + } + + mb->on_drain(mb->batch_buf, n, mb->arg); + } +} + +thread_cmd_mailbox_t *thread_cmd_mailbox_create(const size_t capacity, const size_t batch, + thread_cmd_mailbox_drain_fn on_drain, void *arg) +{ + if (capacity == 0 || batch == 0 || on_drain == NULL) { + return NULL; + } + + thread_cmd_mailbox_t *mb = pecalloc(1, sizeof(*mb), 0); + + mb->on_drain = on_drain; + mb->arg = arg; + mb->batch = batch; + mb->batch_buf = pemalloc(batch * sizeof(reactor_cmd_t), 0); + mb->queue = thread_cmd_mpsc_create(capacity); + + if (mb->queue == NULL) { + goto fail; + } + + mb->trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT_EX(sizeof(thread_cmd_mailbox_t *)); + if (mb->trigger == NULL) { + goto fail; + } + + *(thread_cmd_mailbox_t **) ((char *) &mb->trigger->base + mb->trigger->base.extra_offset) = mb; + + zend_async_event_callback_t *cb = ZEND_ASYNC_EVENT_CALLBACK(cmd_mailbox_on_signal); + mb->trigger->base.add_callback(&mb->trigger->base, cb); + + return mb; + +fail: + if (mb->trigger != NULL) { + ZEND_ASYNC_EVENT_SET_CLOSED(&mb->trigger->base); + mb->trigger->base.dispose(&mb->trigger->base); + } + + if (mb->queue != NULL) { + thread_cmd_mpsc_free(mb->queue); + } + + pefree(mb->batch_buf, 0); + pefree(mb, 0); + return NULL; +} + +void thread_cmd_mailbox_free(thread_cmd_mailbox_t *mb) +{ + if (mb == NULL) { + return; + } + + if (mb->trigger != NULL) { + ZEND_ASYNC_EVENT_SET_CLOSED(&mb->trigger->base); + mb->trigger->base.dispose(&mb->trigger->base); + } + + thread_cmd_mpsc_free(mb->queue); + pefree(mb->batch_buf, 0); + pefree(mb, 0); +} + +bool thread_cmd_mailbox_post(thread_cmd_mailbox_t *mb, const reactor_cmd_t *cmd) +{ + if (UNEXPECTED(!thread_cmd_mpsc_enqueue(mb->queue, cmd))) { + return false; + } + + mb->trigger->trigger(mb->trigger); + + return true; +} + +size_t thread_cmd_mailbox_count(const thread_cmd_mailbox_t *mb) +{ + return thread_cmd_mpsc_count(mb->queue); +} + +void thread_cmd_mailbox_keepalive(thread_cmd_mailbox_t *mb, const bool enable) +{ + if (enable) { + mb->trigger->base.start(&mb->trigger->base); + } else { + mb->trigger->base.stop(&mb->trigger->base); + } +} diff --git a/src/core/thread_queue.cc b/src/core/thread_queue.cc index 0eb60e2f..a96c5b72 100644 --- a/src/core/thread_queue.cc +++ b/src/core/thread_queue.cc @@ -23,6 +23,7 @@ #endif #include "core/thread_queue.h" +#include "core/reactor_cmd.h" #if !defined(__cplusplus) || __cplusplus < 201103L # error "thread_queue.cc requires a C++11 compiler" @@ -264,3 +265,70 @@ size_t thread_spsc_count(const thread_spsc_t *q) { return q->count.load(std::memory_order_acquire); } + +/* ------------------------------------------------------------------ */ +/* MPSC of reactor_cmd_t by value — moodycamel::ConcurrentQueue */ +/* ------------------------------------------------------------------ */ + +struct thread_cmd_mpsc_s { + moodycamel::ConcurrentQueue q; + std::atomic count; + size_t capacity; + + explicit thread_cmd_mpsc_s(const size_t cap) : q(cap), count(0), capacity(cap) {} + + static void *operator new(const size_t sz) { return aligned_new(sz, alignof(thread_cmd_mpsc_s)); } + static void operator delete(void *p) noexcept { aligned_delete(p); } +}; + +thread_cmd_mpsc_t *thread_cmd_mpsc_create(const size_t capacity) +{ + if (capacity == 0) { + return nullptr; + } + + try { + return new thread_cmd_mpsc_s(capacity); + } catch (...) { + return nullptr; + } +} + +void thread_cmd_mpsc_free(thread_cmd_mpsc_t *q) +{ + delete q; +} + +bool thread_cmd_mpsc_enqueue(thread_cmd_mpsc_t *q, const reactor_cmd_t *cmd) +{ + size_t prev; + if (!cap_reserve(q->count, q->capacity, prev)) { + return false; + } + + if (!q->q.try_enqueue(*cmd)) { + q->count.fetch_sub(1, std::memory_order_release); + return false; + } + + return true; +} + +size_t thread_cmd_mpsc_drain(thread_cmd_mpsc_t *q, reactor_cmd_t *out, const size_t max) +{ + if (max == 0) { + return 0; + } + + const size_t n = q->q.try_dequeue_bulk(out, max); + if (n != 0) { + q->count.fetch_sub(n, std::memory_order_acq_rel); + } + + return n; +} + +size_t thread_cmd_mpsc_count(const thread_cmd_mpsc_t *q) +{ + return q->count.load(std::memory_order_acquire); +} diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 90c4841a..4d8d0426 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -275,7 +275,11 @@ static int h3_begin_headers_cb(nghttp3_conn *conn, int64_t stream_id, * (it does not fire stream_close on remaining streams). */ if (c != NULL) { s->conn = c; + s->list_prev = NULL; s->list_next = c->streams_head; + if (c->streams_head != NULL) { + c->streams_head->list_prev = s; + } c->streams_head = s; } @@ -1194,8 +1198,16 @@ int h3_stream_append_chunk(void *ctx, zend_string *chunk) h3_chunk_queue_init(s); } + const size_t chunk_len = ZSTR_LEN(chunk); h3_chunk_queue_push(s, chunk); + /* Static delivery: credit this chunk to the per-worker in-flight cap. + * The ACK path debits it; teardown reconciles any leftover. */ + if (s->tracks_static_bytes) { + s->static_inflight += chunk_len; + h3_static_account_alloc(chunk_len); + } + /* Telemetry — match the H1/H2 vantage so operators see one * unified streaming-load picture across protocols. */ http_server_counters_t *counters = c->counters; @@ -1597,6 +1609,11 @@ static int h3_acked_stream_data_cb(nghttp3_conn *conn, int64_t stream_id, zend_string_release(head); s->chunk_queue[s->chunk_queue_head] = NULL; s->chunk_queue_head++; + + if (s->tracks_static_bytes) { + s->static_inflight -= (hlen <= s->static_inflight) ? hlen : s->static_inflight; + h3_static_account_debit(hlen); + } } return 0; @@ -1935,7 +1952,11 @@ static int http3_hq_recv_stream_data(http3_connection_t *c, ngtcp2_conn *qconn, (void)ngtcp2_conn_set_stream_user_data(qconn, stream_id, s); s->conn = c; + s->list_prev = NULL; s->list_next = c->streams_head; + if (c->streams_head != NULL) { + c->streams_head->list_prev = s; + } c->streams_head = s; } @@ -2189,13 +2210,10 @@ static int stream_close_cb(ngtcp2_conn *conn, uint32_t flags, return 0; } -static int stream_reset_cb(ngtcp2_conn *conn, int64_t stream_id, - uint64_t final_size, uint64_t app_error_code, - void *user_data, void *stream_user_data) +/* RESET_STREAM and STOP_SENDING both mean the peer will send no more request + * data on this stream — shut the read side down in nghttp3 either way. */ +static int h3_shutdown_stream_read(http3_connection_t *c, int64_t stream_id) { - (void)conn; (void)final_size; (void)app_error_code; (void)stream_user_data; - http3_connection_t *const c = (http3_connection_t *)user_data; - if (c == NULL || c->nghttp3_conn == NULL) { return 0; } @@ -2208,23 +2226,20 @@ static int stream_reset_cb(ngtcp2_conn *conn, int64_t stream_id, return 0; } +static int stream_reset_cb(ngtcp2_conn *conn, int64_t stream_id, + uint64_t final_size, uint64_t app_error_code, + void *user_data, void *stream_user_data) +{ + (void)conn; (void)final_size; (void)app_error_code; (void)stream_user_data; + return h3_shutdown_stream_read((http3_connection_t *)user_data, stream_id); +} + static int stream_stop_sending_cb(ngtcp2_conn *conn, int64_t stream_id, uint64_t app_error_code, void *user_data, void *stream_user_data) { (void)conn; (void)app_error_code; (void)stream_user_data; - http3_connection_t *const c = (http3_connection_t *)user_data; - - if (c == NULL || c->nghttp3_conn == NULL) { - return 0; - } - - if (nghttp3_conn_shutdown_stream_read( - (nghttp3_conn *)c->nghttp3_conn, stream_id) != 0) { - return NGTCP2_ERR_CALLBACK_FAILURE; - } - - return 0; + return h3_shutdown_stream_read((http3_connection_t *)user_data, stream_id); } static int extend_max_remote_streams_bidi_cb(ngtcp2_conn *conn, diff --git a/src/http3/http3_connection.c b/src/http3/http3_connection.c index b5ad82f9..4b1d07e6 100644 --- a/src/http3/http3_connection.c +++ b/src/http3/http3_connection.c @@ -141,39 +141,14 @@ void http3_debug_logger(void *user_data, const char *fmt, ...) * The proper plumbing for this is `zend_async_udp_sockname`, which is * not yet available; until it lands we fabricate the sockaddr from the * bind config so at least it is stable across calls. peer_family lets us produce v4 / v6 to match - * the inbound datagram. Returns 0 on success. */ + * the inbound datagram. The result is precomputed once per family at listener + * spawn; this just copies the cached value. Returns 0 on success. */ int http3_build_listener_local(const http3_listener_t *l, int peer_family, struct sockaddr_storage *out, socklen_t *out_len) { - memset(out, 0, sizeof(*out)); - const char *host = http3_listener_host(l); - const int port = http3_listener_port(l); - - if (host == NULL) host = (peer_family == AF_INET6) ? "::" : "0.0.0.0"; - - if (peer_family == AF_INET6) { - struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)out; - s6->sin6_family = AF_INET6; - s6->sin6_port = htons((uint16_t)port); - - if (inet_pton(AF_INET6, host, &s6->sin6_addr) != 1) { - /* Listener bound to v4-only host but peer is v6 — use ::1. */ - inet_pton(AF_INET6, "::1", &s6->sin6_addr); - } - *out_len = sizeof(*s6); - } else { - struct sockaddr_in *s4 = (struct sockaddr_in *)out; - s4->sin_family = AF_INET; - s4->sin_port = htons((uint16_t)port); - - if (inet_pton(AF_INET, host, &s4->sin_addr) != 1) { - s4->sin_addr.s_addr = htonl(INADDR_ANY); - } - *out_len = sizeof(*s4); - } - + http3_listener_local_sockaddr(l, peer_family, out, out_len); return 0; } diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index 16dbb54b..c4bb7394 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -195,11 +195,23 @@ static void http3_stream_dispatch_to_worker(http3_connection_t *c, http3_stream_ s->refcount++; /* worker-borrow ref; dropped by the consumed apply */ if (UNEXPECTED(!worker_inbox_post(inbox, s->request))) { - /* Backpressure: the request was not handed off. Undo so the normal + /* Hard backpressure: the worker inbox is at capacity and the request + * was not handed off. Undo the dispatch bookkeeping so the normal * teardown reclaims it (s->dispatched=false => reactor teardown frees - * the fields). */ + * the fields). Then RESET the stream with H3_REQUEST_REJECTED so the + * client learns immediately the request was refused WITHOUT processing + * — safe to retry elsewhere — instead of hanging until its QUIC PTO. */ s->refcount--; s->dispatched = false; + + if (c->ngtcp2_conn != NULL) { + (void)ngtcp2_conn_shutdown_stream_write( + (ngtcp2_conn *)c->ngtcp2_conn, 0, s->stream_id, + NGHTTP3_H3_REQUEST_REJECTED); + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); + } + return; } diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index c9309b98..a731f1df 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -195,6 +195,13 @@ void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk); int h3_stream_append_chunk(void *ctx, zend_string *chunk); void h3_stream_mark_ended(void *ctx); +/* Per-worker global memory accounting for HTTP/3 static delivery + * (http3_static_response.c). append_chunk credits queued bytes via _alloc + * when the stream tracks static bytes; the ACK path and teardown debit them. + * Bounds total static read-ahead across concurrent streams (mirrors H2). */ +void h3_static_account_alloc(size_t n); +void h3_static_account_debit(size_t n); + /* Static-file body delivery for HTTP/3. Wired into h3_stream_ops via * the .send_static_response slot. See http3_static_response.c for the * lifecycle contract. */ diff --git a/src/http3/http3_listener.c b/src/http3/http3_listener.c index cf68555c..de24c356 100644 --- a/src/http3/http3_listener.c +++ b/src/http3/http3_listener.c @@ -97,6 +97,15 @@ struct _http3_listener_s { int port; bool closed; + /* Fabricated local sockaddr for the writev_stream path, cached per peer + * family at spawn since (host, port) never change afterward — the drain + * path copies the matching one instead of re-running inet_pton/htons on + * every packet. */ + struct sockaddr_storage local_v4; + socklen_t local_v4_len; + struct sockaddr_storage local_v6; + socklen_t local_v6_len; + /* DCID → http3_connection_t* routing table. Every conn appears under * one or two keys (server SCID + client's original DCID) so both * retransmitted Initials and post-handshake short-header packets @@ -1351,6 +1360,52 @@ HashTable *http3_listener_conn_map(http3_listener_t *l) * (the symptom: every reply is an Initial-with-ACK, no CRYPTO). */ extern int ngtcp2_crypto_ossl_init(void); +/* Fabricate the listener's local sockaddr for a given peer family from the + * bind config. See http3_build_listener_local for the rationale; this is the + * one-time computation whose result the listener caches. */ +static void http3_listener_compute_local(const char *host, int port, + int peer_family, + struct sockaddr_storage *out, + socklen_t *out_len) +{ + memset(out, 0, sizeof(*out)); + + if (host == NULL) host = (peer_family == AF_INET6) ? "::" : "0.0.0.0"; + + if (peer_family == AF_INET6) { + struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)out; + s6->sin6_family = AF_INET6; + s6->sin6_port = htons((uint16_t)port); + + if (inet_pton(AF_INET6, host, &s6->sin6_addr) != 1) { + inet_pton(AF_INET6, "::1", &s6->sin6_addr); + } + *out_len = sizeof(*s6); + } else { + struct sockaddr_in *s4 = (struct sockaddr_in *)out; + s4->sin_family = AF_INET; + s4->sin_port = htons((uint16_t)port); + + if (inet_pton(AF_INET, host, &s4->sin_addr) != 1) { + s4->sin_addr.s_addr = htonl(INADDR_ANY); + } + *out_len = sizeof(*s4); + } +} + +void http3_listener_local_sockaddr(const http3_listener_t *l, int peer_family, + struct sockaddr_storage *out, + socklen_t *out_len) +{ + if (peer_family == AF_INET6) { + memcpy(out, &l->local_v6, sizeof(*out)); + *out_len = l->local_v6_len; + } else { + memcpy(out, &l->local_v4, sizeof(*out)); + *out_len = l->local_v4_len; + } +} + http3_listener_t *http3_listener_spawn(const char *host, int port, void *ssl_ctx, void *server_obj, const http3_reactor_ctx_t *reactor_ctx) @@ -1364,6 +1419,10 @@ http3_listener_t *http3_listener_spawn(const char *host, int port, listener->fd = -1; listener->host = estrdup(host); listener->port = port; + http3_listener_compute_local(listener->host, port, AF_INET, + &listener->local_v4, &listener->local_v4_len); + http3_listener_compute_local(listener->host, port, AF_INET6, + &listener->local_v6, &listener->local_v6_len); listener->ssl_ctx = ssl_ctx; listener->server_obj = server_obj; listener->reactor_ctx = reactor_ctx; diff --git a/src/http3/http3_listener.h b/src/http3/http3_listener.h index 05f9d8b9..71705749 100644 --- a/src/http3/http3_listener.h +++ b/src/http3/http3_listener.h @@ -160,6 +160,15 @@ int http3_listener_local_port(const http3_listener_t *listener); void http3_listener_destroy(http3_listener_t *listener); struct sockaddr; +struct sockaddr_storage; + +/* Copy the listener's cached fabricated local sockaddr for the given peer + * family into *out (see http3_build_listener_local). Read-only after spawn. */ +void http3_listener_local_sockaddr(const http3_listener_t *listener, + int peer_family, + struct sockaddr_storage *out, + socklen_t *out_len); + /* Synchronous best-effort UDP send. On Linux (raw-fd path) issues one * sendmsg(MSG_DONTWAIT). On other platforms (incl. Windows) falls back * to the libuv udp_try_send / udp_sendto pair (the legacy code path); diff --git a/src/http3/http3_static_response.c b/src/http3/http3_static_response.c index ae9ea12d..680af340 100644 --- a/src/http3/http3_static_response.c +++ b/src/http3/http3_static_response.c @@ -72,6 +72,87 @@ #define H3_STATIC_READ_CHUNK_BYTES (16u * 1024u) +/* Per-worker static-delivery memory cap. Unlike H2's read-ahead ring, the H3 + * pump reads one 16 KiB chunk at a time and blocks on per-stream backpressure, + * so the only unbounded axis is concurrency: N streams each holding up to a + * BDP of un-ACKed chunks. This global counter bounds that sum. Formula and + * clamps mirror http2_static_response.c: budget = memory_limit / 4, floored, + * and never above memory_limit minus a reserve so it can't starve the heap. */ +#define H3_STATIC_BUDGET_FLOOR (4u * 1024u * 1024u) +#define H3_STATIC_BUDGET_FALLBACK (64u * 1024u * 1024u) /* memory_limit = -1 */ +#define H3_STATIC_BUDGET_RESERVE_FRAC 8u /* hard top = 87.5% */ +#define H3_STATIC_THROTTLE_POLL_MS 2u /* pump re-check cadence */ + +/* 0 = not yet initialised (h3_static_budget_init_once). */ +ZEND_TLS size_t h3_static_budget_bytes = 0; +ZEND_TLS size_t h3_static_global_bytes_in_flight = 0; +ZEND_TLS size_t h3_static_global_high_water = 0; + +static void h3_static_budget_init_once(void) +{ + if (h3_static_budget_bytes != 0) { + return; + } + + const zend_long limit = PG(memory_limit); + size_t budget; + + if (limit <= 0) { + budget = H3_STATIC_BUDGET_FALLBACK; + } else { + const size_t mem = (size_t)limit; + const size_t reserve = mem / H3_STATIC_BUDGET_RESERVE_FRAC; + const size_t hard_top = mem > reserve ? mem - reserve : mem; + + budget = mem / 4u; + if (budget > hard_top) { budget = hard_top; } + } + + if (budget < H3_STATIC_BUDGET_FLOOR) { budget = H3_STATIC_BUDGET_FLOOR; } + + h3_static_budget_bytes = budget; +} + +void h3_static_account_alloc(const size_t n) +{ + h3_static_global_bytes_in_flight += n; + + if (h3_static_global_bytes_in_flight > h3_static_global_high_water) { + h3_static_global_high_water = h3_static_global_bytes_in_flight; + } +} + +void h3_static_account_debit(const size_t n) +{ + /* Clamp: the one-shot teardown remainder can overlap chunks already + * debited on the ACK path if a release raced, so never underflow. */ + h3_static_global_bytes_in_flight -= + (n <= h3_static_global_bytes_in_flight) ? n : h3_static_global_bytes_in_flight; +} + +static bool h3_static_over_budget(void) +{ + return h3_static_global_bytes_in_flight >= h3_static_budget_bytes; +} + +/* Suspend the pump for `ms` on a one-shot timer so the reactor keeps draining + * (and ACKing, which debits the counter) while we wait. Best-effort outside a + * coroutine. Mirrors worker_dispatch.c's credit sleep. */ +static void h3_static_throttle_sleep(zend_coroutine_t *co, const zend_ulong ms) +{ + zend_async_timer_event_t *const t = ZEND_ASYNC_NEW_TIMER_EVENT(ms, false); + + if (UNEXPECTED(t == NULL)) { + zend_clear_exception(); + return; + } + + t->base.start(&t->base); + zend_async_resume_when(co, &t->base, true, zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + ZEND_ASYNC_WAKER_DESTROY(co); +} + typedef struct { http3_stream_t *stream; http3_connection_t *conn; @@ -115,17 +196,33 @@ static void h3_static_finalize(h3_static_state_t *state) * backpressure inside append_chunk; bails on stream death. */ static void h3_static_pump_entry(void) { - const zend_coroutine_t *co = ZEND_ASYNC_CURRENT_COROUTINE; + zend_coroutine_t *co = ZEND_ASYNC_CURRENT_COROUTINE; h3_static_state_t *state = (h3_static_state_t *)co->extended_data; if (state == NULL || state->stream == NULL || state->conn == NULL) { return; } + h3_static_budget_init_once(); + state->stream->tracks_static_bytes = true; + char *buf = emalloc(H3_STATIC_READ_CHUNK_BYTES); while (state->bytes_sent < state->body_length && !state->stream->peer_closed) { + /* Global cap: hold off starting the next read while the per-worker + * in-flight total is over budget. Already-queued bytes keep draining + * (and ACKing, which debits) independently, so this can't deadlock — + * it only paces this stream against the shared ceiling. */ + while (h3_static_over_budget() && !state->stream->peer_closed) { + h3_static_throttle_sleep(co, H3_STATIC_THROTTLE_POLL_MS); + + if (EG(exception) != NULL) { + zend_clear_exception(); + break; + } + } + const uint64_t remaining = state->body_length - state->bytes_sent; const size_t want = remaining < H3_STATIC_READ_CHUNK_BYTES ? (size_t)remaining diff --git a/src/http3/http3_steer.c b/src/http3/http3_steer.c index 1dcc6c04..9f43bcf5 100644 --- a/src/http3/http3_steer.c +++ b/src/http3/http3_steer.c @@ -30,27 +30,30 @@ static bool g_steer_ready = false; static bool g_steer_active = false; /* One AES-128-ECB block. Used as a PRF: the input is the CID nonce, the first - * output byte is the keystream that masks the id. Cold path only (CID mint and - * conn_map misses) — the per-call EVP context is dwarfed by the stateless-reset - * HMAC the miss path already pays, and pairs alloc/free so it is leak-clean. */ + * output byte is the keystream that masks the id. The cipher context is kept + * thread-local and reset per call instead of new/free'd every time — each + * reactor thread reuses its own; the handful of contexts are reclaimed at + * process exit. */ static bool http3_steer_block(const uint8_t in[16], uint8_t out[16]) { - EVP_CIPHER_CTX *const ctx = EVP_CIPHER_CTX_new(); + static __thread EVP_CIPHER_CTX *ctx = NULL; if (ctx == NULL) { - return false; + ctx = EVP_CIPHER_CTX_new(); + + if (ctx == NULL) { + return false; + } + } else { + EVP_CIPHER_CTX_reset(ctx); } int outl = 0; - const bool ok = - EVP_EncryptInit_ex(ctx, EVP_aes_128_ecb(), NULL, g_steer_key, NULL) == 1 + + return EVP_EncryptInit_ex(ctx, EVP_aes_128_ecb(), NULL, g_steer_key, NULL) == 1 && EVP_CIPHER_CTX_set_padding(ctx, 0) == 1 && EVP_EncryptUpdate(ctx, out, &outl, in, 16) == 1 && outl == 16; - - EVP_CIPHER_CTX_free(ctx); - - return ok; } /* Keystream byte for a 7-byte nonce: AES(key, nonce || zero-pad)[0]. */ diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index 308fde07..fdf835bc 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -22,6 +22,10 @@ #include "http_body_stream.h" /* sever body_h3_conn + wake a parked consumer */ #include "http3_listener.h" /* http3_listener_stream_pool */ +/* Static-delivery memory accounting (http3_static_response.c). Declared here + * rather than via the heavy http3_internal.h — teardown only needs the debit. */ +extern void h3_static_account_debit(size_t n); + static void http3_stream_release_via_request(http_request_t *req); @@ -159,6 +163,13 @@ void http3_stream_release(http3_stream_t *s) s->chunk_queue = NULL; } + /* Static delivery: one-shot reconcile of any in-flight bytes not debited on + * the ACK path (chunks freed above, or on a submit-failure branch). */ + if (s->tracks_static_bytes && s->static_inflight > 0) { + h3_static_account_debit(s->static_inflight); + s->static_inflight = 0; + } + /* Reverse-path credit: the stream is going away — unblock a parked * producer, then drop the reactor's ref. */ if (s->wire_credit != NULL) { @@ -208,13 +219,14 @@ void http3_stream_release(http3_stream_t *s) /* Unlink from the owning connection's live-stream list. */ if (s->conn != NULL) { - http3_stream_t **p = &s->conn->streams_head; - while (*p != NULL && *p != s) { - p = &(*p)->list_next; + if (s->list_prev != NULL) { + s->list_prev->list_next = s->list_next; + } else { + s->conn->streams_head = s->list_next; } - if (*p == s) { - *p = s->list_next; + if (s->list_next != NULL) { + s->list_next->list_prev = s->list_prev; } s->conn = NULL; From 9dfa5ebe5475cd0082300aa8fb69451c541f1052 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:48:31 +0000 Subject: [PATCH 31/33] fix(http): three latent use-after-free bugs found under ASAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three pass normally (Zend arena hides them) but abort under ASAN + USE_ZEND_ALLOC=0: - ws_handler_coroutine_dispose read w->committed after releasing the websocket zval that may hold w's last ref — capture it before the dtor. - http_log_server_stop awaited the writer's req, but writer_complete_cb frees that req from inside the await; drain by yielding to the reactor and polling until idle instead. - ws_dispatch_try_upgrade reject paths (and spawn-fail) freed the request while the h1 parser still borrowed it via parser->request — sever it with http_parser_clear_request, mirroring the h1 dispatch path. --- src/log/http_log.c | 50 +++++++++++++++++++++++++++++-------- src/websocket/ws_dispatch.c | 42 +++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/log/http_log.c b/src/log/http_log.c index 02537d71..a17f3959 100644 --- a/src/log/http_log.c +++ b/src/log/http_log.c @@ -13,10 +13,10 @@ #include "php.h" #include "php_streams.h" #include "Zend/zend_enum.h" +#include "Zend/zend_exceptions.h" /* zend_clear_exception */ #include "Zend/zend_async_API.h" #include "log/http_log.h" -#include "core/http_connection_internal.h" /* async_io_req_await */ #include "../../stubs/LogSeverity.php_arginfo.h" #include @@ -64,6 +64,11 @@ static void *g_formatter_ud = NULL; #define HTTP_LOG_PENDING_MAX (64u * 1024u) +/* Stop-time drain of in-flight log writes: yield to the reactor in short beats + * until the writer is idle, bounded so a wedged sink can't pin teardown. */ +#define HTTP_LOG_STOP_DRAIN_SLEEP_MS 1u +#define HTTP_LOG_STOP_DRAIN_MAX_SPINS 3000u /* ~3s ceiling → leak-fallback */ + struct http_log_writer_cb { zend_async_event_callback_t base; http_log_state_t *state; @@ -540,6 +545,25 @@ void http_log_server_start(http_log_state_t *state, state->severity = severity; } +/* Yield to the reactor for a short beat so a pending log-write completion can + * be delivered to writer_complete_cb (which disposes the req and kicks the + * next). Stop-drain only; best-effort. */ +static void http_log_stop_yield(zend_coroutine_t *co) +{ + zend_async_timer_event_t *const t = + ZEND_ASYNC_NEW_TIMER_EVENT((zend_ulong)HTTP_LOG_STOP_DRAIN_SLEEP_MS, false); + + if (UNEXPECTED(t == NULL)) { + zend_clear_exception(); + return; + } + + t->base.start(&t->base); + zend_async_resume_when(co, &t->base, true, zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + ZEND_ASYNC_WAKER_DESTROY(co); +} + void http_log_server_stop(http_log_state_t *state) { if (state == NULL) { @@ -548,18 +572,22 @@ void http_log_server_stop(http_log_state_t *state) state->severity = HTTP_LOG_OFF; - /* Drain in-flight writes before tearing down — closing the io - * with reqs still pending would UAF on completion. writer_complete_cb - * fires from inside the await, kicks any pending coalesced bytes, - * and we loop until idle. Requires a coroutine context, which the - * normal HttpServer::stop() path provides. */ + /* Drain in-flight writes before tearing down — closing the io with reqs + * still pending would UAF on completion. writer_complete_cb fires on the + * reactor, disposes the req, and kicks any coalesced pending bytes, so we + * must NOT await the req ourselves: async_io_req_await reads req->completed + * after resuming, but the callback has already freed it by then (a UAF, + * caught under ASAN). Instead yield to the reactor and re-poll until idle. + * Requires a coroutine context, which the normal HttpServer::stop() path + * provides. */ if (state->async_io != NULL && state->writer_cb != NULL && ZEND_ASYNC_CURRENT_COROUTINE != NULL) { - while (state->writer_cb->active_req != NULL) { - zend_async_io_req_t *req = state->writer_cb->active_req; - (void)async_io_req_await(req, state->async_io, - /* timeout_ms */ 0, HTTP_IO_REQ_WRITE, - /* log_state */ NULL); + zend_coroutine_t *const co = ZEND_ASYNC_CURRENT_COROUTINE; + unsigned spins = 0; + + while (state->writer_cb->active_req != NULL + && spins++ < HTTP_LOG_STOP_DRAIN_MAX_SPINS) { + http_log_stop_yield(co); } } diff --git a/src/websocket/ws_dispatch.c b/src/websocket/ws_dispatch.c index 18182b1f..bfbcf8a7 100644 --- a/src/websocket/ws_dispatch.c +++ b/src/websocket/ws_dispatch.c @@ -82,6 +82,17 @@ typedef struct { char *buf; } ws_pending_write_t; +/* Destroy a request the reject path owns, first severing the h1 parser's + * borrow (parser->request) so a later read tick can't read it freed via + * http_parser_is_complete. Mirrors the h1 dispatch spawn-fail path. */ +static void ws_reject_destroy_request(http_connection_t *conn, http_request_t *req) +{ + if (conn->parser != NULL) { + http_parser_clear_request(conn->parser); + } + http_request_destroy(req); +} + bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) { /* Cheapest probe first — no Upgrade header → not our concern. */ @@ -100,7 +111,7 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) if (handler == NULL) { zend_string *resp = build_error_response(426, "Sec-WebSocket-Version: 13\r\n"); - if (resp == NULL) { conn->keep_alive = false; http_request_destroy(req); return true; } + if (resp == NULL) { conn->keep_alive = false; ws_reject_destroy_request(conn, req); return true; } char *buf = emalloc(ZSTR_LEN(resp)); memcpy(buf, ZSTR_VAL(resp), ZSTR_LEN(resp)); size_t len = ZSTR_LEN(resp); @@ -110,7 +121,7 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) } /* Reject path owns req (no HttpRequest object wraps it here). */ - http_request_destroy(req); + ws_reject_destroy_request(conn, req); return true; } @@ -125,7 +136,7 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) default: status = 400; extra = NULL; break; } zend_string *resp = build_error_response(status, extra); - if (resp == NULL) { conn->keep_alive = false; http_request_destroy(req); return true; } + if (resp == NULL) { conn->keep_alive = false; ws_reject_destroy_request(conn, req); return true; } char *buf = emalloc(ZSTR_LEN(resp)); memcpy(buf, ZSTR_VAL(resp), ZSTR_LEN(resp)); size_t len = ZSTR_LEN(resp); @@ -135,7 +146,7 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) } /* Reject path owns req (no HttpRequest object wraps it here). */ - http_request_destroy(req); + ws_reject_destroy_request(conn, req); return true; } @@ -146,14 +157,14 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) "sec-websocket-key", sizeof("sec-websocket-key") - 1); if (UNEXPECTED(key_zv == NULL || Z_TYPE_P(key_zv) != IS_STRING)) { conn->keep_alive = false; - http_request_destroy(req); + ws_reject_destroy_request(conn, req); return true; } char accept_value[WS_ACCEPT_LEN]; if (ws_handshake_compute_accept(Z_STRVAL_P(key_zv), Z_STRLEN_P(key_zv), accept_value) != 0) { conn->keep_alive = false; - http_request_destroy(req); + ws_reject_destroy_request(conn, req); return true; } @@ -198,6 +209,10 @@ bool ws_dispatch_try_upgrade(http_connection_t *conn, http_request_t *req) zval_ptr_dtor(&ctx->upgrade_zv); efree(ctx); conn->keep_alive = false; + /* req was freed with request_zv; sever the parser's borrow. */ + if (conn->parser != NULL) { + http_parser_clear_request(conn->parser); + } return true; } @@ -423,6 +438,11 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) w->session = NULL; } + /* Capture w->committed before the dtor below: releasing websocket_zv may + * drop w's last ref and free it, so reading w->committed at teardown time + * (further down) would be a use-after-free. */ + const bool w_committed = (w != NULL) && w->committed; + zval_ptr_dtor(&ctx->request_zv); zval_ptr_dtor(&ctx->websocket_zv); zval_ptr_dtor(&ctx->upgrade_zv); @@ -431,6 +451,14 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) conn->keep_alive = false; conn->current_request = NULL; + /* request_zv's dtor above may have freed the http_request the h1 parser + * still borrows (parser->request). Sever it exactly as the h1 dispatch + * path does (http_connection.c) — otherwise a later read tick reads a + * freed request via http_parser_is_complete. */ + if (conn->parser != NULL) { + http_parser_clear_request(conn->parser); + } + ZEND_ASSERT(conn->handler_refcount > 0); conn->handler_refcount--; @@ -444,7 +472,7 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) * NOT for the reject / never-committed path: there an async 4xx write * is in flight and its completion callback (ws_pending_write_complete_cb) * owns teardown — destroying here would free conn under that write. */ - if (w != NULL && w->committed) { + if (w_committed) { if (conn->handler_refcount == 0) { http_connection_destroy(conn); } else { From ad3949fbb2e5862c1bac378c6c5d2044fe9d7b78 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:47:32 +0000 Subject: [PATCH 32/33] docs(comments): trim branch comments to contracts and constraints Cut ~475 comment lines across the gRPC/HTTP2/HTTP3 work: section banners, code restatement, per-site duplication, and review narrative. Kept one-line function descriptions, API return/ownership contracts, and non-derivable protocol/UAF constraints. --- fuzz/fuzz_stubs.c | 12 +- .../compression/http_compression_request.h | 15 +- include/core/reactor_cmd.h | 25 +--- include/core/response_wire.h | 34 ++--- include/core/stream_credit.h | 26 +--- include/core/thread_mailbox.h | 8 +- include/core/thread_queue.h | 8 +- include/http1/http_parser.h | 21 +-- include/http3/http3_stream.h | 31 +---- include/php_http_server.h | 4 +- src/compression/http_compression_gzip.c | 5 +- src/compression/http_compression_request.c | 8 +- src/core/http_protocol_handlers.h | 7 +- src/core/reactor_pool.c | 6 +- src/core/thread_mailbox.c | 7 +- src/core/worker_dispatch.c | 88 ++---------- src/grpc/grpc.c | 16 +-- src/grpc/grpc.h | 73 +++------- src/grpc/grpc_call.c | 17 +-- src/grpc/grpc_call.h | 42 ++---- src/http2/http2_session.c | 41 ++---- src/http2/http2_strategy.c | 23 +--- src/http3/http3_callbacks.c | 129 +++++------------- src/http3/http3_dispatch.c | 45 ++---- src/http3/http3_internal.h | 22 +-- src/http3/http3_listener.c | 10 +- src/http3/http3_static_response.c | 17 +-- src/http3/http3_steer.c | 7 +- src/http3/http3_stream.c | 13 +- src/http_request.c | 31 +---- src/http_response.c | 49 ++----- src/http_response_internal.h | 5 +- src/http_server_class.c | 31 +---- src/log/http_log.c | 15 +- src/websocket/ws_dispatch.c | 15 +- 35 files changed, 211 insertions(+), 695 deletions(-) diff --git a/fuzz/fuzz_stubs.c b/fuzz/fuzz_stubs.c index e56d067d..6363362d 100644 --- a/fuzz/fuzz_stubs.c +++ b/fuzz/fuzz_stubs.c @@ -125,20 +125,16 @@ __attribute__((weak)) void h2_session_schedule_emit(struct http2_session_t *sess (void)session; } -/* http_response_get_trailers lives in http_response_server_api.c (the - * PHP-object TU, not linked into the fuzz harness). Referenced by - * http2_session_submit_response_trailers; fuzz sessions have no PHP - * response object, and the caller treats a NULL map as "no trailers". */ +/* Real impl in http_response_server_api.c (not linked here); the caller + * treats NULL as "no trailers". */ __attribute__((weak)) HashTable *http_response_get_trailers(zend_object *obj) { (void)obj; return NULL; } -/* grpc_request_is_grpc_web_text lives in src/grpc/grpc.c (not linked into - * the fuzz harness). Referenced by the body-streaming policy gate in - * http2_session.c; fuzz sessions never dispatch to a handler, so a plain - * "not web-text" answer keeps the gate inert. */ +/* Real impl in src/grpc/grpc.c (not linked here); "not web-text" keeps the + * body-streaming gate inert. */ __attribute__((weak)) bool grpc_request_is_grpc_web_text(const struct http_request_t *req) { (void)req; diff --git a/include/compression/http_compression_request.h b/include/compression/http_compression_request.h index efed5afe..0fd48a3f 100644 --- a/include/compression/http_compression_request.h +++ b/include/compression/http_compression_request.h @@ -49,17 +49,10 @@ int http_compression_decode_request_body(http_request_t *req, int http_compression_decode_request_brotli(http_request_t *req, size_t cap); int http_compression_decode_request_zstd(http_request_t *req, size_t cap); -/* One-shot whole-buffer gzip helpers for message-level compression (gRPC - * per-message frames), reusing the same zlib backend as the body paths. - * - * deflate: compress `in` as a standalone gzip member; returns a new - * zend_string the caller owns, or NULL on failure. `level` clamped to 1..9. - * Defined in http_compression_gzip.c. - * - * inflate: returns 0 on success (*out set, caller owns it), -1 on a - * malformed stream, -2 when the output would exceed `max_out` - * (max_out == 0 → unbounded). Shares the inflate loop + zip-bomb guard - * with the request-body decoder in this TU. */ +/* One-shot whole-buffer gzip helpers (gRPC per-message frames). + * deflate: new zend_string (caller owns) or NULL; `level` clamped to 1..9. + * inflate: 0 = ok (*out owned by caller), -1 = malformed, -2 = would exceed + * max_out (0 → unbounded). */ zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, int level); int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, diff --git a/include/core/reactor_cmd.h b/include/core/reactor_cmd.h index d00e6544..c7e316b0 100644 --- a/include/core/reactor_cmd.h +++ b/include/core/reactor_cmd.h @@ -15,26 +15,13 @@ extern "C" { #endif -/* - * Inbound message a reactor thread drains from its mailbox. Passed BY VALUE - * through the lock-free ring (no per-message heap allocation), so it must stay - * a trivially-copyable POD. The drain dispatches on `kind`: - * NOOP — opaque liveness token (reactor_pool_post); `payload` is only counted. - * EXEC — reactor_pool_exec: run fn(arg), then publish completion by storing 1 - * through `done` (a zend_atomic_int* on the blocking caller's stack — - * a pointer, not an embedded atomic, precisely because the envelope is - * copied into the ring). - * POST — reactor_pool_post_exec: fire-and-forget fn(arg); no `done` ack and - * nothing to free (the value lived in the ring, not the heap). - * STOP — cooperative shutdown token (reactor_pool_destroy): the reactor sets - * its stopping flag and leaves its loop. Travels the same ring as work - * (a value queue has no out-of-band sentinel pointer). - */ +/* Mailbox message, passed BY VALUE through the lock-free ring — must stay a + * trivially-copyable POD. */ typedef enum { - REACTOR_CMD_NOOP, - REACTOR_CMD_EXEC, - REACTOR_CMD_POST, - REACTOR_CMD_STOP, + REACTOR_CMD_NOOP, /* liveness token; payload only counted */ + REACTOR_CMD_EXEC, /* run fn(arg), then store 1 through `done` */ + REACTOR_CMD_POST, /* fire-and-forget fn(arg) */ + REACTOR_CMD_STOP, /* cooperative shutdown token */ } reactor_cmd_kind_t; typedef struct reactor_cmd_s { diff --git a/include/core/response_wire.h b/include/core/response_wire.h index 5d3af328..76cd294e 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -37,20 +37,15 @@ typedef struct response_wire_s response_wire_t; -/* Message kind on the reverse channel. FULL is the original single-shot shape - * (everything in one wire at dispose). The STREAM_* kinds carry a streamed - * response incrementally — same routing, same arena, posted in order on one - * reactor mailbox (FIFO): one STREAM_HEADERS, any number of STREAM_CHUNKs, - * one STREAM_END (which may carry trailers). */ +/* FULL = single-shot at dispose. Streamed responses go FIFO on one reactor + * mailbox: one STREAM_HEADERS, N STREAM_CHUNKs, one STREAM_END (may carry + * trailers). */ typedef enum { RESPONSE_WIRE_FULL = 0, RESPONSE_WIRE_STREAM_HEADERS, RESPONSE_WIRE_STREAM_CHUNK, RESPONSE_WIRE_STREAM_END, - /* The stream died mid-flight on the worker (credit timeout, cancelled - * handler, dropped fragment): the reactor must RESET the QUIC stream so - * the peer sees an abort — a truncated body must never terminate with a - * clean FIN that reads as a complete response. */ + /* stream died mid-flight — the reactor must RESET, not send a clean FIN */ RESPONSE_WIRE_STREAM_ABORT, } response_wire_kind_t; @@ -62,31 +57,24 @@ response_wire_t *response_wire_create(uint32_t reactor_id, int64_t stream_id, vo void response_wire_set_kind(response_wire_t *rw, response_wire_kind_t kind); response_wire_kind_t response_wire_kind(const response_wire_t *rw); -/* Flow-control credit handoff (STREAM_HEADERS only): an opaque - * stream_credit_t* riding the wire from the worker to the reactor. The wire - * does not own it — the reactor adopts the ref at apply time (or marks it - * dead + releases when the stream is already gone). */ +/* Credit handoff (STREAM_HEADERS only): opaque stream_credit_t*, not owned + * by the wire — the reactor adopts the ref at apply time. */ void response_wire_set_credit(response_wire_t *rw, void *credit); void *response_wire_credit(const response_wire_t *rw); -/* STREAM_CHUNK payload handoff: a PERSISTENT zend_string* whose ownership - * rides the wire (one copy total: handler ZMM -> persistent). The consumer - * takes it; a drop site must take + release it — the wire itself is a pure - * malloc TU and cannot. */ +/* STREAM_CHUNK payload: a persistent zend_string* whose ownership rides the + * wire; a drop site must take + release it (this TU cannot). */ void response_wire_set_chunk(response_wire_t *rw, void *persistent_str); void *response_wire_take_chunk(response_wire_t *rw); -/* Builders — copy bytes into the arena. set_status replaces; add_header - * appends; set_body replaces (FULL wires only; streamed bodies ride - * STREAM_CHUNK wires). All accept non-NUL-terminated spans; the pair - * builders return false on allocation failure (the wire stays freeable). */ +/* Builders — copy bytes into the arena; pair builders return false on + * allocation failure. set_body is FULL wires only. */ void response_wire_set_status(response_wire_t *rw, int status); bool response_wire_add_header(response_wire_t *rw, const char *name_ptr, size_t name_len, const char *value_ptr, size_t value_len); bool response_wire_set_body(response_wire_t *rw, const char *ptr, size_t len); -/* Trailers mirror headers: appended pairs, delivered by the transport after - * the last body byte (H2 trailer HEADERS / nghttp3 submit_trailers at EOF). */ +/* Trailers mirror headers; the transport delivers them after the last body byte. */ bool response_wire_add_trailer(response_wire_t *rw, const char *name_ptr, size_t name_len, const char *value_ptr, size_t value_len); diff --git a/include/core/stream_credit.h b/include/core/stream_credit.h index 7fe11b16..48d8e0ec 100644 --- a/include/core/stream_credit.h +++ b/include/core/stream_credit.h @@ -15,22 +15,10 @@ #include "Zend/zend_atomic.h" -/* - * Per-stream flow-control credit for the reactor/worker streaming reverse - * path (#80, step 3). Pure malloc-domain, atomics only — the ONLY object the - * two threads touch concurrently, so neither side ever dereferences the - * other's Zend state. - * - * Protocol: the worker creates the block (two refs), attaches it to the - * STREAM_HEADERS wire, and thereafter tracks posted-bytes locally. When - * posted - acked exceeds its cap, the producer coroutine sleeps on a short - * timer and re-reads `acked` — no cross-thread event objects. The reactor - * (owner of the second ref) advances `acked` as the QUIC peer acknowledges - * stream bytes, and sets `dead` when the stream dies (RST / connection - * close / failed submit) so a waiting producer unblocks into the standard - * stream-dead path. Each side releases its ref exactly once; the last - * release frees the block. - */ +/* Per-stream flow-control credit shared worker↔reactor. Malloc + atomics + * only — the one object both threads touch. Worker creates it with two refs + * (one per side); reactor advances `acked` on peer ACK and sets `dead` on + * stream death; last release frees. */ typedef struct { zend_atomic_int64 acked; /* bytes the reactor has retired (peer ACK) */ @@ -60,8 +48,7 @@ static inline void stream_credit_release(stream_credit_t *sc) } } -/* Reactor thread ONLY (single writer) — load+store instead of fetch-add is - * safe because nothing else ever writes `acked`. */ +/* Reactor thread only — single writer, so load+store beats fetch-add. */ static inline void stream_credit_ack(stream_credit_t *sc, const uint64_t bytes) { zend_atomic_int64_store_ex(&sc->acked, @@ -79,8 +66,7 @@ static inline void stream_credit_mark_dead(stream_credit_t *sc) zend_atomic_int_store_ex(&sc->dead, 1); } -/* Drop-site helper, NULL-safe. Mark dead BEFORE release — a parked producer - * must observe `dead` before the block can vanish. */ +/* NULL-safe. Dead must be set before release so a parked producer sees it. */ static inline void stream_credit_abandon(stream_credit_t *sc) { if (sc != NULL) { diff --git a/include/core/thread_mailbox.h b/include/core/thread_mailbox.h index 05a7dddc..25bd4aa2 100644 --- a/include/core/thread_mailbox.h +++ b/include/core/thread_mailbox.h @@ -66,12 +66,8 @@ void thread_mailbox_keepalive(thread_mailbox_t *mb, bool enable); /* Approximate number of queued items. */ size_t thread_mailbox_count(const thread_mailbox_t *mb); -/* --------------------------------------------------------------------------- - * reactor_cmd_t mailbox — same wakeup/backpressure contract as the void* - * mailbox above, but the command POD travels through the ring by value (no - * per-message malloc on the hot worker->reactor path). Used by reactor_pool; - * the void* mailbox stays for the opaque-pointer consumers (WebSocket, etc.). - * ------------------------------------------------------------------------- */ +/* reactor_cmd_t mailbox — same contract as the void* mailbox above, but the + * POD travels by value: no per-message malloc on the hot worker→reactor path. */ typedef struct reactor_cmd_s reactor_cmd_t; /* core/reactor_cmd.h */ typedef struct thread_cmd_mailbox_s thread_cmd_mailbox_t; diff --git a/include/core/thread_queue.h b/include/core/thread_queue.h index 13f0a1ef..4db3db5e 100644 --- a/include/core/thread_queue.h +++ b/include/core/thread_queue.h @@ -76,12 +76,8 @@ bool thread_spsc_dequeue(thread_spsc_t *q, void **item); size_t thread_spsc_drain(thread_spsc_t *q, void **items, size_t max); size_t thread_spsc_count(const thread_spsc_t *q); -/* ------------------------------------------------------------------ */ -/* MPSC carrying reactor_cmd_t BY VALUE — many producers, single */ -/* consumer. Same bounded/lock-free contract as the void* MPSC, but */ -/* the fixed-size command POD is stored in the ring itself, so the hot */ -/* worker->reactor path enqueues without a per-message malloc. */ -/* ------------------------------------------------------------------ */ +/* MPSC carrying reactor_cmd_t by value — same bounded/lock-free contract as + * the void* MPSC, no per-message malloc. */ typedef struct reactor_cmd_s reactor_cmd_t; /* core/reactor_cmd.h */ typedef struct thread_cmd_mpsc_s thread_cmd_mpsc_t; diff --git a/include/http1/http_parser.h b/include/http1/http_parser.h index c7cada67..897bd980 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -91,21 +91,15 @@ struct http_request_t { /* Body */ zend_string *body; - /* gRPC deframer cursor — advanced by HttpRequest::readMessage() as it - * extracts each 5-byte-length-prefixed message. For a buffered request it - * indexes `body`; for a streaming request (body_streaming) it indexes the - * grpc_reassembly buffer below. Zero-initialised (memset on alloc). */ + /* readMessage() deframer cursor: indexes `body` (buffered), + * grpc_reassembly (streaming) or grpc_text_body (web-text) */ size_t grpc_read_offset; - /* gRPC incremental reassembly buffer for streaming requests: readMessage() - * appends popped body-stream chunks here until a full length-prefixed - * message is available, so messages that span DATA frames are handled. - * Empty {NULL,0} until first use; freed in http_request_destroy. */ + /* streaming reassembly of chunks into whole gRPC messages; + * freed in http_request_destroy */ smart_str grpc_reassembly; - /* grpc-web-text: the base64-decoded request body, materialized lazily on - * the first readMessage() (the deframer cursor then indexes THIS buffer, - * not `body`). NULL until first use; freed in http_request_destroy. */ + /* grpc-web-text: lazily base64-decoded body; freed in http_request_destroy */ zend_string *grpc_text_body; /* Body-progress event. @@ -216,9 +210,8 @@ struct http_request_t { void *body_h2_session; int32_t body_h2_stream_id; - /* H3 mirror of the pair above: the owning http3_connection_t and QUIC - * stream id, set when the streaming policy engages. http_body_stream_pop - * grants deferred QUIC flow-control credit through these (issue #26). */ + /* H3 mirror of the pair above; http_body_stream_pop grants deferred + * QUIC credit through these */ void *body_h3_conn; int64_t body_h3_stream_id; size_t body_h3_uncredited; /* drained, not yet flushed */ diff --git a/include/http3/http3_stream.h b/include/http3/http3_stream.h index f0fd41bd..e9d3d011 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -121,12 +121,8 @@ struct _http3_stream_s { size_t chunk_read_offset; /* bytes already handed from queue[chunk_read_idx] */ size_t chunk_ack_credit; /* leftover acked bytes not yet applied to head release */ - /* Static-file delivery global memory cap (http3_static_response.c). - * tracks_static_bytes latches on only for the static pump's stream; while - * set, each queued chunk is added to the per-worker in-flight counter on - * push and debited on ACK. static_inflight is this stream's running share, - * debited in one shot at teardown to reconcile any chunk freed off the ACK - * path (submit failure, connection close). */ + /* static-file memory cap: charged on push, debited on ACK, reconciled + * at teardown (http3_static_response.c) */ bool tracks_static_bytes; size_t static_inflight; @@ -135,23 +131,13 @@ struct _http3_stream_s { * queue empties instead of NGHTTP3_ERR_WOULDBLOCK. */ bool streaming_ended; - /* gRPC (issue #4). is_grpc: the request is application/grpc — route to - * the addGrpcHandler callable, default grpc-status (web/web-text - * delivery is the response's grpc_mode stamp, not stream state). - * has_trailers / trailers_submitted mirror http2_stream_t: the data - * reader sets NO_END_STREAM + submits nghttp3 trailers once at EOF. */ bool is_grpc; bool has_trailers; bool trailers_submitted; - /* Response trailers captured in dispose (while response_zv is alive) and - * submitted by the data reader at true EOF — nghttp3 requires the trailer - * submit AFTER the last DATA stamps NO_END_STREAM, but response_zv is - * freed by then. trailer_nv is a malloc'd nghttp3_nv[] whose name/ - * value point into trailer_bytes; both freed in http3_stream_release. - * void* keeps nghttp3 out of this header. Canonical consumer: gRPC - * (grpc-status / grpc-message), but any streaming response with a - * trailer map is delivered this way. */ + /* Trailers captured in dispose (response_zv still alive), submitted by + * the data reader at true EOF. malloc'd nghttp3_nv[] pointing into + * trailer_bytes; freed in http3_stream_release. */ void *trailer_nv; size_t trailer_count; char *trailer_bytes; @@ -163,11 +149,8 @@ struct _http3_stream_s { * unwinds cleanly with HttpException(499). */ bool peer_closed; - /* Reverse-path flow control (#80 step 3): the worker's credit block, - * adopted from the STREAM_HEADERS wire. void* keeps core headers out - * of this one; the reactor advances acked on peer ACK, marks dead on - * stream death, releases its ref at teardown. NULL for local / - * buffered streams. */ + /* worker's stream_credit_t*, adopted from the STREAM_HEADERS wire; + * NULL for local/buffered streams */ void *wire_credit; /* Trigger event the handler awaits on under flow-control diff --git a/include/php_http_server.h b/include/php_http_server.h index f3d33783..30fbfb83 100644 --- a/include/php_http_server.h +++ b/include/php_http_server.h @@ -743,9 +743,7 @@ typedef struct { uint64_t stream_bytes_sent_total; uint64_t stream_send_backpressure_events_total; - /* Reactor-pool reverse path: STREAM_* wires dropped because the reactor - * mailbox stayed full past the sink's bounded retry (the stream is then - * aborted, never silently truncated). */ + /* STREAM_* wires dropped after the sink's bounded retry (stream aborted) */ uint64_t worker_wire_dropped_total; /* HTTP/2 stream-level. h2_streams_active is a gauge (++/--); diff --git a/src/compression/http_compression_gzip.c b/src/compression/http_compression_gzip.c index 6b895540..7fe47076 100644 --- a/src/compression/http_compression_gzip.c +++ b/src/compression/http_compression_gzip.c @@ -188,9 +188,8 @@ const http_encoder_vtable_t http_compression_gzip_vt = { .destroy = gz_destroy, }; -/* One-shot whole-buffer gzip compress — see http_compression_request.h. Sizes - * the output via deflateBound and finishes in a single deflate() pass, so it - * never needs the streaming NEED_OUTPUT loop above. */ +/* One-shot gzip compress: deflateBound sizes the output, single deflate() + * pass — no need for the streaming loop above. */ zend_string *http_compression_gzip_deflate_buffer(const char *in, size_t in_len, int level) { diff --git a/src/compression/http_compression_request.c b/src/compression/http_compression_request.c index d2ae1220..8bf430e7 100644 --- a/src/compression/http_compression_request.c +++ b/src/compression/http_compression_request.c @@ -47,12 +47,8 @@ #include -/* Inflate a standalone gzip/zlib member into a fresh zend_string. Shared by - * the request-body decoder below and the message-level path - * (e.g. gRPC per-message decompression), so the - * inflate loop + zip-bomb guard live in exactly one place. Returns 0 on - * success (*out set, caller owns it), -1 on a malformed stream, -2 when the - * output would exceed max_out (max_out == 0 → unbounded). */ +/* Inflate one gzip/zlib member; the shared inflate loop + zip-bomb guard. + * 0 = ok (*out owned by caller), -1 = malformed, -2 = exceeds max_out. */ int http_compression_gzip_inflate_buffer(const char *in, size_t in_len, size_t max_out, zend_string **out) { diff --git a/src/core/http_protocol_handlers.h b/src/core/http_protocol_handlers.h index af94b8a9..a2397b60 100644 --- a/src/core/http_protocol_handlers.h +++ b/src/core/http_protocol_handlers.h @@ -31,11 +31,8 @@ zend_fcall_t* http_protocol_get_handler(HashTable *handlers, /* Check if handler exists */ bool http_protocol_has_handler(HashTable *handlers, http_protocol_type_t protocol); -/* Resolve the user handler with the ONE shared precedence every dispatch - * site uses: the gRPC slot first when the call was classified gRPC, then - * HTTP1 (addHttpHandler's slot), then HTTP2 — so a server registered only - * via addHttp2Handler still services H3/worker requests. Returns NULL when - * nothing matches (callers synthesise a 404 / bail). */ +/* The one shared handler precedence for every dispatch site: gRPC (when + * classified) → HTTP1 → HTTP2. NULL when nothing matches. */ zend_fcall_t *http_protocol_pick_handler(HashTable *handlers, bool is_grpc); /* Remove handler from HashTable */ diff --git a/src/core/reactor_pool.c b/src/core/reactor_pool.c index bc36f0e7..9e9d9587 100644 --- a/src/core/reactor_pool.c +++ b/src/core/reactor_pool.c @@ -259,10 +259,8 @@ bool reactor_pool_exec(reactor_pool_t *rp, const int idx, const reactor_exec_fn return false; } - /* The envelope is copied into the ring by value, so `done` cannot be an - * embedded atomic — the reactor would ack the ring's copy. Keep the atomic - * on our stack and hand the reactor a pointer to it; we block on it below, - * so the frame outlives the reactor's store. */ + /* `done` can't ride the ring by value — the reactor would ack the copy. + * Stack atomic + pointer; we block below, so the frame outlives the store. */ zend_atomic_int done; ZEND_ATOMIC_INT_INIT(&done, 0); diff --git a/src/core/thread_mailbox.c b/src/core/thread_mailbox.c index 0ff4a264..f0a2da43 100644 --- a/src/core/thread_mailbox.c +++ b/src/core/thread_mailbox.c @@ -148,11 +148,8 @@ void thread_mailbox_keepalive(thread_mailbox_t *mb, const bool enable) } } -/* --------------------------------------------------------------------------- - * reactor_cmd_t mailbox. Mirrors the void* mailbox above; the only difference - * is the ring carries the command POD by value, so post copies it in and the - * drain hands back a scratch array of reactor_cmd_t rather than void*. - * ------------------------------------------------------------------------- */ +/* reactor_cmd_t mailbox — mirrors the void* mailbox above, ring carries the + * POD by value. */ struct thread_cmd_mailbox_s { thread_cmd_mpsc_t *queue; diff --git a/src/core/worker_dispatch.c b/src/core/worker_dispatch.c index 91c61359..f066f045 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -25,9 +25,6 @@ #include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ #include "Zend/zend_hrtime.h" /* zend_hrtime — request-service sampling */ -/* Streaming reverse-path flow control: how many un-acked bytes a stream may - * have in flight before append_chunk parks the producer coroutine, and how - * often a parked producer re-reads the credit counter. */ #define WORKER_STREAM_INFLIGHT_CAP (1024 * 1024) #define WORKER_CREDIT_POLL_MS 2 @@ -64,23 +61,13 @@ typedef struct { bool skip_handler; /* synthetic 404 already populated */ bool is_head; /* suppress the body on render */ - /* gRPC classification (once, at dispatch — the transports do the same). */ bool is_grpc; - /* Streaming reverse path: STREAM_HEADERS posted on the first - * append_chunk; STREAM_END posted by mark_ended (idempotent). When - * stream_started, dispose skips the buffered FULL render. - * stream_failed = the stream died mid-flight (credit timeout / - * cancellation / dropped wire) — the terminal wire becomes - * STREAM_ABORT so the peer sees a reset, not a clean FIN. */ bool stream_started; bool stream_ended; - bool stream_failed; + bool stream_failed; /* terminal wire becomes STREAM_ABORT */ - /* Flow control (step 3): `credit` is shared with the reactor (see - * stream_credit.h); posted_bytes is worker-local. In-flight = - * posted_bytes - acked; append parks over WORKER_STREAM_INFLIGHT_CAP. */ - stream_credit_t *credit; + stream_credit_t *credit; /* shared with the reactor */ uint64_t posted_bytes; } worker_dispatch_ctx_t; @@ -195,8 +182,7 @@ static void worker_wire_copy_head(response_wire_t *rw, zend_object *resp) } ZEND_HASH_FOREACH_END(); } -/* Flatten the trailer map (setTrailer / gRPC grpc-status) onto a wire — flat - * string pairs, same discipline as http3_stream_capture_trailers locally. */ +/* Flatten the response trailer map onto the wire. */ static void worker_wire_copy_trailers(response_wire_t *rw, zend_object *resp) { HashTable *const trailers = http_response_get_trailers(resp); @@ -217,9 +203,7 @@ static void worker_wire_copy_trailers(response_wire_t *rw, zend_object *resp) } ZEND_HASH_FOREACH_END(); } -/* Hand a wire to the reverse channel; the sink owns it in every outcome. - * A failed STREAM_* delivery marks the stream failed (terminal wire becomes - * an ABORT — see response_wire_kind_t). */ +/* The sink owns the wire in every outcome. */ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) { const response_wire_kind_t kind = response_wire_kind(rw); @@ -228,8 +212,7 @@ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) if (ctx->sink != NULL) { delivered = ctx->sink(rw, ctx->sink_arg); } else { - /* No sink: nobody adopts a HEADERS wire's credit ref — take it - * over so a parked producer cannot hang. */ + /* nobody adopts the credit ref — release it or the producer hangs */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); zend_string *const orphan_chunk = @@ -250,22 +233,7 @@ static bool worker_wire_post(worker_dispatch_ctx_t *ctx, response_wire_t *rw) return delivered; } -/* --------------------------------------------------------------------- - * Streaming reverse path (worker side of #80 D3, streaming leg). - * - * The worker's HttpResponse gets these stream ops so send()/writeMessage() - * work under the pool: each op flattens its payload into a STREAM_* wire - * and posts it to the originating reactor. Ordering is the mailbox FIFO; - * the reactor applies HEADERS (streaming submit), CHUNKs (chunk_queue), - * END (trailers + EOF) or ABORT (reset — the stream failed mid-flight). - * Backpressure: append_chunk parks the producer coroutine on the shared - * credit block (stream_credit.h) while > WORKER_STREAM_INFLIGHT_CAP bytes - * are un-acked, so it suspends INSIDE the op — BACKPRESSURE is never - * reported outward (same discipline as the local H2/H3 ops). - * ------------------------------------------------------------------- */ -/* Suspend the producer coroutine for `ms` on a one-shot timer so the worker - * loop keeps draining while we wait for credit. Best-effort: bails silently - * outside a coroutine (flush stays best-effort, mirroring the local path). */ +/* Suspend the producer for `ms` on a one-shot timer; no-op outside a coroutine. */ static void worker_credit_sleep_ms(zend_coroutine_t *co, const zend_ulong ms) { zend_async_timer_event_t *const t = ZEND_ASYNC_NEW_TIMER_EVENT(ms, false); @@ -281,8 +249,7 @@ static void worker_credit_sleep_ms(zend_coroutine_t *co, const zend_ulong ms) ZEND_ASYNC_WAKER_DESTROY(co); } -/* Park the producer until in-flight drops under the cap, the stream dies, - * or the write timeout elapses. Returns false when the stream is dead. */ +/* Park until in-flight < cap; false = stream dead. */ static bool worker_stream_wait_credit(worker_dispatch_ctx_t *ctx) { if (ctx->credit == NULL) { @@ -319,8 +286,6 @@ static bool worker_stream_wait_credit(worker_dispatch_ctx_t *ctx) return false; /* peer stopped reading — treat as dead */ } - /* Back off while no progress (idle peer burns fewer timer allocs); - * snap back the moment ACKs move. */ const uint64_t acked = stream_credit_acked(ctx->credit); if (acked == last_acked) { @@ -344,9 +309,7 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) return HTTP_STREAM_APPEND_STREAM_DEAD; } - /* First send(): the response just committed — flatten status + headers - * into the STREAM_HEADERS wire that opens the stream on the reactor, - * carrying the shared credit block (the reactor adopts one ref). */ + /* first send(): open the stream; the reactor adopts one credit ref */ if (!ctx->stream_started) { response_wire_t *const hw = response_wire_create(ctx->reactor_id, ctx->stream_id, ctx->conn); @@ -386,9 +349,6 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) worker_wire_post(ctx, cw); zend_string_release(chunk); /* bytes copied into the wire arena */ - /* Flow control: account the bytes, then park until the reactor retires - * enough of the backlog (peer ACKs) — mirrors the local path suspending - * on write_event, but over the thread boundary via the credit block. */ ctx->posted_bytes += chunk_len; if (!worker_stream_wait_credit(ctx)) { @@ -399,8 +359,7 @@ static int worker_stream_append_chunk(void *vctx, zend_string *chunk) return HTTP_STREAM_APPEND_OK; } -/* Advisory for HttpResponse::sendable(): true while append_chunk would not - * park (room under the in-flight cap and the stream is alive). */ +/* sendable() advisory: true while append_chunk would not park. */ static bool worker_stream_sendable(void *vctx) { worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; @@ -452,12 +411,7 @@ static const http_response_stream_ops_t worker_stream_ops = { .get_wait_event = NULL, /* backpressure parks inside append_chunk */ }; -/* gRPC finish ops (grpc_call_finish seam) — the worker-side delivery - * actions; what to send is decided in src/grpc/grpc_call.c. */ - -/* grpc-web in-body trailer frame. Streamed reply: one more CHUNK + END. - * Zero-message reply: the frame becomes the buffered body — the FULL wire - * rendered right after grpc_call_finish carries it. Consumes the ref. */ +/* grpc-web in-body trailer frame; consumes the ref. */ static void worker_grpc_append_frame_and_end(void *vctx, zend_string *frame) { worker_dispatch_ctx_t *const ctx = (worker_dispatch_ctx_t *)vctx; @@ -480,9 +434,7 @@ static void worker_grpc_end_stream(void *vctx) worker_stream_mark_ended(vctx); } -/* Trailers-Only: grpc_call_finish already promoted the trailers into the - * initial HEADERS; dispose renders + posts the FULL wire right after this - * returns, so there is nothing left to commit here. */ +/* Trailers-Only: dispose posts the FULL wire right after this returns. */ static void worker_grpc_commit(void *vctx) { (void)vctx; @@ -545,8 +497,7 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) if (!Z_ISUNDEF(ctx->response_zv)) { zend_object *const resp = Z_OBJ(ctx->response_zv); - /* A gRPC outcome is expressed as grpc-status on a 200, not as an - * HTTP 500 — grpc_call_ensure_status maps the exception below. */ + /* gRPC maps exceptions to grpc-status, not HTTP 500 */ if (coroutine->exception != NULL && !ctx->is_grpc && !http_response_is_committed(resp)) { http_response_reset_to_error(resp, 500, "Internal Server Error"); @@ -560,18 +511,13 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) http_response_set_committed(resp); } - /* Delivery shape. gRPC policy decides via grpc_call_finish; a plain - * streamed response just needs its END (idempotent — a handler that - * called end() already posted it). A response that never streamed is - * the buffered FULL wire, exactly as before. */ if (ctx->is_grpc) { grpc_call_finish(resp, &worker_grpc_finish_ops, ctx); } else if (http_response_is_streaming(resp)) { worker_stream_mark_ended(ctx); } - /* A started stream must ALWAYS get a terminal wire (grpc finish can - * fail mid-way; the reactor's reader would park forever). Idempotent. */ + /* a started stream must always get a terminal wire */ if (ctx->stream_started && !ctx->stream_ended) { worker_stream_mark_ended(ctx); } @@ -584,9 +530,7 @@ static void worker_dispatch_dispose(zend_coroutine_t *coroutine) } } - /* Detach the ops: a handler may have kept $response alive beyond - * this dispose; ctx dies below, so a late send() must throw the - * standard "not available" instead of touching freed memory. */ + /* ctx dies below; a late send() on a kept $response must throw, not UAF */ http_response_replace_stream_ops(resp, NULL, NULL); } @@ -629,8 +573,6 @@ bool worker_dispatch_request(http_server_object *server, void *const conn = req->reactor_conn; const bool is_head = http_request_method_is_head(req); - /* gRPC classification — once, through the same seam the transports - * use (content-type mode gated on a registered addGrpcHandler). */ HashTable *const handlers = http_server_get_protocol_handlers(server); const grpc_mode_t grpc_mode = grpc_classify(req, handlers); const bool is_grpc = grpc_mode != GRPC_MODE_NONE; @@ -660,8 +602,6 @@ bool worker_dispatch_request(http_server_object *server, object_init_ex(&ctx->response_zv, http_response_ce); http_response_set_protocol_version(Z_OBJ(ctx->response_zv), "3.0"); - /* Streaming reverse path: send()/writeMessage() flatten into STREAM_* - * wires posted through the sink instead of throwing. */ http_response_install_stream_ops(Z_OBJ(ctx->response_zv), &worker_stream_ops, ctx); diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c index e6c5c77d..d0f38fe6 100644 --- a/src/grpc/grpc.c +++ b/src/grpc/grpc.c @@ -46,8 +46,7 @@ grpc_mode_t grpc_request_mode(const http_request_t *req) return GRPC_MODE_NONE; } - /* Past "application/grpc" — the suffix picks the variant. Web-text - * before web: "-web" is a prefix of "-web-text". */ + /* "-web" is a prefix of "-web-text" — check web-text first */ val += grpc_len; len -= grpc_len; @@ -102,14 +101,9 @@ static bool b64_decode_append(smart_str *out, const char *in, size_t len) zend_string *grpc_web_text_decode(const char *in, const size_t len) { - /* The body is a CONCATENATION of independently base64-encoded frames, - * each with its own '='-padding (that is what grpc-web clients and our - * own encoder emit). PHP's non-strict decoder does not reset its 6-bit - * group at padding, so a single pass garbles everything after the first - * block whose byte length is not a multiple of 3 — decode block-wise, - * splitting after each padding run. Blocks that need no padding - * (len % 3 == 0) leave the bit stream 4-char aligned, so letting them - * merge into the next block is correct. */ + /* The body concatenates independently base64-encoded frames, each with + * its own '='-padding; PHP's decoder does not reset at padding, so a + * single pass garbles the tail — decode block-wise at each padding run. */ smart_str out = {0}; size_t start = 0; @@ -287,8 +281,6 @@ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, int grpc_message_inflate(const http_request_t *req, const char *in, size_t in_len, zend_string **out) { - /* Compressed flag set → the algorithm is named by grpc-encoding. gRPC's - * baseline is gzip; anything else is unsupported here. */ zval *enc = (req->headers != NULL) ? zend_hash_str_find(req->headers, "grpc-encoding", sizeof("grpc-encoding") - 1) diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h index 0bf65827..11550276 100644 --- a/src/grpc/grpc.h +++ b/src/grpc/grpc.h @@ -17,13 +17,9 @@ #include #include -/* http_request_t lives in http1/http_parser.h. Forward-declare so this - * header stays cheap to include from dispatch / request / response TUs. */ struct http_request_t; -/* gRPC canonical status codes (grpc.github.io/grpc/core/md_doc_statuscodes). - * Only the codes the server emits directly are named; the wire value is the - * decimal integer carried in the `grpc-status` trailer. */ +/* canonical status codes — decimal value of the grpc-status trailer */ #define GRPC_STATUS_OK 0 #define GRPC_STATUS_CANCELLED 1 #define GRPC_STATUS_UNKNOWN 2 @@ -34,31 +30,19 @@ struct http_request_t; #define GRPC_STATUS_INTERNAL 13 #define GRPC_STATUS_UNAVAILABLE 14 -/* Hard ceiling on a single inbound message length taken from the 5-byte - * frame header — defends the deframer against a forged 4 GiB length field. - * 16 MiB matches grpc-go's default max-receive-message-size ballpark. */ +/* inbound message ceiling — the frame-header length is attacker-controlled */ #define GRPC_MAX_RECV_MESSAGE (16u * 1024u * 1024u) -/* Canonical gRPC content-type prefix (matches application/grpc, - * application/grpc+proto, application/grpc;charset=..., AND the grpc-web - * variants below, which also start with this prefix). */ #define GRPC_CONTENT_TYPE "application/grpc" -/* Variant suffixes after the GRPC_CONTENT_TYPE prefix ("-web" also prefixes - * "-web-text" — match web-text first) and the response content-types the - * server emits. grpc-web carries trailers inside the response body (a - * 0x80-flagged frame) because browsers cannot read HTTP trailers; web-text - * additionally base64-encodes every frame independently, for clients that - * cannot carry binary bodies (XHR). */ +/* "-web" also prefixes "-web-text" — match web-text first */ #define GRPC_WEB_SUFFIX "-web" #define GRPC_WEB_TEXT_SUFFIX "-web-text" #define GRPC_WEB_RESPONSE_CONTENT_TYPE "application/grpc-web+proto" #define GRPC_WEB_TEXT_RESPONSE_CONTENT_TYPE "application/grpc-web-text+proto" -/* Delivery mode of a gRPC call, classified once at dispatch from the request - * content-type and stamped on the response (grpc_call_init_response). The - * framing layer (writeMessage / grpc_call_finish) reads it back to pick the - * per-frame transform; transports never branch on it. */ +/* Classified once at dispatch, stamped on the response; transports never + * branch on it. */ typedef enum { GRPC_MODE_NONE = 0, /* not a gRPC call */ GRPC_MODE_NATIVE, /* application/grpc — trailers ride HTTP trailers */ @@ -66,65 +50,40 @@ typedef enum { GRPC_MODE_WEB_TEXT, /* application/grpc-web-text — WEB + per-frame base64 */ } grpc_mode_t; -/* Classify the delivery mode from the request content-type. Returns - * GRPC_MODE_NONE for a non-gRPC request; the caller still gates on a - * registered gRPC handler. */ +/* Delivery mode from the request content-type; NONE for non-gRPC. */ grpc_mode_t grpc_request_mode(const struct http_request_t *req); -/* Convenience for the transports' buffering decision (grpc-web-text is - * buffered by protocol nature). */ bool grpc_request_is_grpc_web_text(const struct http_request_t *req); -/* Classify-once entry point for dispatch sites: the delivery mode gated on - * a registered addGrpcHandler — GRPC_MODE_NONE when the request is not - * gRPC or no gRPC handler exists (the call then routes as plain HTTP). - * Every dispatch path (H2 / H3 / worker) must classify through THIS so a - * request can never be gRPC on one transport and plain HTTP on another. */ +/* Mode gated on a registered gRPC handler; every dispatch path (H2 / H3 / + * worker) must classify through this. */ grpc_mode_t grpc_classify(const struct http_request_t *req, HashTable *handlers); -/* Per-frame base64 transform for grpc-web-text. Both return a new - * zend_string the caller owns; decode returns NULL on malformed input. */ +/* New string; decode returns NULL on malformed input. */ zend_string *grpc_web_text_encode(const char *in, size_t len); zend_string *grpc_web_text_decode(const char *in, size_t len); -/* Build the grpc-web in-body trailer frame from a response trailer map: - * byte 0 : 0x80 (trailer frame, uncompressed) - * bytes 1..4 : trailer block length, uint32 big-endian - * bytes 5.. : `name: value\r\n` lines (HTTP/1.1 style) - * Returns a new zend_string the caller owns. `trailers` may be NULL/empty. */ +/* In-body trailer frame: 0x80, u32be length, "name: value\r\n" lines. */ zend_string *grpc_web_trailer_frame(HashTable *trailers); -/* Parse the `grpc-timeout` request header (``, unit ∈ - * {H,M,S,m,u,n}) into nanoseconds. Returns 0 when absent or malformed. */ +/* grpc-timeout header → ns; 0 when absent or malformed. */ uint64_t grpc_parse_timeout_ns(const struct http_request_t *req); -/* Frame one message with the 5-byte gRPC prefix: - * byte 0 : compressed flag (0 = identity, 1 = compressed) - * bytes 1..4 : message length, uint32 big-endian - * bytes 5.. : message payload - * Returns a new zend_string the caller owns. */ +/* 5-byte prefix (compressed flag + u32be length) + payload. */ zend_string *grpc_frame_message(const char *msg, size_t len, bool compressed); -/* Deframe the next message from buf[*cursor .. len). - * returns 1 : one message extracted; *out is a new zend_string (caller - * owns it) and *cursor advances past the frame, - * returns 0 : incomplete frame — need more bytes; *cursor unchanged, - * returns -1 : protocol error (declared length exceeds `max_msg`). - * `out_compressed` (nullable) receives the compressed flag. */ +/* 1 = *out extracted (caller owns), *cursor advanced; 0 = need more bytes; + * -1 = declared length exceeds max_msg. */ int grpc_deframe_next(const char *buf, size_t len, size_t *cursor, uint32_t max_msg, bool *out_compressed, zend_string **out); #ifdef HAVE_HTTP_COMPRESSION -/* Decompress a message that carried the compressed flag, per the request's - * grpc-encoding header (gzip only, via the shared compression backend). - * Returns 0 on success (*out owned by caller), -1 on an unsupported - * encoding or corrupt data. */ +/* Per the request's grpc-encoding (gzip only). 0 = ok, -1 = unsupported/corrupt. */ int grpc_message_inflate(const struct http_request_t *req, const char *in, size_t in_len, zend_string **out); -/* Gzip-compress a message for `grpc-encoding: gzip`. Returns a new - * zend_string the caller owns, or NULL on failure. */ +/* Gzip-compress for `grpc-encoding: gzip`; NULL on failure. */ zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len); #endif diff --git a/src/grpc/grpc_call.c b/src/grpc/grpc_call.c index 740485b5..b1e8136d 100644 --- a/src/grpc/grpc_call.c +++ b/src/grpc/grpc_call.c @@ -51,12 +51,8 @@ void grpc_call_finish(zend_object *response_obj, (grpc_mode_t)http_response_get_grpc_mode(response_obj); if (mode == GRPC_MODE_WEB || mode == GRPC_MODE_WEB_TEXT) { - /* Trailers ride the response body as a 0x80 frame, never as HTTP - * trailers. Clear the trailer map so the transport's generic EOF - * path does not also emit them as a terminal trailer block. - * Handles both a streamed reply and a zero-message status/error - * (the trailer frame is then the only DATA). web-text encodes the - * frame independently, like every other frame on the stream. */ + /* trailers ride in-body as a 0x80 frame; clear the map so the + * transport's EOF path doesn't emit them again */ zend_string *frame = grpc_web_trailer_frame(http_response_get_trailers(response_obj)); @@ -74,17 +70,12 @@ void grpc_call_finish(zend_object *response_obj, } if (http_response_is_streaming(response_obj)) { - /* Native gRPC over a streamed body: just make sure the stream is - * ended; grpc-status/grpc-message ride the transport's generic - * response-trailer path at true EOF. */ + /* native streamed: trailers ride the transport's trailer path at EOF */ ops->end_stream(ctx); return; } - /* Handler streamed no messages (immediate status / error) → - * Trailers-Only: fold grpc-status/grpc-message into the initial - * HEADERS so the buffered commit sends a single HEADERS(:status 200, - * fin) — the canonical gRPC shape for a bodiless response. */ + /* zero messages → Trailers-Only: fold trailers into the initial HEADERS */ http_response_promote_trailers_to_headers(response_obj); ops->commit(ctx); } diff --git a/src/grpc/grpc_call.h b/src/grpc/grpc_call.h index 228b9dfe..aade81c6 100644 --- a/src/grpc/grpc_call.h +++ b/src/grpc/grpc_call.h @@ -16,53 +16,29 @@ #include "php.h" #include -/* - * gRPC call lifecycle policy — the transport-independent half of gRPC - * orchestration. The wire codec lives in grpc.c; this module owns the - * per-call decisions (response defaults, outcome → grpc-status, which - * finalize shape to use). Transports stay gRPC-agnostic: they classify - * the request (cheap inline predicate), then call the two hooks below - * and provide the tiny ops vtable for the genuinely transport-specific - * bits (how bytes are appended / streams are ended / buffered responses - * are committed). Native trailer delivery is NOT part of the seam — it - * rides each transport's generic response-trailer path. - */ +/* gRPC call lifecycle policy — transport-independent; the wire codec lives + * in grpc.c. Transports provide the ops vtable and stay gRPC-agnostic. */ /* Transport ops for grpc_call_finish. ctx is the transport's stream. */ typedef struct grpc_finish_ops { - /* Append one body frame (consumes the zend_string ref) and end the - * stream. Used by grpc-web to deliver the in-body trailer frame. */ + /* consumes the zend_string ref */ void (*append_frame_and_end)(void *ctx, zend_string *frame); - /* End a streaming response (idempotent). Trailers are delivered by - * the transport's generic trailer path at true EOF. */ + /* idempotent */ void (*end_stream)(void *ctx); - /* Commit a buffered response now (single HEADERS + fin — the - * Trailers-Only shape). May be a no-op if the connection is gone. */ + /* commit a buffered response (Trailers-Only shape) */ void (*commit)(void *ctx); } grpc_finish_ops_t; -/* Dispatch-time response defaults: content-type per delivery mode (native / - * grpc-web / grpc-web-text) + the mode stamped on the response so the - * framing layer picks the right per-frame transform. A handler may still - * override the content-type before its first writeMessage(). */ +/* Dispatch-time response defaults: content-type + mode stamp. */ void grpc_call_init_response(zend_object *response_obj, int grpc_mode); -/* Outcome → grpc-status trailer. Success defaults to 0; an uncaught - * handler exception maps to INTERNAL (13) unless the handler already set - * a status. Call from dispose before delivery decisions. */ +/* Outcome → grpc-status; exception maps to INTERNAL unless already set. */ void grpc_call_ensure_status(zend_object *response_obj, bool had_exception); -/* Finalize delivery of a gRPC reply (dispose-time). The delivery mode is - * read back off the response (stamped by grpc_call_init_response): - * grpc-web[-text] → trailers become an in-body 0x80 frame (base64-encoded - * for web-text), stream ends (browsers cannot read HTTP - * trailers), - * streaming → end the stream; native trailers ride the transport's - * generic trailer path at EOF, - * zero-message → Trailers-Only: fold grpc-status/grpc-message into the - * initial HEADERS and commit the buffered response. */ +/* Dispose-time delivery: grpc-web → in-body trailer frame + end; streaming + * → end_stream; zero-message → Trailers-Only commit. */ void grpc_call_finish(zend_object *response_obj, const grpc_finish_ops_t *ops, void *ctx); diff --git a/src/http2/http2_session.c b/src/http2/http2_session.c index 748e478d..677f1268 100644 --- a/src/http2/http2_session.c +++ b/src/http2/http2_session.c @@ -456,14 +456,8 @@ static int cb_on_data_chunk_recv(nghttp2_session *ng, body_cap = HTTP2_MAX_BODY_SIZE; } - /* Cap the LIVE (queued, not-yet-drained) bytes, not the cumulative - * total. body_bytes_consumed is monotonic, so the old - * consumed+queued check RST'd long client-streaming / bidi streams - * once their *total* passed max_body_size — even when the handler - * drained every chunk (issue #4 §4.3). A streaming body is - * legitimately unbounded in total; max_body_size now bounds only the - * in-flight buffer (backpressure), matching readBody()/readMessage() - * consumers that keep up with the peer. */ + /* cap the LIVE (queued) bytes, not the cumulative total — a + * streaming body is legitimately unbounded in total */ if (req->body_bytes_queued + len > body_cap) { return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; } @@ -548,11 +542,8 @@ static void finalize_request_body(http2_stream_t *stream) return; } - /* Streaming mode (issue #26): no smart_str to finalize — close the - * queue so a parked readBody()/readMessage() consumer sees EOF, and - * fire body_event too: a handler suspended in awaitBody() (dispatched - * at headers, END_STREAM not yet seen) waits on THAT event, not the - * queue's data event. Mirrors the H1 parser's streaming-mode fire. */ + /* Streaming: close the queue (parked consumer sees EOF) and fire + * body_event — awaitBody() waits on that, not the queue's data event. */ if (req->body_streaming) { http_body_stream_close(req); req->complete = true; @@ -631,12 +622,10 @@ static int cb_on_frame_recv(nghttp2_session *ng, * CL == 0 (chunked) or CL >= AUTO → stream immediately * SMALL <= CL < AUTO → buffer + install upgrade hook * CL < SMALL → buffer, never stream. */ + /* grpc-web-text stays buffered: readMessage() decodes the + * assembled body */ if (session->conn != NULL && session->conn->view != NULL && session->conn->view->body_streaming_enabled - /* grpc-web-text is buffered by protocol nature: - * readMessage() base64-decodes the ASSEMBLED body; a - * streamed queue would leave req->body NULL and silently - * lose the call. */ && !grpc_request_is_grpc_web_text(stream->request)) { http_request_t *r = stream->request; @@ -1494,12 +1483,7 @@ bool http2_session_want_write(const http2_session_t *session) * Response submission * ------------------------------------------------------------------------- */ -/* Extract response trailers (name => value zend_string map) from the PHP - * HttpResponse and submit them via nghttp2_submit_trailer. Shared by the - * buffered (unary) commit — which submits eagerly before its drain — and - * the streaming path, which submits lazily at true EOF (h2_dp_mark_eof) - * once every queued DATA chunk has drained. No-op when the response has no - * trailers. Canonical gRPC consumer: grpc-status / grpc-message. */ +/* Submit the response trailer map via nghttp2_submit_trailer. No-op when empty. */ void http2_session_submit_response_trailers(http2_session_t *session, const uint32_t stream_id, zend_object *response_obj) @@ -1543,14 +1527,9 @@ void http2_session_submit_response_trailers(http2_session_t *session, if (heap != NULL) { efree(heap); } } -/* Stamp DATA_FLAG_EOF (+ NO_END_STREAM if trailers pending) on the - * provider's data_flags. - * - * Streaming responses submit their trailers HERE — at true EOF, inside - * nghttp2's send loop, after every queued DATA chunk has drained. - * Submitting earlier (while data is still queued) makes nghttp2 drop the - * pending DATA. The buffered/unary path submits eagerly in its commit, so - * it is excluded via the chunk_queue check; run at most once per stream. */ +/* Stamp DATA_FLAG_EOF (+ NO_END_STREAM if trailers pending). Streaming + * trailers submit HERE, at true EOF — submitting while DATA is still queued + * makes nghttp2 drop the pending DATA. */ static inline void h2_dp_mark_eof(http2_stream_t *stream, uint32_t *data_flags) { diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index e4eb94ce..815874fe 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -101,9 +101,6 @@ extern const http_response_stream_ops_t h2_stream_ops; * handler skipped $res->end(). Defined further down. */ static void h2_stream_mark_ended(void *ctx); -/* gRPC finish ops (grpc_call_finish seam) — the transport-specific wire - * actions; what to send is decided in src/grpc/grpc_call.c. end_stream is - * h2_stream_mark_ended directly (it is idempotent). Bodies further down. */ static int h2_stream_append_chunk(void *ctx, zend_string *chunk); static void h2_grpc_append_frame_and_end(void *ctx, zend_string *frame); static void h2_grpc_commit(void *ctx); @@ -237,11 +234,6 @@ static void http2_strategy_dispatch(struct http_request_t *request, } #endif - /* gRPC classification (issue #4): a request whose content-type begins - * with application/grpc, routed to the handler registered via - * addGrpcHandler(). Detected here — like the WS check — so a gRPC-only - * server (no addHttpHandler) still dispatches past the handler-null - * guards below. */ const grpc_mode_t grpc_mode = grpc_classify( stream->request, http_server_get_protocol_handlers(self->conn->server)); @@ -300,8 +292,6 @@ static void http2_strategy_dispatch(struct http_request_t *request, Z_OBJ(stream->response_zv), self->conn->config->json_encode_flags); } - /* gRPC: response defaults (content-type + delivery mode) live in the - * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (is_grpc) { grpc_call_init_response(Z_OBJ(stream->response_zv), grpc_mode); } @@ -427,8 +417,6 @@ static void http2_handler_coroutine_entry(void) return; } - /* gRPC streams route to the addGrpcHandler() callable; every other - * request uses the connection's HTTP handler. */ zend_fcall_t *handler = conn->handler; if (stream->is_grpc) { @@ -1681,16 +1669,12 @@ static int h2_stream_append_chunk(void *ctx, zend_string *chunk) return HTTP_STREAM_APPEND_OK; } -/* Append one body frame (consumes the ref), then end the stream normally - * with END_STREAM on DATA. Works for both a streamed reply (messages - * already queued) and a zero-message reply (the frame is the only DATA, - * committing HEADERS on the first append). */ +/* Append one gRPC frame (consumes the ref) and end the stream. */ static void h2_grpc_append_frame_and_end(void *ctx, zend_string *frame) { http2_stream_t *stream = (http2_stream_t *)ctx; - /* Connection may already be torn down when a late coroutine disposes - * (same guard as h2_grpc_commit); append_chunk derefs conn unchecked. */ + /* conn may be torn down already; append_chunk derefs it unchecked */ if (http2_session_get_conn(stream->session) == NULL) { zend_string_release(frame); return; @@ -1700,8 +1684,7 @@ static void h2_grpc_append_frame_and_end(void *ctx, zend_string *frame) h2_stream_mark_ended(stream); } -/* Buffered commit — a single HEADERS(:status 200, END_STREAM) frame when - * the response carries no body (the Trailers-Only shape). */ +/* Buffered commit — the Trailers-Only shape. */ static void h2_grpc_commit(void *ctx) { http2_stream_t *stream = (http2_stream_t *)ctx; diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 4d8d0426..70d24b43 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -372,12 +372,8 @@ static int h3_recv_header_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } -/* Splice whatever h3_recv_data_cb already buffered for this stream into the - * body queue as one chunk, then flip body_streaming so subsequent chunks - * push directly. Invoked from HttpRequest::readBody()/readMessage() for the - * "buffered → stream" upgrade (Case 2). Idempotent. The pre-upgrade bytes - * were already credited eagerly; re-crediting them on pop merely over-grants - * window, which QUIC permits. */ +/* "Buffered → stream" upgrade: splice buffered bytes into the queue, flip + * body_streaming. Idempotent. */ static void http3_request_body_upgrade(http_request_t *req) { if (req->body_streaming) { @@ -410,24 +406,16 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } - /* Reactor mode: defer dispatch to h3_end_stream_cb. The reactor must - * not write into the request after hand-off, so the body is assembled - * (persistent) before the worker gets the pointer — buffered, not - * streamed (the body queue is same-thread by contract). */ + /* Reactor mode: defer dispatch to h3_end_stream_cb — the body must be + * fully assembled before hand-off (body queue is same-thread only). */ if (c != NULL && http3_listener_reactor_ctx(c->listener) != NULL) { return 0; } - /* Streaming body mode (issue #26). Three-case policy by Content-Length - * — mirror of H2 cb_on_frame_recv: - * CL == 0 (unknown) or CL >= AUTO → stream immediately - * SMALL <= CL < AUTO → buffer + install upgrade hook - * CL < SMALL → buffer, never stream. */ + /* Content-Length policy, mirror of H2 cb_on_frame_recv. grpc-web-text + * stays buffered: readMessage() base64-decodes the assembled body. */ if (c != NULL && c->view != NULL && c->view->body_streaming_enabled && s->request != NULL - /* grpc-web-text is buffered by protocol nature: readMessage() - * base64-decodes the ASSEMBLED body; a streamed queue would leave - * req->body NULL and silently lose the call. */ && !grpc_request_is_grpc_web_text(s->request)) { http_request_t *const r = s->request; @@ -452,10 +440,8 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } -/* Grant QUIC flow-control credit for `len` request-body bytes. - * nghttp3_conn_read_stream's consumed count deliberately EXCLUDES DATA - * payload (deferred-consume contract) — the application must extend the - * stream + connection windows itself once it has taken the bytes. */ +/* nghttp3's consumed count excludes DATA payload (deferred-consume contract) + * — the application extends the windows itself. */ static void h3_extend_body_window(http3_connection_t *c, const int64_t stream_id, const size_t len) { @@ -475,18 +461,13 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, http3_stream_t *const s = (http3_stream_t *)stream_user_data; if (UNEXPECTED(s == NULL || s->rejected)) { - /* Bytes still count against the peer's window — return the credit - * so an already-rejected stream cannot wedge the connection cap. */ + /* return the credit so a rejected stream can't wedge the connection cap */ h3_extend_body_window(c, stream_id, datalen); return 0; } - /* Streaming mode (issue #26) — push the chunk into the per-request - * queue instead of accumulating into body_buf. Credit is NOT granted - * here: http_body_stream_pop extends the window as the handler drains - * (http3_request_body_consume), which is the backpressure. The cap - * bounds only the LIVE (queued, un-drained) bytes — a long-lived - * client-streaming body is legitimately unbounded in total. */ + /* Streaming: no credit here — http_body_stream_pop extends the window + * as the handler drains; that is the backpressure. */ if (s->request != NULL && s->request->body_streaming) { http_request_t *const req = s->request; size_t body_cap = HTTP_SERVER_G(parser_pool).max_body_size; @@ -542,9 +523,7 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, smart_str_appendl(&s->body_buf, (const char *)data, datalen); - /* Buffered mode consumes the bytes right here — return the window - * immediately so an upload larger than the initial stream window - * (256 KiB default) keeps flowing. */ + /* buffered mode consumes right here — return the window immediately */ h3_extend_body_window(c, stream_id, datalen); return 0; } @@ -562,10 +541,8 @@ static int h3_recv_data_cb(nghttp3_conn *conn, int64_t stream_id, * pointer alive until h3_acked_stream_data_cb fires (the peer ACK'd * the bytes). Releasing earlier is a UAF — under packet loss nghttp3 * retransmits from the same iov, so a freed zend_string would crash. */ -/* True-EOF epilogue shared by the reader's streaming and buffered branches: - * submit the captured trailers (nghttp3 requires the submit after the last - * DATA) and keep the sending side open with NO_END_STREAM so the trailer - * HEADERS carry the fin instead of the final DATA. No-op without a capture. */ +/* True-EOF: submit captured trailers (must follow the last DATA); the trailer + * HEADERS carry the fin. No-op without a capture. */ static void h3_read_data_eof_trailers(nghttp3_conn *conn, http3_stream_t *s, uint32_t *pflags) { @@ -617,8 +594,6 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, if (s->streaming_ended) { *pflags |= NGHTTP3_DATA_FLAG_EOF; - /* The trailer nv was captured in dispose (response_zv is - * gone by the time the reader runs). */ h3_read_data_eof_trailers(conn, s, pflags); return 0; } @@ -635,9 +610,7 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, return 1; } - /* Buffered REST path — single-slice semantics, plus the trailer submit - * at EOF (a buffered response with a trailer map: worker-rendered - * response_wire or a local setTrailer without streaming). */ + /* buffered path — single slice, trailer submit at EOF */ if (s->response_body == NULL) { *pflags |= NGHTTP3_DATA_FLAG_EOF; h3_read_data_eof_trailers(conn, s, pflags); @@ -658,9 +631,7 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, vec[0].len = remaining; s->response_body_offset += remaining; - /* With trailers pending, hand the final slice without EOF — the submit - * must FOLLOW the last DATA, so the next invocation (remaining == 0) - * performs it. Without trailers keep the one-call fast path. */ + /* trailers pending: no EOF on the final slice — the submit must follow it */ if (s->trailer_nv == NULL) { *pflags |= NGHTTP3_DATA_FLAG_EOF; } @@ -728,15 +699,8 @@ static inline bool h3_nv_push(h3_nv_buf_t *b, * - Streaming (HttpResponse::send loop): chunk_queue is the body * source; we skip the body copy entirely. Caller has already * primed the queue with the first chunk by the time we run. */ -/* Capture the response trailer map into a malloc'd nghttp3_nv[] + backing - * byte buffer on the stream. Called from dispose while response_zv is still - * alive; the data reader submits it at true EOF, because - * nghttp3_conn_submit_trailers must follow the last DATA (which stamps - * NO_END_STREAM) yet response_zv is freed by then. malloc (not emalloc) — the - * data reader runs outside a request memory context. No-op when there are no - * trailers or a capture already exists. Freed in http3_stream_release. */ -/* Shared malloc'd trailer capture (s->trailer_nv/count/bytes): init sizes - * the buffers, add packs one pair, callers iterate their own source. */ +/* Trailer capture: malloc'd (the data reader runs outside a request memory + * context, after response_zv is freed); released in http3_stream_release. */ typedef struct { nghttp3_nv *nv; char *bytes; @@ -949,10 +913,8 @@ bool http3_stream_submit_response(http3_connection_t *c, return false; } -/* Copy the wire's trailer pairs into the stream's malloc'd capture — the - * wire dies right after the apply, and the data reader submits at true EOF - * (same fields http3_stream_capture_trailers fills on the local path). - * No-op when the wire has no trailers or a capture already exists. */ +/* Copy the wire's trailers into the stream's capture — the wire dies right + * after the apply. */ void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t *rw) { const size_t tcount = response_wire_trailer_count(rw); @@ -1049,17 +1011,12 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, nvi++; } - /* Body source. FULL: single-slice from response_body. STREAM_HEADERS: - * prime the chunk ring — the data reader parks on WOULDBLOCK until the - * first STREAM_CHUNK apply pushes bytes (or STREAM_END fires EOF). */ const bool streaming = response_wire_kind(rw) == RESPONSE_WIRE_STREAM_HEADERS; if (streaming) { h3_chunk_queue_init(s); - /* Adopt the worker's credit ref: acked advances in - * h3_acked_stream_data_cb, dead on stream death, release at - * teardown. */ + /* adopt the worker's credit ref; released at teardown */ s->wire_credit = response_wire_credit(rw); } else { size_t blen = 0; @@ -1101,10 +1058,7 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, } if (streaming) { - /* The reader was never registered; make sure late STREAM_CHUNK / - * STREAM_END applies see a dead stream and drop, and unblock a - * producer parked on credit. The teardown release still runs, so - * only mark here. */ + /* reader never registered — late applies must see a dead stream */ s->streaming_ended = true; if (s->wire_credit != NULL) { @@ -1126,8 +1080,7 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, * the caller suspends on write_event, which extend_max_stream_data_cb * fires when the peer extends the window via MAX_STREAM_DATA. * ------------------------------------------------------------------- */ -/* Lazily create the streaming chunk ring. Idempotent. A non-NULL queue is - * what flips the data reader into its streaming branch. */ +/* Idempotent; a non-NULL queue flips the data reader into streaming mode. */ void h3_chunk_queue_init(http3_stream_t *s) { if (s->chunk_queue != NULL) { @@ -1144,9 +1097,7 @@ void h3_chunk_queue_init(http3_stream_t *s) s->chunk_ack_credit = 0; } -/* Enqueue a chunk (takes the ref). Grows the ring if full — compact head→0 - * first to avoid unbounded growth on long-lived streams. Compaction shifts - * head, read_idx, and tail by the same delta. */ +/* Takes the ref. Compacts before growing so long-lived streams stay bounded. */ void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk) { if (s->chunk_queue_tail == s->chunk_queue_cap) { @@ -1189,9 +1140,6 @@ int h3_stream_append_chunk(void *ctx, zend_string *chunk) return HTTP_STREAM_APPEND_STREAM_DEAD; } - /* First send() — lazy queue + HEADERS commit (submit_response with - * the streaming data_reader). The data_reader reads the chunk we - * are about to enqueue. */ const bool first_call = s->chunk_queue == NULL; if (first_call) { @@ -1201,8 +1149,7 @@ int h3_stream_append_chunk(void *ctx, zend_string *chunk) const size_t chunk_len = ZSTR_LEN(chunk); h3_chunk_queue_push(s, chunk); - /* Static delivery: credit this chunk to the per-worker in-flight cap. - * The ACK path debits it; teardown reconciles any leftover. */ + /* static delivery: charge the per-worker in-flight cap; ACK path debits */ if (s->tracks_static_bytes) { s->static_inflight += chunk_len; h3_static_account_alloc(chunk_len); @@ -1385,11 +1332,8 @@ static void http3_finalize_request_body(http3_stream_t *s) http_request_t *const req = s->request; ZEND_ASSERT(req != NULL); - /* Streaming mode (issue #26): no smart_str to move — close the queue - * so a parked readBody()/readMessage() consumer sees EOF, and fire - * body_event too: a handler suspended in awaitBody() (dispatched at - * headers, fin not yet seen) waits on THAT event, not the queue's - * data event. Mirrors the H1 parser's explicit streaming-mode fire. */ + /* Streaming: close the queue (parked consumer sees EOF) and fire + * body_event — awaitBody() waits on that, not the queue's data event. */ if (req->body_streaming) { http_body_stream_close(req); req->complete = true; @@ -1492,8 +1436,7 @@ static void h3_stream_mark_peer_closed(http3_stream_t *s) stream_credit_mark_dead((stream_credit_t *)s->wire_credit); } - /* A consumer parked in readBody()/readMessage() on a streamed body - * must wake too: RST before fin means the body will never complete. */ + /* RST before fin: wake a parked body consumer, the body never completes */ if (s->request != NULL && s->request->body_streaming && !s->fin_received) { http_body_stream_error(s->request); @@ -1584,8 +1527,7 @@ static int h3_acked_stream_data_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } - /* Reverse-path flow control: retire the acked bytes so a worker - * producer parked on the credit cap resumes. */ + /* retire acked bytes so a worker producer parked on credit resumes */ if (s->wire_credit != NULL) { stream_credit_ack((stream_credit_t *)s->wire_credit, datalen); } @@ -2044,10 +1986,8 @@ static int recv_stream_data_cb(ngtcp2_conn *conn, uint32_t flags, return 0; } -/* Deferred inbound flow control (issue #26): called from - * http_body_stream_pop on the connection's own thread as the handler - * drains queued body chunks. Extends the QUIC windows and drives the - * socket so the MAX_STREAM_DATA actually reaches the peer. */ +/* Called from http_body_stream_pop as the handler drains: extends the QUIC + * windows and drives the socket so MAX_STREAM_DATA reaches the peer. */ void http3_request_body_consume(http_request_t *req, const size_t len, const bool queue_empty) { @@ -2057,9 +1997,7 @@ void http3_request_body_consume(http_request_t *req, const size_t len, return; } - /* Coalesce: a drain_out per ~1.2 KB chunk is a full send-path walk. - * Flush at 64 KiB, or when the queue just emptied (the consumer is - * about to wait — the peer may need the window to send more). */ + /* coalesce: flush at 64 KiB or when the queue just emptied */ req->body_h3_uncredited += len; if (req->body_h3_uncredited < 64 * 1024 && !queue_empty) { @@ -2210,8 +2148,7 @@ static int stream_close_cb(ngtcp2_conn *conn, uint32_t flags, return 0; } -/* RESET_STREAM and STOP_SENDING both mean the peer will send no more request - * data on this stream — shut the read side down in nghttp3 either way. */ +/* RESET_STREAM / STOP_SENDING: no more request data — shut the read side. */ static int h3_shutdown_stream_read(http3_connection_t *c, int64_t stream_id) { if (c == NULL || c->nghttp3_conn == NULL) { diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index c4bb7394..bf2e9b1b 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -195,12 +195,8 @@ static void http3_stream_dispatch_to_worker(http3_connection_t *c, http3_stream_ s->refcount++; /* worker-borrow ref; dropped by the consumed apply */ if (UNEXPECTED(!worker_inbox_post(inbox, s->request))) { - /* Hard backpressure: the worker inbox is at capacity and the request - * was not handed off. Undo the dispatch bookkeeping so the normal - * teardown reclaims it (s->dispatched=false => reactor teardown frees - * the fields). Then RESET the stream with H3_REQUEST_REJECTED so the - * client learns immediately the request was refused WITHOUT processing - * — safe to retry elsewhere — instead of hanging until its QUIC PTO. */ + /* inbox full: undo the dispatch bookkeeping, RESET with + * H3_REQUEST_REJECTED so the client can retry instead of hanging */ s->refcount--; s->dispatched = false; @@ -238,9 +234,7 @@ void http3_reactor_apply_response(void *arg) http3_connection_t *const c = (s != NULL) ? s->conn : NULL; if (c == NULL || c->closed || c->nghttp3_conn == NULL) { - /* The stream is already gone, so nobody will adopt a HEADERS - * wire's credit ref — take it over: unblock the parked producer - * and drop the reactor-side ref here. */ + /* stream gone: nobody adopts the credit ref — unblock the producer */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); zend_string *const orphan_chunk = @@ -254,10 +248,7 @@ void http3_reactor_apply_response(void *arg) return; } - /* Coalesce output: several wires for one connection often land in one - * mailbox batch — mark the conn dirty and flush ONCE in the reactor's - * drain epilogue (same discipline as the steer feed / recvmmsg tick) - * instead of a full send-path walk per wire. */ + /* mark dirty + flush once in the drain epilogue, not per wire */ switch (response_wire_kind(rw)) { case RESPONSE_WIRE_FULL: case RESPONSE_WIRE_STREAM_HEADERS: @@ -268,9 +259,6 @@ void http3_reactor_apply_response(void *arg) break; case RESPONSE_WIRE_STREAM_CHUNK: { - /* Validate-and-drop: the stream may have died (peer RST) or the - * HEADERS submit may have failed (no ring) since the worker - * posted this chunk. */ zend_string *const chunk = (zend_string *)response_wire_take_chunk(rw); @@ -406,10 +394,6 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) HashTable *handlers = http_server_get_protocol_handlers(server); zend_async_scope_t *scope = http_server_get_scope(server); - /* gRPC (issue #4): classify ONCE through the shared seam (content-type - * mode gated on a registered addGrpcHandler); grpc-web carries trailers - * in-body. The handler pick below uses the shared precedence, so a - * gRPC-only server still dispatches. */ const grpc_mode_t grpc_mode = grpc_classify(s->request, handlers); s->is_grpc = grpc_mode != GRPC_MODE_NONE; @@ -462,8 +446,6 @@ void http3_stream_dispatch(http3_connection_t *c, http3_stream_t *s) http_response_install_stream_ops(Z_OBJ(s->response_zv), &h3_stream_ops, s); - /* gRPC: response defaults (content-type + delivery mode) live in the - * gRPC layer; the mode stamp is what finish/writeMessage read back. */ if (s->is_grpc) { grpc_call_init_response(Z_OBJ(s->response_zv), grpc_mode); } @@ -787,12 +769,8 @@ static bool h3_arm_sendfile(http3_connection_t *c, http3_stream_t *s) return true; } -/* End a streamed reply: capture the response trailer map for the data - * reader's EOF submit (no-op when the map is empty), then resume so the - * reader drains to EOF. The capture must happen here, while response_zv is - * still alive — the data reader runs async, after dispose frees the zvals. - * Shared by the dispose streaming branch and the gRPC finish ops (void *ctx - * so it slots into grpc_finish_ops_t.end_stream directly). */ +/* End a streamed reply. Trailers must be captured here, while response_zv + * is still alive — the data reader runs after dispose frees the zvals. */ static void h3_stream_finish_streaming(void *ctx) { http3_stream_t *s = (http3_stream_t *)ctx; @@ -808,12 +786,7 @@ static void h3_stream_finish_streaming(void *ctx) } } -/* gRPC finish ops (grpc_call_finish seam) — the transport-specific wire - * actions; what to send is decided in src/grpc/grpc_call.c. */ - -/* Append one body frame (consumes the ref) and end the stream. Works for - * streamed and zero-message replies — append_chunk commits HEADERS + inits - * the chunk queue on first use. */ +/* Append one gRPC frame (consumes the ref) and end the stream. */ static void h3_grpc_append_frame_and_end(void *ctx, zend_string *frame) { http3_stream_t *s = (http3_stream_t *)ctx; @@ -918,9 +891,7 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) (void)http3_stream_submit_response(c, s, false); } else { H3T(s->stream_id, "5.buffered_submit"); - /* A buffered response can carry a trailer map too (setTrailer - * without streaming) — capture now, while response_zv is alive; - * the data reader submits at EOF. */ + /* capture trailers while response_zv is alive */ http3_stream_capture_trailers(s); (void)http3_stream_submit_response(c, s, false); } diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index a731f1df..a0bc2f40 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -159,16 +159,12 @@ bool http3_stream_submit_response(http3_connection_t *c, http3_stream_t *s, bool streaming); -/* Capture the response trailers (grpc-status/grpc-message) onto the stream - * from the streaming dispose branch, while response_zv is still alive. The - * data reader submits them via nghttp3_conn_submit_trailers at true EOF (the - * reader runs async, after response_zv is freed). */ +/* Capture response trailers onto the stream while response_zv is alive; + * the data reader submits them at true EOF. */ void http3_stream_capture_trailers(http3_stream_t *s); -/* Reverse path: submit a response from a worker-rendered response_wire - * instead of the per-stream HttpResponse zval. Reactor thread. FULL wires - * submit buffered; STREAM_HEADERS wires submit with the streaming reader - * (chunk ring primed, fed by the STREAM_CHUNK/STREAM_END applies below). */ +/* Submit a worker-rendered response_wire (reactor thread). FULL = buffered; + * STREAM_HEADERS = streaming reader over the chunk ring. */ typedef struct response_wire_s response_wire_t; bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, const response_wire_t *rw); @@ -182,9 +178,7 @@ void http3_stream_adopt_wire_trailers(http3_stream_t *s, const response_wire_t * void http3_request_body_consume(struct http_request_t *req, size_t len, bool queue_empty); -/* Streaming chunk ring, factored for the reverse path: init is lazy and - * idempotent (a non-NULL ring is what flips the data reader to streaming); - * push takes the chunk ref. */ +/* Streaming chunk ring: init is idempotent; push takes the chunk ref. */ void h3_chunk_queue_init(http3_stream_t *s); void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk); @@ -195,10 +189,8 @@ void h3_chunk_queue_push(http3_stream_t *s, zend_string *chunk); int h3_stream_append_chunk(void *ctx, zend_string *chunk); void h3_stream_mark_ended(void *ctx); -/* Per-worker global memory accounting for HTTP/3 static delivery - * (http3_static_response.c). append_chunk credits queued bytes via _alloc - * when the stream tracks static bytes; the ACK path and teardown debit them. - * Bounds total static read-ahead across concurrent streams (mirrors H2). */ +/* Per-worker memory accounting for static delivery: alloc on push, debit on + * ACK/teardown (http3_static_response.c). */ void h3_static_account_alloc(size_t n); void h3_static_account_debit(size_t n); diff --git a/src/http3/http3_listener.c b/src/http3/http3_listener.c index de24c356..f8f1a4fe 100644 --- a/src/http3/http3_listener.c +++ b/src/http3/http3_listener.c @@ -97,10 +97,8 @@ struct _http3_listener_s { int port; bool closed; - /* Fabricated local sockaddr for the writev_stream path, cached per peer - * family at spawn since (host, port) never change afterward — the drain - * path copies the matching one instead of re-running inet_pton/htons on - * every packet. */ + /* local sockaddr per peer family, cached at spawn — saves inet_pton + * on every packet */ struct sockaddr_storage local_v4; socklen_t local_v4_len; struct sockaddr_storage local_v6; @@ -1360,9 +1358,7 @@ HashTable *http3_listener_conn_map(http3_listener_t *l) * (the symptom: every reply is an Initial-with-ACK, no CRYPTO). */ extern int ngtcp2_crypto_ossl_init(void); -/* Fabricate the listener's local sockaddr for a given peer family from the - * bind config. See http3_build_listener_local for the rationale; this is the - * one-time computation whose result the listener caches. */ +/* One-time local-sockaddr computation the listener caches per peer family. */ static void http3_listener_compute_local(const char *host, int port, int peer_family, struct sockaddr_storage *out, diff --git a/src/http3/http3_static_response.c b/src/http3/http3_static_response.c index 680af340..9a5b4000 100644 --- a/src/http3/http3_static_response.c +++ b/src/http3/http3_static_response.c @@ -72,12 +72,8 @@ #define H3_STATIC_READ_CHUNK_BYTES (16u * 1024u) -/* Per-worker static-delivery memory cap. Unlike H2's read-ahead ring, the H3 - * pump reads one 16 KiB chunk at a time and blocks on per-stream backpressure, - * so the only unbounded axis is concurrency: N streams each holding up to a - * BDP of un-ACKed chunks. This global counter bounds that sum. Formula and - * clamps mirror http2_static_response.c: budget = memory_limit / 4, floored, - * and never above memory_limit minus a reserve so it can't starve the heap. */ +/* Per-worker static-delivery memory cap across concurrent streams. + * Formula and clamps mirror http2_static_response.c. */ #define H3_STATIC_BUDGET_FLOOR (4u * 1024u * 1024u) #define H3_STATIC_BUDGET_FALLBACK (64u * 1024u * 1024u) /* memory_limit = -1 */ #define H3_STATIC_BUDGET_RESERVE_FRAC 8u /* hard top = 87.5% */ @@ -135,9 +131,7 @@ static bool h3_static_over_budget(void) return h3_static_global_bytes_in_flight >= h3_static_budget_bytes; } -/* Suspend the pump for `ms` on a one-shot timer so the reactor keeps draining - * (and ACKing, which debits the counter) while we wait. Best-effort outside a - * coroutine. Mirrors worker_dispatch.c's credit sleep. */ +/* Suspend the pump on a one-shot timer so the reactor keeps draining/ACKing. */ static void h3_static_throttle_sleep(zend_coroutine_t *co, const zend_ulong ms) { zend_async_timer_event_t *const t = ZEND_ASYNC_NEW_TIMER_EVENT(ms, false); @@ -210,10 +204,7 @@ static void h3_static_pump_entry(void) while (state->bytes_sent < state->body_length && !state->stream->peer_closed) { - /* Global cap: hold off starting the next read while the per-worker - * in-flight total is over budget. Already-queued bytes keep draining - * (and ACKing, which debits) independently, so this can't deadlock — - * it only paces this stream against the shared ceiling. */ + /* over budget: pace this stream; queued bytes keep draining, so no deadlock */ while (h3_static_over_budget() && !state->stream->peer_closed) { h3_static_throttle_sleep(co, H3_STATIC_THROTTLE_POLL_MS); diff --git a/src/http3/http3_steer.c b/src/http3/http3_steer.c index 9f43bcf5..c6ff152e 100644 --- a/src/http3/http3_steer.c +++ b/src/http3/http3_steer.c @@ -29,11 +29,8 @@ static uint8_t g_steer_key[16]; static bool g_steer_ready = false; static bool g_steer_active = false; -/* One AES-128-ECB block. Used as a PRF: the input is the CID nonce, the first - * output byte is the keystream that masks the id. The cipher context is kept - * thread-local and reset per call instead of new/free'd every time — each - * reactor thread reuses its own; the handful of contexts are reclaimed at - * process exit. */ +/* One AES-128-ECB block as a PRF: CID nonce in, keystream byte out. The + * cipher context is thread-local and reused, not new/free'd per call. */ static bool http3_steer_block(const uint8_t in[16], uint8_t out[16]) { static __thread EVP_CIPHER_CTX *ctx = NULL; diff --git a/src/http3/http3_stream.c b/src/http3/http3_stream.c index fdf835bc..b70ca479 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -177,16 +177,9 @@ void http3_stream_release(http3_stream_t *s) s->wire_credit = NULL; } - /* Inbound streaming (issue #26): the request can outlive this stream — - * a handler coroutine holds its own request ref and may still be - * draining the chunk queue. Sever the raw connection pointer NOW so a - * later http_body_stream_pop cannot call http3_request_body_consume on - * a freed connection (the conn dies right after this on the - * connection-free force-release path), and wake a consumer parked on - * the queue if the body never completed. Local mode only — - * body_h3_conn is never set under the reactor pool. The slab (and thus - * s->request) is guaranteed alive here: the request refcount keeps the - * slot allocated until http_request_destroy. */ + /* The request can outlive the stream (handler holds its own ref). Sever + * body_h3_conn so a later pop can't touch a freed connection, and wake + * a parked consumer if the body never completed. */ if (s->request != NULL && s->request->body_h3_conn != NULL) { s->request->body_h3_conn = NULL; diff --git a/src/http_request.c b/src/http_request.c index ea6042e3..76892dbe 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -269,12 +269,7 @@ ZEND_METHOD(TrueAsync_HttpRequest, getBody) } /* {{{ proto HttpRequest::readMessage(): ?string - * - * Deframe and return the next gRPC message from the request body (one - * 5-byte-length-prefixed frame), advancing an internal cursor. Returns - * null once no complete message remains — call once for a unary RPC, loop - * for client-streaming. The returned bytes are the raw (protobuf) message; - * decoding stays in PHP userland. */ + * Next gRPC message from the request body; null when none remain. */ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) { http_request_object *intern = Z_HTTP_REQUEST_P(ZEND_THIS); @@ -290,9 +285,7 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) bool compressed = false; int rc; - /* grpc-web-text: the body is the base64-encoded frame stream. Decode - * once (lazily) and deframe from the decoded buffer — buffered-only, - * which matches the protocol (grpc-web clients can't stream requests). */ + /* grpc-web-text: decode the base64 body once, deframe from the result */ const bool web_text = grpc_request_is_grpc_web_text(req); if (web_text && req->grpc_text_body == NULL) { @@ -310,9 +303,6 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) } } - /* Middle-band request (SMALL <= Content-Length < AUTO): the parser - * buffered so far; upgrade to the streaming queue on first read so - * incremental deframing works there too (mirror of readBody Case 2). */ if (!web_text && !req->body_streaming && req->body_upgrade_to_stream != NULL) { req->body_upgrade_to_stream(req); } @@ -323,14 +313,8 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) &req->grpc_read_offset, GRPC_MAX_RECV_MESSAGE, &compressed, &msg); } else if (req->body_streaming) { - /* True full-duplex path: accumulate popped body-stream chunks into - * grpc_reassembly until one length-prefixed message is framed, then - * deframe it — a handler can readMessage() while the client is still - * sending. Suspends between chunks. Enabled when the server ran with - * setBodyStreamingEnabled(true) and the request qualified. */ - - /* Drop the previous message's consumed prefix so the buffer holds - * only the not-yet-deframed tail (bounds memory on long streams). */ + /* Full-duplex: accumulate chunks into grpc_reassembly, suspend + * between them. Drop the consumed prefix first to bound memory. */ if (req->grpc_read_offset > 0 && req->grpc_reassembly.s != NULL) { const size_t total = ZSTR_LEN(req->grpc_reassembly.s); const size_t remain = req->grpc_read_offset < total @@ -441,12 +425,7 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage) } /* {{{ proto HttpRequest::getGrpcTimeout(): ?float - * - * The gRPC call deadline parsed from the `grpc-timeout` request header, in - * seconds (fractional), or null when the client sent none / it was - * malformed. The server does not itself abort the handler when the deadline - * elapses (the client enforces its own deadline); a handler can read this - * and bound its own work against it. */ + * grpc-timeout header in seconds, or null. The server does not enforce it. */ ZEND_METHOD(TrueAsync_HttpRequest, getGrpcTimeout) { http_request_object *intern = Z_HTTP_REQUEST_P(ZEND_THIS); diff --git a/src/http_response.c b/src/http_response.c index 5d64f86b..1617e491 100644 --- a/src/http_response.c +++ b/src/http_response.c @@ -67,13 +67,8 @@ static inline bool response_check_closed(const http_response_object *response) return false; } -/* Weaker guard for trailer setters. Trailers are emitted at stream end, - * AFTER the body — so unlike headers/body they may legitimately be set - * once a streaming response has already committed its HEADERS via send(). - * Only end() (closed) and sendFile() sealing forbid them. This is what the - * "trailers are still allowed via non-guarded setters" contract means and - * is required for gRPC server-streaming, where grpc-status is known only - * after the messages have been streamed. */ +/* Trailers go out after the body, so setting them post-commit is legal; + * only end() and sendFile() sealing forbid them. */ static inline bool response_check_trailer_sealed(const http_response_object *response) { if (response->closed) { @@ -464,12 +459,7 @@ static inline void ensure_trailers_table(http_response_object *response) } } -/* Fold every response trailer into the response headers, then clear the - * trailer table. Used by the gRPC dispose path to build a Trailers-Only - * reply — a single HEADERS(:status 200 + content-type + grpc-status - * [+ grpc-message], END_STREAM) frame — when the handler streamed no - * messages (immediate status / error). Trailer names are already - * lowercased by setTrailer, so they are valid HPACK header names. */ +/* Fold trailers into headers and clear the table (gRPC Trailers-Only reply). */ void http_response_promote_trailers_to_headers(zend_object *obj) { http_response_object *response = http_response_from_obj(obj); @@ -493,9 +483,7 @@ void http_response_promote_trailers_to_headers(zend_object *obj) zend_hash_clean(response->trailers); } -/* Clear the response trailer table (without touching headers). Used by the - * grpc-web dispose path: it moves grpc-status/grpc-message into an in-body - * trailer frame, so the HTTP/2 trailer emission must find nothing. */ +/* grpc-web moved the trailers in-body; the HTTP trailer emission must find nothing. */ void http_response_clear_trailers(zend_object *obj) { http_response_object *response = http_response_from_obj(obj); @@ -505,10 +493,7 @@ void http_response_clear_trailers(zend_object *obj) } } -/* Default the `grpc-status` trailer to `status` (decimal) unless the handler - * already set one. Called from the H2/H3 gRPC dispose path so every gRPC - * response carries a status even when the handler forgot — grpc-status is - * mandatory on the wire, including on success (0). */ +/* grpc-status is mandatory on the wire, even on success (0). */ void http_response_ensure_grpc_status(zend_object *obj, int status) { http_response_object *response = http_response_from_obj(obj); @@ -1028,13 +1013,8 @@ ZEND_METHOD(TrueAsync_HttpResponse, send) } /* }}} */ -/* {{{ proto HttpResponse::writeMessage(string $message): static - * - * Frame $message with the 5-byte gRPC length prefix (identity encoding) - * and stream it as one gRPC message. Activates streaming mode on the first - * call, exactly like send(). Call once for a unary reply, repeatedly for - * server-streaming. grpc-status is carried separately via setTrailer(); the - * server defaults it to 0 if the handler set none. */ +/* {{{ proto HttpResponse::writeMessage(string $message, bool $compress = false): static + * Stream one gRPC length-prefixed message; first call commits, like send(). */ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) { zend_string *message; @@ -1066,9 +1046,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) return; } - /* Optional per-message gzip. If gzip is not compiled in, transparently - * fall back to identity (flag 0). grpc-encoding must ride the initial - * HEADERS, so declare it before the first message commits. */ + /* grpc-encoding must ride the initial HEADERS — declare before commit */ zend_string *payload = message; bool gzipped = false; @@ -1093,17 +1071,13 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) (void)compress; #endif - /* First message — lock headers and switch to streaming mode. Mirrors - * send(): after this, setBody / setHeader / setStatusCode throw, but - * setTrailer() stays allowed (grpc-status is set post-commit). */ if (!response->streaming) { response->streaming = true; response->committed = true; response->headers_sent = true; } - /* Prepend the 5-byte gRPC frame header. The queue takes ownership of - * this fresh string (append_chunk consumes the ref), so no release. */ + /* append_chunk consumes the ref */ zend_string *framed = grpc_frame_message( ZSTR_VAL(payload), ZSTR_LEN(payload), gzipped); @@ -1111,10 +1085,7 @@ ZEND_METHOD(TrueAsync_HttpResponse, writeMessage) zend_string_release(payload); /* framed copied it; drop the gz buffer */ } - /* grpc-web-text: every frame goes out base64-encoded, each with its own - * padding (the grpc-web protocol allows per-frame encoding, so no codec - * state spans messages). The trailer frame is encoded the same way in - * grpc_call_finish. */ + /* grpc-web-text: each frame is base64-encoded independently */ if (response->grpc_mode == GRPC_MODE_WEB_TEXT) { zend_string *const b64 = grpc_web_text_encode(ZSTR_VAL(framed), ZSTR_LEN(framed)); diff --git a/src/http_response_internal.h b/src/http_response_internal.h index 0d976996..6a61803d 100644 --- a/src/http_response_internal.h +++ b/src/http_response_internal.h @@ -59,9 +59,8 @@ typedef struct { bool streaming; /* send() has been called — setBody/setHeader now throw */ bool sse_mode; /* SSE helpers committed the stream — send() now throws, sse* re-entry is allowed */ - /* gRPC delivery mode (grpc_mode_t), stamped by grpc_call_init_response - * at dispatch. writeMessage / grpc_call_finish read it to pick the - * per-frame transform (grpc-web-text base64). 0 = not a gRPC call. */ + /* grpc_mode_t stamped at dispatch; picks the per-frame transform. + * 0 = not a gRPC call. */ uint8_t grpc_mode; /* Compression module state (issue #8). Opaque ptr — owned by the diff --git a/src/http_server_class.c b/src/http_server_class.c index bba3c94e..5f9a9f7d 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -1553,11 +1553,7 @@ ZEND_METHOD(TrueAsync_HttpServer, addGrpcHandler) Z_OBJ_P(ZEND_THIS) ); - /* gRPC is carried over HTTP/2 — enable the h2 transport so the h2c - * preface / h2 ALPN is accepted even on a gRPC-only server (no - * addHttp2Handler). The per-stream dispatch then routes application/grpc - * requests to the gRPC handler and everything else to the HTTP handler - * (absent here → 404). */ + /* gRPC rides h2 — a gRPC-only server must still accept h2c/h2 ALPN */ server->view.protocol_mask |= HTTP_PROTO_MASK_GRPC | HTTP_PROTO_MASK_HTTP2; } /* }}} */ @@ -1831,9 +1827,7 @@ static void http_server_accept_callback( handler = http_protocol_get_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP2); } - /* gRPC-only servers register no h1/h2 handler — accept the connection - * anyway (gRPC rides h2). conn->handler stays NULL; the per-stream - * dispatch routes application/grpc to the gRPC handler. */ + /* gRPC-only servers register no h1/h2 handler — accept anyway */ const bool accept_grpc = http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_GRPC); @@ -1943,10 +1937,7 @@ static void pool_worker_handler(zend_async_event_t *event, void *vctx) fflush(stderr); zend_clear_exception(); } else { - /* Clean exit — the normal path when a worker retires on reload - * (paired with the "worker shutting down" line above) or on a - * graceful stop. A clean exit while the server should still be - * running would instead point at a swallowed bailout. */ + /* normal on reload/stop; while running points at a swallowed bailout */ fprintf(stderr, "[true-async-server] worker exited\n"); fflush(stderr); @@ -2572,13 +2563,7 @@ static bool http_server_worker_response_sink(response_wire_t *rw, void *arg) } /* STREAM_* wires are ordered fragments — dropping one corrupts the - * stream, so ride out a transiently full mailbox (1024 slots; the - * reactor drains continuously). The credit protocol already paces - * CHUNK bytes, so a mailbox that stays full for ~100 ms means the - * reactor is gone or wedged — fail fast then: the caller marks the - * stream failed and its terminal wire becomes an ABORT. Short - * bounded sleeps, not a busy spin: this stalls only wires, never - * burns a core. */ + * stream. Retry ~100 ms on a full mailbox, then fail fast (→ ABORT). */ if (response_wire_kind(rw) != RESPONSE_WIRE_FULL) { for (int attempt = 0; attempt < 100; attempt++) { #ifdef PHP_WIN32 @@ -2597,8 +2582,7 @@ static bool http_server_worker_response_sink(response_wire_t *rw, void *arg) } #endif - /* Undeliverable: the reactor never adopts a HEADERS wire's credit ref, - * so take it over — unblock the parked producer, drop the ref. */ + /* undeliverable: nobody adopts the credit ref — unblock the producer */ stream_credit_abandon((stream_credit_t *)response_wire_credit(rw)); zend_string *const orphan_chunk = (zend_string *)response_wire_take_chunk(rw); @@ -2981,11 +2965,6 @@ ZEND_METHOD(TrueAsync_HttpServer, start) RETURN_FALSE; } - /* Require at least one handler that serves requests (h1 or h2, or a - * gRPC handler over h2), OR at least one static mount. h2-only - * deployments use addHttp2Handler; dual-protocol use addHttpHandler; - * static-only use addStaticHandler (issue #13); gRPC-only servers use - * addGrpcHandler (issue #4). */ if (!http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP1) && !http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_HTTP2) && !http_protocol_has_handler(&server->protocol_handlers, HTTP_PROTOCOL_GRPC) && diff --git a/src/log/http_log.c b/src/log/http_log.c index a17f3959..7c7e0d44 100644 --- a/src/log/http_log.c +++ b/src/log/http_log.c @@ -545,9 +545,7 @@ void http_log_server_start(http_log_state_t *state, state->severity = severity; } -/* Yield to the reactor for a short beat so a pending log-write completion can - * be delivered to writer_complete_cb (which disposes the req and kicks the - * next). Stop-drain only; best-effort. */ +/* Yield to the reactor so a pending write completion can land. Best-effort. */ static void http_log_stop_yield(zend_coroutine_t *co) { zend_async_timer_event_t *const t = @@ -572,14 +570,9 @@ void http_log_server_stop(http_log_state_t *state) state->severity = HTTP_LOG_OFF; - /* Drain in-flight writes before tearing down — closing the io with reqs - * still pending would UAF on completion. writer_complete_cb fires on the - * reactor, disposes the req, and kicks any coalesced pending bytes, so we - * must NOT await the req ourselves: async_io_req_await reads req->completed - * after resuming, but the callback has already freed it by then (a UAF, - * caught under ASAN). Instead yield to the reactor and re-poll until idle. - * Requires a coroutine context, which the normal HttpServer::stop() path - * provides. */ + /* Drain in-flight writes before teardown. Must NOT async_io_req_await: + * writer_complete_cb frees the req before the awaiter re-reads it (UAF + * under ASAN) — yield to the reactor and re-poll instead. */ if (state->async_io != NULL && state->writer_cb != NULL && ZEND_ASYNC_CURRENT_COROUTINE != NULL) { zend_coroutine_t *const co = ZEND_ASYNC_CURRENT_COROUTINE; diff --git a/src/websocket/ws_dispatch.c b/src/websocket/ws_dispatch.c index bfbcf8a7..82a49f37 100644 --- a/src/websocket/ws_dispatch.c +++ b/src/websocket/ws_dispatch.c @@ -82,9 +82,8 @@ typedef struct { char *buf; } ws_pending_write_t; -/* Destroy a request the reject path owns, first severing the h1 parser's - * borrow (parser->request) so a later read tick can't read it freed via - * http_parser_is_complete. Mirrors the h1 dispatch spawn-fail path. */ +/* Destroy a reject-path request, severing the parser's borrow first + * (a later read tick must not see it freed). */ static void ws_reject_destroy_request(http_connection_t *conn, http_request_t *req) { if (conn->parser != NULL) { @@ -438,9 +437,7 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) w->session = NULL; } - /* Capture w->committed before the dtor below: releasing websocket_zv may - * drop w's last ref and free it, so reading w->committed at teardown time - * (further down) would be a use-after-free. */ + /* capture before the dtor below frees w — reading it later is a UAF */ const bool w_committed = (w != NULL) && w->committed; zval_ptr_dtor(&ctx->request_zv); @@ -451,10 +448,8 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) conn->keep_alive = false; conn->current_request = NULL; - /* request_zv's dtor above may have freed the http_request the h1 parser - * still borrows (parser->request). Sever it exactly as the h1 dispatch - * path does (http_connection.c) — otherwise a later read tick reads a - * freed request via http_parser_is_complete. */ + /* the dtor above may have freed the request the parser still borrows — + * sever it or a later read tick reads it freed */ if (conn->parser != NULL) { http_parser_clear_request(conn->parser); } From a120f467f28d56f6df1bc799262111af256bc5db Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:28:07 +0000 Subject: [PATCH 33/33] docs: complete Unreleased changelog (ASAN UAF fixes, reactor perf), fix version links; README grpc-web-text + reactor-pool --- CHANGELOG.md | 42 +++++++++++++++++++++++++++++++++++++++++- README.md | 10 +++++----- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56688a1b..27b2ae87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 buffered body bytes — now `h3_recv_data_cb` returns the credit as it consumes them. +- **Three latent use-after-frees found under ASAN** (masked by the Zend + arena in normal runs): the WebSocket dispose read `w->committed` after + the zval dtor could free `w`; `http_log_server_stop` awaited a write + request that its own completion callback frees (now drains by yielding + to the reactor and re-polling); the WebSocket reject / spawn-fail paths + freed a request the H1 parser still borrowed via `parser->request`. + +### Performance + +- **HTTP/3 reactor-pool hot paths** (reactor review follow-up): reactor + commands travel the mailbox by value (no malloc/free per message), + O(1) intrusive stream unlink, listener local sockaddr cached per peer + family, thread-local cipher context in CID steering, and a per-worker + memory budget for H3 static delivery (ported from H2). Hard + backpressure on a full worker inbox now RESETs the stream with + `H3_REQUEST_REJECTED` instead of silently dropping the request. + ### Changed - **gRPC layering: call-lifecycle policy extracted out of the transports (#4).** @@ -825,8 +842,31 @@ on the [TrueAsync](https://github.com/true-async) event loop. and Windows, quick start), `docs/` (coding standards, contributor recommendations, llhttp upstream notes), Apache 2.0 `LICENSE`. -[Unreleased]: https://github.com/true-async/server/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/true-async/server/compare/v0.9.3...HEAD +[0.9.3]: https://github.com/true-async/server/compare/v0.9.2...v0.9.3 +[0.9.2]: https://github.com/true-async/server/compare/v0.9.1...v0.9.2 +[0.9.1]: https://github.com/true-async/server/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/true-async/server/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/true-async/server/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/true-async/server/compare/v0.7.3...v0.8.0 +[0.7.2]: https://github.com/true-async/server/compare/v0.7.1...v0.7.2 +[0.7.1]: https://github.com/true-async/server/compare/v0.7.0...v0.7.1 +[0.7.0]: https://github.com/true-async/server/compare/v0.6.7...v0.7.0 +[0.6.7]: https://github.com/true-async/server/compare/v0.6.6...v0.6.7 +[0.6.6]: https://github.com/true-async/server/compare/v0.6.5...v0.6.6 +[0.6.5]: https://github.com/true-async/server/compare/v0.6.4...v0.6.5 +[0.6.4]: https://github.com/true-async/server/compare/v0.6.3...v0.6.4 +[0.6.3]: https://github.com/true-async/server/compare/v0.6.2...v0.6.3 +[0.6.2]: https://github.com/true-async/server/compare/v0.6.1...v0.6.2 +[0.6.1]: https://github.com/true-async/server/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/true-async/server/compare/v0.5.3...v0.6.0 +[0.5.3]: https://github.com/true-async/server/compare/v0.5.2...v0.5.3 +[0.5.2]: https://github.com/true-async/server/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/true-async/server/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/true-async/server/compare/v0.4.2...v0.5.0 +[0.4.0]: https://github.com/true-async/server/compare/v0.3.2...v0.4.0 +[0.3.2]: https://github.com/true-async/server/compare/v0.3.1...v0.3.2 +[0.3.1]: https://github.com/true-async/server/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/true-async/server/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/true-async/server/compare/v0.1.5...v0.2.0 [0.1.0]: https://github.com/true-async/server/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a37a45c7..5656a0b2 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ This means you can serve a REST API over HTTP/2, push real-time events over Serv | ✅ Ready | **Compression** | gzip (zlib-ng / zlib), Brotli, zstd — response encoding + inbound decode across H1/H2/H3. Server-side preference `zstd > br > gzip`; per-codec level setters. See [docs/COMPRESSION.md](docs/COMPRESSION.md). | | ✅ Ready | **WebSocket** | RFC 6455, upgrade from HTTP/1.1 and HTTP/2 (RFC 8441 Extended CONNECT), `wss://`, permessage-deflate (RFC 7692), full duplex, backpressure — 246/246 Autobahn conformance | | ✅ Ready | **SSE (Server-Sent Events)** | `text/event-stream` framing (WHATWG §9.2) over H1/H2/H3 via `HttpResponse::sseStart/sseEvent/sseComment/sseRetry` | -| ✅ Ready | **gRPC** | Over HTTP/2 **and** HTTP/3 via `addGrpcHandler()` — unary + all streaming shapes incl. full-duplex bidi (`readMessage`/`writeMessage`), `grpc-status`/`grpc-message` trailers + Trailers-Only, `grpc-timeout`, per-message gzip, grpc-web (binary, in-body `0x80` trailer frame) | +| ✅ Ready | **gRPC** | Over HTTP/2 **and** HTTP/3 via `addGrpcHandler()` — unary + all streaming shapes incl. full-duplex bidi (`readMessage`/`writeMessage`), `grpc-status`/`grpc-message` trailers + Trailers-Only, `grpc-timeout`, per-message gzip, grpc-web + grpc-web-text (in-body `0x80` trailer frame; per-frame base64 for web-text), works under the reactor pool | ### Development Progress @@ -306,10 +306,10 @@ $server->addGrpcHandler(function ($request, $response) { Unary and all three streaming shapes use this one API; `grpc-status` / `grpc-message` ride real HTTP trailers (or the in-body `0x80` frame for -grpc-web), uncaught exceptions map to `13 INTERNAL`, inbound -`grpc-encoding: gzip` messages inflate transparently, and -`writeMessage($msg, compress: true)` gzips replies. The client's deadline is -exposed via `$request->getGrpcTimeout()`. +grpc-web, base64-encoded per frame for grpc-web-text), uncaught exceptions +map to `13 INTERNAL`, inbound `grpc-encoding: gzip` messages inflate +transparently, and `writeMessage($msg, compress: true)` gzips replies. The +client's deadline is exposed via `$request->getGrpcTimeout()`. Working examples live under [`examples/`](examples/): [`minimal-server.php`](examples/minimal-server.php),