Skip to content

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

Draft
Guikingone wants to merge 25 commits 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 25 commits 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 from 13c0c96 to 2f341ce Compare July 8, 2026 14:01
Guikingone added 21 commits July 9, 2026 23:02
…C correctness

Adds a third web execution mode, --web-worker=script, that runs the persistent
worker on unmodified, standard PHP: the whole top-level is re-executed per
request (like --web) while function statics, static properties, and globals
persist across requests (like --web-worker), so a boot-once cache is expressed
in pure portable PHP (static $c = null; if ($c === null) $c = build();) with no
proprietary registration API. Includes exit()/die() request-end bailout and an
EIR container/static-locals correctness suite.

Also lands the EIR ownership/GC correctness this mode requires for a long-lived
worker to stay leak-free:

- Per-frame activation-record cleanup on non-local unwind: throw/exit/die now
  release each unwound frame's owned refcounted locals (previously leaked out
  of functions, OOM-ing a persistent worker after ~hundreds of requests).
- Caught exception object release + throwable message ownership: the compact
  Throwable persists an owned message copy (conditional on operand ownership)
  and getMessage()/__toString() hand back independent copies, so object_free_deep
  frees the message exactly once; x86_64 decref/free paths accept heap kind 6.
- Refcount leak fix on property/method access of Mixed-object locals: gate the
  Mixed->Object unbox incref on Owned ownership so a borrowed receiver load is
  not over-retained (reassigning an object in a loop now frees the old one).
- parent::__construct() on builtin Throwable subclasses: lower it to an inline
  message/code field stamp on $this instead of a call to the never-emitted
  builtin constructor symbol, fixing a link failure for custom exception classes.
- top-level static null-guard SIGSEGV fix.

3-target green (macOS-aarch64 local; linux-x86_64 and linux-arm64 Docker).
Build only the request superglobals a program references (safe over-
approximation over the resolved AST), precompute each header's HTTP_* key
in Rust, keep the request body reference-counted, collect headers straight
into CString form, use a static protocol string, gzip at the fast level,
build the HTTP connection builder once per worker, and set TCP_NODELAY on
accepted connections. Fix a per-request leak where the request bridge
overwrote the prior request's method/URI/headers/body without freeing it.
In trampoline --web-worker mode $_ENV is now built once at boot and kept
for the worker's lifetime instead of being rebuilt per request.
Fix O(n^2) clone+leak on "static $c=null; $c[]=..." (the worker-script boot-once cache idiom). New runtime helpers __rt_array_uncow_if_cell_unique and __rt_mixed_slot_publish make copy-on-write aware of the boxed Mixed cell refcount; plus narrow releases for borrowed Mixed-boxed-static reads/copies/returns/foreach. 3-target (ARM64 + x86_64). Adds runtime_gc regression coverage.
Sort interface_map/class_map keys before assigning ids so class/interface ids (and the InstanceOf / exception-match metadata derived from them) are stable across compiles, fixing non-reproducible builds and occasional id-mismatch miscompiles caused by HashMap key-order randomization.
Add two opt-in flags to the compiled --web / --web-worker[=script] server that bound how long a keep-alive connection stays pinned to its SO_REUSEPORT-chosen worker:

- --max-requests-per-connection N: after N responses, send "Connection: close" so the client reconnects and the kernel re-picks a worker; also drains a worker that reached its --max-requests recycle cap instead of cutting keep-alive connections at process exit (fixes the C3 drain gap). - --idle-timeout SECS: a watchdog gracefully shuts down a connection idle for SECS, finishing any in-flight response first (no truncation).

Both default to 0 (off): per-connection state is allocated only when a feature is enabled, so the default hot path is structurally identical to before (zero regression). Benchmarks showed rotation is a cheap mitigation, not a substitute for a shared work queue — under mixed fast/slow load aggressive rotation did not improve, and could worsen, fast-route tail latency — hence off by default. Portable hyper/tokio, all three web modes; web_tests cover the per-connection cap, idle close, non-truncation, and the drain.
Add --tls-cert PEM / --tls-key PEM to the compiled --web / --web-worker[=script] server: the listener then serves HTTPS (HTTP/1.1 over TLS 1.2/1.3 via rustls + the ring provider) in place of plaintext on the same --listen address, and PHP sees $_SERVER['HTTPS']='on' / REQUEST_SCHEME='https' (the HTTPS key stays ABSENT on plaintext, matching PHP-FPM).

