Under construction

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.

clientHTTP/1·2·3 HTTP httpjetLSAPI handler · CGI env LSAPI · UDS lsphp pool — capped by --php-children / maxConns php #1 php #2 … php #N streamed response · php-slow log · lsapi_ttfb histogram
Native LSAPI over a Unix socket. The pool may be owned by the web process (spawn mode) or run as a separate httpjet lsphp process (external mode, the production shape).
Native LSAPI · vendored 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.

proto · the NUL-inclusive length prefix
// each env/header field:  [u16 BE = byte_len + 1] [bytes…] [0x00]
const MAX_FIELD: usize = (u16::MAX as usize) - 1;   // 65534

The 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.

MetricMeaning
httpjet_lsapi_generation_advances_totalEpoch advances this web process observed.
httpjet_lsapi_stale_idle_drops_total / _stale_checked_out_drops_totalIdle sockets discarded / checked-out sockets refused re-pooling after an advance.
httpjet_lsapi_stale_worker_retire_signals_total / _failures_totalOld-generation workers retired via pinned pidfd after their response completed / signals that failed.
httpjet_lsapi_worker_attribution_failures_totalConnections whose worker could not be pinned.
httpjet_lsapi_acquire_seconds · _dispatch_seconds · _ttfb_secondsPool 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.

!
Packaging boundary

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
httpjet serve(web tier, --lsphp-external)generation file/run/httpjet/lsphp.generation (16 B)LSAPI listener(systemd-owned socket, backlog 8192)httpjet lsphp(supervisor, Type=notify)operatorsystemctl reload httpjet-lsphplsphp-reload over root-only control socket (timeout 90 s)1spawn CANDIDATE family (new marker LSAPI_Z_HTTPJET_GENERATION_MARKER)2candidate workers accept on the SAME listener fd3require one real, marker-attributed LSAPI response4advance epoch + promoted marker (atomic 16-byte write)5observe new epoch (mmap)6drop idle pooled sockets — never re-pool checked-out ones7attribute each in-flight socket's worker (UNIX_DIAG + /proc) — retire stale worker via pinned pidfd AFTER its response completes8drain old family, bounded forced cleanup (pidfds retained through every exit path)9done — listener never closed, queued dials preserved10
Candidate-first generation reload. The supervisor promotes a new family only after a marker-attributed response, then advances the shared 16-byte epoch. Web pools observe the epoch, drop idle sockets, and retire each in-flight stale worker only after its response completes, through a pidfd pinned before the epoch moved. The old family is drained with bounded forced cleanup.
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
external pool wiring (see Operations for the units)
# 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 90
!
The generation file is the trust anchor

The 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.

!
Never restart the socket unit during a reload

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.

VariableWhat it holds
REQUEST_URIThe original request URI, preserved through rewrites.
SCRIPT_FILENAME / SCRIPT_NAMEThe 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_USERSet by a successful Basic auth realm.
special-env sectionphp_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:

!
Source-disclosure guard

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).

⎁
Measured

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-30

httpjet · 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.