diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7152109d..4955d779 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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 45892ff5..27b2ae87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,93 @@ 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()`. + - **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). + +- **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. + +- **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).** + `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.3] - 2026-07-07 ### Fixed @@ -755,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/CMakeLists.txt b/CMakeLists.txt index 8abad25d..4b6240b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,8 @@ set(CORE_SOURCES src/http_server_exceptions.c 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/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 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-нарушение. diff --git a/README.md b/README.md index 1c7d2c39..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` | -| 📋 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 + grpc-web-text (in-body `0x80` trailer frame; per-frame base64 for web-text), works under the reactor pool | ### Development Progress @@ -57,7 +57,7 @@ HTTP/2 ████████████████████ 100% HTTP/3 ████████████████████ 100% WebSocket ████████████████████ 100% SSE ████████████████████ 100% -gRPC ░░░░░░░░░░░░░░░░░░░░ 0% +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. @@ -285,6 +285,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, 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), [`demo-server.php`](examples/demo-server.php), diff --git a/config.m4 b/config.m4 index 25c35643..09ac6741 100644 --- a/config.m4 +++ b/config.m4 @@ -530,6 +530,8 @@ 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/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 16af34e5..e5b65e2b 100644 --- a/config.w32 +++ b/config.w32 @@ -20,6 +20,8 @@ if (PHP_TRUE_ASYNC_SERVER == "yes") { "src\\http_server_class.c " + "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/docs/PLAN_GRPC.md b/docs/PLAN_GRPC.md new file mode 100644 index 00000000..a19b46d5 --- /dev/null +++ b/docs/PLAN_GRPC.md @@ -0,0 +1,424 @@ +# 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). + 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 + 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). +- **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). +- **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. +- **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 +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 +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/docs/PLAN_REVIEW_FIXES.md b/docs/PLAN_REVIEW_FIXES.md new file mode 100644 index 00000000..d74999ab --- /dev/null +++ b/docs/PLAN_REVIEW_FIXES.md @@ -0,0 +1,169 @@ +# 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 — 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). +- DEFERRED: a helper used once is worse than the duplication. Convert + opportunistically when a site is touched anyway. + +## 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 485abb09..6363362d 100644 --- a/fuzz/fuzz_stubs.c +++ b/fuzz/fuzz_stubs.c @@ -125,6 +125,22 @@ __attribute__((weak)) void h2_session_schedule_emit(struct http2_session_t *sess (void)session; } +/* 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; +} + +/* 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; + 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/compression/http_compression_request.h b/include/compression/http_compression_request.h index 6fb75e5c..0fd48a3f 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,15 @@ 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 (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, + size_t max_out, zend_string **out); + #ifdef __cplusplus } #endif diff --git a/include/core/reactor_cmd.h b/include/core/reactor_cmd.h new file mode 100644 index 00000000..c7e316b0 --- /dev/null +++ b/include/core/reactor_cmd.h @@ -0,0 +1,39 @@ +/* + +----------------------------------------------------------------------+ + | 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 + +/* Mailbox message, passed BY VALUE through the lock-free ring — must stay a + * trivially-copyable POD. */ +typedef enum { + 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 { + 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/response_wire.h b/include/core/response_wire.h index c593b830..76cd294e 100644 --- a/include/core/response_wire.h +++ b/include/core/response_wire.h @@ -37,27 +37,52 @@ typedef struct response_wire_s response_wire_t; +/* 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, + /* stream died mid-flight — the reactor must RESET, not send a clean FIN */ + RESPONSE_WIRE_STREAM_ABORT, +} 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); -/* 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. */ +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); + +/* 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: 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; 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, bool complete); +bool response_wire_set_body(response_wire_t *rw, const char *ptr, size_t len); +/* 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); /* Accessors. Returned pointers are valid until response_wire_free; *len * 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. */ @@ -65,6 +90,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/include/core/stream_credit.h b/include/core/stream_credit.h new file mode 100644 index 00000000..48d8e0ec --- /dev/null +++ b/include/core/stream_credit.h @@ -0,0 +1,83 @@ +/* + +----------------------------------------------------------------------+ + | 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 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) */ + 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, 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, + 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); +} + +/* 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) { + 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; +} + +#endif /* STREAM_CREDIT_H */ diff --git a/include/core/thread_mailbox.h b/include/core/thread_mailbox.h index b139854b..25bd4aa2 100644 --- a/include/core/thread_mailbox.h +++ b/include/core/thread_mailbox.h @@ -66,4 +66,22 @@ 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 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; + +/* 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..4db3db5e 100644 --- a/include/core/thread_queue.h +++ b/include/core/thread_queue.h @@ -76,6 +76,21 @@ 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 — 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; + +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/core/worker_dispatch.h b/include/core/worker_dispatch.h index de1a5ab5..13429a3c 100644 --- a/include/core/worker_dispatch.h +++ b/include/core/worker_dispatch.h @@ -33,11 +33,10 @@ 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 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 * 1), wrap it in an HttpRequest on THIS (worker) thread, spawn the user handler @@ -53,8 +52,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/http1/http_parser.h b/include/http1/http_parser.h index 73b92c53..897bd980 100644 --- a/include/http1/http_parser.h +++ b/include/http1/http_parser.h @@ -91,6 +91,17 @@ struct http_request_t { /* Body */ zend_string *body; + /* readMessage() deframer cursor: indexes `body` (buffered), + * grpc_reassembly (streaming) or grpc_text_body (web-text) */ + size_t grpc_read_offset; + + /* streaming reassembly of chunks into whole gRPC messages; + * freed in http_request_destroy */ + smart_str grpc_reassembly; + + /* grpc-web-text: lazily base64-decoded body; 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. @@ -198,6 +209,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; 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 */ int32_t body_h2_consume_pending; void *body_h3_stream; 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/http3/http3_stream.h b/include/http3/http3_stream.h index 266cdc90..e9d3d011 100644 --- a/include/http3/http3_stream.h +++ b/include/http3/http3_stream.h @@ -121,11 +121,27 @@ 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 memory cap: charged on push, debited on ACK, reconciled + * at teardown (http3_static_response.c) */ + 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. */ bool streaming_ended; + bool is_grpc; + bool has_trailers; + bool trailers_submitted; + + /* 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; + /* 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 @@ -133,6 +149,10 @@ struct _http3_stream_s { * unwinds cleanly with HttpException(499). */ bool peer_closed; + /* 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 * backpressure. The data_reader fires it (lazily) when ngtcp2 * extends the per-stream write window; lazy-created on first wait, @@ -167,8 +187,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/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/include/php_http_server.h b/include/php_http_server.h index c55ed31f..e0ddc6e4 100644 --- a/include/php_http_server.h +++ b/include/php_http_server.h @@ -743,6 +743,9 @@ typedef struct { uint64_t stream_bytes_sent_total; uint64_t stream_send_backpressure_events_total; + /* 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 (++/--); * h2_ping_rtt_ns is the latest sample (overwrite). */ uint64_t h2_streams_active; @@ -902,6 +905,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++; @@ -1129,6 +1137,18 @@ 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); + +/* 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/compression/http_compression_gzip.c b/src/compression/http_compression_gzip.c index d561ccfb..7fe47076 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_request.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,45 @@ const http_encoder_vtable_t http_compression_gzip_vt = { .reset = gz_reset, .destroy = gz_destroy, }; + +/* 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) +{ + 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..8bf430e7 100644 --- a/src/compression/http_compression_request.c +++ b/src/compression/http_compression_request.c @@ -47,10 +47,14 @@ #include -static int decode_gzip(http_request_t *req, size_t cap) +/* 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) { - 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 +63,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 +87,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 +116,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/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..a2397b60 100644 --- a/src/core/http_protocol_handlers.h +++ b/src/core/http_protocol_handlers.h @@ -31,6 +31,10 @@ 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); +/* 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 */ void http_protocol_remove_handler(HashTable *handlers, http_protocol_type_t protocol); diff --git a/src/core/reactor_pool.c b/src/core/reactor_pool.c index 25a99430..9e9d9587 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,19 @@ 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; + /* `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); + + 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 +280,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 +300,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 +324,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 +336,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/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/response_wire.c b/src/core/response_wire.c index 763f84a7..5dbb946b 100644 --- a/src/core/response_wire.c +++ b/src/core/response_wire.c @@ -31,6 +31,9 @@ struct response_wire_s { int64_t stream_id; void *conn; + 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. */ @@ -40,11 +43,14 @@ struct response_wire_s { size_t body_off, body_len; bool body_set; - bool body_complete; 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 @@ -104,26 +110,61 @@ 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_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_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; } -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,17 +179,33 @@ 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_set_body(response_wire_t *rw, const char *ptr, const size_t len, const bool complete) +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 size_t off = arena_append(rw, ptr, len); @@ -156,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; } @@ -180,25 +236,23 @@ 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; } -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 +261,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 +305,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/thread_mailbox.c b/src/core/thread_mailbox.c index 82a3c929..f0a2da43 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,121 @@ 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, ring carries the + * POD by value. */ + +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/core/worker_dispatch.c b/src/core/worker_dispatch.c index 58c74672..f066f045 100644 --- a/src/core/worker_dispatch.c +++ b/src/core/worker_dispatch.c @@ -18,8 +18,16 @@ #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_classify */ +#include "grpc/grpc_call.h" /* call lifecycle policy (init/status/finish) */ #include "Zend/zend_hrtime.h" /* zend_hrtime — request-service sampling */ +#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 @@ -52,6 +60,15 @@ typedef struct { bool skip_handler; /* synthetic 404 already populated */ bool is_head; /* suppress the body on render */ + + bool is_grpc; + + bool stream_started; + bool stream_ended; + bool stream_failed; /* terminal wire becomes STREAM_ABORT */ + + stream_credit_t *credit; /* shared with the reactor */ + uint64_t posted_bytes; } worker_dispatch_ctx_t; /* Handler coroutine body: run the registered user handler with (request, response). */ @@ -68,11 +85,8 @@ 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); - - 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; @@ -123,19 +137,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,35 +150,318 @@ 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(); +} + +/* 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); + + 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(); +} + +/* 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); + bool delivered = false; + + if (ctx->sink != NULL) { + delivered = ctx->sink(rw, ctx->sink_arg); + } else { + /* 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 = + (zend_string *)response_wire_take_chunk(rw); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + + response_wire_free(rw); + } + + if (!delivered && kind != RESPONSE_WIRE_FULL) { + ctx->stream_failed = true; + http_server_on_worker_wire_dropped(ctx->counters); + } + + return delivered; +} + +/* 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); + + 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 until in-flight < cap; false = stream 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; + 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) { + 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, poll_ms); + + if (EG(exception) != NULL) { + return false; /* cancelled while parked */ + } + + waited_ms += poll_ms; + + if (timeout_ms > 0 && waited_ms >= timeout_ms) { + return false; /* peer stopped reading — treat as dead */ + } + + 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; +} + +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 || ctx->stream_failed) + || (ctx->credit != NULL && stream_credit_is_dead(ctx->credit))) { + zend_string_release(chunk); + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + /* 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); + + if (UNEXPECTED(hw == NULL)) { + zend_string_release(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; + } + + 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); + + /* 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); + + response_wire_set_chunk(cw, pchunk); + + const size_t chunk_len = ZSTR_LEN(chunk); + + worker_wire_post(ctx, cw); + zend_string_release(chunk); /* bytes copied into the wire arena */ + + ctx->posted_bytes += chunk_len; + + if (!worker_stream_wait_credit(ctx)) { + ctx->stream_failed = true; /* credit timeout / cancelled while parked */ + return HTTP_STREAM_APPEND_STREAM_DEAD; + } + + return HTTP_STREAM_APPEND_OK; +} + +/* 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; + + 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; + + 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; + } + + 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); +} + +static const http_response_stream_ops_t worker_stream_ops = { + .append_chunk = worker_stream_append_chunk, + .sendable = worker_stream_sendable, + .mark_ended = worker_stream_mark_ended, + .get_wait_event = NULL, /* backpressure parks inside append_chunk */ +}; + +/* 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; + + 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: dispose posts the FULL wire right after this returns. */ +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). */ @@ -182,12 +469,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; @@ -210,23 +497,46 @@ 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)) { + /* 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"); } + 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); + 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 */ + if (ctx->stream_started && !ctx->stream_ended) { + worker_stream_mark_ended(ctx); + } - if (rw != NULL) { - if (ctx->sink != NULL) { - ctx->sink(rw, ctx->sink_arg); /* sink owns rw now */ - } else { - response_wire_free(rw); + if (!ctx->stream_started) { + response_wire_t *const rw = worker_render_response(ctx); + + if (rw != NULL) { + worker_wire_post(ctx, rw); /* sink owns rw now */ } } + + /* ctx dies below; a late send() on a kept $response must throw, not UAF */ + 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)) { @@ -263,6 +573,10 @@ bool worker_dispatch_request(http_server_object *server, void *const conn = req->reactor_conn; const bool is_head = http_request_method_is_head(req); + 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; + zval *const req_obj = http_request_create_from_parsed(req); if (UNEXPECTED(req_obj == NULL)) { @@ -280,6 +594,7 @@ 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; ZVAL_COPY_VALUE(&ctx->request_zv, req_obj); efree(req_obj); /* the heap zval wrapper, not the object */ @@ -287,15 +602,17 @@ 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"); - /* 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); + http_response_install_stream_ops(Z_OBJ(ctx->response_zv), + &worker_stream_ops, ctx); - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); + if (is_grpc) { + grpc_call_init_response(Z_OBJ(ctx->response_zv), grpc_mode); } + /* No handler registered: synthesise a 404 so the sink still fires with a + * response instead of leaving the stream hanging. */ + 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); http_response_static_set_header(Z_OBJ(ctx->response_zv), diff --git a/src/grpc/grpc.c b/src/grpc/grpc.c new file mode 100644 index 00000000..d0f38fe6 --- /dev/null +++ b/src/grpc/grpc.c @@ -0,0 +1,304 @@ +/* + +----------------------------------------------------------------------+ + | 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" +#include "zend_smart_str.h" +#include "ext/standard/base64.h" +#include "core/http_protocol_handlers.h" + +#include +#include + +#ifdef HAVE_HTTP_COMPRESSION +# include "compression/http_compression_request.h" +#endif + +grpc_mode_t grpc_request_mode(const http_request_t *req) +{ + if (req->headers == NULL) { + return GRPC_MODE_NONE; + } + + zval *content_type = zend_hash_str_find(req->headers, "content-type", + sizeof("content-type") - 1); + + if (content_type == NULL || Z_TYPE_P(content_type) != IS_STRING) { + return GRPC_MODE_NONE; + } + + const char *val = Z_STRVAL_P(content_type); + size_t len = Z_STRLEN_P(content_type); + + const size_t grpc_len = sizeof(GRPC_CONTENT_TYPE) - 1; + + if (len < grpc_len || strncasecmp(val, GRPC_CONTENT_TYPE, grpc_len) != 0) { + return GRPC_MODE_NONE; + } + + /* "-web" is a prefix of "-web-text" — check web-text first */ + val += grpc_len; + len -= grpc_len; + + 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; + } + + if (len >= web_len && strncasecmp(val, GRPC_WEB_SUFFIX, web_len) == 0) { + return GRPC_MODE_WEB; + } + + return GRPC_MODE_NATIVE; +} + +bool grpc_request_is_grpc_web_text(const http_request_t *req) +{ + return grpc_request_mode(req) == GRPC_MODE_WEB_TEXT; +} + +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; +} + +zend_string *grpc_web_text_encode(const char *in, const size_t len) +{ + return php_base64_encode((const unsigned char *)in, len); +} + +static bool b64_decode_append(smart_str *out, const char *in, size_t 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) +{ + /* 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; + + smart_str_alloc(&out, (len / 4) * 3 + 3, 0); + + for (size_t i = 0; i < len; i++) { + if (in[i] != '=') { + continue; + } + + size_t end = i + 1; + + while (end < len && in[end] == '=') { + end++; + } + + if (!b64_decode_append(&out, in + start, end - start)) { + smart_str_free(&out); + return NULL; + } + + start = end; + i = end - 1; + } + + 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) +{ + zend_string *name; + zval *val; + size_t tlen = 0; + + if (trailers != NULL) { + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + tlen += ZSTR_LEN(name) + 2 + Z_STRLEN_P(val) + 2; /* ": " + CRLF */ + } ZEND_HASH_FOREACH_END(); + } + + zend_string *out = zend_string_alloc(5 + tlen, 0); + char *w = ZSTR_VAL(out); + + grpc_write_frame_header((unsigned char *)w, 0x80u /* trailer frame */, tlen); + w += 5; + + 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(); + } + + *w = '\0'; + return out; +} + +uint64_t grpc_parse_timeout_ns(const http_request_t *req) +{ + if (req == NULL || req->headers == NULL) { + return 0; + } + + zval *timeout = zend_hash_str_find(req->headers, "grpc-timeout", + sizeof("grpc-timeout") - 1); + + if (timeout == NULL || Z_TYPE_P(timeout) != IS_STRING) { + return 0; + } + + 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) { + 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; + 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; + } + + 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); + + grpc_write_frame_header((unsigned char *)ZSTR_VAL(out), + compressed ? 1u : 0u, len); + + if (len > 0) { + memcpy(ZSTR_VAL(out) + 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; +} + +#ifdef HAVE_HTTP_COMPRESSION + +int grpc_message_inflate(const http_request_t *req, + const char *in, size_t in_len, zend_string **out) +{ + zval *enc = (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; +} + +zend_string *grpc_message_deflate_gzip(const char *in, size_t in_len) +{ + return http_compression_gzip_deflate_buffer(in, in_len, 6 /* default */); +} + +#endif /* HAVE_HTTP_COMPRESSION */ diff --git a/src/grpc/grpc.h b/src/grpc/grpc.h new file mode 100644 index 00000000..11550276 --- /dev/null +++ b/src/grpc/grpc.h @@ -0,0 +1,90 @@ +/* + +----------------------------------------------------------------------+ + | 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 + +struct http_request_t; + +/* 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 +#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 + +/* inbound message ceiling — the frame-header length is attacker-controlled */ +#define GRPC_MAX_RECV_MESSAGE (16u * 1024u * 1024u) + +#define GRPC_CONTENT_TYPE "application/grpc" + +/* "-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" + +/* 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 */ + 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; + +/* Delivery mode from the request content-type; NONE for non-gRPC. */ +grpc_mode_t grpc_request_mode(const struct http_request_t *req); + +bool grpc_request_is_grpc_web_text(const struct http_request_t *req); + +/* 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); + +/* 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); + +/* In-body trailer frame: 0x80, u32be length, "name: value\r\n" lines. */ +zend_string *grpc_web_trailer_frame(HashTable *trailers); + +/* grpc-timeout header → ns; 0 when absent or malformed. */ +uint64_t grpc_parse_timeout_ns(const struct http_request_t *req); + +/* 5-byte prefix (compressed flag + u32be length) + payload. */ +zend_string *grpc_frame_message(const char *msg, size_t len, bool compressed); + +/* 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 +/* 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 for `grpc-encoding: gzip`; 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/grpc/grpc_call.c b/src/grpc/grpc_call.c new file mode 100644 index 00000000..b1e8136d --- /dev/null +++ b/src/grpc/grpc_call.c @@ -0,0 +1,81 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ +*/ + +#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, const int grpc_mode) +{ + 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) +{ + http_response_ensure_grpc_status(response_obj, + had_exception ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK); +} + +void grpc_call_finish(zend_object *response_obj, + const grpc_finish_ops_t *ops, void *ctx) +{ + 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 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)); + + 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; + } + + if (http_response_is_streaming(response_obj)) { + /* native streamed: trailers ride the transport's trailer path at EOF */ + ops->end_stream(ctx); + return; + } + + /* 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 new file mode 100644 index 00000000..aade81c6 --- /dev/null +++ b/src/grpc/grpc_call.h @@ -0,0 +1,45 @@ +/* + +----------------------------------------------------------------------+ + | 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 — 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 { + /* consumes the zend_string ref */ + void (*append_frame_and_end)(void *ctx, zend_string *frame); + + /* idempotent */ + void (*end_stream)(void *ctx); + + /* commit a buffered response (Trailers-Only shape) */ + void (*commit)(void *ctx); +} grpc_finish_ops_t; + +/* Dispatch-time response defaults: content-type + mode stamp. */ +void grpc_call_init_response(zend_object *response_obj, int grpc_mode); + +/* Outcome → grpc-status; exception maps to INTERNAL unless already set. */ +void grpc_call_ensure_status(zend_object *response_obj, bool had_exception); + +/* 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); + +#endif /* HTTP_GRPC_CALL_H */ diff --git a/src/http1/http_parser.c b/src/http1/http_parser.c index a6bbc528..5dfd5486 100644 --- a/src/http1/http_parser.c +++ b/src/http1/http_parser.c @@ -1082,6 +1082,16 @@ 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); + + /* 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_session.c b/src/http2/http2_session.c index 5d5af5f8..677f1268 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 @@ -455,7 +456,9 @@ 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) 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; } @@ -539,11 +542,21 @@ 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: 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; + + 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; } @@ -609,8 +622,11 @@ 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) { + && session->conn->view->body_streaming_enabled + && !grpc_request_is_grpc_web_text(stream->request)) { http_request_t *r = stream->request; if (r->content_length == 0 @@ -1467,12 +1483,68 @@ bool http2_session_want_write(const http2_session_t *session) * Response submission * ------------------------------------------------------------------------- */ -/* 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, +/* 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) +{ + 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). 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) { *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..815874fe 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -33,6 +33,9 @@ #include #endif #include "http1/http_parser.h" /* http_request_t */ +#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" #include "http_send_file.h" #include "http_response_internal.h" @@ -98,6 +101,16 @@ 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); +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); + +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 * and opened the flow-control window for us), or @@ -220,6 +233,13 @@ static void http2_strategy_dispatch(struct http_request_t *request, return; } #endif + + 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; + /* 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 +247,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 +292,10 @@ static void http2_strategy_dispatch(struct http_request_t *request, Z_OBJ(stream->response_zv), self->conn->config->json_encode_flags); } + if (is_grpc) { + grpc_call_init_response(Z_OBJ(stream->response_zv), grpc_mode); + } + /* 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,19 @@ static void http2_handler_coroutine_entry(void) return; } - if (conn->handler == NULL) { return; } + 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 +468,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 +483,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 +570,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 +600,12 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(stream->response_zv)); } + /* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */ + if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) { + grpc_call_ensure_status(Z_OBJ(stream->response_zv), + coroutine->exception != NULL); + } + /* 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 @@ -578,7 +621,11 @@ 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->is_grpc && !Z_ISUNDEF(stream->response_zv)) { + /* Delivery shape is gRPC policy — grpc_call_finish decides. */ + grpc_call_finish(Z_OBJ(stream->response_zv), + &h2_grpc_finish_ops, stream); + } else if (is_streaming) { if (!stream->streaming_ended) { h2_stream_mark_ended(stream); } @@ -1014,36 +1061,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. */ @@ -1650,6 +1669,32 @@ static int h2_stream_append_chunk(void *ctx, zend_string *chunk) return HTTP_STREAM_APPEND_OK; } +/* 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; + + /* conn may be torn down already; append_chunk derefs it 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); +} + +/* Buffered commit — 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); + } +} + static void h2_stream_mark_ended(void *ctx) { http2_stream_t *stream = (http2_stream_t *)ctx; 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. */ diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 83c97c1e..70d24b43 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -39,6 +39,9 @@ #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 "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 */ @@ -272,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; } @@ -365,11 +372,33 @@ static int h3_recv_header_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } +/* "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) { + 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; @@ -377,13 +406,31 @@ 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. */ + /* 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; } + /* 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_request_is_grpc_web_text(s->request)) { + 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 @@ -393,15 +440,63 @@ static int h3_end_headers_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } +/* 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) +{ + 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)) { + /* return the credit so a rejected stream can't wedge the connection cap */ + h3_extend_body_window(c, stream_id, datalen); + return 0; + } + + /* 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; + + 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; } @@ -414,6 +509,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; @@ -426,6 +522,9 @@ 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 right here — return the window immediately */ + h3_extend_body_window(c, stream_id, datalen); return 0; } @@ -442,6 +541,25 @@ 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: 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) +{ + 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, @@ -475,6 +593,8 @@ 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; + + h3_read_data_eof_trailers(conn, s, pflags); return 0; } @@ -490,9 +610,10 @@ static nghttp3_ssize h3_read_data_cb(nghttp3_conn *conn, int64_t stream_id, return 1; } - /* Buffered REST path — unchanged single-slice semantics. */ + /* 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); return 0; } @@ -502,13 +623,19 @@ 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; + + /* trailers pending: no EOF on the final slice — the submit must follow it */ + if (s->trailer_nv == NULL) { + *pflags |= NGHTTP3_DATA_FLAG_EOF; + } + return 1; } @@ -572,6 +699,90 @@ 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. */ +/* 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; + 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) { + 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(); + + h3_trailer_pack_t pack; + + if (count == 0 || !h3_trailer_pack_init(&pack, count, total)) { + return; + } + + ZEND_HASH_FOREACH_STR_KEY_VAL(trailers, name, val) { + if (name == NULL || Z_TYPE_P(val) != IS_STRING) { continue; } + h3_trailer_pack_add(&pack, ZSTR_VAL(name), ZSTR_LEN(name), + Z_STRVAL_P(val), Z_STRLEN_P(val)); + } ZEND_HASH_FOREACH_END(); + + h3_trailer_pack_commit(s, &pack); +} + bool http3_stream_submit_response(http3_connection_t *c, http3_stream_t *s, bool streaming) @@ -702,6 +913,45 @@ bool http3_stream_submit_response(http3_connection_t *c, return false; } +/* 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); + + 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; + } + } + + h3_trailer_pack_t pack; + + if (!h3_trailer_pack_init(&pack, tcount, total)) { + return; + } + + 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)) { + h3_trailer_pack_add(&pack, nm, nl, val, vl); + } + } + + h3_trailer_pack_commit(s, &pack); +} + /* 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 @@ -761,12 +1011,23 @@ 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); + const bool streaming = + response_wire_kind(rw) == RESPONSE_WIRE_STREAM_HEADERS; + + if (streaming) { + h3_chunk_queue_init(s); + /* adopt the worker's credit ref; released at teardown */ + s->wire_credit = response_wire_credit(rw); + } else { + 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; + } - if (body != NULL && blen > 0) { - s->response_body = zend_string_init(body, blen, 0); - s->response_body_offset = 0; + http3_stream_adopt_wire_trailers(s, rw); } const nghttp3_data_reader dr = { .read_data = h3_read_data_cb }; @@ -781,6 +1042,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; } @@ -791,6 +1057,15 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, s->response_body = NULL; } + if (streaming) { + /* reader never registered — late applies must see a dead stream */ + s->streaming_ended = true; + + if (s->wire_credit != NULL) { + stream_credit_mark_dead((stream_credit_t *)s->wire_credit); + } + } + return false; } @@ -805,41 +1080,26 @@ 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) +/* Idempotent; a non-NULL queue flips the data reader into streaming mode. */ +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. */ +/* 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) { if (s->chunk_queue_head > 0) { const size_t shift = s->chunk_queue_head; @@ -862,6 +1122,38 @@ 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; + } + + const bool first_call = s->chunk_queue == NULL; + + if (first_call) { + h3_chunk_queue_init(s); + } + + const size_t chunk_len = ZSTR_LEN(chunk); + h3_chunk_queue_push(s, chunk); + + /* 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); + } /* Telemetry — match the H1/H2 vantage so operators see one * unified streaming-load picture across protocols. */ @@ -1040,6 +1332,25 @@ static void http3_finalize_request_body(http3_stream_t *s) http_request_t *const req = s->request; ZEND_ASSERT(req != NULL); + /* 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; + 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; + } + /* 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. */ @@ -1120,6 +1431,17 @@ 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); + } + + /* 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); + } + if (s->write_event != NULL) { zend_async_trigger_event_t *trig = s->write_event; @@ -1205,6 +1527,11 @@ static int h3_acked_stream_data_cb(nghttp3_conn *conn, int64_t stream_id, return 0; } + /* 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); + } + s->chunk_ack_credit += datalen; while (s->chunk_queue_head < s->chunk_read_idx) { zend_string *head = s->chunk_queue[s->chunk_queue_head]; @@ -1224,6 +1551,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; @@ -1562,7 +1894,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; } @@ -1650,6 +1986,30 @@ static int recv_stream_data_cb(ngtcp2_conn *conn, uint32_t flags, return 0; } +/* 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) +{ + http3_connection_t *const c = (http3_connection_t *)req->body_h3_conn; + + if (c == NULL || c->closed || c->ngtcp2_conn == NULL) { + return; + } + + /* 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) { + 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); +} + /* 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 @@ -1788,13 +2148,9 @@ 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 / STOP_SENDING: no more request data — shut the read side. */ +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; } @@ -1807,23 +2163,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 e148387a..bf2e9b1b 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -24,6 +24,9 @@ #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 */ #include "core/response_wire.h" /* response_wire_* (reverse path) */ @@ -192,11 +195,19 @@ 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 - * teardown reclaims it (s->dispatched=false => reactor teardown frees - * the fields). */ + /* inbox full: undo the dispatch bookkeeping, RESET with + * H3_REQUEST_REJECTED so the client can retry instead of hanging */ 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; } @@ -222,11 +233,84 @@ 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)) { - http3_connection_drain_out(c); - http3_connection_arm_timer(c); + if (c == NULL || c->closed || c->nghttp3_conn == NULL) { + /* 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 = + (zend_string *)response_wire_take_chunk(rw); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + + response_wire_free(rw); + return; + } + + /* 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: + if (http3_stream_submit_response_wire(c, s, rw)) { + http3_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); + } + break; + + case RESPONSE_WIRE_STREAM_CHUNK: { + zend_string *const chunk = + (zend_string *)response_wire_take_chunk(rw); + + 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, 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_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); + 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_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); + break; + + case RESPONSE_WIRE_STREAM_ABORT: + 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_listener_mark_flush(c->listener, c); + http3_listener_queue_epilogue_flush(c->listener); + break; } response_wire_free(rw); @@ -310,15 +394,11 @@ 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); + const grpc_mode_t grpc_mode = grpc_classify(s->request, handlers); - if (fcall == NULL) { - fcall = http_protocol_get_handler(handlers, HTTP_PROTOCOL_HTTP2); - } + s->is_grpc = grpc_mode != GRPC_MODE_NONE; + + 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. @@ -366,6 +446,10 @@ 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); + if (s->is_grpc) { + grpc_call_init_response(Z_OBJ(s->response_zv), grpc_mode); + } + #ifdef HAVE_HTTP_COMPRESSION /* Attach compression state. Server pointer comes from * the listener — same pattern that http3_handler_coroutine uses @@ -493,6 +577,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 +769,50 @@ static bool h3_arm_sendfile(http3_connection_t *c, http3_stream_t *s) return true; } +/* 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; + + 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 *)s->conn->nghttp3_conn, + s->stream_id); + } +} + +/* 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; + + (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; @@ -703,7 +839,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 +850,12 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine) http_response_set_committed(Z_OBJ(s->response_zv)); } + /* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */ + if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) { + grpc_call_ensure_status(Z_OBJ(s->response_zv), + coroutine->exception != NULL); + } + /* Streaming-vs-buffered decision (mirror of H2 dispose). * * Streaming path: HEADERS were submitted on the first send() via @@ -729,14 +871,11 @@ 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) { - 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); - } + if (s->is_grpc) { + /* Delivery shape is gRPC policy — grpc_call_finish decides. */ + 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))) { /* sendFile: hand off to the static pump. On success it owns the * stream + response until on_done runs the tail — the pump @@ -752,6 +891,8 @@ 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"); + /* capture trailers while response_zv is alive */ + http3_stream_capture_trailers(s); (void)http3_stream_submit_response(c, s, false); } } else { diff --git a/src/http3/http3_internal.h b/src/http3/http3_internal.h index 8107c856..a0bc2f40 100644 --- a/src/http3/http3_internal.h +++ b/src/http3/http3_internal.h @@ -159,12 +159,29 @@ bool http3_stream_submit_response(http3_connection_t *c, http3_stream_t *s, bool streaming); -/* Reverse path: submit a buffered response from a worker-rendered - * response_wire instead of the per-stream HttpResponse zval. Reactor thread. */ +/* 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); + +/* 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); +/* 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); + +/* 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: 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); + /* 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. @@ -172,6 +189,11 @@ bool http3_stream_submit_response_wire(http3_connection_t *c, http3_stream_t *s, int h3_stream_append_chunk(void *ctx, zend_string *chunk); void h3_stream_mark_ended(void *ctx); +/* 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); + /* 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 4fd874d4..f8f1a4fe 100644 --- a/src/http3/http3_listener.c +++ b/src/http3/http3_listener.c @@ -97,6 +97,13 @@ struct _http3_listener_s { int port; bool closed; + /* 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; + 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 @@ -998,6 +1005,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 +1026,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); @@ -1345,6 +1358,50 @@ 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); +/* 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, + 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) @@ -1358,6 +1415,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 eac1bec3..71705749 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 @@ -159,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..9a5b4000 100644 --- a/src/http3/http3_static_response.c +++ b/src/http3/http3_static_response.c @@ -72,6 +72,81 @@ #define H3_STATIC_READ_CHUNK_BYTES (16u * 1024u) +/* 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% */ +#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 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); + + 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 +190,30 @@ 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) { + /* 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); + + 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..c6ff152e 100644 --- a/src/http3/http3_steer.c +++ b/src/http3/http3_steer.c @@ -29,28 +29,28 @@ 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. 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. */ +/* 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]) { - 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 f02d9a1d..b70ca479 100644 --- a/src/http3/http3_stream.c +++ b/src/http3/http3_stream.c @@ -18,8 +18,14 @@ #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 "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); @@ -157,6 +163,43 @@ 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) { + stream_credit_abandon((stream_credit_t *)s->wire_credit); + s->wire_credit = NULL; + } + + /* 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; + + 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) { + free(s->trailer_nv); + s->trailer_nv = NULL; + } + + if (s->trailer_bytes != NULL) { + free(s->trailer_bytes); + s->trailer_bytes = NULL; + } + if (s->write_event != NULL) { zend_async_event_t *ev = &s->write_event->base; @@ -169,13 +212,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; diff --git a/src/http_body_stream.c b/src/http_body_stream.c index aa1bbc2d..283d6c7d 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: return window credit for the drained bytes + * (coalesced inside consume). */ + if (req->body_h3_conn != NULL) { + http3_request_body_consume(req, ZSTR_LEN(data), + req->body_queue_head == NULL); + } +#endif + return data; } diff --git a/src/http_request.c b/src/http_request.c index 3a950727..76892dbe 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,178 @@ ZEND_METHOD(TrueAsync_HttpRequest, getBody) http_request_retval_str(return_value, intern->request->body); } +/* {{{ proto HttpRequest::readMessage(): ?string + * 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); + ZEND_PARSE_PARAMETERS_NONE(); + + http_request_t *req = intern->request; + + if (req == NULL) { + RETURN_NULL(); + } + + zend_string *msg = NULL; + bool compressed = false; + int rc; + + /* 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) { + 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(); + } + } + + if (!web_text && !req->body_streaming && req->body_upgrade_to_stream != NULL) { + req->body_upgrade_to_stream(req); + } + + 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) { + /* 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 + ? 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, + "gRPC message length exceeds the maximum allowed size", 0); + RETURN_NULL(); + } + + if (rc == 0) { + /* No complete message left at the cursor. */ + RETURN_NULL(); + } + + 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) { + zend_string_release(msg); + RETURN_STR(inflated); + } +#endif + zend_string_release(msg); + zend_throw_exception(http_server_runtime_exception_ce, + "unsupported or invalid gRPC message compression", 0); + RETURN_NULL(); + } + + RETURN_STR(msg); +} + +/* {{{ proto HttpRequest::getGrpcTimeout(): ?float + * 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); + 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..1617e491 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,25 @@ static inline bool response_check_closed(const http_response_object *response) return false; } +/* 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) { + 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 +459,60 @@ static inline void ensure_trailers_table(http_response_object *response) } } +/* 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); + + 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); +} + +/* 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); + + if (response->trailers != NULL) { + zend_hash_clean(response->trailers); + } +} + +/* 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); + + 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 +526,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 +556,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 +587,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 +1013,102 @@ ZEND_METHOD(TrueAsync_HttpResponse, send) } /* }}} */ +/* {{{ 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; + bool compress = false; + + 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); + + 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; + } + + /* grpc-encoding must ride the initial HEADERS — declare before commit */ + 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)); + + 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); + } + } + } +#else + (void)compress; +#endif + + if (!response->streaming) { + response->streaming = true; + response->committed = true; + response->headers_sent = true; + } + + /* append_chunk consumes the ref */ + zend_string *framed = grpc_frame_message( + ZSTR_VAL(payload), ZSTR_LEN(payload), gzipped); + + if (gzipped) { + zend_string_release(payload); /* framed copied it; drop the gz buffer */ + } + + /* 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)); + + zend_string_release(framed); + framed = b64; + } + + 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() @@ -1159,6 +1329,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; @@ -1258,6 +1429,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..6a61803d 100644 --- a/src/http_response_internal.h +++ b/src/http_response_internal.h @@ -59,6 +59,10 @@ 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_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 * compression TU; allocated by http_compression_attach at dispatch * and freed by http_compression_state_free at object dtor. */ @@ -106,6 +110,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/src/http_server_class.c b/src/http_server_class.c index 0ae49958..7ee9abad 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 @@ -1545,14 +1546,15 @@ 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 rides h2 — a gRPC-only server must still accept h2c/h2 ALPN */ + server->view.protocol_mask |= HTTP_PROTO_MASK_GRPC | HTTP_PROTO_MASK_HTTP2; } /* }}} */ @@ -1825,7 +1827,12 @@ 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 anyway */ + 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; } @@ -1931,10 +1938,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); @@ -2554,20 +2558,50 @@ 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; #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 true; /* the reactor owns rw now */ + } + + /* STREAM_* wires are ordered fragments — dropping one corrupts the + * 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 + 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 true; + } + } + } } #endif + /* 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); + + if (orphan_chunk != NULL) { + zend_string_release(orphan_chunk); + } + response_wire_free(rw); + return false; } /* Suspend the current coroutine for `ms` on a one-shot timer; lets the worker @@ -2963,16 +2997,13 @@ 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). */ 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/src/log/http_log.c b/src/log/http_log.c index 02537d71..7c7e0d44 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,23 @@ void http_log_server_start(http_log_state_t *state, state->severity = severity; } +/* 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 = + 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 +570,17 @@ 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 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) { - 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..82a49f37 100644 --- a/src/websocket/ws_dispatch.c +++ b/src/websocket/ws_dispatch.c @@ -82,6 +82,16 @@ typedef struct { char *buf; } ws_pending_write_t; +/* 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) { + 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 +110,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 +120,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 +135,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 +145,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 +156,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 +208,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 +437,9 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) w->session = NULL; } + /* 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); zval_ptr_dtor(&ctx->websocket_zv); zval_ptr_dtor(&ctx->upgrade_zv); @@ -431,6 +448,12 @@ static void ws_handler_coroutine_dispose(zend_coroutine_t *coroutine) conn->keep_alive = false; conn->current_request = NULL; + /* 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); + } + ZEND_ASSERT(conn->handler_refcount > 0); conn->handler_refcount--; @@ -444,7 +467,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 { 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..5a193110 100644 --- a/stubs/HttpResponse.php +++ b/stubs/HttpResponse.php @@ -178,6 +178,26 @@ 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). + * + * 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, 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 55c5c65d..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: 8a423bc943bfc02703da487693f605779200d059 */ + * Stub hash: 14984263ea86d343e0adf70437811f37593bb96e */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpResponse___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -68,6 +68,11 @@ 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_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) ZEND_END_ARG_INFO() @@ -151,6 +156,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 +196,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/core/020-builtin-worker-pool.phpt b/tests/phpt/server/core/020-builtin-worker-pool.phpt index 321064ec..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; @@ -41,7 +39,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 +54,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/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/grpc/008-grpc-compression.phpt b/tests/phpt/server/grpc/008-grpc-compression.phpt new file mode 100644 index 00000000..1dae862b --- /dev/null +++ b/tests/phpt/server/grpc/008-grpc-compression.phpt @@ -0,0 +1,89 @@ +--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 + $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) { + usleep(30000); + + $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'); + 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 . ':empty-ok'), "\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 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 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/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 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..50d32576 --- /dev/null +++ b/tests/phpt/server/grpc/014-grpc-reactor-pool.phpt @@ -0,0 +1,89 @@ +--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"; + + $server->stop(); +}); + +$server->start(); +?> +--EXPECTF-- +%Asaw_status_200=1 +saw_ctype=1 +saw_grpc_status=1 +reply=echo:ping +%A 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..5fe590ce --- /dev/null +++ b/tests/phpt/server/grpc/015-grpc-web-text.phpt @@ -0,0 +1,112 @@ +--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(); + $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); + + /* 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($frame1) . base64_encode($frame2)); + + $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:hi+ping,bye +trailer_status=1 +no_http_trailer=1 +Done 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 new file mode 100644 index 00000000..992e350f --- /dev/null +++ b/tests/phpt/server/grpc/_h3grpc_client.py @@ -0,0 +1,81 @@ +#!/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 + +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 +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): + 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, + 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])) 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 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 new file mode 100644 index 00000000..951d2b86 --- /dev/null +++ b/tests/phpt/server/h3/046-h3-reactor-pool-trailers.phpt @@ -0,0 +1,87 @@ +--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"; + + $server->stop(); +}); + +$server->start(); +?> +--EXPECTF-- +%Asaw_status_200=1 +body=pool-body +saw_wire_trailer=1 +saw_grpc_status=1 +%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..f98e9cb3 --- /dev/null +++ b/tests/phpt/server/h3/047-h3-reactor-pool-streaming.phpt @@ -0,0 +1,83 @@ +--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"; + + $server->stop(); +}); + +$server->start(); +?> +--EXPECTF-- +%Astatus=200 +body=chunk1;chunk2;chunk3;chunk4;chunk5; +%A 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..e750a142 --- /dev/null +++ b/tests/phpt/server/h3/048-h3-reactor-pool-backpressure.phpt @@ -0,0 +1,95 @@ +--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"; + + $server->stop(); +}); + +$server->start(); +?> +--EXPECTF-- +%Astatus=200 +len_ok=1 +hash_ok=1 +%A 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 diff --git a/tests/phpt/server/static/004-static-workers.phpt b/tests/phpt/server/static/004-static-workers.phpt index 27ba548b..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,10 +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"; - /* Issue #11: clean cross-thread shutdown is a follow-up; SIGKILL - * exits without running PHP shutdown so worker threads cannot - * deadlock on exit. */ - posix_kill(getmypid(), SIGKILL); + $server->stop(); }); $server->start();