- The ServerConfig (cert chain + key, ALPN http/1.1, shared session ticketer) is built once in the master before fork and shared with every worker via a write-once OnceLock; a bad/missing/mismatched cert or key fails fast (exit 2) before any worker is forked. - The TLS handshake runs inside each connection task (10s timeout), never in the accept loop, via wrap_accepted(stream, acceptor) so accept() stays outside it and a later fd-dispatch path can hand it an SCM_RIGHTS-received fd. - Single TLS listener (no dual HTTP+HTTPS), restart-only, ALPN http/1.1 only (the single hook a future HTTP/2 mode extends). rustls pinned to the same 0.23 + ring line as elephc-tls (no aws-lc-rs); test cert generated at runtime with rcgen (no key committed). Composes with PR1 keep-alive/idle controls.

web_tests 101/0, elephc-web 18/0.
Add an opt-in --dispatch master|kernel flag (default kernel = the current SO_REUSEPORT per-worker listeners, byte-for-byte unchanged). In master mode a single master process owns the listener, accept()s each connection, and passes its fd via SCM_RIGHTS over a per-worker socketpair to an idle worker (slot=1), with a bounded backlog (--dispatch-backlog, default 1024) and SYN backpressure when full. This is the real shared queue: a slow handler no longer blocks every connection the kernel hashed to that worker, so p99 under heterogeneous handler latency stops tracking the slowest worker (also fixes macOS's poorer SO_REUSEPORT distribution).

The raw-libc fd-passing (socketpair/sendmsg/recvmsg via the libc CMSG macros, per-target #[cfg] confined to dispatch.rs) and the synchronous master loop (poll + ready-queue + reap/respawn with fresh socketpairs + teardown) are new; the per-connection lifecycle is factored into one shared drive_connection so PR1 keep-alive controls and PR2 TLS (the worker handshakes the raw received fd) compose identically in both modes. Pairs with --max-requests-per-connection for keep-alive rebalancing. 3-target (Linux fd-passing via CI). web_tests 111/0, elephc-web 22/0.

Reviewed adversarially (6 lenses); fixed 4 found defects before commit: a backlog-fd inheritance leak on respawn, a SERVER_ADDR wildcard divergence from kernel mode, a double-READY on a hard recv error, and a connection stranded behind a just-crashed worker.
Run the blocking PHP handler on one dedicated php-handler OS thread per
worker, fed a bounded mpsc job queue by the tokio I/O thread, so
request/response I/O of other connections overlaps PHP execution. Handlers
still never overlap (single consumer thread; the N=1 case of future ZTS).

--handler-offload is opt-in (default off); the inline synchronous handler
path is byte-for-byte unchanged when off. --max-pending N (default 16; 0
exits 2) bounds the queue; queue-full returns 503 + Retry-After: 1 built
entirely on the I/O thread (no PHP). Queued-body memory is bounded by
N x --max-body-size.

Handler-thread affinity is the correctness core: the php-handler thread
is the only thread that touches any PHP-visible state (request statics,
response buffers, capture flag, GC, handler()); the I/O thread only moves
owned Send values (RequestJob/ResponseParts) through the channels, so no
runtime state is made thread-safe and no unsafe impl Send is needed.

exit/die is unchanged: the setjmp bailout anchor lives in the compiled
handler prologue, so moving handler() to the handler thread moves the
anchor with it; the longjmp stays same-thread/same-stack. No codegen change.

--max-execution-time: SIGALRM is blocked on the I/O thread before the
handler thread is spawned and unblocked on the handler thread, so a
runaway-handler alarm is delivered to the handler thread deterministically.
An RAII AlarmDisarmGuard around each job cancels any armed alarm during a
Rust panic unwind before catch_unwind returns, so the worker dies via the
designed _exit(1) (master respawn) rather than the default SIGALRM action
preempting recovery.

Applies to all three web modes (--web, --web-worker, --web-worker=script).
The runtime stays current-thread tokio; the handler thread is a plain
std::thread with an 8 MiB stack (matching the main thread) so PHP recursion
depth does not shrink.
Add HTTP/2 to the --web server behind --http2 (default off). Uses
hyper_util server conn auto::Builder with a LocalExec that spawn_locals
per-stream futures on the worker's LocalSet (F not Send), so h2 streams
run on the single worker thread. --http2 auto-requires --handler-offload
(exit 2 otherwise): without offload the blocking handler runs inline in
the stream service future and stalls all multiplexed streams on the
connection. --http2-max-streams (default 8) and --http2-max-header-size
(default 64 KiB) bound per-connection memory and the HPACK header bomb
surface. When --http2 is off, conn_builder calls http1_only() on the
auto builder (one code path; the h2c preface is rejected as a malformed
h1 request) so the h1 path is observably unchanged.

Streams serialize on the single php-handler thread (no intra-worker
parallelism until ZTS); the win is HPACK + fewer connections, not req/s.
Per-connection --max-requests stream budget triggers GOAWAY via the
inherent graceful_shutdown. h2 connection-level response headers are
filtered defense-in-depth. h2c prior-knowledge works on plaintext; h2
over TLS (ALPN h2) is a follow-up (tls.rs advertises http/1.1 only today).

Tests: 4 http2 unit tests + 13 h2 e2e tests (prior-knowledge get,
SERVER_PROTOCOL, two-stream multiplex, max-streams, GOAWAY budget,
header-bomb reject, RST_STREAM isolation, gzip, header sanitization,
worker-script h2) + 25 PR1-PR4 regression tests. h2 dev-dep (test client
only, never linked into the --web binary).
…ench

Add a standalone elephc-globals-audit crate that extracts every mutable
global symbol the compiler/runtime/bridges emit (.comm + mutable .globl +
Rust-bridge static mut) and classifies each as CONST, RO_BOOT, SHARED_LOCK,
or TLS, committing a versioned baseline per target (macos-aarch64,
linux-aarch64, linux-x86_64). A --check mode diffs the live extraction
against the baseline and FAILS when a new unclassified global appears or an
existing symbol's classification changes, so a future mutable global that
would break per-thread-state invariants cannot land silently. A guard test
scans src/codegen/** and src/codegen_ir/** for raw emitter.adrp() calls
outside src/codegen/abi/symbols.rs.

Centralize the ~39 raw emitter.adrp symbol-address outliers onto
emit_symbol_address (including the mutable _strtotime_clock), so every
symbol-address load goes through the single structural seam. The generated
assembly is observably identical on macos-aarch64 (ADRP+ADD pair, same
registers/relocation); focused codegen regressions (strtotime 79/0,
arrays/json 250/0) and a warning-free workspace build confirm no behavior
change.

Add an addressing-mode microbench harness comparing the current absolute
addressing (ADRP+ADD / RIP-relative) against native TLS (per-target,
including macOS Mach-O TLV) and a reserved context register (x28 aarch64 /
r15 x86_64) on a fixed realistic corpus (alloc-intensive str_repeat /
array_push, json_encode, a Symfony-ish boot) across the three targets.
microbench-results.md records the measured data and asm verification; it
deliberately does NOT recommend a mechanism — the data is an input to a
separate decision. No user-visible change; no runtime/CLI flag added.

Docs: docs/internals/global-state.md + README index + CHANGELOG.
Plumb args.http2 into load_acceptor so the ALPN list is conditional:
["h2","http/1.1"] when --http2 is on, ["http/1.1"] when off (byte-for-byte
unchanged). Closes the PR5 follow-up where --http2 --tls-cert silently did
h1 over TLS. Plaintext h2c prior-knowledge is untouched.

load_acceptor body factored into load_acceptor_config (returns the
Arc<ServerConfig>) so a unit test can read .alpn_protocols back. E2e tests
assert ALPN negotiation: Some("h2") with --http2, Some("http/1.1") without
(plus an h1-over-TLS 200 regression). The full h2-over-TLS frame round-trip
is deferred to a tokio_rustls root dev-dep; the ALPN assertion proves the
server advertises h2 and a capable client selects it, and the h2 frame layer
above TLS is identical to the h2c path covered by the PR5 suite.
Opt-in --max-rss MiB makes a worker self-measure its resident set in the
accept loop and exit cleanly when over the cap, so the master respawns a
fresh worker (bounding memory growth over time, complementing --max-requests).
0 = off (default, byte-for-byte unchanged). New target-aware rss.rs (Linux
/proc/self/status VmRSS + macOS mach_task_basic_info resident_size; other
targets return None = safe no-op). Measurement gated to at most once per 64
accepts (plus the first accept) so the RSS syscall never lands on every
hot-path iteration; an idle worker's RSS does not grow while parked in
accept, so a request-count gate is sufficient. Applies to all three web modes
(worker.rs + worker_mode.rs accept loops, symmetric with the --max-requests
check). OFF path skips the RSS branch entirely (no syscall, no allocation).
SIGHUP to the master triggers a rolling worker reload: the master
SIGUSR1s one worker at a time, that worker stops accepting, drains
in-flight connections, exits 0, and the master respawns a fresh worker
before moving to the next (N-1 always serve). New --reload-grace SECS
(default 10) bounds the per-worker drain wait; 0 = the worker waits
forever for in-flight to finish. Both dispatch backends (supervise
kernel mode and dispatch::master_loop master mode) perform the rolling
restart one worker at a time, reusing reap_and_respawn in the master
path. SIGINT/SIGTERM stay hard-kill (unchanged) -- only SIGUSR1 is
caught by the worker, installed after reset_signal_handlers_to_default
so it is not reset to DFL. An always-on Rc<AtomicUsize> active-conn
counter + ActiveConnGuard let the drain observe in-flight connection
tasks on the current-thread runtime before letting it drop. All three
web modes. The master applies a reload_grace+5s exit backstop then
force-recycles an idle worker that does not wake on SIGUSR1 (tokio
accept retries EINTR; the crate cannot use tokio::select!/tokio::signal
without the macros/signal features). 4 new web_tests including a
SIGTERM-hard-kill guard.
`--metrics` exposes a per-worker JSON snapshot at `--metrics-path` (default
`/_status`), intercepted in the HTTP service_fn before the PHP handler so it is
cheap and is not itself recorded as a request. The snapshot reports pid, mode,
uptime, served_total, rps_last_60s, latency_us (p50/p99/max/samples over the last
256 requests), status_classes, overload_503, handler_offload, handler_inflight,
max_pending, active_conns, rss_bytes, max_rss_bytes, h2_enabled, h2_streams_active,
reload_grace_secs, and draining.

The endpoint is per-worker: under SO_REUSEPORT a request lands on a random
worker, so the operator scrapes repeatedly for a cluster view (documented; the
master sees no per-request data, so cross-process aggregation is out of scope for
this item). Off by default; when off, the hot path is byte-for-byte (the intercept
is `if metrics && path == metrics_path`, short-circuited by the `metrics` bool —
no string compare, no allocation).

Recording is single-threaded on the I/O thread (the offload path records after
reply_rx resolves), using Relaxed atomics. T1#2's graceful-drain path is
byte-for-byte: the only change to it is `ActiveConnGuard` mirroring the active-
connection count into `metrics::ACTIVE_CONNS` (one extra relaxed atomic inc/dec
per connection) so the snapshot can report `active_conns` without touching the
drain `Rc<AtomicUsize>`.

`WorkerConfig.metrics_path` is `&'static str` (leaked once in the master before
fork) rather than `String` so `WorkerConfig` stays `Copy` and the OFF path keeps
no per-connection allocation. The `mode` label is threaded from the call site
(`enter_worker_loop("web-worker" | "web-worker-script")`) rather than a new
`WorkerConfig` field. All three web modes.

Tests: 5 web_tests (serves_json, disabled_by_default, custom_path, records_latency,
records_status_classes) + 4 metrics unit tests. 145 web_tests / 0, 37 unit / 0
(one pre-existing tls.rs rcgen parallelism flake passes in isolation).
--static-dir DIR serves files under DIR from --static-prefix (default
/assets) on the I/O thread without invoking the PHP handler, raising
effective concurrency under N=1. Per-worker bounded LRU cache
(--static-cache-size, default 64 MiB); on-demand disk read via
spawn_blocking on miss; ETag + If-None-Match 304 revalidation; traversal
guard (canonicalize + strip_prefix, 404 on escape); GET+HEAD only;
--static-max-file-size (default 10 MiB, 0 = no cap); --static-max-age
(default 3600). All three web modes; off by default (byte-for-byte hot
path). SIGHUP rolling reload refreshes the cache per respawned worker.

Also removes a dead #[cfg(test)] reader left in metrics.rs by the T1#3
metrics endpoint (handler_inflight_load) so the test build is warning-free.
--worker-affinity (default off) pins each forked worker to CPU
getpid() % ncpus (round-robin via consecutive PIDs) before entering
the serve loop — a best-effort lever that reduces scheduler migration
and improves per-worker L1/L2 cache warmth under N=1.

Linux (aarch64 + x86_64): hard pin via sched_setaffinity to a single
CPU. The libc crate does not expose cpu_set_t/CPU_ZERO/CPU_SET on
glibc/musl (inline macros), so affinity.rs declares the syscalls via a
raw extern "C" block (same pattern as the macOS Mach fallback) backed
by a local CpuSet whose layout matches glibc cpu_set_t (1024 bits,
[usize; 16] = 128 bytes).

macOS (aarch64): advisory thread_policy_set (THREAD_AFFINITY_POLICY)
tag hint — macOS does NOT support hard CPU pinning. Uses libc::
symbols (mach_thread_self is #[allow(deprecated)] to stay within libc).

Failure is best-effort: a non-zero return logs a one-line warning and
the worker continues (never killed).

OFF path byte-for-byte: spawn_worker's child arm guards the pin with
if cfg.worker_affinity, which short-circuits at the false bool; the
request hot path is untouched. The pin runs once at worker startup.

Tests: 2 unit (cpu_for_pid round-robin + single-cpu) + 1 Linux
round-trip (pins to CPU 0, reads back via sched_getaffinity, restores
the original mask) + 1 e2e (web_worker_affinity_serves). web_tests
151/0, elephc-web 48/0, 0 warnings.
…egen_support move

illegalstudio#481 moved src/codegen/abi/ to src/codegen_support/abi/ and dropped four
helpers that the web-scaling branch calls: emit_copy_frame_pointer (was
#[cfg(test)]-gated, ungate for non-test use), emit_cleanup_callback_prologue
and emit_cleanup_callback_epilogue (port from the pre-move frame.rs), and
emit_load_symbol_to_reg_via_page (port from the pre-move symbols.rs). Also
drop a dead loaded_ty assignment in static_locals.rs that became a warning
after the move.
write_temp_cert_key used a nanosecond timestamp alone for the temp PEM
filename, which collides when two tests call it in the same nanosecond
under parallel execution; one thread's cleanup then deletes the other's
cert mid-read, failing load_acceptor_alpn_reflects_http2_flag
deterministically under 'cargo test -p elephc-web'. Add a per-process
AtomicU64 counter combined with the timestamp so every call gets a
distinct path.
…b worker round-trip

global_alias_type returned PhpType::Mixed for every ordinary global so a
'global $x' in any function can replace the value with a different runtime
type. The compiler-internal __elephc_worker_handler slot was caught by that
widening: lower_elephc_worker_register stores a raw Callable descriptor into
it (and the prelude stores a raw dummy), but the trampoline loaded it back as
a boxed Mixed cell, reinterpreted the raw descriptor as a cell pointer, and
fatally failed the callable tag check ('callable_descriptor_invoke mixed value
is not callable') on every worker request. Special-case the slot to stay
Callable-typed so the load agrees with both stores and the trampoline invokes
the descriptor directly.
emit_function_return_epilogue (the path every non-main Return terminator
takes) skipped emit_activation_record_pop, unlike the shared epilogue label
emit_function_epilogue which does pop. A function with cleanup-tracked
locals that returned via Terminator::Return therefore left its
exception-cleanup activation record on _exc_call_frame_top. A later
throw/exit in the same request then unwound into the stale record and
re-ran its cleanup callback, which releases the full owned-local set with
include_returned=true - prematurely freeing the object the caller now owns
(e.g. a `new Exception` handed to `throw`), so _exc_value dangled,
__rt_exception_matches read freelist garbage as the class_id, the catch
failed, and the worker fatalled with an uncaught exception.

Pop the record at the top of emit_function_return_epilogue, mirroring the
shared epilogue. The pop is a no-op unless a record was pushed and touches
only scratch registers, so the already-loaded return value survives.

Fixes web_worker_fill_exception_500.
lower_store_global_releasing stored the operand with its native codegen
representation into the global symbol, but ordinary (non-superglobal)
globals are Mixed-typed (see global_alias_type) and the load path
(eir_read_global_* -> __rt_mixed_unbox) expects a boxed Mixed cell pointer
in the slot. A concrete stored value (e.g. the int 7 from `\$g = 7`)
left the raw scalar in the slot, so the next __rt_mixed_unbox dereferenced
it as a pointer (SIGSEGV at the scalar value, e.g. 0x7), surfacing as the
per-request reset crashing when an ordinary global alias was reassigned
between requests.

Box the value to Mixed before the store for non-superglobals, mirroring
lower_store_global (owned vs borrowed boxing via value_ownership).
Superglobals keep their native array storage type and are stored unboxed.

Fixes web_reset_clears_ordinary_global_alias_between_requests.
generate_user_asm_from_ir_with_options gained two bool parameters
(web_worker, web_worker_script) from the worker-script campaign, but the
tests/codegen/support/projects.rs fixture call site still supplied the
prior 8-argument form, so the codegen_tests target failed to compile
(blocking the CI Build & Archive jobs on all three targets). Pass false
for both web worker modes - the fixture is not a web worker.
…ifts

The illegalstudio#481 codegen_ir->codegen move (and subsequent origin/main builtin
lowering edits) shifted line numbers in the codegen files, so the
committed builtins docs and builtin_registry.json held stale lowering
line references (e.g. builtins.rs:992 -> :1052, codegen_line 1578 -> 1576),
failing the CI Builtins docs in sync check.

Regenerate via scripts/docs/extract_builtins.py --render --force (873 pages,
446 registry entries). audit_builtins.py and validate_site_compat.py both
pass. Docs are byte-identical to a fresh regen from the current source.
sched_getaffinity and cpu_is_set in crates/elephc-web/src/affinity.rs are
used only by the #[cfg(test)] round-trip test pin_roundtrip_sets_cpu_zero,
but were compiled into the non-test lib on linux. The CI Build & Archive
step builds the lib without --cfg test (when linking it into integration
test binaries / the compiler binary), so both were dead_code there, and
with warnings-as-errors the linux-aarch64 and linux-x86_64 archive jobs
failed (macos passed because the linux helpers are cfg(target_os = "linux")
out). Gate both to cfg(test) so they are present only when the test module
that uses them is compiled.
…studio#481 rebase

Repairs 14 codegen_tests regressions introduced when illegalstudio#481 made the EIR
backend canonical, without regressing --web-worker state persistence.
Three distinct Mixed-ownership defects, each verified in isolation:

- Return epilogue freed a borrowed unbox payload: loading a Mixed/union
  local as a refcounted result (e.g. DatePeriod::current returning a
  DateTime|DateTimeImmutable slot) goes through __rt_mixed_unbox, a borrow
  that does not incref for MaybeOwned/Borrowed loads. The return epilogue
  then __rt_decref_mixed'd the source local, freeing the cell and the
  payload handed to the caller ("Call to a member function format() on
  null"). return_cleanup_skip_slot now skips such a source local on the
  return path. Fixes 8 DatePeriod tests.

- Static-local store double-counted owned containers: the store increffed
  unconditionally, but box_current_result_for_static_slot's owned-source
  path already transfers the reference into the fresh Mixed cell. That
  leaked a growing container one reference per assignment (static $map=[];
  $map[$k]='v' -> allocs=1025 frees=125 over 40 iterations). The incref is
  now gated on value_released_elsewhere: emitted only when EIR keeps the
  value live past the store (a trailing Op::Release exists, e.g.
  static $c=0; return ++$c), which is exactly what a persistent worker
  needs to accumulate state across requests. Fixes 7 mixed-static-array /
  static-empty-map leak tests.

- Checked-arithmetic Mixed temp leaked through a scalar cast: a boxed
  ICheckedAdd/Sub/Mul result narrowed to fill a typed slot (e.g.
  $arr[$i] = $i * 2) is consumed by Op::Cast, which emits no trailing
  release, so the box leaked one cell per cast. lower_cast and
  coerce_to_int/coerce_to_float now release it, scoped narrowly to
  checked-arithmetic producers so an Op::Acquire'd value stored elsewhere
  (and released by its own lifecycle) is never over-freed. Fixes the
  index-set-loop leak test and the http_response heap-balance test.

Verified: full codegen_tests 5092/0, web_tests 152/0, elephc-web 48/0,
zero warnings, git diff --check clean.
@Guikingone Guikingone force-pushed the feat/web-worker-script branch from 8b55e94 to b37a177 Compare July 9, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant