Skip to content

feat(web): worker modes, EIR exception/GC correctness, and the opt-in web-scaling stack#456

Draft
Guikingone wants to merge 1 commit into
illegalstudio:mainfrom
Guikingone:feat/web-worker-script
Draft

feat(web): worker modes, EIR exception/GC correctness, and the opt-in web-scaling stack#456
Guikingone wants to merge 1 commit into
illegalstudio:mainfrom
Guikingone:feat/web-worker-script

Conversation

@Guikingone

@Guikingone Guikingone commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR consolidates the --web stack for elephc: the persistent-worker execution model, the EIR exception/GC correctness that makes long-lived workers leak-free, a per-request performance pass across all web modes, and the opt-in web-scaling stack (keep-alive, TLS, master fd-dispatch, handler offload, HTTP/2, RSS recycle, SIGHUP reload, metrics, static-asset fast path, worker CPU affinity). Every scaling flag is opt-in and OFF by default — the hot path is byte-for-byte unchanged when a flag is absent.

Web execution models

Three web modes share the prefork model (one process per worker, SO_REUSEPORT or master fd-dispatch, single-threaded I/O per worker, N=1 PHP handler per worker):

  • --web — classic prefork. Every request re-runs the whole top-level PHP and resets all state (function statics, static properties, globals, superglobals). Mirrors PHP-FPM: fully isolated per request, no shared state.

  • --web-worker (handler) — FrankenPHP-style. The top-level runs once at boot to build long-lived state, then hands a handler closure to the runtime via elephc_worker_register($handler), invoked per request. Statics / static properties / globals persist for the worker's lifetime.

  • --web-worker=script (non-intrusive) — the same persistence, but on unmodified, standard PHP with no proprietary registration API. The whole top-level is re-executed per request (like --web) while statics / static properties / globals persist (like the handler flavor), so a boot-once cache is expressed in pure, portable PHP:

    static $c = null;
    if ($c === null) { $c = build(); } // runs once for the worker's lifetime
    $c->handle();                       // runs on every request

    The script stays 100% standard PHP — still runnable under php-fpm / php -S. exit()/die() get a proper request-end bailout instead of killing the worker.

