Skip to content

Built-in static file handler #13

@EdmondDantes

Description

@EdmondDantes

Tracks FUTURES.md item #2. Unblocks the production tier for HttpArena static profiles (static, static-h2, static-h3) — the only remaining blocker before meta.json can flip from "type": "tuned" to "type": "production".

Full design + implementation plan: docs/PLAN_STATIC_HANDLER.md.

Why

Today entry.php slurps /data/static into a userland hash table and hand-rolls .br/.gz selection. Production rules forbid user-land MIME lookup, in-memory caching, and hand-rolled compression — so the static profiles cannot be marked production until the framework provides a native primitive.

HttpServer / HttpServerConfig have no static-file API; this issue introduces one.

Surface (PHP)

final class TrueAsync\StaticHandler {
    public function __construct(string $urlPrefix, string $rootDirectory);
    public function setIndexFiles(string ...$files): static;
    public function setOnMissing(StaticOnMissing $mode): static;        // NotFound | Next
    public function enablePrecompressed(string ...$encodings): static;  // 'br' | 'gzip' | 'zstd'
    public function setDotfilePolicy(StaticDotfiles $p): static;        // Deny (default) | Allow | Ignore
    public function setSymlinkPolicy(StaticSymlinks $p): static;        // Reject (default) | Follow | OwnerMatch
    public function hide(string ...$globs): static;
    public function setEtagEnabled(bool $enabled): static;              // weak ETag, default true
    public function setCacheControl(string $value): static;
    public function setHeader(string $name, string $value): static;     // declarative, no callbacks
    public function setBrowseEnabled(bool $enabled): static;            // default false
    public function setMimeType(string $extension, string $contentType): static;
}

$server->addStaticHandler(StaticHandler $h): static;

Builder class (not array $options) — symmetric to HttpServerConfig, gives IDE autocomplete and per-setter validation. No PHP callback per request — that re-enters the VM and kills the zero-coroutine fast path.

Architecture (key decision)

Dispatch happens in C, before ZEND_ASYNC_NEW_COROUTINE at src/core/http_connection.c:1614. The static path is a pure FSM driven by uv_fs_openfstat → read-loop → close. No coroutine alloc, no zend_try, no PHP-VM entry on matched static requests.

if (UNEXPECTED(server->static_handler_count > 0)) {
    http_static_result_t rc = http_static_try_serve(server, conn, ctx, req);
    if (rc == HTTP_STATIC_HANDLED) return;        // C owns lifecycle
    if (rc == HTTP_STATIC_ERROR)   { emit_short_error(...); return; }
    /* PASSTHROUGH falls through to coroutine + PHP handler */
}

A shared http_request_finalize(conn, ctx) helper, extracted from http_handler_coroutine_dispose, runs from both the coroutine path and the static FSM tail.

For HTTP/2 / HTTP/3 the file is plumbed as an nghttp2 / nghttp3 data provider — natural fit, still zero coroutine.

Hot-path budget

Documented in detail in §4 of the plan. Highlights:

  • One open(O_RDONLY|O_CLOEXEC) + one fstat per request. No probe-stat.
  • 64 KiB read buffer, single emalloc per request, freed in close-callback.
  • Built-in MIME table (perfect-hash / sorted binary search), per-mount overrides only on miss.
  • Weak ETag = mtime_ns ^ (size << 17) ^ ino, single 64-bit mix, 16 hex chars.
  • EXPECTED / UNEXPECTED on dispatcher and FSM branches.
  • All per-mount config validated and materialized at addStaticHandler time, not per request.
  • writev-coalesced headers + first read chunk for small files (H1 plain).

PR breakdown

PR Scope Blocks
#1 StaticHandler class, dispatch hook (H1 only), MIME, traversal guard, weak ETag, conditional GET, uv_fs_* chain, http_request_finalize extraction. PHPT matrix: 200 / 304 / 404 / 403 / traversal / dotfile-deny.
#2 H2/H3 integration via nghttp2/nghttp3 data providers. PHPT matrix on H2 + H3. #1
#3 Range support (single, suffix, multipart/byteranges, If-Range, 416). #1
#4 Precompressed sidecars .br / .gz / .zst. Reuses compression/http_compression_negotiate.c. #1, #3
#5 sendfile(2) / splice(2) zero-copy fast path (H1 plain, no on-the-fly encoding). #1
#6 Optional browse (dir listing). Lowest priority. #1

After PR #1+#2+#3+#4 land: rewrite entry.php to drop the in-memory map and the hand-rolled encoding chooser, then flip frameworks/true-async-server/meta.json to "type": "production" for the static profiles. Closes FUTURES #2.

Acceptance

  • StaticHandler class compiles, stubs regenerate, arginfo hash matches.
  • addStaticHandler rejects calls after start().
  • enablePrecompressed('foo') throws at the setter, not at start().
  • Path traversal (.., %2e%2e, %00, NUL, absolute paths) → 400/404.
  • Symlink policy: Reject 404s, Follow serves, OwnerMatch serves only when owner-of-link == owner-of-target.
  • If-None-Match / If-Modified-Since → 304 with empty body.
  • Dotfile policy default Deny → 404 on /.git/config.
  • on_missing: Next falls through to addHttpHandler; default NotFound returns 404 in C.
  • Telemetry confirms zero coroutines spawned on matched static requests.
  • wrk -c 256 -t 4 -d 30 /static/main.css does not regress vs current entry.php; expected 5–15% gain from the coroutine skip.
  • No new leaks under valgrind on a million-request run.

Open questions parked

  1. Per-worker FD/stat cache (open_file_cache-style) — defer until bench shows it matters.
  2. Per-listener static handler scoping (admin port wants no static) — currently global, additive option later.
  3. browse HTML dir listing — PR Support running on stock PHP (without TrueAsync fork) #6, low priority.

Anchors

  • Insertion site: src/core/http_connection.c:1562 (http_connection_dispatch_request).
  • Coroutine spawn to skip: src/core/http_connection.c:1614.
  • Compression hook order: src/core/http_connection.c:1601.
  • H2 dispatch: src/http2/http2_strategy.c:156.
  • H3 dispatch: src/http3/http3_dispatch.c:134.
  • Handler registration template: src/http_server_class.c:944-966 (addHttpHandler).
  • Config-class template: src/http_server_config.c.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Status

In Progress

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions