Skip to content

13 built in static file handler - #14

Merged
EdmondDantes merged 75 commits into
mainfrom
13-built-in-static-file-handler
May 11, 2026
Merged

EdmondDantes merged 75 commits into
mainfrom
13-built-in-static-file-handler

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

No description provided.

)

Foundation for the built-in static file handler — see
docs/PLAN_STATIC_HANDLER.md for the full design.

New PHP surface:
  - TrueAsync\StaticHandler (final, strict-properties)
    Constructor: (urlPrefix, rootDirectory). Fluent setters covering
    index files, on-missing behaviour, precompressed sidecars,
    dotfile/symlink policies, hide globs, ETag, cache-control,
    extra headers, browse, MIME overrides. Validation at the setter
    (bad encoding name, traversal-shaped index file, header injection,
    etc.) instead of at start(). Locks on attach.
  - TrueAsync\StaticOnMissing / StaticDotfiles / StaticSymlinks
    (int-backed enums for the policy knobs).
  - HttpServer::addStaticHandler(StaticHandler $h): static
    Stores a strong ref + locks the handler. start() now accepts
    static-only deployments (no addHttpHandler required).

Dispatch wiring is the next slice: http_static_try_serve() is a
passthrough stub so subsequent commits can land the FSM, MIME table,
path resolution, and ETag without churning the call site again.

Internal layout follows src/compression/ — header in
include/static/static_handler.h, implementation in
src/static/static_handler_class.c. arginfo for StaticHandler is
manually maintained (mirrors stubs/LogSeverity.php_arginfo.h
pattern; gen_stub.php needs the tokenizer extension).
Three pure-C helpers that the dispatch FSM will compose:

  http_static_mime_lookup — sorted-array MIME table covering the
  HttpArena set plus the long tail (44 extensions). Per-mount
  overrides (set via StaticHandler::setMimeType) take precedence
  over the built-in table; the lookup lowercases the extension into
  a stack buffer first so the per-request path does no allocation.

  http_static_path_resolve — percent-decode + traversal guard +
  dotfile-policy enforcement, then concatenates against the
  canonicalised root. Rejects NUL, "%00", malformed encoding, "..",
  ".", "//"; emits HTTP_STATIC_PATH_FORBIDDEN / BAD_REQUEST / HIDE
  per the policy on the mount. http_static_path_join is the index-
  file directory join used by the FSM; http_static_path_is_hidden
  applies the configured hide-globs via fnmatch(FNM_PATHNAME).

  http_static_etag_format / conditional_match — weak ETag derived
  from (mtime_ns, size, ino) per the plan §4 (single 64-bit mix,
  16 hex chars, W/"..."). conditional_match honours RFC 9110 §13.1.2
  precedence (If-None-Match wins) and parses IMF-fixdate / RFC 850 /
  asctime forms via strptime so well-behaved clients always 304.

No call sites yet — these land hooked up in the next slice when the
serve FSM and the dispatch wiring switch on.
PR #1 of the StaticHandler plan is now functional end-to-end. Files
under a configured StaticHandler mount are served directly in C —
no PHP coroutine, no zend_call_function, no fcall_info_cache lookup
on the static path.

Pipeline:

  http_connection_dispatch_request (src/core/http_connection.c)
    └─ if any mount registered → http_static_try_serve(...)
         ├─ method check (GET/HEAD only; others passthrough)
         ├─ http_static_path_resolve → percent-decode + traversal guard
         │                              + dotfile policy
         ├─ http_static_path_is_hidden (hide-glob match via fnmatch)
         ├─ open(O_RDONLY [|O_NOFOLLOW]) + fstat (synchronous; PR #5
         │                              upgrades to libuv thread pool)
         ├─ ETag/conditional → 304 with empty body
         ├─ slurp into zend_string + http_response_static_set_*
         └─ ctx->skip_php_handler = true

  http_handler_coroutine_entry sees skip_php_handler set and returns
  immediately after the request-counter bump and CoDel sample —
  no PHP VM entry. Existing dispose path flushes the response on the
  wire, so all keep-alive / drain / Alt-Svc / counter convergence
  reuses the existing infrastructure (no http_request_finalize
  extraction needed; the flag-based fold matches the plan's
  acceptance criterion of zero new flush-path duplication).

The accept path now allows static-only deployments — when the
server has zero PHP handlers but at least one static mount,
http_connection_spawn proceeds with handler=NULL and the dispatcher
synthesises a 404 for any request the static path didn't claim.

PHPT matrix in tests/phpt/server/static/ covers:
  001 — 200, MIME (CSS / HTML index), nested file, 404, traversal
        (relative + percent-encoded), HEAD body suppression with
        Content-Length, weak ETag conditional GET → 304, non-GET
        method passthrough.
  002 — dotfile-deny default still 404s under on_missing: Next
        (security guarantee), genuinely-missing files fall through
        to PHP, URLs outside the prefix fall through to PHP.

Pre-existing 153 PHPT tests still pass (1 skip, 0 fail).

Two small public response API helpers added in php_http_server.h:
http_response_static_set_status / set_header / set_body_str —
unguarded direct-field setters for the single-writer C path. The
PHP-facing setters keep their close/streaming guards.

Closes the PR-#1 scope from docs/PLAN_STATIC_HANDLER.md (§5).
PRs #2-#6 (H2/H3, range, precompressed, sendfile, browse) land
incrementally on top of the same dispatch hook.
Code-review pass over PR #1 against docs/CODING_STANDARDS.md:

  Comments — drop decorative WHAT-restatements (function-name says
    it), keep WHY / invariant / RFC-citation comments. Net −72 lines
    of prose for the same information density.

  const — http_static_etag_format takes const struct stat *; fixed-
    size buffer params and read-only locals across the static TUs
    are now const where true. Internal helpers carry const-pointer
    params for inputs they only read.

  Hot-path branch hints — UNEXPECTED on every error/edge branch in
    the dispatch FSM (BAD_REQUEST, FORBIDDEN, HIDE, !opened,
    oversized, slurp failure, hide-glob match), EXPECTED on the
    read-loop success branch. The success path stays straight-
    line.

  MIME table — add precomputed content_type_len per entry so the
    hot path no longer does strlen() on every served file. Single
    MIME() macro keeps the table readable.

  ETag length — replace per-call strlen(etag_buf) with the new
    HTTP_STATIC_ETAG_LEN constant; same for HTTP_STATIC_DATE_LEN.
    The new ZEND_ASSERT in http_static_etag_format caught an
    off-by-one in the original buffer size (was 22, correct is 21
    — the format produces 20 chars + NUL).

  Debug-build invariant — assert_builtin_table_sorted() runs once
    per process under !NDEBUG, replacing a stale "static_assert
    verifies ordering" comment that was never actually wired up.

  Misleading comment — addStaticHandler claimed protocol_mask was
    needed for the start() preflight; in fact static_handler_count
    covers that. Updated to reflect actual intent (symmetry with
    addHttpHandler convention).

All 153 pre-existing PHPT tests still pass; the two static tests
in tests/phpt/server/static/ continue to pass on this refactor.
Security audit pass over PR #1 — five findings, all addressed.

  HIGH — symlink traversal via intermediate components.
    O_NOFOLLOW only protects the final path component; an attacker-
    or operator-created symlink at any earlier level inside the
    mount root could redirect open(2) outside. Added a realpath()-
    based prefix check in resolved_under_root() that runs after
    every successful try_open_candidate; mismatch → 404. The
    realpath/open TOCTOU is acceptable — exploiting it requires
    filesystem write access on the host.

  HIGH — backslash bypass on Windows. The segment validator splits
    on '/' only, so "..\..\etc\passwd" arrived as one segment that
    didn't match ".." and would have escaped the mount on a host
    where the kernel treats '\' as a separator. Backslash is now
    rejected anywhere in the percent-decoded path (literal or %5C);
    URLs never legitimately contain '\' under RFC 3986 anyway.

  MEDIUM — 403 on EACCES disclosed the existence of restricted
    files. Collapsed every "open failed" branch (ENOENT, ELOOP,
    ENOTDIR, EACCES, EPERM) onto the same 404 / on_missing
    fallback used elsewhere — symmetric with dotfile-deny.

  MEDIUM — setCacheControl accepted CRLF/NUL, allowing response-
    splitting if an operator concatenated user input. Added the
    same control-character rejection used by setHeader.

  MEDIUM — SYMLINKS_OWNER documented as "follow if owner-of-link
    matches" but the post-open uid comparison wasn't implemented;
    the mode behaved like FOLLOW. Aliased to REJECT (O_NOFOLLOW)
    until the real owner-match check lands — the policy is no
    weaker than its advertised security promise.

  LOW — http_static_format_http_date used %04d on tm_year + 1900
    without bound-checking; a far-future mtime (filesystem
    tampering) would overflow the fixed buffer logically and
    produce a malformed Last-Modified. Year is now clamped to
    [0, 9999] with a debug-build ZEND_ASSERT on the snprintf
    return.

New PHPT 003-static-security.phpt covers each finding: symlink
escape via /static/sneak/secret.txt → 404, literal and %5C-encoded
backslash → 400, %00 NUL injection → 400, chmod-000 file → not 403,
setCacheControl("\r\n…") → InvalidArgumentException.

All 154 PHPT tests still pass (1 pre-existing skip, 0 fail).
… v0.11)

The new capability flag tells consumers (notably the static handler's
upcoming sendfile path) whether bytes written to this io_t can land
on the wire without a user-space encryption pass — i.e. whether the
zero-copy syscall is safe.

Wiring:

  - Plain TCP conns get the bit at io_create time (no encryption layer
    in the path).
  - TLS attach clears the bit immediately: OpenSSL stands between us
    and the socket, so plaintext sendfile would leak past the
    encryption layer.
  - Post-handshake hook re-sets the bit when BIO_get_ktls_send returns
    true — kTLS pulled encryption into the kernel, so sendfile output
    will be encrypted by the kernel before it leaves the NIC.

The new sendfile API (libuv_reactor.c v0.11) consults this bit on
every call and silently falls back to a read+write loop on a clear
flag. With this commit, the fallback is exercised on user-space TLS
and the zero-copy path is exercised on plain TCP and kTLS sessions.

All 154 PHPT tests still pass (1 pre-existing skip).
…spose

Pull the cleanup tail of http_handler_coroutine_dispose (zval +
ctx release, request_ready/headers_complete reset, keep-alive deadline
re-arm, handler_refcount decrement, TLS resume / pipeline drain /
destroy decision) into a shared helper exposed via
http_connection_internal.h.

Same call site inside dispose, identical behaviour for the existing
coroutine path (verified by the full PHPT suite, 154/155 still pass).
The new entry point is the prerequisite for the static-handler
hard-zero callback chain (PR #5 in docs/PLAN_STATIC_HANDLER.md): the
sendfile completion handler will own the response lifecycle without
ever spawning a coroutine, and needs the same finalize tail to wire
keep-alive / drain / TLS resume the same way the coroutine path does.

Also adds HTTP_STATIC_HARD_ZERO to the dispatch result enum so the
upcoming state machine can signal "no coroutine, no dispose, I own
the lifecycle" to http_connection_dispatch_request. Not yet
returned by any caller in this commit.
The capability bit was supposed to gate the sendfile API's auto-
fallback decision. Both the bit and the fallback turned out to be
security-broken (the fallback wrote via uv_write, bypassing
user-space TLS the same way sendfile does), and the parent
zend_async API commit removes the flag entirely.

Plain TCP and TLS-attach paths now both create conn->io with just
READABLE | WRITABLE. The kTLS post-handshake hook drops back to
just bumping the existing telemetry counter — the static handler
will pick its serve path (sendfile vs sync read + http_connection_send)
by checking conn->tls + tls_session_ktls_tx_active() directly.
The static handler now serves plain-TCP GET / HEAD requests for
concrete files entirely on a callback chain — no coroutine ever
spawns, no PHP VM ever loads:

  http_static_try_serve  ← read-callback context
    └─ resolve URL → fs_path
       └─ ss_kick_off
          ├─ ZEND_ASYNC_FS_OPEN(fs_path)        ← thread-pool open
          ├─ add_callback(ss_dispatch) on file_io->event
          ├─ handler_refcount++ + on_request_dispatch
          └─ return HTTP_STATIC_HARD_ZERO

  ss_dispatch (single persistent callback, switch on phase):
    SS_PHASE_OPEN     → submit ZEND_ASYNC_IO_STAT
    SS_PHASE_STAT     → ETag/conditional/MIME, send headers
                        fire-and-forget, submit
                        ZEND_ASYNC_IO_SENDFILE
    SS_PHASE_SENDFILE → finalize

  ss_finalize → http_request_finalize (the shared cleanup tail
                extracted earlier so coroutine and callback paths
                converge on the same keep-alive / drain / TLS-resume
                logic).

Two design beats picked up the hard way:

1. The state machine uses a single persistent callback with a
   phase-discriminated dispatch and req-identity filter. An earlier
   "swap callback per phase" version was tripped by NOTIFY's vector
   iteration — adding a new callback during a NOTIFY call could
   re-fire it inside the same iteration with a stale (NULL) result,
   masquerading as the next phase's completion.

2. http_connection_send is suspending — strictly off-limits in a
   no-coroutine callback chain. Headers go through
   http_connection_send_str_owned (fire-and-forget via
   ZEND_ASYNC_IO_WRITE_EX with reactor-managed buffer release).

Hard-zero is gated on:
  - concrete file (directory + index walk stays on the sync path),
  - plain TCP only (TLS would need a non-suspending TLS write
    helper; kTLS engagement isn't enough by itself because the
    OpenSSL state machine still needs to drive the BIO),
  - mount has no on_missing: Next (we can't ENOENT back into a
    coroutine spawn after committing to the callback chain),
  - resolved path stays inside the mount root after canonicalisation
    (sync realpath check; same security guarantee the sync path
    already enforced).

Falls through to the existing synchronous-populate path for every
case the hard-zero path declines to handle, so the soft-skip code
stays as the always-correct safety net.

Dispatcher (http_connection.c) now early-returns on
HTTP_STATIC_HARD_ZERO without spawning a coroutine — the state
machine has the request lifetime and pins the conn via
handler_refcount until ss_finalize releases it.

http_static_serve.c renamed to http_static.c — the FSM file is now
the canonical static-handler implementation, "_serve" was redundant.

All 154 PHPT tests still pass (1 pre-existing skip).
Add §5a "Status" tracking what's actually shipped in this branch
versus what remains. Highlights:

  Closed: full hard-zero coroutine path on plain TCP, security
  fixes (backslash, symlink-traversal via realpath prefix-check,
  EACCES→404, header-injection in setCacheControl), zend_async API
  v0.11 (fs_open + sendfile, in php-src + ext/async submodules).

  Remaining for PR #1's acceptance: PHPT for If-Modified-Since,
  telemetry counter for zero-coroutine, bench vs entry.php,
  valgrind on a million-request run, TLS hard-zero (needs
  non-suspending TLS write helper), headers↔sendfile ordering on
  EAGAIN (TCP_CORK or wait-for-completion).

  Future PRs untouched: H2/H3 (#2), Range (#3), precompressed (#4),
  browse (#6). Note that PR #5 (sendfile) effectively merged into
  #1 because zero-copy sendfile required the zend_async API
  extension anyway, and it's cleaner than gating it behind a flag.

Mark acceptance checklist items in §6 with [x] / [~] / [ ] so the
status is at-a-glance. Open questions §7 picks up TLS hard-zero,
headers/sendfile ordering, and SYMLINKS_OWNER as deferred items
the next iteration owns.
Issue #13 / TODO §1 (merge-blocker): addStaticHandler stored a pointer
into the user-side StaticHandler PHP object's embedded descriptor, and
http_server_transfer_obj never copied it across to worker clones.  With
workers > 1 every worker had static_handler_count == 0 and every static
request fell through to the (often missing) PHP handler.

Fix: on addStaticHandler the locked draft is frozen into a persistent
refcounted snapshot (pemalloc + persistent zend_strings/HashTables,
atomic refcount).  Server stores the snapshot pointer; the worker-pool
TRANSFER side-cars pre-addref'd pointers; LOAD addrefs once more into
each worker's array.  http_server_free atomic-decrefs each slot.

Also folds in TODO §2 (also merge-blocker): http_server_count_request
is now bumped before ss_finalize on the SENDFILE-submit-failure branch
so total_requests no longer undercounts on submit errors.

New PHPT (004-static-workers) verifies workers=2 with addStaticHandler
returns the static body byte-exact on all 16 hammered requests.
Issue #13 / TODO §3.  ss_kick_off queues the headers via the existing
fire-and-forget batched uv_write while sendfile writes the body via
uv_fs_sendfile against the same fd.  On a slow / congested socket the
two could interleave on the wire (uncoordinated kernel flushes).

ss_cork_set toggles TCP_CORK on the conn's TCP socket — cork at kick-
off, uncork unconditionally at finalize.  Linux-only via #ifdef
TCP_CORK; on other platforms the helper is a no-op (the race is
theoretical there since ports without TCP_CORK either coalesce by
default or are not yet exercised).
Issue #13 / TODO §6.  ss_build_headers used to walk extra_headers per
request with a per-iteration strncasecmp("content-",...) for the 304-
eligibility check.  After freeze() the mount is read-only, so render
the immutable block once into two persistent zend_strings:

  prebaked_headers_full        — used on 200/206/etc.
  prebaked_headers_no_content  — used on 304 (RFC 9110 §15.4.5: no
                                  Content-* on Not Modified).

Hot path becomes one smart_str_append.  Sync path is left as-is —
it sets headers into the response-object HashTable, not a smart_str.
Issue #13 / TODO §5b.  Directory requests with an index list used to
take the sync slurp path: each candidate was opened+fstat'd, the body
read with blocking read(2), and emitted via the response object.

Resolve the index synchronously via stat(2) — cold-cache stat is a
single inode lookup (microseconds), the expensive read+send is what
we want async.  On hit, promote fs_path_len to the joined path; the
hard-zero gate then runs uniformly and the read+send rides
ZEND_ASYNC_FS_OPEN + uv_fs_sendfile.

On miss, resolve to 404 (or PASSTHROUGH for on_missing:Next) without
touching the sync open at all.  The sync try_open_candidate branch is
now only reached when the hard-zero gate rejects (TLS, on_missing:Next,
or post-canonicalisation root-escape).
Issue #13 / TODO §5c.  Previously on_missing:Next mounts were excluded
from the hard-zero gate — once we committed to the async chain we had
no way back to a coroutine spawn on ENOENT.  The intricate-but-doable
rollback design from the TODO is now implemented:

  - Drop the gate from http_static_try_serve.  Hard-zero is taken on
    on_missing:Next mounts whenever the other prerequisites pass.
  - On open-error in ss_handle_open, branch on the mount flag.  For
    on_missing:Next, call ss_rollback_to_php_handler which:
      * Uncorks the socket (idempotent on non-cork paths).
      * Detaches the static FSM (file_io + persistent callback) without
        running on_request_dispose / http_request_finalize.
      * Spawns ZEND_ASYNC_NEW_COROUTINE(conn->scope), binds it to
        http_handler_coroutine_entry / _dispose, sets req->coroutine.
      * Synthesises the static-only-deployment 404 if conn->handler ==
        NULL (mirrors the regular dispatch tail).
      * ENQUEUE_COROUTINE; efree(state).  The dispatch counter and
        handler_refcount that ss_kick_off bumped are left in place —
        the coroutine's dispose decrements them, balancing the books.

http_handler_coroutine_entry / http_handler_coroutine_dispose are now
non-static; declared in http_connection_internal.h.  Existing PHPT 002
already exercises both paths (visible file via hard-zero, missing file
via the new rollback to PHP).
  #8  Drop the prefixed[PATH_MAX] scratch in http_static_path_resolve.
      validate_segments already tolerates either a leading '/' or none.

  #9  http_static_path_is_hidden no longer memcpy's `relative` into a
      4 KiB scratch buffer — http_static_path_resolve writes the NUL
      right after `relative` inside out_buf, so fnmatch can read it
      directly.

  #12 MIME header comment now describes override-wins semantics
      (mime_overrides consulted before the built-in table) instead of
      the inverted version.

  #13 ss_state_t.fs_path drops from char[PATH_MAX] to emalloc'd
      char *.  New ss_state_free helper centralises ownership.  With
      10 K concurrent in-flight static requests this saves up to 40
      MiB of working set.

  #15 canonicalise_root_directory rejects realpath() == "/" up front;
      previously a root="/" StaticHandler would silently serve nothing
      because resolved_under_root needs canonical[root_len] in {NUL,'/'}.
  #10 Expose http_response_status_line_http11(code, *len) so the static
      handler's ss_status_line is a one-line wrapper around the
      already-present table in http_response.c (one source of truth).

  #14 Document in the StaticHandler stub that a directory URL whose
      index files all miss returns 404, not the 301-redirect-to-
      trailing-slash that nginx / Apache emit.  Behaviour is unchanged.
TODO §4 / validation gaps.  Three tightly-related test additions:

  - Valgrind sweep (#4): all six static PHPTs (001-006) pass under
    TEST_PHP_ARGS=-m with 0 leaked tests, confirming the freeze /
    shared-snapshot / on_missing:Next-rollback / fs_path-emalloc
    refactors all release cleanly.

  - 005-static-if-modified-since.phpt covers the IMS path that the
    parser already implemented (RFC 9110 §13.1.3): IMS strictly
    after / before / equal to mtime → 304 / 200 / 304.

  - 006-static-zero-coroutine-counter.phpt drives a new telemetry
    counter, http_server_counters_t::static_zero_coroutine_total.
    Bumped on every successful ss_kick_off; exposed via getTelemetry();
    reset by resetTelemetry().  Reads zero on a synthetic 404 that
    never reaches kick-off, three on three hard-zero hits.
PLAN §5a "Закрыто" / "Осталось" lists were stale w.r.t. recent
commits.  Promote four items to closed (docs only — no code change):

  - 005-static-if-modified-since.phpt covers IMS past mtime
    (commit 2c46937).
  - static_zero_coroutine_total counter + 006 PHPT (2c46937).
  - Valgrind sweep — all 6 static PHPTs clean under TEST_PHP_ARGS=-m
    (2c46937).
  - TCP_CORK gate closes headers+sendfile ordering race (67eadb9).

Remaining §5a in-codebase items:

  - User-space TLS hard-zero FSM (the real 5a, not yet started).
  - Bench wrk -c 256 -t 4 -d 30 vs entry.php (out-of-codebase).

§7 open question 5 (TLS hard-zero design) is now scoped to the
user-space FSM only.  The TODO doc grew an "ARCH NOTE" under §5a:
the kTLS-fast-path branch the original sketch proposed is dead
code in this server, because tls_session_new wires SSL through
BIO_pair (memory BIOs).  BIO_get_ktls_send returns 0 in that
layout — see existing comment at tls_layer.c:944 — so kTLS never
engages and the "engaged → sendfile" branch never runs.  Engaging
kTLS requires re-architecting the TLS layer onto a socket BIO
(uv_poll_t-driven read FSM instead of uv_tcp_t-driven), which is
a separate, larger PR.  Out of scope for §5a.
User-space TLS connections now ride ss_kick_off too: instead of the
sync slurp_fd path that blocks dispatch on the file read, the body
streams through a callback FSM with no coroutine spawned.

Flow:

  ZEND_ASYNC_FS_OPEN       (existing)
  → ZEND_ASYNC_IO_STAT     (existing)
  → atomic_send headers    (tls_fsm_send_plaintext_atomic
                            instead of bypass-the-TLS-layer
                            send_str_owned which would put plaintext
                            on the wire — pre-existing bug latent
                            because hard-zero gate was plain-only)
  → loop until EOF:
      SS_PHASE_TLS_READ   ZEND_ASYNC_IO_READ(file_io, 16 KiB)
      SS_PHASE_TLS_DRAIN  atomic_send(chunk) sets tls_zc_write_n;
                          park here until the new
                          conn->tls_zc_write_done_cb hook fires
                          on the existing tls_zc_write_free_cb path.
  → ss_finalize           (existing keep-alive / drain / counters)

Chunk size 16 KiB ≤ HTTP_TLS_PLAINTEXT_RING_BYTES (32 KiB) so a
single SSL_write never trips SSL_ERROR_WANT_WRITE — atomic helper
always succeeds in one shot.  No new ext/async API needed; the
observer hook (`conn->tls_zc_write_done_cb` + matching data
pointer) is the only TLS-layer extension and it's strictly
additive — fires once per ZC write completion, NULL when no
observer is registered.

Bench (debug-zts build, 16 cores WSL2, 64 KiB file, wrk -c64 -t4):
  - plain TCP sendfile : 19327 req/s, 1.18 GB/s, 3.1 ms p50
  - TLS chunked SSL_write: 4843 req/s, 303 MB/s, 12.7 ms p50
  → ~25% throughput ratio matches user-space AES-GCM baseline on
  debug build; release build with AES-NI inlining recovers most
  of the gap.

The original §5a sketch in TODO_STATIC_HANDLER_REVIEW had a kTLS
fast-path branch — that's dead code today (BIO_pair architecture
precludes BIO_get_ktls_send returning 1; documented separately).
Treated the path matrix as just plain-TCP-sendfile vs user-space-
TLS-FSM.  conn_supports_sendfile remains the gate — it now picks
between sub-paths instead of allow-vs-deny.

Tests:
  - tests/phpt/server/static/007-static-tls-streaming.phpt new:
    1 MiB body over TLS, byte-exact verify, telemetry counter
    confirms the new path runs.
  - All 19 existing static + tls PHPTs pass (1 pre-existing skip).

Known follow-up: under wrk-style burst-RST shutdown with multiple
in-flight streaming TLS conns, conn_arena_cleanup hits an
assertion — the static FSM parked in TLS_DRAIN doesn't finalize
when the loop is stopped before the cipher write completion
arrives.  PHPT 007 / normal keep-alive teardown is clean. Pass
over the shutdown chain is filed against §5a as a follow-up.

Adds:
  src/core/http_connection.h      conn->tls_zc_write_done_cb hook
  src/core/http_connection_tls.c  hook invocation in free_cb
  src/static/http_static.c        SS_PHASE_TLS_READ/DRAIN, helpers
  tests/phpt/.../007-...phpt      PHPT
  docs/PLAN_STATIC_HANDLER.md     §5a closed; follow-ups recorded
Bounded per-server cache of recent static-handler path lookups.
On a warm cache, http_static_try_serve skips the realpath/stat
walk that runs on every request — the dominant cost on hot
serving when the dentry cache is cold.

Defaults: 512 entries, 60-second TTL (matches nginx
open_file_cache / open_file_cache_valid). Both tunable per worker
via env: TRUE_ASYNC_STATIC_CACHE_ENTRIES=0 disables; TTL=0 also
disables. No locking — single-thread per event loop is the
invariant; worker pool gives each worker its own snapshot.

Per-entry: zend_string key (the absolute resolved path), full
struct stat copy, content_type pointer (borrowed from the
persistent MIME table), pemalloc'd ETag and Last-Modified
buffers, LRU links. HashTable destructor frees the entry; cache
destroy fires at server free.

Wire-up in http_static_try_serve: lookup before
resolved_under_root; on hit skip the realpath. On miss + realpath
success: insert. Hit-side reuse is currently realpath-only — st,
etag, content_type fields in the view are NULL/zero. Full
metadata reuse (skip stat / etag / mime lookup) is a separate
follow-up that needs a one-shot stat at insert time inside the
async chain.

Bench (debug-zts build, 16 cores WSL2, 64 KiB file, wrk -c64 -t4 -d5s,
warm dentry cache, 3-run average):
  - cache on  : ~11017 req/s
  - cache off : ~11748 req/s
On warm dentry cache realpath is essentially free, so the
HashTable lookup is pure overhead in this microbench. The cache
earns its keep on cold-cache scenarios and once follow-up #1
lands (skip stat/etag/mime on hit). Tests: 19/20 PASS + 1
pre-existing SKIP across static + tls suites.
The previous integration left the cache in a half-built state:
the pre-flight inserted a zero-stat placeholder under the
resolved path, so a hit could only skip realpath — every
follow-up still paid for IO_STAT, the MIME table binary search,
and ETag/Last-Modified formatting.

Move the insert to ss_handle_stat where st, etag_buf,
last_modified_buf and content_type are all in hand. The cache
now carries everything a future request would compute — wiring
the hit-side to actually consume st/etag/MIME (skip IO_STAT,
short-circuit conditional GET to 304) is a separate follow-up
that needs ss_kick_off to accept a pre-populated view; the FSM
plumbing is non-trivial and lands next.

Hot-path cleanup in http_static_cache_lookup: drop the
zend_string_init / release pair around zend_hash_find_ptr in
favour of zend_hash_str_find_ptr — one allocation per lookup
gone. The transient zstring was a leftover from an earlier
sketch that compared against a zend_string-typed key; the index
keys live in entry->path_key but the hash is byte-identical so
str_find_ptr is correct.

Telemetry: add static_cache_hits_total / _misses_total counters,
exposed through getTelemetry() and reset by resetTelemetry().
Operators now see the cache's effectiveness directly; tests
assert hit/miss arithmetic across repeated requests.

Bench (debug-zts, 16 cores WSL2, 64 KiB file, wrk -c64 -t4 -d5s,
warm dentry, 3-run avg):
  - cache on  : ~12505 req/s
  - cache off : ~12824 req/s
Still neutral-to-slightly-negative on warm dentry — exactly as
expected with a half-wired hit-side. The win comes once the
follow-up consumes the cached view to skip IO_STAT.

Tests: 21/22 PASS + 1 pre-existing SKIP across static + tls
suites. New: 008-static-cache-counters.phpt — 5 requests across
2 distinct paths assert hits=3 misses=2.
Both helpers ran on every static response (Last-Modified always,
ETag when enabled). Callgrind on plain-TCP / 64 KiB / wrk-c64-t4
showed format_converter at ~7% of total instructions and
http_static_format_http_date alone at ~5.6% inclusive — printf
machinery (format-string scan, va_list dispatch, ap_php_conv_10
divmod-loop) is gross overkill for fixed-width unsigned ints.

Replace with direct table-and-divmod writers:

  put_u8_2digit  / put_u16_4digit  — ASCII decimal, no branches
  put_u64_16hex                    — lower-hex, 16-byte unrolled

The IMF-fixdate body becomes a sequence of byte stores at fixed
offsets; ETag becomes 'W/"' + 16 hex + '"'. Output bytes are
identical, asserted at compile time via ZEND_STATIC_ASSERT against
the existing length constants and at runtime via the existing
PHPT 005 (If-Modified-Since round-trip on a Date string).

After: format_converter falls out of the top-20 self-Ir entries.
Top now dominated by zend_mm (alloc/free) and llhttp parser —
the natural per-request shape.

Tests: 21/22 PASS + 1 pre-existing SKIP, full static + tls suites.
Move cache configuration from process-wide env vars
(TRUE_ASYNC_STATIC_CACHE_ENTRIES / _TTL) onto the StaticHandler
itself, where every other static-mount tunable lives. Two new
PHP methods:

  StaticHandler::setOpenFileCache(int $maxEntries, int $ttlSeconds = 60)
  StaticHandler::disableOpenFileCache()

Both are pre-lock setters, throw on a frozen handler. Defaults
at construction: maxEntries=0 (cache off), ttlSeconds=60. Off by
default because the cache is net-negative on warm-dentry / local
disk workloads where realpath/stat/MIME walks already cost only
microseconds — operators opt in for cold-dentry / large-docroot
/ network-FS deployments where the cache earns its keep.

Cache instance still lives on http_server_object (per-worker
after worker-pool transfer, no locking, classic nginx layout).
http_static_cache_acquire() now derives effective settings on
first call by walking registered mounts:

  max_entries = max(mount->cache_max_entries) over enabled mounts
  ttl_seconds = min(mount->cache_ttl_seconds) over enabled mounts

Two mounts with mismatched settings see the union (largest cache,
tightest TTL) — documented in the StaticHandler stub. A new
static_cache_resolved flag distinguishes "still uninitialised"
from "intentionally disabled" so the no-mount-opted-in case
short-circuits without re-walking on every request.

Removed: env-var reads in http_server_class.c. PHPT 008 updated
to opt the test mount into the cache via the new method;
existing static suite stays cache-off and exercises the no-op
fast path.

Tests: 21/22 PASS + 1 pre-existing SKIP across static + tls.
 §5a)

Closes the open-file cache loop. Until now a hit only skipped
realpath; the FSM still ran IO_STAT, etag formatting, MIME
binary-search and IMF-date formatting per request — so on warm
dentry the cache was net-neutral in benches because every other
syscall and string operation kept happening anyway.

Now ss_kick_off accepts a cache view. When non-NULL:

  - state->st is pre-filled from the entry → ss_handle_open
    skips the IO_STAT submit and jumps straight into
    ss_handle_stat's post-stat path.
  - cached_etag_buf / cached_lm_buf / cached_content_type travel
    on the state struct → ss_handle_stat reads them with memcpy
    instead of running http_static_etag_format,
    http_static_format_http_date and http_static_mime_lookup.
  - The conditional GET check runs against the cached etag and
    cached mtime, so 304 short-circuits land just as fast.
  - The cache insert at the end of ss_handle_stat is gated on
    !has_cached_meta — hits don't re-insert their own entry.

FS_OPEN still runs (sendfile / IO_READ need an async fd) but the
expensive metadata derivation is now memory work. This is the
classic nginx open_file_cache contract: trust the entry within
TTL, skip every derivation; revalidate by full miss after expiry.

Bench (debug-zts, 16 cores WSL2, 64 KiB file, wrk -c64 -t4 -d5s,
3-run avg, cache enabled via StaticHandler::setOpenFileCache(64, 60)):
  - cache on  : ~19622 req/s   (hits ~99.98%)
  - cache off : ~16287 req/s
~+20% on the warm-dentry baseline that was previously a wash.

Tests: 21/22 PASS + 1 pre-existing SKIP. PHPT 008 still asserts
hit/miss arithmetic; conditional-GET PHPTs (005, 001) ride the
hit branch on their second request and confirm the 304 path
stays byte-identical.
 §6)

Acceptance §6 marked OwnerMatch as aliased to Reject because the
post-open uid comparison was punted to the async serve PR. Closes
that gap; while there, fixes a hole in REJECT on the hard-zero
path where final-segment symlinks pointing inside the mount
slipped through.

OwnerMatch (path_segments_owner_match):
  Walk the resolved path one segment at a time from mount root to
  the final component. For each segment that is a symlink, lstat()
  yields the link's uid, stat() yields the target's uid; mismatch
  on any segment denies the request. Cost is one lstat per
  segment plus one stat per symlink — handful of microseconds on
  warm dentry. Open-file cache hits skip the sweep (entry was
  already approved within the same TTL window).

REJECT pre-flight (symlink_policy_admits):
  open_for_policy() uses O_NOFOLLOW on the sync fallback so the
  kernel rejects symlinks on the final component with ELOOP. The
  hard-zero path goes through ZEND_ASYNC_FS_OPEN, which doesn't
  expose that flag, and resolved_under_root() only catches links
  pointing outside the mount — leaving inside-mount-to-inside-mount
  symlinks served as if they were regular files. Add an explicit
  lstat on the final component in the pre-flight that covers the
  same ground O_NOFOLLOW would.

open_for_policy: stop OR-ing OWNER into the O_NOFOLLOW mask now
that the sweep approves links explicitly — open() needs to follow.

Tests: 21/22 PASS + 1 pre-existing SKIP, including new
009-static-symlink-owner.phpt that mounts the same root under
both policies and asserts:
  OwnerMatch /alias.txt → 200 (same-uid link followed)
  Reject    /alias.txt → 404 (final-segment link denied)
Cross-uid mismatch isn't testable without root; the deny side of
OwnerMatch shares the same lstat/stat compare with the mismatch
case in 003-static-security.phpt under Reject.
…ng (#13 PR #4)

When the mount enables one or more codings via enablePrecompressed
and the request lists a matching coding in Accept-Encoding, the
pre-flight swaps fs_path to the "<original>.<suffix>" sibling,
the FSM serves the compressed bytes via the existing sendfile/TLS
plumbing, and the response carries Content-Encoding +
Vary: Accept-Encoding.

Picker reuses the existing http_compression_negotiate state
machine — same q-prio handling, same identity-only-by-default
behaviour as the dynamic compression path, server preference
zstd > br > gzip. Codecs the mount didn't opt into are skipped at
the flag-mask level (no stat). The first sidecar that exists wins.

Content-Type stays derived from the *original* path: MIME-by-
extension on "<x>.gz" would produce application/gzip otherwise.
The pre-flight resolves the original type before swapping fs_path
and threads it through ss_kick_off as override_content_type;
ss_handle_stat consumes the override instead of running a second
MIME lookup against the wrong extension.

Cache integration: the cache key is the rewritten fs_path
("<x>.gz"), so each variant gets its own LRU slot — repeat
requests for the same encoding hit; switching encoding misses,
walks the same code path, inserts the new variant. ETag is
derived from the sidecar's inode/mtime/size (already different
from the identity variant, satisfying RFC 9110 §8.8.3 weak-equal
distinctness).

Trade-off (documented in code): every request to a precompressed-
enabled mount costs one stat for the sidecar candidate before
cache lookup, including hit-flow requests. We could drop this by
keying the cache off (path, encoding) and looking up identity
first, but the current shape is simpler and the stat is a single
syscall on warm dentry. Revisit if benches show it.

Tests: 23 PHPTs total, 22 PASS + 1 pre-existing SKIP. New
010-static-precompressed.phpt asserts:
  AE: gzip + .gz exists       → 200, CE: gzip, body = sidecar
  AE: identity + .gz exists   → 200 identity (gzip refused)
  AE: gzip, no sidecar        → 200 identity
  no AE header                → 200 identity (BREACH-safe default)
RFC 9110 §14.2 single-range slicing on the plain-TCP hard-zero
path. Three Range-header forms accepted:

  bytes=A-B    first..last (inclusive)
  bytes=A-     first..size-1
  bytes=-N     size-N..size-1 (suffix-length)

Multi-range syntax (`,` between ranges) is recognised by the
parser and rejected — caller falls through to 200 with the full
body, which §14.2 paragraph 1 explicitly permits ("a server MAY
ignore Range"). multipart/byteranges responses are filed as a
separate follow-up; the surface area (boundary string, per-part
headers, framed sendfile chunks) doesn't fit a minimal hot-path
extension.

If-Range (§13.1.5): strong-equal compare against the formatted
ETag. Mismatch ignores Range and serves 200; absent If-Range
processes Range unconditionally; If-Range present but ETag
disabled on the mount disables Range conservatively.

Status codes:
  206 Partial Content       successful slice
  416 Range Not Satisfiable  syntactically valid but unsatisfiable
                             (start past EOF, last-N=0, etc).
                             Carries Content-Range: bytes star/size
                             per §14.1.2.
  200 OK                    falls through (multi-range, garbled
                             header, If-Range mismatch, full body)

Headers: Accept-Ranges: bytes goes on every 200/206 (advertises
support). Content-Range only on 206. Content-Length is the slice
length, not the file length, so callers don't have to special-case.

Sendfile: ZEND_ASYNC_IO_SENDFILE already takes (offset, len) — we
just thread state->range_first / range_len through. Plain TCP
benefits immediately.

Known limitation: TLS body path uses ZEND_ASYNC_IO_READ which has
no offset argument, so Range over TLS falls through to 200 full
body. Adding offset support needs an async-API extension; the
practical impact is small — TLS streaming clients that need Range
either retry over plaintext or download the full file. Documented
in the code and PLAN_STATIC_HANDLER §5a.

Adds 416 to the http_response.c HTTP/1.1 status-line table — the
hard-zero ss_send_response previously fell back to 500 for it.

Tests: 24 PHPTs, 23 PASS + 1 pre-existing SKIP. PHPT 011 covers
all three Range syntaxes, 416 unsatisfiable, multi-range
fall-through, both If-Range outcomes.
Move open file cache, OwnerMatch, precompressed sidecars and
single byte-range from "Осталось" to "Закрыто" with commit SHAs;
flip §6 acceptance Symlink line from [~] to [x]; close Open
Question 1 (open_file_cache deferral). Adds two PR #3 follow-ups
(multi-range/multipart, TLS Range) to "Осталось" so they aren't
forgotten.
…trofit (#13)

Standards (docs/CODING_STANDARDS.md):
  - §9.2 cross-platform filesystem rules (lstat/realpath/fnmatch/strptime/
    PATH_MAX/IS_SLASH/st_uid portability gates)
  - §9.3 compiler intrinsics
  - §13a formatting (mandatory blank lines, mandatory braces, function
    length, no inline issue/PR refs)
  - §13b naming for security predicates and first-of-many returns
  - §13c decoder rules (caps, reject-not-normalize, mandatory cmocka
    tests, check PHP-core before reinventing)
  - §13d TOCTOU — lstat-walk + plain open is forbidden
  - §13e where defensive NULL checks belong vs ZEND_ASSERT

Code:
  - clang-format -i on all 11 static-handler files (InsertBraces: true)
  - find_request_header → find_first_request_header (§13b.3)
  - path_segments_owner_match → verify_path_owner_chain (§13b.2)
  - PATH_MAX → MAXPATHLEN (§9.2)
  - mime find_extension_offset uses is_path_separator (IS_SLASH on win32)
  - parse_http_date validates tm fields after strptime (rejects day=99,
    leap-second=60, etc.)
  - validate_segments caps depth at HTTP_STATIC_PATH_MAX_SEGMENTS=256
  - http_static_cache_* defensive NULL → ZEND_ASSERT
  - try_open_candidate now does post-open lstat + dev/ino re-check
    (§13d retrofit until full openat-chain lands)
  - inline (issue #13 …) / (PR #N) parentheticals stripped from
    function bodies; module headers retain feature anchors

Tests (tests/unit/static/test_static_decoders.c):
  - 32 cmocka tests across MIME lookup, If-None-Match / If-Modified-
    Since matching, percent-decode + segment validation
  - exercises NUL injection, %5C / literal backslash, ../, ./, //, %
    truncation, leap-second, out-of-range tm, depth-cap, dotfile
    deny/ignore, prefix mismatch
  - wired into tests/unit/CMakeLists.txt; passes 32/32

Audit findings + remaining work tracked in docs/REVIEW_STATIC_BRANCH.md.
Deferred (own PRs): full Win32 port (timelib + fnmatch shim + IS_SLASH
segmenter + php_sys_lstat), openat-chain TOCTOU rewrite, http_static.c
split (2004 LoC), type vs param naming decision.
EdmondDantes and others added 25 commits May 9, 2026 12:08
Per docs/ARCH_FILE_DELIVERY.md §9.6 — the protocol-side dispatch cbs
struct (http_static_dispatch_cbs_t) and the engine cbs struct
(send_file_cbs_t) had identical shapes with different field names.
Make the static one a typedef-alias of the engine one and unify
field names:

  on_hard_zero_armed   → on_armed
  on_static_done       → on_done
  on_passthrough_to_php → on_passthrough
  keep_alive           (unchanged)

Drop the static_adapter_t shim from http_static.c — cbs is now passed
straight to send_file(). H1 (src/core/http_connection.c) and H2
(src/http2/http2_strategy.c) callback-structure literals updated to
the new names.

Tests: 145 server tests (144 PASS / 1 SKIP) + 11 multipart, green.
Mark all 6 steps as done, replace target layout / sizes / risks with
actuals, drop §11.1 (rfc5987) since it's been done in Шаг 2.
…p if-blocks

- Remove trailing `*const` from pointer declarations across src/ and include/
  (`T *const p` → `T *p`, `T **const p` → `T **p`). 392 occurrences.
- Insert blank lines between consecutive `if (...) { release; }` blocks in
  cleanup/destructor sections for readability.

No functional change; build clean, 167/168 phpt PASS (1 SKIP — unrelated
ext/openssl in CLI).
- Insert blank line before fresh `if (...)` statement when previous line is
  not `{`, comment, label, preprocessor, or `else`.
- Insert blank line after closing `}` of a block before any non-exempt next
  statement (assignments, calls, declarations, returns, etc.).
- Mark `bool include_content_headers` value-parameter as `const` per
  read-only convention.

No functional change; build clean, 167/168 phpt PASS (1 SKIP unrelated).
The .br/.gz/.zst sidecar lookup lived in two near-identical copies —
try_select_precompressed (http_static.c) gated codecs via mount flags,
sf_try_precompressed (http_send_file.c) had no per-codec gate but
hand-parsed request->headers instead of using http_request_find_header.

Move the resolver into src/http_precompressed.c with a HTTP_PRECOMP_*
codec mask. StaticHandler maps mount->flags via mount_precomp_mask();
sendFile passes HTTP_PRECOMP_ALL. Closes §11.7 of ARCH_FILE_DELIVERY.

http_send_file.c also stops poking request->headers directly — the
shared module reads Accept-Encoding through the public request API.
Both parse_content_disposition and parse_content_type hand-rolled the
same `name=value | name="value"` scan inline (4 copies between them
across name=, filename=, filename*=, charset=).

Add src/http_param_parse.c with http_header_param_next() — an iterator
that walks `;`-separated header parameters and yields {name, value,
quoted} views into the source. Both multipart helpers shrink to a
single while-loop dispatching by parameter name; the RFC 5987 ext-value
parsing for filename* moves into a small decode_rfc5987_value() helper.

Closes §11.2 of ARCH_FILE_DELIVERY backlog. All 11 multipart + 27
static/sendfile + 133 server tests still pass.
Three identical copies of the same blocklist (Connection, Keep-Alive,
Transfer-Encoding, Upgrade, Content-Length) lived in http2_strategy,
http2_static_response, and http3_callbacks — http2_static_response.c
even acknowledged the duplication ("Mirrors the strategy's
response_header_allowed but kept local").

Move to src/http_response_header_filter.c as
http_response_header_allowed_h2h3(). The list is RFC-defined as
identical for HTTP/2 (RFC 9113 §8.2.2) and HTTP/3 (RFC 9114 §4.2),
so a single function is correct, not just convenient — future
additions land in one place.
http_static.c carried ~230 lines of security-sensitive path validation
(open_for_policy + symlink_policy_admits + verify_path_owner_chain +
resolved_under_root + try_open_candidate) wedged between mount
helpers and dispatch logic. The cluster forms a cohesive
"safely open a file under a static mount" subsystem and benefits
from its own home so future hardening lands in one obvious place.

Move to src/static/http_static_safety.{c,h}. Public surface is the
three functions http_static.c actually calls; open_for_policy and
verify_path_owner_chain stay file-local. Behaviour identical, all
27 static/sendfile and 133 server tests still pass.
http_static.c::apply_mount_headers and send_file.c::engine_apply_extra_headers
ran identical ZEND_HASH_FOREACH_STR_KEY_VAL loops over operator-supplied
header tables, both with the same content-* skip for 304 responses.

Add http_response_apply_extra_headers() next to the existing static_set_*
primitives in http_response.c. The static-handler version now calls it
after the cache_control prologue; the send_file engine drops its local
helper entirely.
h2: replace sync pread() in the data_provider with ZEND_ASYNC_IO_READ +
NGHTTP2_ERR_DEFERRED. Per-stream 16 KiB buffer, persistent dispatch
callback on file_io, lazy alloc. Top-of-file comment explains why we
do not use NGHTTP2_DATA_FLAG_NO_COPY + sendfile(2) (incompatible with
mem_send-based serialization; see nghttp2 issues #796/#817, h2o#671).

h3: add send_static_response to h3_stream_ops. New TU drives a producer
coroutine that reads file chunks via ZEND_ASYNC_IO_READ and pushes
through h3_stream_append_chunk, reusing the existing streaming path
(chunk_queue + acked_stream_data release + window-extend backpressure).
h3_stream_append_chunk / h3_stream_mark_ended are now non-static and
declared in http3_internal.h.

config.m4: register src/http3/http3_static_response.c in the H3 source
list.
zend_async_callbacks_notify can re-enter a freshly-registered callback
during the very iteration that completed an earlier req on the same
event. The H2 static path triggers exactly this: stat-completion notify
iterates the engine cb, which deregisters itself and registers the H2
cb mid-iteration; current_iterator gets adjusted back to 0; the loop
then calls the H2 cb with `result` pointing at the now-disposed stat
req. submit_read's fresh pecalloc reuses that address, so identity
match `req == pending_req` is true — but `req->transferred = 0`
because the worker hasn't run yet. We falsely flagged eof_reached and
the stream finished with zero DATA.

Repro under h2load -c10 -m32 was a SIGSEGV after ~320 streams (the
spurious fires also corrupted libuv's queue). Now gate dispatch on
`req->completed`, which the libuv reactor sets just before NOTIFY for
real completions. Same guard added to the engine's STAT-phase
dispatch as defence in depth.
…er.so

PHP_NEW_EXTENSION in config.m4 builds modules/true_async_server.so, but
seven run_bench*.sh scripts still asserted on the old http_server.so
filename and exited "extension not built" before launching the bench
server. Update the EXT= line in each script to match the actual build
output. The -d extension=true_async_server load lines were already
correct.
bench_static_server.php mounts a StaticHandler at /static/ over a tmp
docroot pre-populated with four fixed-size sample files (256 B / 16 KiB
/ 256 KiB / 8 MiB) and registers H1 + h2c-prior-knowledge listeners.
ETag and Cache-Control are enabled so the 304 short-circuit path is
exercisable too.

run_bench_static.sh boots the server, smoke-checks each size against
the expected byte count, then drives wrk over H1 (keep-alive, four
sizes) and h2load over H2 (-c10 -m32, request count scaled to size),
plus an extra wrk pass with If-None-Match for the 304 path.

The H2 leg uncovered fix(static): gate async dispatch on
req->completed (cd72a95) — without that fix, h2load against tiny.txt
crashed the server after ~320 streams.
php-src ext/async libuv reactor's libuv_io_close is a no-op for
ZEND_ASYNC_IO_TYPE_FILE — the comment in the source even says "FILE
type: no uv handle to close" — and there is no fs_open ↔ fs_close
pairing anywhere in the reactor. As a result every fd opened via
ZEND_ASYNC_FS_OPEN survives every dispose, leaking ~1 fd per request.

Repro: 5 000 H2 GETs on a static file took /proc/<pid>/fd from 14 to
5 014. EMFILE is ~60 k requests away on a default ulimit.

Workaround in our finalize paths: close(2) the fd before disposing
the io. close() on a regular file is a no-I/O syscall, so doing it on
the loop thread is fine. Sites:

  * src/send_file.c — engine_dispose_file_io helper used by
    engine_finalize, engine_rollback_to_php, and the head-only branch
    of engine_delegate_to_protocol;
  * src/http2/http2_static_response.c::h2_static_finalize;
  * src/http3/http3_static_response.c::h3_static_finalize.

Real fix belongs in php-src's libuv_io_close. Drop these workarounds
once that lands.
Mirror how users would actually deploy a hot-asset endpoint —
open_file_cache(1024, 60s). The cache hit path skips the async stat
+ etag/MIME re-derivation, so the bench measures the same code path
real workloads exercise. Cold-cache numbers are still reachable by
flipping the line off if needed.
The engine previously routed open() and fstat() through
ZEND_ASYNC_FS_OPEN / ZEND_ASYNC_IO_STAT — both submit work to libuv's
thread pool and wake the loop via uv_async. On a warm dentry cache
those syscalls cost a few hundred nanoseconds; the round-trip
(submit → worker wake → uv_async back) costs an order of magnitude
more. The dispatch was pure overhead.

Now the prologue runs inline:

  open(path, O_RDONLY | O_CLOEXEC)
  fstat(fd, &st)               (skipped on cache hit)
  ZEND_ASYNC_IO_CREATE(fd, FILE, READABLE | OWNS_FD)

A 0-ms timer then defers engine_handle_stat off the synchronous
send_file() tail so on_done cannot unwind through the request
dispatcher on its own call stack — the async contract the protocol
ops + adapters depend on is preserved without paying for a thread.

Benchmark (bench_static_server.php, wrk -t2 -c50 -d5s, cache on):

  tiny    256B   19k → 35k req/s   (+85%)
  medium  256K   28k → 39k         (+40%)
  large     8M  1095 → 1211        (+11%)
  304 INM        24k → 123k        (+410%)

ZEND_ASYNC_IO_OWNS_FD lives in zend_async_API.h v0.12.0; older
installed headers omit the macro but the loaded runtime honours the
bit, so the TU defines it defensively.
…endfile

libuv's uv_fs_sendfile on Linux is doubly broken for file→TCP-socket:

  1. It tries copy_file_range(2) first, which returns EINVAL on socket
     destinations (per `man 2 copy_file_range`: "Either fd_in or fd_out
     is not a regular file").
  2. The EINVAL fallback routes into uv__fs_sendfile_emul — a userspace
     pread + write loop through an 8 KiB buffer. No kernel zero-copy,
     and it all runs inside a worker thread, paying a futex / cond
     round-trip per request.

The bug is unreported upstream and maintainers (libuv #1831) have
explicitly declined to optimise kernel sendfile→socket since 2020.

For files ≤ 64 KiB the round-trip dominates request cost — the
"savings" of streaming through the worker are imaginary because the
file fits in one write. Slurp it synchronously into an emalloc'd
zend_string, hand it to the response object, delegate with file_io
NULL — the protocol op then emits headers + body together via the
same per-socket write queue that headers normally use. Ordering with
prior writes on the socket is guaranteed by libuv's stream layer.

Benchmark (bench_static_server.php, wrk -t2 -c50 -d5s, cache on):

  H1 tiny    256B   36k → 103k req/s   (+185%)
  H1 small   16K    39k →  73k         (+87%)
  H2 tiny    256B   35k → 154k         (+340%)
  H2 small   16K    27k →  73k         (+170%)

Files > 64 KiB stay on the existing sendfile path. Disk-stall risk
for the slurp is the same trade-off h2o makes — small files are
almost certainly page-cache hot.
H2 body delivery for a Range request stored body_offset in the FSM
state but never applied it to the file. ZEND_ASYNC_IO_READ goes
through uv_fs_read with offset=-1, which uses the fd's tracked
position — that is 0 right after open. Result: regardless of the
requested range, the response carried bytes from the start of the
file. H1 was unaffected because uv_fs_sendfile takes an explicit
offset argument.

Seek the io once before the first IO_READ when body_offset > 0;
subsequent reads advance naturally via the fd position.

Closes pre-existing failure of tests/phpt/server/static/012-static-h2
- CHANGELOG.md: Performance section documenting inline open/fstat,
  small-file slurp fast path, and the H2 range fix.
- docs/ARCH_FILE_DELIVERY.md: new §14 covering sync prologue, slurp
  fast path with the libuv copy_file_range/sendfile rationale,
  threshold sweep results, the H2 range bug, and out-of-scope items
  (large-file sendfile, threshold knob, cache stats).
… into 13-built-in-static-file-handler

# Conflicts:
#	docs/ARCH_FILE_DELIVERY.md
Single-function TU was disproportionate for the 5-name blocklist.
Move http_response_header_allowed_h2h3 next to the rest of the
shared response helpers in http_response.c, declared in
php_http_server.h. The implementation now uses zend_binary_strcasecmp
for consistency with the chunked-emit path right next door
(http_response.c:1658), shedding the bespoke <strings.h>
dependency on the way.

Deletes:
  - src/http_response_header_filter.c
  - include/http_response_header_filter.h

No behaviour change.
@EdmondDantes EdmondDantes linked an issue May 11, 2026 that may be closed by this pull request
@github-actions

github-actions Bot commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 80.37% → 77.68% (-2.68 pp)

File Baseline Current Δ Touched
src/compression/http_compression.c 68.75% 68.75% +0.00 pp ●
src/compression/http_compression_brotli.c 76.24% 76.24% +0.00 pp ●
src/compression/http_compression_gzip.c 89.74% 89.74% +0.00 pp ●
src/compression/http_compression_negotiate.c 93.26% 94.38% +1.12 pp ●
src/compression/http_compression_request.c 93.65% 93.65% +0.00 pp ●
src/compression/http_compression_response.c 80.62% 80.47% -0.15 pp ●
src/compression/http_compression_zstd.c 81.48% 81.48% +0.00 pp ●
src/core/conn_arena.c 100.00% 100.00% +0.00 pp ●
src/core/http_connection.c 77.73% 76.65% -1.08 pp ●
src/core/http_connection_internal.h 100.00% 100.00% +0.00 pp ●
src/core/http_connection_tls.c 73.35% 71.95% -1.40 pp ●
src/core/http_known_strings.c 100.00% 100.00% +0.00 pp ●
src/core/http_protocol_handlers.c 71.88% 71.88% +0.00 pp ●
src/core/http_protocol_strategy.c 78.26% 78.26% +0.00 pp ●
src/core/tls_layer.c 76.84% 76.84% +0.00 pp ●
src/formats/multipart_parser.c 65.16% 65.16% +0.00 pp ●
src/formats/multipart_processor.c 76.08% 75.38% -0.70 pp ●
src/fs_util.c 0.00% 63.16% +63.16 pp ●
src/http1/http1_sendfile.c 0.00% 72.49% +72.49 pp ●
src/http1/http1_stream.c 66.10% 66.10% +0.00 pp ●
src/http1/http_parser.c 81.86% 81.86% +0.00 pp ●
src/http2/http2_session.c 87.08% 87.35% +0.26 pp ●
src/http2/http2_static_response.c 0.00% 82.16% +82.16 pp ●
src/http2/http2_strategy.c 76.04% 69.08% -6.97 pp ●
src/http2/http2_stream.c 94.59% 94.59% +0.00 pp ●
src/http3/http3_callbacks.c 79.45% 79.21% -0.24 pp ●
src/http3/http3_connection.c 82.76% 82.76% +0.00 pp ●
src/http3/http3_dispatch.c 78.08% 78.08% +0.00 pp ●
src/http3/http3_io.c 83.25% 83.25% +0.00 pp ●
src/http3/http3_listener.c 68.76% 68.76% +0.00 pp ●
src/http3/http3_packet.c 96.97% 96.97% +0.00 pp ●
src/http3/http3_static_response.c 0.00% 0.00% +0.00 pp ●
src/http3/http3_stream.c 95.56% 95.56% +0.00 pp ●
src/http3/http3_stream_pool.c 100.00% 100.00% +0.00 pp ●
src/http_conditional.c 0.00% 100.00% +100.00 pp ●
src/http_date.c 0.00% 87.88% +87.88 pp ●
src/http_etag.c 0.00% 89.58% +89.58 pp ●
src/http_mime.c 0.00% 89.80% +89.80 pp ●
src/http_param_parse.c 0.00% 80.00% +80.00 pp ●
src/http_precompressed.c 0.00% 96.77% +96.77 pp ●
src/http_range.c 0.00% 92.45% +92.45 pp ●
src/http_request.c 94.14% 92.73% -1.41 pp ●
src/http_response.c 86.43% 84.30% -2.12 pp ●
src/http_rfc5987.c 0.00% 0.00% +0.00 pp ●
src/http_send_file.c 0.00% 82.80% +82.80 pp ●
src/http_send_file_options.c 0.00% 89.06% +89.06 pp ●
src/http_server.c 94.12% 94.55% +0.43 pp ●
src/http_server_class.c 76.42% 75.79% -0.63 pp ●
src/http_server_config.c 86.52% 86.53% +0.01 pp ●
src/log/http_log.c 66.79% 66.79% +0.00 pp ●
src/log/trace_context.c 100.00% 100.00% +0.00 pp ●
src/send_file.c 0.00% 84.19% +84.19 pp ●
src/static/http_static.c 0.00% 61.86% +61.86 pp ●
src/static/http_static_cache.c 0.00% 71.43% +71.43 pp ●
src/static/http_static_path.c 0.00% 94.62% +94.62 pp ●
src/static/http_static_safety.c 0.00% 77.03% +77.03 pp ●
src/static/static_handler_class.c 0.00% 47.62% +47.62 pp ●
src/uploaded_file.c 75.00% 75.00% +0.00 pp ●

❌ Regression in touched files (> 1.0 pp drop)

  • src/core/http_connection.c dropped -1.08 pp
  • src/core/http_connection_tls.c dropped -1.40 pp
  • src/http2/http2_strategy.c dropped -6.97 pp
  • src/http_request.c dropped -1.41 pp
  • src/http_response.c dropped -2.12 pp

Add [coverage-drop-ok] to a commit message in this PR to override.

multipart_processor.c grew references to http_header_param_next
(aa138c4) and http_rfc5987_decode (0c0f4d4) but fuzz/Makefile was
never updated to compile those TUs into HTTP1_SRC / H2_SRC. Result:
fuzz_http1_parser and fuzz_h2_session failed to link with undefined
references — visible only in CI (Fuzz-embedded job).

Adds src/http_param_parse.c + src/http_rfc5987.c to both link lists.
Verified by building both targets locally.
@EdmondDantes
EdmondDantes merged commit 9217ee6 into main May 11, 2026
5 checks passed
@EdmondDantes
EdmondDantes deleted the 13-built-in-static-file-handler branch May 11, 2026 11:53
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.

Built-in static file handler

1 participant