gRPC over HTTP/2 + HTTP/3 (#4): full feature + transport-layering refactor#104
Open
EdmondDantes wants to merge 34 commits into
Open
gRPC over HTTP/2 + HTTP/3 (#4): full feature + transport-layering refactor#104EdmondDantes wants to merge 34 commits into
EdmondDantes wants to merge 34 commits into
Conversation
Core of #4: gRPC server support on the existing nghttp2 HTTP/2 stack (no grpc c-core). Protobuf stays in PHP userland; C only moves opaque length-prefixed octets. See docs/PLAN_GRPC.md. Phase 0 — trailers: - Streaming responses emit trailers at true EOF inside the nghttp2 data provider (h2_dp_mark_eof), after all DATA drains — carries grpc-status/grpc-message on server-streaming/bidi replies. - Allow setTrailer/setTrailers/resetTrailers on a committed streaming response (only end()/sendFile() seal them), as the API contract intended and gRPC server-streaming requires. Phase 1 — unary: - Route application/grpc to addGrpcHandler; accept a gRPC-only server (no addHttpHandler) by enabling the h2 transport and relaxing the start()/ accept gates. conn->handler stays NULL; per-stream dispatch routes by content-type. - New src/grpc/ framing codec: 5-byte length-prefix framer/deframer with a max-message guard, status codes, grpc-timeout parser. - HttpRequest::readMessage() / HttpResponse::writeMessage(). - Auto content-type: application/grpc; default grpc-status: 0; Trailers-Only for bodiless errors; uncaught exception -> grpc-status 13. Phase 2 — streaming (no new C code): server-streaming, client-streaming and half-duplex bidi all fall out of the Phase 0/1 machinery. Phase 3 — grpc-timeout parsed + exposed via HttpRequest::getGrpcTimeout(). Tests: tests/phpt/server/grpc/001-007 + h2/027 (streaming trailers). Full h2 + core suites green. Deferred (documented in docs/PLAN_GRPC.md): full-duplex bidi (incremental read) + body-cap fix, server-side deadline auto-cancel, gzip, grpc-web, and gRPC-over-HTTP/3. The deadline auto-cancel and exception->status mapping are gated on the pre-existing h2 handler-exception teardown leak (#101).
gRPC message-level compression (distinct from HTTP-body Content-Encoding: each framed message carries its own compressed flag; the algorithm is named by grpc-encoding). Reuses the existing zlib(-ng) backend rather than adding a second wrapper: - src/compression: one-shot whole-buffer helpers sharing the module's ZS_* abstraction — http_compression_gzip_deflate_buffer (in http_compression_gzip.c) and http_compression_gzip_inflate_buffer, factored out of the request-body decode_gzip (which now calls it, so the inflate loop + zip-bomb guard live in one place). Declared in include/compression/http_compression_message.h. - grpc: grpc_message_inflate / grpc_message_deflate_gzip wrap them. readMessage() transparently inflates a message whose compressed flag is set (per grpc-encoding, gzip only); writeMessage($msg, compress: true) gzips the reply and sets grpc-encoding: gzip on the initial HEADERS. Gated on HAVE_HTTP_COMPRESSION — identity-only when no zlib backend is built. Test: tests/phpt/server/grpc/008 (10 KB gzip round-trip both directions). Compression + core + h1 + h2 + gRPC suites green.
Browsers can't read HTTP/2 trailers, so grpc-web carries grpc-status / grpc-message inside the response body as a 0x80-flagged frame. Same addGrpcHandler / readMessage / writeMessage API — only the finalize differs. - grpc: grpc_request_is_grpc_web (content-type application/grpc-web…) and grpc_web_trailer_frame (0x80 + uint32 len + `name: value\r\n` lines). - dispatch sets stream->grpc_web and the application/grpc-web+proto response content-type; dispose runs h2_grpc_web_finalize — build the trailer frame, clear the HTTP trailers, append the frame as the final DATA, END_STREAM (no HTTP trailer). Works for streamed and zero-message replies. - http_response_clear_trailers helper. Tests: grpc/009 (binary round-trip + in-body trailers), grpc/010 (zero-message error). Full gRPC (9) + h2 suites green. grpc-web-text (base64) deferred to Phase 5c; binary is the common case.
Maps gRPC-over-HTTP/3 against the live tree + nghttp3 1.15.0: high reuse (the message data plane already works on H3's shared stream_ops), the trailer API (nghttp3_conn_submit_trailers + NO_END_STREAM) is available, and the H3-only work is routing + trailer emission + finalize + a C test-client extension. Largest single phase; scoped as its own effort.
…#4) gRPC now works over HTTP/3 (non-reactor path). The message data plane was already transport-agnostic (writeMessage/readMessage on the shared H3 stream_ops); this adds the H3-specific routing + trailer emission. - Routing: http3_stream_dispatch + coroutine entry classify via grpc_request_is_grpc/_web, add HTTP_PROTOCOL_GRPC to the handler lookup, and default the response content-type. New http3_stream_t fields is_grpc / grpc_web / has_trailers / trailers_submitted + a captured-trailer nv. - Native trailers: http3_stream_capture_trailers snapshots grpc-status/grpc-message in dispose (while response_zv is alive), and the data reader submits them via nghttp3_conn_submit_trailers at true EOF with NGHTTP3_DATA_FLAG_NO_END_STREAM. The capture-then-submit split is required because the H3 data reader runs async, after dispose frees the zvals (unlike H2's synchronous data provider). The nv is malloc'd, freed in http3_stream_release. - grpc-web: h3_grpc_web_finalize reuses grpc_web_trailer_frame + clear_trailers (in-body 0x80 trailer frame), mirroring the H2 path. - dispose: default grpc-status (0, or 13 on exception), Trailers-Only for zero-message replies, exception->500 gated off for gRPC. Tests: grpc/011 (native H3 unary + trailers), grpc/012 (grpc-web H3), driven by a real aioquic H3 client (_h3grpc_client.py) — the bundled C h3client can't read HTTP/3 trailers. H3 suite (47) + gRPC suite (12) green. Deferred: the reactor/worker H3 path (worker_dispatch.c, REACTOR_POOL=1) needs the same gRPC routing.
…p fix (#4) Phase 2b. readMessage() gains an incremental path over the streaming request body (issue #26): when the request qualifies for http_body_stream (setBodyStreamingEnabled + CL 0/>=1MiB, or the 64KiB-1MiB upgrade band), it pops chunks into a per-request reassembly buffer (grpc_reassembly on http_request_t) and deframes messages as they arrive — a handler can readMessage() while the client is still sending, without awaitBody(). The buffered path is unchanged, so the existing gRPC tests still pass. Body-cap fix (§4.3): the H2 streaming ingest (http2_session.c) now caps the LIVE queued bytes instead of the monotonic cumulative total, so long client-streaming / bidi streams are no longer RST once their total passes max_body_size while the handler keeps draining. Test: grpc/013 (three ~40-50KiB messages spanning DATA frames, drained incrementally). h2 + h1 + gRPC + 050 (streaming body) suites green. Note: the H3 dispatch has no body_streaming policy yet, so incremental read is H2-only; H3 gRPC uses the buffered path.
gRPC orchestration was copy-pasted inline in both transports. Now:
- src/grpc/grpc_call.{c,h}: init_response (content-type default),
ensure_status (exception -> INTERNAL, else OK), finish (grpc-web
in-body 0x80 frame / streaming EOF / Trailers-Only) — decided once,
transport-independent.
- Transports provide a 3-op grpc_finish_ops_t (append_frame_and_end /
end_stream / commit) and stay gRPC-agnostic on delivery; both
*_grpc_web_finalize copies and both Trailers-Only blocks deleted.
- H3 trailer capture/submit de-gRPC-ified: grpc_trailer_* fields ->
trailer_*; capture now runs for any streaming response with trailers
(parity with H2's generic h2_dp_mark_eof path).
- Inline per-transport classification (is_grpc predicate at dispatch)
kept as-is — deliberate hot-path choice.
- Hardening: NULL-conn guard in the H2 grpc-web append op (latent
pre-existing path), grpc_call.c added to config.w32.
236/236 server phpt green (12/12 grpc, incl. aioquic H3 client).
Contributor
CoverageTotal lines: 74.29% → 80.94% (+6.66 pp)
|
http2_session.c calls it since 8533afa (generic response-trailer submit); the fuzz link has no PHP-object TUs. NULL map == no trailers, same pattern as the other weak stubs in this file.
Coverage gate flagged drops in touched files, but every one traces to a test SKIPping in CI rather than missing tests: - PHP was configured --disable-all without --with-zlib, so gzencode() is absent and grpc/008 (per-message gzip) skips — leaving http_compression_gzip_deflate_buffer and the writeMessage compress branch at zero hits. - aioquic was never installed, so grpc/011 + grpc/012 (H3 native trailers / grpc-web over a real QUIC client) skip — leaving the H3 trailer capture/submit paths and h3_grpc_* finish ops uncovered.
…empty compressed message - CHANGELOG: Unreleased Added/Changed entries for gRPC over H2+H3 (#4) and the call-lifecycle layering refactor. - README: gRPC row -> Ready, progress 90% (grpc-web-text + reactor-pool gRPC remain), Quick Start bidi example. - grpc/008: the client now also sends a compressed EMPTY message (flag=1, len=0) — covers the in_len==0 branch of the shared http_compression_gzip_inflate_buffer, restoring the coverage-gate drop in http_compression_request.c.
Step 1 of the reactor-pool streaming reverse path: - response_wire: trailer pairs in the arena (add/count/at), mirroring headers; add_header/header_at refactored onto shared list helpers. - worker_render_response copies the response trailer map onto the wire (same string-pair discipline as http3_stream_capture_trailers). - http3_stream_submit_response_wire copies wire trailers into the stream's malloc'd trailer_nv capture; the data reader submits them. - h3_read_data_cb: trailer submit at true EOF generalized to the buffered branch (was streaming-only) — final DATA slice withholds EOF when a capture is pending so submit_trailers follows the last DATA. - Local parity: buffered dispose now captures the trailer map too, so setTrailer works without streaming on H3. Test: 046-h3-reactor-pool-trailers — buffered worker response under TRUE_ASYNC_SERVER_REACTOR_POOL=1 delivers x-wire-trailer + grpc-status to a real aioquic client. h3+grpc suites 60/60.
…split (#80, #4) Step 2: the worker's HttpResponse gets real stream ops under the pool instead of throwing 'streaming not available'. - response_wire: kind (FULL | STREAM_HEADERS | STREAM_CHUNK | STREAM_END). One message type, one sink, FIFO on the reactor mailbox. - worker_dispatch: stream ops flatten each leg into a STREAM_* wire — first send() posts HEADERS (status+headers), each chunk a CHUNK, end()/ dispose an idempotent END carrying the trailer map. Dispose skips the buffered FULL render once a stream started; ops detach before ctx dies. - gRPC rides the generic path: worker classifies application/grpc once (same predicates as the transports), routes to addGrpcHandler, and drives grpc_call init/ensure_status/finish with worker-side finish ops (grpc-web frame -> CHUNK+END or buffered body; commit -> FULL wire). - H3 reactor: apply dispatches on kind — STREAM_HEADERS submits with the streaming data reader (chunk ring primed), CHUNK pushes + resumes, END adopts wire trailers + fires EOF. Ring init/push and wire-trailer adoption factored out of append_chunk/submit for reuse. - sink: STREAM_* wires retry a transiently full mailbox (ordered fragments must not drop silently); FULL keeps drop-on-full. Interim until the step-3 credit protocol paces the producer. Tests: 047 (streamed send() chunks in order under the pool), grpc/014 (unary gRPC + grpc-status trailer under the pool). 303/303.
…80) Step 3: a worker producer can no longer flood the shared reactor mailbox through a slow client. - stream_credit_t (include/core/stream_credit.h): malloc-domain, atomics only — acked (advanced by the reactor on peer ACK), dead (stream died), two refs. The only object both threads touch; no Zend state crosses. - worker: the credit rides the STREAM_HEADERS wire; append_chunk accounts posted bytes and parks the producer coroutine (2 ms timer polls) while in-flight >= 1 MiB, resuming as the reactor retires acked bytes. Bounded by the configured write timeout; dead unparks into the standard stream-dead path (send() throws 499). sendable() reports cap room. - reactor: adopts the credit at streaming submit; h3_acked_stream_data_cb advances acked; peer RST / failed submit / stream teardown mark dead; the teardown releases the reactor ref. Orphaned HEADERS wires (conn gone before apply, undeliverable sink) mark dead + release so a parked producer never hangs. Test: 048 — 4 MiB streamed in 64 KiB chunks under the pool (4x the cap; producer must park and resume), byte-exact hash. Suite 304/304.
The delivery mode (NATIVE / WEB / WEB_TEXT) is classified once at dispatch (grpc_request_mode — web-text checked before the web prefix that also matches it) and stamped ON the response by grpc_call_init_response; the framing layer reads it back, so the transports carry zero web-text knowledge (their grpc-web prefix match already covers web-text for in-body-trailer routing). - Outbound: writeMessage base64-encodes each framed message when the response mode is WEB_TEXT; grpc_call_finish encodes the 0x80 trailer frame the same way. Per-frame independent padding, as the grpc-web protocol allows — no codec state spans frames. - Inbound: readMessage lazily base64-decodes the buffered body into http_request_t.grpc_text_body and deframes from it (buffered-only — grpc-web clients cannot stream requests). Malformed base64 throws. - Seam cleanup: grpc_call_init_response takes the mode (sets the matching response content-type, incl. application/grpc-web-text+proto) and grpc_call_finish drops its grpc_web bool — mode comes from the response. Worker + H2 + H3 call sites updated; worker_dispatch also gains the missing zend_exceptions/http_response_internal includes. Test: grpc/015 — base64 request in, two writeMessage frames + b64 0x80 trailer frame out (two frames prove per-frame encoding). Suite 305/305.
…oad window fix Two halves, one inbound path: Fix: uploads larger than the initial stream window (256 KiB default) stalled forever. nghttp3_conn_read_stream's consumed count EXCLUDES DATA payload by contract (deferred-consume), and h3_recv_data_cb never extended the QUIC windows for the body bytes it buffered. Buffered mode now returns credit as it consumes (h3_extend_body_window), including the rejected/oversize early-exits so a dead stream can't wedge the connection cap. Feature: with setBodyStreamingEnabled(true) the H3 dispatch applies the same three-case Content-Length policy as H2 (unknown/>=1MiB stream immediately; 64KiB..1MiB buffered with an upgrade hook; below buffered) — so readBody() and incremental readMessage() (true full-duplex gRPC) now work over HTTP/3, not just H2: - h3_recv_data_cb streaming branch pushes into the per-request chunk queue, capping LIVE (un-drained) bytes at max_body_size; credit is NOT granted at receive time. - http_body_stream_pop grants the deferred QUIC credit through the new body_h3_conn/stream_id pair (http3_request_body_consume: extend windows + drain so MAX_STREAM_DATA leaves) — H3 mirror of the nghttp2_session_consume leg. - end_stream closes the queue; RST before fin errors it so a parked consumer wakes. Reactor-pool requests stay buffered by design (the queue is same-thread by contract). Tests: h3/049 — 1 MiB buffered upload (4 window refills; stalls without the fix); grpc/016 — H3 mirror of grpc/013, 3 x 200 KiB messages read incrementally (2.3x the window — pins policy + pop credit together). _h3grpc_client.py accepts @file for bodies beyond argv limits. Suite 307/307.
[coverage-drop-ok] The -2.11pp drop in http3_dispatch.c is the reactor-pool apply switch (STREAM_CHUNK/STREAM_END legs). Pool phpts exit via SIGKILL (issue #11: no clean cross-thread pool shutdown yet), so gcov never flushes their counters — the pool code is structurally invisible to the coverage job (worker_dispatch.c sits at 0.00% for the same reason). The paths ARE exercised locally by h3/046-048 + grpc/014.
…finalize gaps, stream-abort semantics Review findings b38301e..f81e58d (docs/PLAN_REVIEW_FIXES.md items 1-6): 1. grpc-web-text: decode block-wise. A body is a CONCATENATION of independently padded base64 frames; PHP's non-strict decoder does not realign at '=', so a single pass garbled everything after the first frame with len % 3 != 0. grpc/015 now sends two frames (first one 7 bytes, padding mid-stream) and reads both. 2. UAF: sever req->body_h3_conn at stream teardown. The request (and its queued body chunks) outlives the stream AND the connection via the handler's ref; a pop after http3_connection_free called http3_request_body_consume on freed memory. Teardown now NULLs the pointer and errors the queue when fin never arrived (wakes a parked consumer). Covers the connection-free force-release path too (it goes through http3_stream_release). 3. web-text x body_streaming: the issue-#26 policy keyed on Content-Length alone and could stream a grpc-web-text body — the buffered-only readMessage branch then saw req->body == NULL forever (silent request loss, window credit never returned). Both dispatch policies (H2 + H3) now skip web-text requests. fuzz_stubs gains a weak grpc_request_is_grpc_web_text (http2_session.c links standalone). 4. Streaming finalize fires body_event (H3 + H2): a handler suspended in awaitBody() before fin waits on body_event, not the queue's data event — the streaming branch closed the queue and returned without firing it, hanging the coroutine. Mirrors the H1 parser. 5+6. Mid-stream death is now an ABORT, not a clean FIN: - response_wire gains RESPONSE_WIRE_STREAM_ABORT; the reactor resets the QUIC stream (ngtcp2 shutdown_stream_write, INTERNAL_ERROR). - worker: ctx->stream_failed set on credit timeout/cancel and on any dropped STREAM_* wire; the terminal wire becomes ABORT; dispose has an idempotent safety net so a started stream ALWAYS terminates. - sink: bool verdict (worker_response_sink_fn signature), and the 1M busy-spin is now <=100 x 1ms sleep retries — a wedged reactor fails the stream in ~100ms instead of burning a core; drops are counted (worker_wire_dropped_total). Suite 307/307; fuzz_h2_session links and runs (the 1-leak report is pre-existing, reproduced on the unpatched tree).
…ed trailer pack, credit abandon
- http_protocol_pick_handler(handlers, is_grpc): the single GRPC->HTTP1->
HTTP2 precedence; worker (both sites) and H3 dispatch converted. H2
keeps its cached conn->handler shape.
- grpc_classify(req, handlers): mode gated on a registered addGrpcHandler;
worker + H2 + H3 classify through it and reuse the mode at
grpc_call_init_response (no re-classification).
- h3_trailer_pack_{init,add,commit}: one malloc packer behind
http3_stream_capture_trailers and http3_stream_adopt_wire_trailers.
- stream_credit_abandon(): the mark-dead-then-release pair, used at all
four drop sites.
- Plan items 10/11 dropped/deferred (a new module or a helper-used-once
costs more than the duplication).
Suite 307/307.
- STREAM_CHUNK payload rides the wire as one persistent zend_string (ownership handoff worker->ring) instead of zend_string -> arena -> zend_string: one copy per chunk, not three. Drop sites release an untaken chunk alongside the credit abandon. - Credit poll backs off 2->32ms while ACKs stall, snaps back on progress (fewer timer allocs per parked second). - Inbound body credit coalesced: flush MAX_STREAM_DATA at 64 KiB drained or when the queue empties, not a drain_out per ~1.2 KB pop. - Reactor apply marks the conn dirty and flushes once per mailbox batch via the existing drain epilogue (http3_listener_queue_epilogue_flush, extracted from the steer feed) instead of drain+arm per wire. Suite 307/307.
- response_wire 'complete' flag had no readers since the kind system — removed (set_body loses the param). - stream->grpc_web (H2 + H3) became write-only after the grpc_mode stamp — removed; delivery mode lives on the response. - http_body_stream.h: pop() performs transport I/O (flow-control credit) and readMessage is a second consumer — the same-thread contract now says so. Suite 307/307.
…or of bf09e51) The request outlives the stream via the wrapper ref; a handler resuming after session teardown could pop a queued body chunk and call nghttp2_session_consume on the freed session. Sever the pointer at stream release and error the queue when the body never completed. Suite 307/307.
The 'no clean cross-thread shutdown (issue #11)' comments were stale — the #93 reload rotation + #74 in-flight drain made pool stop() work. Clean exit also lets gcov flush, so the reactor-pool paths stop being coverage-invisible (the reason f81e58d needed [coverage-drop-ok]). 9 tests converted, 3x flake-run + full suite green (307/307).
020/021/039/044/050 converted from SIGKILL (the issue-#11 comments were stale — pool stop() works since #93/#74). static/004 stays on SIGKILL with an honest comment: stop() there trips the ext/async debug assert 'The event loop must be stopped' — a static-handler libuv handle survives worker teardown (real leak, reproducible 6/6, to be fixed separately). Suite 307/307.
…t's own bug The f06203a claim of a static-handler teardown leak was wrong: the failing variant had also captured $server into the test's register_shutdown_function closure (batch-regex slip), which held the object past scheduler finalize and tripped the loop-alive assert. A/B-confirmed: capture only in the client spawn -> clean stop() passes (3x). All worker-pool phpts are now SIGKILL-free except the hot-reload family. Suite 307/307.
This was referenced Jul 7, 2026
… 4-grpc-support-http2-http3
…p message.h, tighten codec paths - classify content-type in one pass (grpc_request_mode): fetch the header once, compare the application/grpc prefix once, then only the variant suffix; grpc_request_is_grpc / _is_grpc_web removed - shared grpc_write_frame_header for the 5-byte frame prefix - grpc_web_trailer_frame: two-pass exact-size fill, no smart_str - grpc_web_text_decode: preallocate (len/4)*3 output - grpc_message_inflate/deflate exist only under HAVE_HTTP_COMPRESSION; call sites ifdef'd, stubs removed - http_compression_message.h deleted; one-shot gzip declarations moved into http_compression_request.h - rename ct/to locals, trim noise comments
…async/server into 4-grpc-support-http2-http3
- reactor_cmd_t travels the mailbox BY VALUE (moodycamel value queue + typed mailbox) — no malloc/free on the hot worker->reactor path; EXEC ack is a pointer to the caller's stack atomic, STOP is a command kind. - O(1) intrusive doubly-linked stream unlink (was O(n) list walk). - listener local sockaddr computed once at spawn, copied per packet (was inet_pton/htons on every drain). - thread-local EVP_CIPHER_CTX in CID steering (reset, not new/free). - global per-worker memory budget for H3 static delivery (ported from H2): account on push, debit on ACK, one-shot reconcile at teardown; pump throttles over budget. - hard-backpressure dispatch now RESET_STREAMs with H3_REQUEST_REJECTED instead of silently dropping the request. - shared h3_shutdown_stream_read helper for RESET/STOP_SENDING.
All three pass normally (Zend arena hides them) but abort under ASAN + USE_ZEND_ALLOC=0: - ws_handler_coroutine_dispose read w->committed after releasing the websocket zval that may hold w's last ref — capture it before the dtor. - http_log_server_stop awaited the writer's req, but writer_complete_cb frees that req from inside the await; drain by yielding to the reactor and polling until idle instead. - ws_dispatch_try_upgrade reject paths (and spawn-fail) freed the request while the h1 parser still borrowed it via parser->request — sever it with http_parser_clear_request, mirroring the h1 dispatch path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4.
What
Full gRPC support on both HTTP/2 and HTTP/3, finished with a layering refactor that keeps the transports gRPC-agnostic.
Feature (previous commits on this branch)
readMessage)grpc-status/grpc-messagetrailers, Trailers-Only,grpc-timeout, per-message gzip0x80framenghttp3_conn_submit_trailersat true EOF (verified with a real aioquic client)Layering refactor (final commit)
src/grpc/grpc_call.{c,h}— all call-lifecycle policy in one place: response defaults, outcome →grpc-status, delivery shape (grpc-web frame / streaming EOF / Trailers-Only)grpc_finish_ops_t(append_frame_and_end/end_stream/commit); duplicatedh2_/h3_grpc_web_finalize, Trailers-Only and ensure-status blocks deletedgrpc_trailer_*stream fields renamedtrailer_*; capture now serves any streaming response with trailers (parity with H2's generic EOF path)is_grpcpredicate) deliberately stays inline in each transport's dispatch — hot-path choiceconfig.w32build list fixTesting
tests/phpt/server/grpc/: 12/12 (h2c, TLS, grpc-web, H3 via aioquic)