feat(web): worker modes, EIR exception/GC correctness, and the opt-in web-scaling stack#456
Draft
Guikingone wants to merge 25 commits into
Draft
feat(web): worker modes, EIR exception/GC correctness, and the opt-in web-scaling stack#456Guikingone wants to merge 25 commits into
Guikingone wants to merge 25 commits into
Conversation
--web-worker=script non-intrusive worker mode + EIR exception/GC correctness
c2af4dd to
ae0907d
Compare
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. |
904f9de to
afc0c20
Compare
--web-worker=script non-intrusive worker mode + EIR exception/GC correctness13c0c96 to
2f341ce
Compare
…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.
8b55e94 to
b37a177
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR consolidates the
--webstack 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 viaelephc_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: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:
throw/exit/dierelease each unwound frame's owned refcounted locals. EIR emits per-function cleanup callbacks walked via the_exc_call_frame_topchain.getMessage()/__toString()return independent copies, soobject_free_deepfrees the message exactly once. The x86_64decref/freepaths accept heap kind 6 (throwables).Mixed→Objectunbox incref is gated onOwnedownership, so a borrowed receiver load is not over-retained.parent::__construct()on builtin Throwable subclasses — lowered to an inline message/code field stamp on$thisinstead of a call to the never-emitted builtin constructor symbol.staticnull-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):
$_ENVbuilt once per worker in--web-worker(handler);--weband--web-worker=scriptkeep$_ENVper-request fresh.TCP_NODELAY; response gzip at the fast level;HTTP_*$_SERVERkeys 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
--max-requests-per-connection,--idle-timeout) — opt-inConnection: closeafter N + idle watchdog. Defaults 0/0 (rotation hurts mixed p99 in the bench;--dispatch masteris the real load-distribution fix).--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,--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.--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-cert,--tls-key) — rustls 0.23;$_SERVER['HTTPS']via a C-ABI getter.--max-rss MiB) — a worker whose VmRSS exceeds the cap self-exits to be respawned (Linux/proc/self/status+ macOSmach_task_basic_info).--reload-grace) —SIGUSR1graceful drain vsSIGTERMhard; rolling one-at-a-time restart in both kernel and master dispatch.--metrics,--metrics-path /_status) — per-worker JSON snapshot (rps, latency p50/p99, status classes, 503 rate, RSS, active conns, h2 streams).--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; ETag304; path-traversal404. Usesspawn_blocking+std::fs(notokio::fsdep).--worker-affinity) — pins each worker to CPUgetpid() % ncpus(round-robin via consecutive PIDs). Linux: hardsched_setaffinity; macOS: advisorythread_policy_settag (no hard pin). Best-effort, once at startup.Hygiene
crates/elephc-globals-audit/extracts all mutable.comm/.globl+ bridge statics, classifies CONST/RO_BOOT/SHARED_LOCK/TLS, versioned baselines per 3 targets,--checkmode fails on a new unclassified global (silent-miscompilation gate). ~39 rawemitter.adrpoutliers 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:
--max-pendingbound (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).native_tls(__thread, instruction-identical to the currentadrp+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-pendingshedding + 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_testsis 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), plusexceptionsandruntime_gcfor the EIR GC correctness, and theparent::__constructregressions. Updated examples and docs; behavior verified byte-identical to PHP where applicable. Zero compiler warnings;git diff --checkclean.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.