Request-scoped state ($_SERVER, $_GET, $_POST, $_FILES, $_COOKIE, $_REQUEST, php://input) is rebuilt fresh each request in every mode.

Long-lived-worker GC correctness

A persistent worker only survives if request churn is leak-free, so this PR lands the EIR ownership/GC fixes that make that true:

  • Per-frame activation-record cleanup on non-local unwindthrow/exit/die release each unwound frame's owned refcounted locals. EIR emits per-function cleanup callbacks walked via the _exc_call_frame_top chain.
  • Caught exception-object release + throwable message ownership — the compact Throwable persists an owned message copy (conditional on operand ownership), and getMessage()/__toString() return independent copies, so object_free_deep frees the message exactly once. The x86_64 decref/free paths accept heap kind 6 (throwables).
  • Refcount leak on Mixed-object property/method access — the Mixed→Object unbox incref is gated on Owned ownership, so a borrowed receiver load is not over-retained.
  • parent::__construct() on builtin Throwable subclasses — lowered to an inline message/code field stamp on $this instead of a call to the never-emitted builtin constructor symbol.
  • Top-level static null-guard SIGSEGV fix (so the boot-once idiom works at top level, not only inside a function).

Per-request performance pass (all web modes)

No behavior change (a program that reads every superglobal behaves exactly as before):

  • Usage-gated superglobals — request superglobals are built only when the program references them (detection over the fully-resolved AST, safe over-approximation). A handler that reads none pays no per-request superglobal cost.
  • $_ENV built once per worker in --web-worker (handler); --web and --web-worker=script keep $_ENV per-request fresh.
  • Per-request memory leak fixed — the request bridge now drops the previous request's parsed method/URI/headers/body before replacing them.
  • Latency & allocation cutsTCP_NODELAY; response gzip at the fast level; HTTP_* $_SERVER keys precomputed in Rust; request body kept refcounted instead of copied; connection builder constructed once per worker; redundant header/protocol string copies removed.

Web-scaling stack (opt-in, all 3 web modes)

12 commits on top of the worker-mode work. Each flag is opt-in, OFF by default, and bench-verified no-regression at each tier.

Concurrency & dispatch

  • Keep-alive rebalance (--max-requests-per-connection, --idle-timeout) — opt-in Connection: close after N + idle watchdog. Defaults 0/0 (rotation hurts mixed p99 in the bench; --dispatch master is the real load-distribution fix).
  • Master fd-dispatch (--dispatch master|kernel, default kernel) — the master accepts and hands accepted fds to preforked workers over a unix-domain socket, so SO_REUSEPORT's uneven kernel distribution doesn't starve workers.
  • Handler offload (--handler-offload, --max-pending) — runs the blocking PHP handler on ONE dedicated thread per worker, fed by a bounded mpsc queue, so I/O of other connections overlaps PHP execution. The N=1 lever that gives I/O-overlap without ZTS. Queue-full → 503 + Retry-After: 1.
  • HTTP/2 (--http2, --http2-max-streams, --http2-max-header-size) — h2c prior-knowledge; auto-requires --handler-offload (multiplexed streams stall on a single inline handler). ALPN h2-over-TLS wired (["h2","http/1.1"] vs ["http/1.1"]).

Ops & robustness

  • TLS termination (--tls-cert, --tls-key) — rustls 0.23; $_SERVER['HTTPS'] via a C-ABI getter.
  • RSS-based worker recycle (--max-rss MiB) — a worker whose VmRSS exceeds the cap self-exits to be respawned (Linux /proc/self/status + macOS mach_task_basic_info).
  • Zero-downtime SIGHUP reload (--reload-grace) — SIGUSR1 graceful drain vs SIGTERM hard; rolling one-at-a-time restart in both kernel and master dispatch.
  • Metrics endpoint (--metrics, --metrics-path /_status) — per-worker JSON snapshot (rps, latency p50/p99, status classes, 503 rate, RSS, active conns, h2 streams).
  • Static-asset fast path (--static-dir, --static-prefix, --static-cache-size, --static-max-age, --static-max-file-size) — serves files from a dir on the I/O thread before the PHP handler runs; per-worker LRU; ETag 304; path-traversal 404. Uses spawn_blocking + std::fs (no tokio::fs dep).
  • Worker CPU affinity (--worker-affinity) — pins each worker to CPU getpid() % ncpus (round-robin via consecutive PIDs). Linux: hard sched_setaffinity; macOS: advisory thread_policy_set tag (no hard pin). Best-effort, once at startup.

Hygiene

  • Global-state audit + adrp centralizationcrates/elephc-globals-audit/ extracts all mutable .comm/.globl + bridge statics, classifies CONST/RO_BOOT/SHARED_LOCK/TLS, versioned baselines per 3 targets, --check mode fails on a new unclassified global (silent-miscompilation gate). ~39 raw emitter.adrp outliers centralized. Ships an addressing-mode microbench (input to the ZTS decision below).

Concurrency model — final state & the ZTS decision

The concurrency unit is N=1 PHP handler per worker process. The scaling levers above tune how many workers, how they're dispatched, and how the I/O thread overlaps with the single handler — without a thread-safe runtime.

Why no ZTS (thread-safe runtime)? A data-driven probe settled this:

  • Per-worker RSS is ~2–4.5 MiB (measured under load): 32 workers = 61 MiB total. Process count is not memory-limited — prefork scale-out is essentially free.
  • Handler offload already gives the I/O-overlap ZTS would buy: under mixed fast/slow load, offload-ON drives the fast route to 31.5K rps p50 1.6ms while slow requests run on the handler thread — the fast route is never blocked by a slow one (head-of-line blocking is solved at N=1).
  • The 503s under oversubscription are correct load-shedding, not a pathology: the --max-pending bound (default 16) rejects overflow fast to protect the fast route's latency. Raising it to 128 drives 503s to 0 but collapses throughput to 332 rps p99 200ms (everything queues behind slow).
  • ZTS's only marginal benefit — more concurrent handlers per process — is replicated by more worker processes at ~2 MiB each, without the shared-runtime-state locking that PHP ZTS itself proves doesn't yield parallel PHP execution (one executor lock). The addressing-mode microbench confirms the per-thread-global mechanism would be native_tls (__thread, instruction-identical to the current adrp+add/RIP-relative baseline, measured equal) — NOT a reserved context register (2.5× slower in the microbench, and far more invasive). The build-vs-abandon question is settled by the scaling probe: abandon ZTS.

So the concurrency story is: prefork N workers × 1 handler + opt-in offload for I/O overlap + --max-pending shedding + scale workers to load. The knobs (--workers, --max-pending, --max-rss, --worker-affinity, --dispatch) cover the design space without a thread-safe runtime.

Tests

3-target (macos-aarch64 host; linux-x86_64 + linux-arm64 in CI). web_tests is 151 tests (worker + script modes, the perf pass, and the full scaling stack: keep-alive / TLS / fd-dispatch / offload / HTTP2 / ALPN / RSS / SIGHUP / metrics / static-asset / affinity), plus exceptions and runtime_gc for the EIR GC correctness, and the parent::__construct regressions. Updated examples and docs; behavior verified byte-identical to PHP where applicable. Zero compiler warnings; git diff --check clean.

No-regression benches (hello + mixed, 4 workers, release): Tier 1 hello 35234 rps p99 2.59ms / mixed 357.86 rps p99 182ms; Tier 2 hello 34922 rps p99 2.21ms / mixed 375.45 rps p99 186ms — both within the established noise band, 0 errors. The opt-in flags are byte-for-byte off by default.

@Guikingone Guikingone changed the title feat: --web-worker=script non-intrusive worker mode + EIR exception/GC correctness feat(web): add --web-worker=script non-intrusive worker mode + EIR exception/GC correctness Jul 4, 2026
@Guikingone
Guikingone marked this pull request as draft July 4, 2026 18:08
@Guikingone
Guikingone force-pushed the feat/web-worker-script branch 2 times, most recently from c2af4dd to ae0907d Compare July 4, 2026 20:51
@Guikingone

Guikingone commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @nahime0 👋🏻

This PR is highly experimental and inspired by #409 / FrankenPHP, I'm not totally convinced by the CLI options so don't consider the code as polished for now, the main goal is to introduce a "worker mode" that can either be "intrusive through the added function" or "non-intrusive" if you prefer to use Elephc only for production environnements while relying on FPM / Nginx on local ones.

It also brings native fixes for already implemented code as the worker mode forced me to debug exceptions and so on 😓

PS: No benchmarks for now as I'm trying to stabilize the code, a benchmark is planned once it's stable.

@Guikingone
Guikingone force-pushed the feat/web-worker-script branch from 904f9de to afc0c20 Compare July 5, 2026 16:45
@Guikingone Guikingone changed the title feat(web): add --web-worker=script non-intrusive worker mode + EIR exception/GC correctness feat(web): worker modes, EIR exception/GC correctness, and the opt-in web-scaling stack Jul 7, 2026
@Guikingone
Guikingone force-pushed the feat/web-worker-script branch 2 times, most recently from 8b55e94 to b37a177 Compare July 9, 2026 21:42
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. area:web Touches --web mode, its prelude, or elephc-web. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities. labels Jul 13, 2026
Adds the --web-worker=script mode, boot-before-fork optimization, and
associated runtime correctness fixes for the elephc web server.

Web worker modes:
- --web-worker=script: re-executes the top-level per request with
  persistent statics/globals (no register API needed, portable PHP)
- --web-worker (handler): boot PHP once in the master before fork;
  workers inherit the boot state via kernel copy-on-write

Boot-before-fork:
- elephc_web_worker_register() returns instead of diverging
- Workers enter enter_worker_loop directly, inheriting boot state via COW
- exit()/die() during boot longjmps back to the master (exit boundary)
- elephc_worker_register() is a no-op in CLI mode (same file runs
  everywhere: php-cli, elephc CLI, elephc --web-worker)

Immortal GC bit (0x40):
- __rt_end_boot_phase marks all live heap blocks as immortal after boot
- Cycle collector skips immortal blocks in all passes (AArch64 + x86_64)
- decref_any skips immortal blocks (never freed by per-request release)
- macOS C-ABI alias stub for ___rt_end_boot_phase with .no_dead_strip

ABI fix:
- nested_call_reg (x19/r12) unconditionally saved in every EIR function
  prologue and in the legacy runtime callable invoker

FD cleanup:
- Workers close all inherited FDs except stdio and boot-pipe after fork

Per-request cost reductions:
- Superglobals built only when referenced (lazy detection)
- TCP_NODELAY, gzip fast level, CString-direct header collection
- Request body kept as refcounted Bytes
- HTTP connection builder reused per worker
- $_ENV built once at boot in handler mode

Mixed-boxed COW:
- Cell-rc-aware COW for static array append (fixes O(n^2) clone+leak)
- __rt_array_uncow_if_cell_unique + __rt_mixed_slot_publish helpers

Deterministic interface/class ID assignment (sorted keys before ids)
@Guikingone
Guikingone force-pushed the feat/web-worker-script branch from b37a177 to be229e4 Compare July 15, 2026 07:26
@github-actions github-actions Bot added size:l Large pull request. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. and removed size:xl Very large pull request that needs deliberate review planning. labels Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. area:web Touches --web mode, its prelude, or elephc-web. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:l Large pull request. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants