Skip to content

HTTP Server Architecture

This document covers the HTTP server design, PHP execution model, request lifecycle, and configuration system.


Request Lifecycle

   Client request
        │
        ▼
   TCP accept (tokio async)
        │
        ▼
   HTTP parse (hyper — HTTP/1.1; HTTP/2 over TLS)
        │
        ▼
   Router::handle (wrapped by request timeout)
        │
        ▼
   Security checks
     · hidden files · blocked paths · trusted proxy
        │
        ▼
   Fallback resolution
     $uri → $uri/ → rewrite or status
        │
        ▼
   Resolves to:
        │
        ├──── PHP file ─────► Allowlist check ──┐
        │                     Body size check    │
        │                     PHP execute        │
        │                     Gzip               │
        │                                        │
        ├──── Static file ──► Path traversal ───┤
        │                     MIME detect       │
        │                     Cache-Control     │
        │                     Gzip              │
        │                                       │
        └──── Status code ──► 404 / 403 / etc.──┤
                                                ▼
                                            Response

PHP Execution Model

Per-Request Lifecycle

Each HTTP request runs a full php_request_shutdown() / php_request_startup() cycle (ephpm_execute_request() in ephpm_wrapper.c) — the classic php-fpm isolation model. Request shutdown destroys user functions, classes, constants, statics, and the global symbol table, so nothing leaks between requests. OPcache’s compiled bytecode lives in shared memory and survives the cycle, so the opcode cache (and JIT buffer) are preserved.

Per-request sequence:

  1. Tear down the previous request (php_request_shutdown()); reset the thread-local output/header buffers and the POST read cursor
  2. Populate SG(request_info) (method, URI, query string, content type, cookies, content length) before startup — the SAPI callbacks read these fields during startup
  3. Set a non-NULL SG(server_context) sentinel — sapi_activate() only parses the POST body into $_POST when the server context is non-NULL (the same gate cli-server/cgi use)
  4. php_request_startup() builds all superglobals natively through the SAPI callbacks
  5. Reset response state (http_response_code = 200, headers_sent, no_headers) so an explicit status from a prior request on the same worker thread can’t leak
  6. Replay buffered per-request INI overrides (e.g. per-vhost open_basedir) at PHP_INI_STAGE_ACTIVATE — they’re buffered because applying them before startup would be undone by the shutdown/startup cycle
  7. Execute the script under bailout protection

An earlier design reused one long-lived embed request and manually rebuilt the superglobals (destroying PG(http_globals) and re-running sapi_module.treat_data). That manual rebuild was removed: once the per-request lifecycle called php_request_startup() every request, it destroyed arrays startup had just created and caused a use-after-free SIGSEGV under load on tokio spawn_blocking threads. Superglobal construction is now owned entirely by php_request_startup().

Superglobal Population

All superglobals are built natively by PHP request startup, driven by the installed SAPI callbacks:

VariableSource
$_SERVERregister_server_variables callback — values provided by Rust (PhpRequest::server_variables())
$_GETParsed by PHP from SG(request_info).query_string during request startup
$_POST / $_FILESParsed natively by sapi_activate() (including multipart/rfc1867), fed by the read_post callback
$_COOKIEread_cookies callback returns the raw Cookie header; PHP parses it
$_REQUESTBuilt by PHP from $_GET + $_POST + $_COOKIE per request_order

$_SERVER Variables

Key distinction after fallback rewrites (e.g. /blog/hello -> /index.php):

VariableValueDescription
REQUEST_URI/blog/helloOriginal URI from client
SCRIPT_NAME/index.phpResolved script (relative to docroot)
SCRIPT_FILENAME/var/www/html/index.phpAbsolute path to script
PHP_SELF/index.phpSame as SCRIPT_NAME
DOCUMENT_ROOT/var/www/htmlDocument root
QUERY_STRINGpreview=trueWithout leading ?
GATEWAY_INTERFACECGI/1.1Required by many PHP apps
REDIRECT_STATUS200Required by some PHP apps

HTTP headers are mapped to HTTP_* variables, except Content-Type -> CONTENT_TYPE and Content-Length -> CONTENT_LENGTH (no HTTP_ prefix per CGI spec).

Thread Safety

PHP is compiled with ZTS (Zend Thread Safety). Each spawn_blocking thread auto-registers with TSRM on first use, getting its own isolated PHP context. Multiple PHP requests execute concurrently. The Mutex<Option<PhpRuntime>> only protects one-time init/shutdown, not request execution. Windows builds use NTS with serialized execution via mutex.

Signal Handling

PHP installs a SIGPROF handler for max_execution_time. This signal is process-wide and would crash tokio worker threads (NULL dereference in PHP’s handler on non-PHP threads). On Linux we override PHP’s signal functions with no-ops via GNU ld’s --wrap linker flags. macOS’s ld64 and MSVC’s link.exe don’t support --wrap (see crates/ephpm/build.rs), so the wrapping is not applied there. On all platforms, request timeout enforcement is done at the HTTP layer, not by PHP’s signal-based timer.

Bailout Protection

PHP uses setjmp/longjmp for error handling. All PHP calls go through ephpm_wrapper.c which wraps execution in zend_try/zend_catch. PHP 8.x exit()/die() throws an unwind exit exception, which we detect and treat as a normal response (with captured output).

PHP Fatal → HTTP 500

A PHP fatal error must surface as an HTTP 500 even though the embed SAPI gives us several different ways for one to happen. ephpm_execute_request() in ephpm_wrapper.c covers two distinct detection paths:

  1. zend_bailout() longjmp. Out-of-memory, max-execution-time, and the older fatal-class errors call zend_bailout(), which longjmps out of php_execute_script. The SETJMP(__bailout) == 0 guard in the wrapper catches that case and sets fatal_bailout = 1.

  2. PHP 8.x uncaught Throwable. When a script throws and nothing catches it, zend_exception_error() formats the message via zend_error_va(... | E_DONT_BAIL ...) and lets php_execute_script return normally. SETJMP sees nothing — no longjmp ever happens. To catch this path the wrapper also checks PG(last_error_type) against a fatal-class mask (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE). Without that second check, “Fatal error: Uncaught Error: Call to undefined function …” comes back as 200 OK.

PG(last_error_type) is reset to 0 before each request so a fatal from a previous reuse of the embed request can’t leak into the next one.

Once a fatal has been detected by either path, the wrapper only overrides the status when it is still the default 200. Anything the script set explicitly via http_response_code() or exit($status) is preserved — the contract is “200 → 500 on fatal”, not “always 500 on fatal”.

SAPI Callbacks

CallbackPurpose
ub_writeCaptures PHP output into a growable buffer
read_postFeeds POST body from Rust to PHP
read_cookiesReturns raw Cookie header string
register_server_variablesPopulates $_SERVER from Rust-provided key/value pairs
send_headersNo-op (headers captured separately after execution)
log_messageRoutes PHP errors to stderr

Response Header Capture

After script execution, headers are read from SG(sapi_headers).headers. If no explicit Content-Type was set by PHP (e.g. phpinfo() relies on the default), we synthesize one from SG(sapi_headers).mimetype or PHP’s default_mimetype/default_charset settings.

Server Setup

Initialization Sequence

PHP must be initialized before the tokio runtime to avoid signal conflicts:

1. Parse CLI args + load config        (single-threaded)
2. Init tracing with configured level  (single-threaded)
3. Init PHP runtime                    (single-threaded)
   - php_embed_init()
   - ephpm_install_sapi()
   - ephpm_apply_ini_settings()
   - ephpm_finalize_for_http()
4. Create tokio runtime                (spawns worker threads)
5. Run HTTP server
6. Shutdown PHP runtime

Hyper Connection Settings

SettingSourcePurpose
keep_alive(true)hardcodedHTTP/1.1 persistent connections
header_read_timeoutserver.timeouts.header_readSlow client header protection
max_buf_sizeserver.request.max_header_sizeHeader size limit
TimerTokioTimerRequired for timeout functionality

Static File Serving

  • MIME type detection via mime_guess (file extension based)
  • Path traversal protection via canonicalize() + prefix check
  • Gzip compression for compressible content types above minimum size
  • Cache-Control header when configured
  • ETag generation (weak, hash-based) + If-None-Match → 304 Not Modified

Percent-Decoding of URI Paths

hyper hands the router the raw URI path, so /test%2Ehtml would otherwise be looked up as the literal name test%2Ehtml. Before any routing or filesystem lookup happens, the request path is run through percent_decode_path() (in crates/ephpm-server/src/router.rs) so %XX escapes resolve to their bytes — /test%2Ehtml becomes /test.html and matches the file on disk.

The decoder is deliberately strict:

InputResult
%XX with valid hexdecoded to the byte
Truncated %, %X (one digit)400 Bad Request
Non-hex digits (%ZZ, %G1)400 Bad Request
Encoded slash (%2F) or backslash (%5C)400 Bad Request
Decoded byte stream not valid UTF-8400 Bad Request

Rejecting %2F / %5C is what keeps percent encoding from being used to sneak past path-traversal protection or prefix-based blocks like /vendor/* — a request such as /vendor%2Fconfig.php cannot decode into a /-containing path that bypasses the glob check. UTF-8 validation on the decoded bytes lets non-ASCII paths work normally while still rejecting malformed escape sequences.

PHP Response Cache

The static file ETag support only covers non-PHP assets. PHP frameworks (WordPress, Laravel) generate their own ETag headers for dynamic content, but without help every request still hits PHP to compute whether the content changed.

Implemented: the ETag-based 304 short-circuit. Configured via [server.php_etag_cache] (enabled by default; see Router::handle in crates/ephpm-server/src/router.rs). When PHP sets an ETag on a GET/HEAD response, the server stores it in the KV store keyed by method + path + query string. A repeat request with a matching If-None-Match returns 304 Not Modified without executing PHP at all. In clustered mode the KV entries replicate via gossip, so the short-circuit works across nodes.

Future work: full-response caching (serving the cached body on requests without If-None-Match), which would turn ePHPm into an edge cache — see the design decisions below.

Flow (as implemented)

1. First request: /blog/hello
   → PHP executes, returns response with ETag: "abc123"
   → Server stores in KV: <key_prefix><method>:<path>?<query> → "abc123" (with TTL)
   → Response sent to client

2. Repeat request: /blog/hello + If-None-Match: "abc123"
   → Server checks KV for the stored ETag
   → ETag matches → return 304 Not Modified immediately
   → No PHP execution

3. Works across all nodes via gossip replication

Design Decisions

DecisionOptionsNotes
Cache keyURL alone vs URL + vary headers (cookies, auth)Must not serve cached authenticated pages to anonymous users. WordPress sets different cookies for logged-in users — key should include a cookie-based cache group or skip caching entirely when auth cookies are present.
InvalidationTTL, purge header, PHP hookTTL is simplest. A X-Ephpm-Cache-Purge response header from PHP could signal immediate invalidation. For WordPress, a must-use plugin could call a purge endpoint on content updates.
Storage scopeETag-only (304s) vs full response (edge cache)ETag-only saves KV space but still requires PHP on cache miss. Full response storage turns ephpm into an edge cache — much bigger win but needs memory/eviction policy. Start with full response.
Cache bypassCache-Control: no-cache, no-store, privateRespect standard HTTP cache directives from PHP. Never cache responses with Set-Cookie or private.

Impact

This is a significant performance multiplier for PHP applications. Most WordPress page views are anonymous and return identical content. Skipping PHP entirely for repeat visitors frees the spawn_blocking worker threads and lets the async HTTP server handle cached responses at full throughput across all nodes.

TLS

Manual TLS via rustls (pure Rust, no OpenSSL dependency). Certificate and key loaded from PEM files at startup.

Modes

ConfigBehavior
No [server.tls]Plain HTTP on server.listen (default)
tls.cert + tls.key onlyHTTPS on server.listen, no HTTP listener
tls.cert + tls.key + tls.listenHTTPS on tls.listen, HTTP on server.listen
+ tls.redirect_http = trueHTTP listener sends 301 redirects to HTTPS

Connection Flow (TLS)

TCP Accept → TLS Handshake (tokio-rustls) → HTTP/1.1 or HTTP/2 → Router
                  ↓ timeout
         header_read_timeout
  • TLS handshake timeout reuses server.timeouts.header_read (default 30s)
  • ALPN advertises h2 and http/1.1 (h2 preferred — see crates/ephpm-server/src/tls.rs); hyper-util’s auto::Builder serves whichever protocol was negotiated. Plain-TCP listeners are HTTP/1.1 only.
  • is_tls flag propagated to router so $_SERVER['HTTPS'] is set correctly
  • When behind a trusted proxy, X-Forwarded-Proto takes precedence over native TLS status

Automatic TLS (ACME)

Zero-config HTTPS via Let’s Encrypt, like Caddy. Uses rustls-acme crate with TLS-ALPN-01 challenge (works on port 443 alone, no port 80 needed).

Single-node (implemented): DirCache stores certs on the filesystem. On startup, requests a cert from Let’s Encrypt (~5-30s), then hot-swaps on renewal. No restarts needed. Uses LazyConfigAcceptor to inspect each TLS ClientHello — ACME challenges are handled inline, normal connections pass through to hyper.

Renewal timing: rustls-acme renews at 2/3 of remaining certificate validity (~30 days before expiry for standard 90-day Let’s Encrypt certs). This is hardcoded in the library — there is no API to configure it. If we need customizable renewal timing in the future (e.g., for shorter-lived certs or different CAs), options are: contribute the feature upstream to rustls-acme, or switch to instant-acme which gives full control over the ACME flow at the cost of managing renewal scheduling ourselves.

[server.tls]
domains = ["example.com", "www.example.com"]
email = "admin@example.com"
cache_dir = "/var/lib/ephpm/certs"
# staging = true  # use for testing to avoid rate limits

Clustered (requires KV store and gossip): In a multi-node deployment, naive ACME creates several problems that the clustered KV store addresses. The implementation lives in crates/ephpm-server/src/acme.rs.

ProblemWhat happensWhat ePHPm does
Renewal stampedeN nodes all try to renew simultaneously; five duplicate certificates for the same domain set in a week is a hard Let’s Encrypt lockoutOnly the elected leader drives the rustls-acme state machine. A follower’s cache lookup is held open instead of reporting a miss, and a miss is the only thing that makes rustls-acme place an order.
Challenge routingLet’s Encrypt connects to the domain, DNS round-robins to any node, but only the ordering node holds the challenge materialNot yet implemented. ePHPm uses TLS-ALPN-01, and rustls-acme keeps that challenge certificate in the ordering node’s in-memory resolver — it is not shared. Validation succeeds only when Let’s Encrypt’s connection reaches the leader. Point the domain at a single node, or use manual TLS, until challenge sharing lands. (ephpm-server can serve an HTTP-01 response out of acme:challenge:<token>, but nothing writes those keys yet.)
Cert distributionAfter one node obtains the cert, all nodes need itThe leader’s cache writes the PEM to the KV store (acme:cert:<domains>:cert:<directory-hash>) as part of the rustls-acme state machine; gossip replicates it, and each follower loads it on its own first cache lookup.
Leader electionOnly one node should drive ordering and renewalacme:leader key with a TTL heartbeat, plus a lowest-node-id tie-break.

Leader election is not a strict lock. The gossip KV tier exposes only last-write-wins set/get/delete over eventually consistent state — there is no compare-and-swap to build a real distributed lock on. The claim is atomic within a process (SETNX), and across nodes it converges via the tie-break: a node that observes a different holder yields unless its own id sorts lower. A claim must survive two consecutive heartbeats before the node acts on it, which keeps the ~1-3s gossip propagation window from turning into duplicate orders.

Not yet implemented — follower renewal pickup. A follower loads the leader’s certificate once, at startup. rustls-acme consults its cert cache exactly once per AcmeState, and its resolver’s set_cert is pub(crate), so a renewed certificate cannot be pushed into a running follower from outside the state machine. Followers keep serving the certificate they started with until they restart. Closing this requires a KV-backed ResolvesServerCert implementation of our own.

The rustls-acme crate has a pluggable Cache trait, which is what makes this possible: LayeredCache writes through to both a KvCache (cluster-wide) and the local DirCache, and prefers the KV tier on read.

Single-node:  AcmeConfig → DirCache (filesystem)
Clustered:    AcmeConfig → LayeredCache → KvCache (gossip-replicated) + DirCache (local)

Compression

Applied to both PHP and static responses based on the client’s Accept-Encoding header. Brotli (br) is implemented and preferred when the client accepts it (better ratio); gzip is the fallback for clients that only accept gzip (see build_php_response in crates/ephpm-server/src/router.rs).

CheckCondition
Enabledserver.response.compression = true
AlgorithmBrotli if the client accepts br, else gzip if it accepts gzip, else identity
Min sizeResponse body >= server.response.compression_min_size
Content typetext/*, *javascript, *json, *xml, *svg
SmallerCompressed size < original size

Level controlled by server.response.compression_level (1=fast, 9=best).

Security Layers

Evaluated in order for every request:

  1. Hidden files — Paths with dot-prefixed segments (.env, .git, .htaccess) are blocked based on server.static.hidden_files (deny=403, ignore=404, allow=pass).

  2. Blocked paths — URI matched against server.security.blocked_paths glob patterns. Any match returns 403. Supports * wildcards (/vendor/*, /wp-config.php).

  3. PHP allowlist — When server.security.allowed_php_paths is non-empty, only matching PHP files execute. Others get 403. Prevents arbitrary PHP execution in upload directories.

  4. Body size limitContent-Length checked against server.request.max_body_size before reading the body. Returns 413.

  5. Path traversal — Static file paths canonicalized and verified within document root.

Trusted Proxy Resolution

When server.security.trusted_proxies contains CIDR ranges and the connecting IP matches:

  • X-Forwarded-For is parsed right-to-left, returning the first untrusted IP as REMOTE_ADDR
  • X-Forwarded-Proto: https sets $_SERVER['HTTPS'] = 'on'

Fallback Resolution

Nginx-style try_files implemented as a configurable fallback chain:

fallback = ["$uri", "$uri/", "/index.php?$query_string"]
  • Variables: $uri (request path), $query_string (raw query string)
  • Entries ending with / check for directory + index files
  • Last entry is the fallback: either a rewrite target or =NNN status code
  • For static-only sites: ["$uri", "$uri/", "=404"]

Configuration Reference

Implemented

ConfigTypeDefaultDescription
server.listenstring"0.0.0.0:8080"Bind address
server.document_rootpath"."Document root directory
server.index_filesstring[]["index.php", "index.html"]Index file names
server.fallbackstring[]["$uri", "$uri/", "/index.php?$query_string"]URL resolution chain
server.request.max_body_sizeint10485760 (10 MiB)Max request body (0=unlimited)
server.request.max_header_sizeint8192 (8 KiB)Max header buffer size
server.timeouts.header_readint30Seconds to receive headers
server.timeouts.idleint60Idle connection timeout (seconds)
server.timeouts.requestint300Total request timeout (seconds)
server.timeouts.shutdownint30Graceful shutdown drain: grace period for in-flight connections on SIGTERM
server.response.compressionbooltrueEnable response compression (Brotli preferred, gzip fallback)
server.response.compression_levelint1Compression level (1-9)
server.response.compression_min_sizeint1024Min bytes to compress
server.response.headers[string, string][][]Custom response headers (CORS, CSP, HSTS)
server.static.cache_controlstring""Cache-Control header for static files
server.static.hidden_filesstring"deny"Dotfile handling: deny, ignore, allow
server.static.etagbooltrueETag headers + 304 Not Modified support
server.request.trusted_hostsstring[][]Host header validation (421 if no match)
server.security.trusted_proxiesstring[][]CIDR ranges for proxy trust
server.security.blocked_pathsstring[][]Glob patterns to block (403)
server.security.allowed_php_pathsstring[][]PHP execution allowlist
server.logging.levelstring"info"Log level (trace/debug/info/warn/error)
server.logging.accessstring""Access log file path
server.tls.certpathPEM certificate chain file (enables HTTPS)
server.tls.keypathPEM private key file
server.tls.listenstringSeparate HTTPS listen address
server.tls.redirect_httpboolfalse301 redirect HTTP to HTTPS
server.tls.domainsstring[][]Domain names for ACME auto-TLS
server.tls.emailstringContact email for ACME registration
server.tls.cache_dirpath"certs"ACME certificate cache directory
server.tls.stagingboolfalseUse Let’s Encrypt staging environment
php.max_execution_timeint30PHP per-request timeout (seconds)
php.memory_limitstring"128M"PHP memory limit
php.ini_overrides[string, string][][]INI directive overrides
server.metrics.enabledboolfalsePrometheus metrics endpoint
server.metrics.pathstring"/metrics"Metrics endpoint path
server.limits.max_connectionsint0Max total concurrent connections (0 = unlimited)
server.limits.per_ip_max_connectionsint0Max concurrent connections per client IP
server.limits.per_ip_ratefloat0.0Max requests/second per client IP (token bucket)
server.limits.per_ip_burstint50Burst size for per-IP rate limiting

CLI Flags

FlagScopeDescription
-c, --configserveConfig file path (default: ephpm.toml)
-l, --listenserveListen address (overrides config)
-d, --document-rootserveDocument root (overrides config)
-vserveDebug logging (-vv for trace)

The full CLI has grown beyond serve: there are dev, php, and kv subcommands, plus service-lifecycle commands (install, uninstall, start, stop, restart, status). See the CLI reference for the complete list.

Precedence: RUST_LOG env var > -v flag > server.logging.level > "info"

Environment Variables

All config values can be overridden with EPHPM_-prefixed environment variables using __ as the nesting separator:

EPHPM_SERVER__LISTEN=0.0.0.0:9090
EPHPM_SERVER__TIMEOUTS__IDLE=120
EPHPM_PHP__MEMORY_LIMIT=256M

Roadmap

Rate limiting ([server.limits]), Prometheus metrics ([server.metrics]), and graceful shutdown drain (server.timeouts.shutdown) have shipped and moved to the Implemented table above.

ConfigDescriptionPriority
server.static.expiresPer-extension cache lifetimes (e.g., images 1yr, CSS 1wk)Medium
server.static.index_fallbackServe index.html for SPA routes (distinct from PHP fallback)Medium
server.response.server_headerCustom or disabled Server: header (fingerprinting prevention)Low
server.request.max_uri_lengthReject abnormally long URIs (defense in depth)Low
server.worker_threadsTokio worker thread count (auto-detect by default)Low
server.rewritesRegex-based URL rewritingLow
server.logging.formatText vs JSON structured loggingLow
server.logging.access_formatCommon/combined access log formatLow
php.envEnvironment variables passed to PHP (12-factor app support)Medium
php.disable_functionsShortcut for INI directiveLow
php.error_logSeparate PHP error log pathLow