Repository navigation
13 built in static file handler - #14
Merged
Merged
Conversation
) 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.
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.
…fd leak" This reverts commit 01ef843.
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.
…true-async/server into 13-built-in-static-file-handler
Contributor
CoverageTotal lines: 80.37% → 77.68% (-2.68 pp)
❌ Regression in touched files (> 1.0 pp drop)
Add |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.