Skip to content

HTTP/3: static delivery (addStaticHandler + $res->sendFile()) is not wired #60

Description

@EdmondDantes

Problem

Static file delivery over HTTP/3 does not work. addStaticHandler(...) (mount routing) and \$res->sendFile(...) over an H3 connection return 200 with an empty body and no FIN — identical to a baseline with no static support.

Root cause

H1 and H2 both wire static delivery into their request paths:

  • H1: src/core/http_connection.c → http_static_try_serve (mount routing) + http_send_file_dispatch (sendFile in dispose).
  • H2: src/http2/http2_strategy.c → same two calls.

src/http3/ calls neither http_static_try_serve nor http_send_file_dispatch. Consequently the H3 static pump coroutine (http3_static_response.c / h3_static_pump_entry) is dead code — it never runs (verified: empty trace on a sendFile request over H3).

Fix direction

  • In the H3 dispose path, call http_send_file_dispatch (mirror H2 http2_strategy.c), and optionally http_static_try_serve in dispatch for mount routing.
  • The deferred copy-elimination for static chunks (the emalloc(16K scratch) + zend_string_init double-copy in http3_static_response.c, ~:124/:173) should be done inside this wiring work — it only becomes testable once static actually flows (a sendFile phpt with the h3 client across the 16K chunk boundary).

Context

Split out of #59 (HTTP/3 staged performance roadmap), where it was the Phase-5 finding that got deferred.

Activity

  1. EdmondDantes commented on May 31, 2026

    @EdmondDantes
    ContributorAuthor

    Investigation — the machinery exists, only the dispose hand-off is missing

    The H3 static-file pump is fully implemented and registered, just never invoked:

    • h3_stream_send_static_response (http3_static_response.c:213) — coroutine pump that reads the file in 16 KiB slices and feeds the streaming path (h3_stream_append_chunk → lazy submit_response(streaming) → h3_stream_mark_ended), with acked-data/window backpressure.
    • It is wired into the vtable: http3_callbacks.c:840 .send_static_response = h3_stream_send_static_response.
    • It is reached via http_send_file_dispatch() → ops->send_static_response.

    The only gap: h3_handler_coroutine_dispose (http3_dispatch.c) never checks http_response_has_send_file() and never calls http_send_file_dispatch(). H2 does exactly this — http2_strategy.c:555-556 → h2_sendfile_arm (:629) → http_send_file_dispatch(... h2_sendfile_on_done ...).

    The delicate part — async refcount hand-off

    http3_stream_submit_response reads Z_OBJ(s->response_zv) live for :status/headers (http3_callbacks.c:506-511). The pump submits asynchronously (a separate enqueued coroutine, on the first append_chunk). So the dispose must not drop response_zv / release the stream when it hands off — response_zv has to survive until the pump's first submit, and the dispose tail (drop response_zv, drain-evaluate + GOAWAY, drain_out, http3_stream_release) must move into an on_done callback that fires when the pump finishes. This mirrors H2's h2_sendfile_on_done deferring the response-zval dtor.

    Implementation plan

    1. In h3_handler_coroutine_dispose, in the buffered branch: if (http_response_has_send_file(Z_OBJ(s->response_zv))) → take http_response_take_send_file(), http_send_file_dispatch(s->request, Z_OBJ(s->response_zv), sf_req, h3_sendfile_on_done, user); on success return early (skip the response_zv drop + stream_release).
    2. Add h3_sendfile_on_done(user, status) doing the deferred tail: drop response_zv, drain-evaluate/GOAWAY, drain_out + arm_timer, http3_stream_release(s). Dispatch-failure path (returns false → response carries a synthesized 500) falls through to the normal buffered submit.
    3. (Optional, second commit) mount routing: call http_static_try_serve in the H3 dispatch like H2 http2_strategy.c:264, for addStaticHandler.
    4. Phase-5 copy-elim (the emalloc(16K scratch) + zend_string_init per chunk in http3_static_response.c) is done inside this work, now that it is testable.

    Tests

    • New phpt: $res->sendFile() over h3client across the 16 KiB chunk boundary (e.g. ~40 KiB file → 3 slices), plus HEAD, 404/path-traversal, and a dispatch-failure (→ 500) case.
    • Leak/UAF check: sustained c=64 sendFile stress (the regime that surfaced the dirty-list UAF).

    Risk note: the response_zv / stream refcount across the async boundary is the same UAF class as #62 — implement and test carefully, not in a rush.

  2. added 2 commits that reference this issue on May 31, 2026
  3. added a commit that references this issue on May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions