This documentation is still in progress. Some information may be incomplete or out of date. Please check back later.
Docs/LSAPI & PHP
Internals
LSAPI & PHP
httpjet speaks the native LSAPI wire protocol to an operator-configured lsphp executable — no FastCGI shim, no translation layer. hj-lsapi holds the wire codec, a generation-aware UDS client pool, the lsphp supervisor, the CGI environment builder, and a fail-closed jail. The production shape is a persistent external pool so the web tier restarts without cold-starting PHP.
Overview
When suffix routing maps a request to a PHP handler (<phpConfig> or an lsapi extProcessor bound by a scriptHandler, or a .htaccess SetHandler application/x-httpd-php), httpjet talks LSAPI directly to lsphp. PHP is optional: static, proxy and FastCGI deployments need no LSAPI executable.
httpjet lsphp process (external mode, the production shape).lsapidef.h
Operator-supplied lsphp
Generation-tracked UDS pool
Candidate-first zero-downtime reload
Fail-closed jail
The LSAPI wire codec
proto/{frame,builder} implement the protocol straight from the vendored php-src lsapidef.h constants (BSD-3-Clause), so frames match byte-for-byte what lsphp reads. One sharp edge deserves stating precisely: every env key/value and known header value is length-prefixed with a big-endian u16 that includes the trailing NUL, so the largest safe field is u16::MAX − 1 bytes. A longer field would wrap the prefix and desync lsphp's parser for every subsequent frame; the builder rejects it.
// each env/header field: [u16 BE = byte_len + 1] [bytes…] [0x00]
const MAX_FIELD: usize = (u16::MAX as usize) - 1; // 65534The response-header parser is one of the eight fuzz targets (lsapi_resp_header). Inbound tracing context reaches PHP as HTTP_TRACEPARENT in the unknown-header table when OpenTelemetry is on.
The UDS connection pool
Requests reach lsphp over a Unix-domain socket fronted by a pool that carries a generation epoch. In owned mode the epoch is process-local; in external mode it is the 16-byte generation file below, mapped read-only. A bodyless idempotent request whose pooled connection dies before the send gets one transparent retry on a fresh dial, bounded by the processor's retryTimeout. A fresh-dial EAGAIN under backlog pressure is retried and counted (httpjet_lsapi_eagain_*). Accepted connections keep TCP keepalive so a vanished client cannot pin pool permits.
Attribution is what makes generation retirement safe: for every connection the pool identifies the accepting worker through UNIX_DIAG peer-inode lookup plus /proc, pins it with a pidfd, and — after the response completes — retires a stale-generation worker through that pidfd rather than by pid (workers call setsid() and are not reliably in the master's tree). Failures to attribute are counted, never guessed.
| Metric | Meaning |
|---|---|
httpjet_lsapi_generation_advances_total | Epoch advances this web process observed. |
httpjet_lsapi_stale_idle_drops_total / _stale_checked_out_drops_total | Idle sockets discarded / checked-out sockets refused re-pooling after an advance. |
httpjet_lsapi_stale_worker_retire_signals_total / _failures_total | Old-generation workers retired via pinned pidfd after their response completed / signals that failed. |
httpjet_lsapi_worker_attribution_failures_total | Connections whose worker could not be pinned. |
httpjet_lsapi_acquire_seconds · _dispatch_seconds · _ttfb_seconds | Pool contention · backend call time · request-committed-to-first-byte (worker pickup + render start). |
The lsphp supervisor
In spawn mode (serve without --lsphp-external) hj-lsapi spawns and supervises lsphp itself: the listen socket is passed on fd 0, PR_SET_PDEATHSIG ties a worker's life to its parent, and the pre_exec closure between fork and exec calls only async-signal-safe syscalls. The process is a subreaper and tracks each generation family by an injected LSAPI_Z_HTTPJET_GENERATION_MARKER plus pidfds. The config's LSAPI_CHILDREN env is stripped so --php-children / maxConns is the only cap (values below 2 normalize to 2).
Two refusals are unconditional (2026-09-16): the supervisor never runs workers as root (resolve_credentials rejects uid/gid 0 even if suEXEC would compute it) and refuses to spawn a setuid lsphp binary.
--php-max-process-time-ms sets a total per-request deadline: exceeded → 504. In spawn mode the monitor also kills and restarts the worker; under --lsphp-external there is no monitor in the web process, so only the 504 applies and a wedged worker is not auto-reclaimed here — keep the value conservative.
httpjet ships no PHP runtime. The executable, its extensions, php.ini, opcache settings and writable application paths are operator-managed. For the pool unit's sandboxing the repository ships a validated hardening runbook (systemd/httpjet-lsphp.service.d/hardening.conf: a bounded capability set, the standard Protect*/Restrict* family, and MemoryDenyWriteExecute=no because opcache JIT needs RWX, with the opcache helper steps run via +-prefixed ExecStartPre/Reload) — validated against a live pool, but not applied by any unit by default; adopting it is the operator's call.
Zero-downtime deploys: the external pool
Spawn mode couples PHP's lifetime to the web process — a binary deploy cold-starts opcache. The httpjet lsphp subcommand instead runs a persistent pool as its own systemd unit (httpjet-lsphp.service, Type=notify, socket-activated through httpjet-lsphp.socket); the web tier dials it with serve --lsphp-external <socket> --lsphp-generation-file <file> and never spawns workers itself. Both halves then deploy independently:
Web tier deploy
systemctl restart httpjet- systemd holds the :80/:443 fds — no RST for new or queued connections
- PHP workers and opcache stay warm
PHP recycle
systemctl reload httpjet-lsphp→httpjet lsphp-reload- candidate-first: a new family must answer a real, marker-attributed LSAPI request before promotion
- the systemd-owned listener is never closed; queued dials survive
diagram source (mermaid)
sequenceDiagram
autonumber
participant O as operator<br/>systemctl reload httpjet-lsphp
participant SV as httpjet lsphp<br/>(supervisor, Type=notify)
participant L as LSAPI listener<br/>(systemd-owned socket, backlog 8192)
participant G as generation file<br/>/run/httpjet/lsphp.generation (16 B)
participant W as httpjet serve<br/>(web tier, --lsphp-external)
O->>SV: lsphp-reload over root-only control socket (timeout 90 s)
SV->>SV: spawn CANDIDATE family (new marker LSAPI_Z_HTTPJET_GENERATION_MARKER)
SV->>L: candidate workers accept on the SAME listener fd
SV->>SV: require one real, marker-attributed LSAPI response
SV->>G: advance epoch + promoted marker (atomic 16-byte write)
W->>G: observe new epoch (mmap)
W->>W: drop idle pooled sockets — never re-pool checked-out ones
W->>W: attribute each in-flight socket's worker (UNIX_DIAG + /proc) — retire stale worker via pinned pidfd AFTER its response completes
SV->>SV: drain old family, bounded forced cleanup (pidfds retained through every exit path)
SV-->>O: done — listener never closed, queued dials preserved# the PHP half — its own unit, its own identity
httpjet --root /abs/root lsphp --php-socket /run/httpjet/php.sock --php-children 256 \
--control-socket /run/httpjet/lsphp-control.sock \
--generation-file /run/httpjet/lsphp.generation
# the serving half — dial, never spawn; restart freely
httpjet --root /abs/root serve … --lsphp-external /run/httpjet/php.sock \
--lsphp-generation-file /run/httpjet/lsphp.generation
# opcache/PHP recycle without dropping the listener
httpjet lsphp-reload --control-socket /run/httpjet/lsphp-control.sock --timeout-secs 90The 16-byte file (epoch + promoted-family marker fingerprint) is how a web tier tells its pool from a foreign one. Supplying --lsphp-generation-file is fail-closed: startup aborts if it is missing or malformed. Its parent directory must not be group/other-writable. Test pools default their control and generation files beside their own socket so they can never advance the production epoch — which would drop the live web tier's pooled connections and retire its workers against a foreign marker.
httpjet-lsphp.socket is the continuity boundary: keeping it open preserves the listener inode and its 8192-deep accept queue. Replace the supervisor by restarting only httpjet-lsphp.service; it reapplies the XML backlog to the inherited fd with listen(2). Verify with stat of the socket inode before/after, a 16-byte generation file, and ss -xln Send-Q = 8192; LIVE=1 scripts/lsphp-reload-test.sh is the live gate.
The CGI environment
The env builder assembles the CGI/LSAPI environment per request. The load-bearing split is what was requested vs what runs: the LsapiScript request extension pins a resolved SCRIPT_FILENAME while the original REQUEST_URI is kept, with SCRIPT_NAME/QUERY_STRING pushed into ctx.env and upserted over the builder's defaults. That is what makes front-controller routing (every page → /index.php) correct.
| Variable | What it holds |
|---|---|
REQUEST_URI | The original request URI, preserved through rewrites. |
SCRIPT_FILENAME / SCRIPT_NAME | The resolved on-disk script and its URI-space name. |
REMOTE_ADDR, SERVER_NAME, HTTPS, CONTENT_LENGTH, HTTP_AUTHORIZATION, … | Server-computed. Since 2026-09-16 a .htaccess [E=…] or processor env entry that names one of these is denied (shared deny-list with FastCGI) — a directory cannot override the server's view of the client. |
HTTP_* | Request headers after name/value validation, under one forwarding rule (2026-10-06, applied identically to the CGI env table, the LSAPI wire header block and FastCGI params): the Proxy header and underscore-named headers never pass, and forwarding-authority names (CF-Connecting-IP, X-Forwarded-*, X-Real-IP, True-Client-IP, Forwarded) pass only from a peer that is both a trusted proxy and — on a client-cert listener — presented one. HTTP_TRACEPARENT when OTel is enabled. |
REMOTE_USER | Set by a successful Basic auth realm. |
| special-env section | php_value / php_admin_value / php_flag / php_admin_flag from .htaccess, carried on the LSAPI special-env wire path with the right permission level — plain lsphp ignores a regular CGI PHP_VALUE. |
The jail
PHP execution is fenced by a config-gated jail — suEXEC (<suexec> with uidMin/gidMin floors), chroot (chrootMode 0–2, per-vhost chrootPath) and optional Linux namespaces (mount, net, uts, ipc; pid is accepted but deliberately not honored). Resolution has two outcomes and no third:
- suEXEC off, non-root process, or no per-vhost isolation → the all-None case: the default
"php"pool, single-pool behavior preserved byte-for-byte. - A configured jail that turns out unsafe — uid 0, a uid below the floor, a bad chroot, an out-of-range
setUIDMode/chrootMode(hard error since 2026-09-16), or a chroot without credentials to run inside it (hard error since 0.0.5) → httpjet disables PHP for that vhost and returns 503. It never runs unjailed, and it never falls through to the static handler.
A script that resolves to a PHP handler is identified as PHP even when no pool can serve it. Falling through to static would hand back the raw .php source, so an unavailable pool is a 503, always. Operators sharing one pool across vhosts should read the "shared PHP trust boundary" note in the repository's security docs: workers in one pool share credentials, so per-vhost isolation is a suEXEC decision, not a default.
Request bodies
LSAPI needs a concrete m_reqBodyLen before the body is sent, so chunked / no-Content-Length uploads are collected first. collect_to_cap enforces the effective maxReqBodySize as frames arrive — the instant the accumulated size would exceed the cap it returns PayloadTooLarge (413) — and every buffered byte is charged to the process-wide body budget through an owned lease that is released on completion or on connection teardown (H2/H3 teardown leaks were closed in the 2026-09-06 review).
PHP/LSAPI over HTTP/1.1: ~44.8k req/s vs LiteSpeed's ~38.5k at c=40 on the same hardware (LiteSpeed's best, ~41.2k, needed c=60). In production the render inside PHP is ~90% of request wall time; pool acquire is p99.9 < 1 ms.
loopback · PGO · 2026-06 · production telemetry 2026-08-30httpjet · documentation · synced to the source tree on 2026-10-13 (v0.0.5).
Source: github.com/faratech/httpjet · Home: httpjet.net · Found an error? Open an issue on the repository.