Skip to content

gRPC over HTTP/2 + HTTP/3 (#4): full feature + transport-layering refactor#104

Open
EdmondDantes wants to merge 34 commits into
mainfrom
4-grpc-support-http2-http3
Open

gRPC over HTTP/2 + HTTP/3 (#4): full feature + transport-layering refactor#104
EdmondDantes wants to merge 34 commits into
mainfrom
4-grpc-support-http2-http3

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

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)

  • Unary + all 4 streaming shapes; true full-duplex bidi (incremental readMessage)
  • grpc-status/grpc-message trailers, Trailers-Only, grpc-timeout, per-message gzip
  • grpc-web (binary): trailers as in-body 0x80 frame
  • H3: native trailers via nghttp3_conn_submit_trailers at 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)
  • Transports implement a 3-op grpc_finish_ops_t (append_frame_and_end / end_stream / commit); duplicated h2_/h3_grpc_web_finalize, Trailers-Only and ensure-status blocks deleted
  • Generic trailers: H3 grpc_trailer_* stream fields renamed trailer_*; capture now serves any streaming response with trailers (parity with H2's generic EOF path)
  • Classification (is_grpc predicate) deliberately stays inline in each transport's dispatch — hot-path choice
  • Hardening from review: NULL-conn guard in H2 grpc-web append op; config.w32 build list fix

Testing

  • tests/phpt/server/grpc/: 12/12 (h2c, TLS, grpc-web, H3 via aioquic)
  • Full server suite: 236/236, 0 failures

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).
@EdmondDantes EdmondDantes linked an issue Jul 6, 2026 that may be closed by this pull request
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 74.29% → 80.94% (+6.66 pp)

File Baseline Current Δ Touched
src/compression/http_compression_gzip.c 87.50% 88.41% +0.91 pp
src/compression/http_compression_request.c 93.65% 94.52% +0.87 pp
src/core/http_connection.c 74.71% 74.90% +0.19 pp
src/core/http_connection_tls.c 73.67% 73.39% -0.28 pp
src/core/http_protocol_handlers.c 75.00% 78.08% +3.08 pp
src/core/reactor_pool.c 0.00% 75.18% +75.18 pp
src/core/reactor_pool_test_hooks.c 100.00% 100.00% +0.00 pp
src/core/response_wire.c 0.00% 90.00% +90.00 pp
src/core/thread_mailbox.c 0.00% 80.00% +80.00 pp
src/core/worker_dispatch.c 0.00% 68.35% +68.35 pp
src/core/worker_inbox.c 0.00% 93.10% +93.10 pp
src/core/worker_registry.c 0.00% 61.63% +61.63 pp
src/grpc/grpc.c 0.00% 92.91% +92.91 pp
src/grpc/grpc_call.c 0.00% 100.00% +100.00 pp
src/http1/http_parser.c 83.52% 83.65% +0.12 pp
src/http2/http2_session.c 87.80% 88.72% +0.92 pp
src/http2/http2_strategy.c 76.95% 79.30% +2.35 pp
src/http2/http2_stream.c 95.56% 93.88% -1.68 pp
src/http3/http3_callbacks.c 61.64% 79.47% +17.83 pp
src/http3/http3_dispatch.c 62.28% 73.52% +11.24 pp
src/http3/http3_io.c 81.45% 86.55% +5.09 pp
src/http3/http3_listener.c 66.35% 71.93% +5.58 pp
src/http3/http3_steer.c 5.00% 87.50% +82.50 pp
src/http3/http3_stream.c 76.12% 97.50% +21.38 pp
src/http_body_stream.c 82.76% 83.61% +0.85 pp
src/http_request.c 81.40% 82.93% +1.53 pp
src/http_response.c 87.76% 87.44% -0.32 pp
src/http_response_internal.h 100.00% 100.00% +0.00 pp
src/http_server_class.c 52.02% 68.03% +16.02 pp
src/http_server_config.c 89.83% 92.80% +2.98 pp
src/static/static_handler_class.c 96.85% 97.44% +0.59 pp
src/websocket/php_websocket.c 75.10% 77.20% +2.11 pp
src/websocket/ws_handshake.c 60.19% 87.96% +27.78 pp
src/websocket/ws_session.c 65.14% 87.15% +22.00 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.
EdmondDantes and others added 8 commits July 7, 2026 09:03
…ttribution

Verified all 13 findings against current source. Fixed inaccurate line
ranges/function names in items #2, #7, #10, and the http3_listener_remove_connection
note; removed an accidental duplicate bullet.
…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
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gRPC support (HTTP/2 + HTTP/3)

1 participant