Changelog
Curated highlights per release: every notable change to the server, the PHP API and the images. The full, unabridged history lives in CHANGELOG.md.
Worker mode: max_execution_time per request — A request the worker takes while carrying others now gets its own limit, counted from when it was taken, and set_time_limit() affects only the request that calls it. An SSE stream or long poll that lived on the shared timer must call set_time_limit(0) itself; the release image ships no php.ini, so the limit is 30 s.
RUNTIME_HOOKS=1, true and all include net — Connects, TLS handshakes and reads, writes waiting for buffer room, and unix://, udp:// and udg:// reads park the fiber on upgrade, with no edit of your own. runtime_hooks now reports ["sleep","streams","net"], and a stream shared between fibers is guarded whatever its transport. RUNTIME_HOOKS=sleep,streams keeps the old set.
Shareable is reserved for the Shared types — A PHP class can no longer implement OxPHP\Shared\Shareable or extend a Shared type, now final; both are fatal at declaration. Passing such an object to another thread used to crash the server. PHPUnit createMock() of a Shared type or of Shareable now fails; use a marker interface of your own.
Shared\* observability aliases removed — oxphp_shared_channel_pending and oxphp_shared_pool_size leave /metrics, and pending/size leave type_specific at /__ox_shared/entry, along with the startup WARN that announced them. Dashboards and alerts must switch to oxphp_shared_channel_count, oxphp_shared_pool_count and count.
TRUSTED_PROXIES reads IP:port entries — Behind a load balancer that writes IP:port into X-Forwarded-For (Azure Application Gateway), REMOTE_ADDR is now the client and REMOTE_PORT its port. Where a CDN in front of such a gateway also appends, the address is now the CDN's own; list its ranges too (for Cloudflare, the cloudflare keyword).
DOCUMENT_ROOT behind a release symlink — The root is resolved once at startup and every static file read from disk and every PHP script is checked against it when opened, so once /app/current is switched they answer 404 until the server restarts. Point DOCUMENT_ROOT at a path that does not change across releases, or restart as part of the switch.
Scripts the server cannot open — Outside worker mode a deleted script answers 404 even where OPcache holds it with validate_timestamps off, so a deploy that deletes and recreates files in place (git checkout, rsync --delete) has a 404 window; write new files beside the old and rename over them. An unreadable script answers 500 and logs Cannot open script.
Symlinked scripts run as their target — Outside worker mode a script reached through a link into another directory runs with that directory as its working directory, and whether PHP treats it as a phar depends on the resolved file name, not the URL. Build relative paths from __DIR__.
fiber.stack_size must clear zend.reserved_stack_size — In worker mode and ASYNC_WORKERS tasks the fiber stack limit now keeps PHP's reserve (48 KiB in a release build) free, so a fiber.stack_size at or below it fails every request with Maximum call stack size of 0 bytes, and deep recursion stops 44 KiB sooner than before.
Profile\start() does nothing while the profiler is disabled — It no longer makes Profile\is_active() return true with PROFILER_ENABLED off, so a test that starts a profile and asserts is_active() needs the profiler enabled. With OTEL_APM_ENABLED, the APM spans the call used to discard are now kept in the trace.
UploadedFile::type() after moveTo() reads the destination — Type detection no longer runs before the move, so a file moved on, deleted or rewritten by the time type() is first called reports what is there now, or application/octet-stream with a warning. Call type() before moveTo() where the moved file does not stay put or sits behind a stream wrapper.
.oxphpdeny path deny rules — A .gitignore-syntax file at the top of DOCUMENT_ROOT keeps the paths it lists from being served, in every routing mode, for static files and PHP alike, before any disk access. Denied paths answer PHP_DENY_FALLBACK and a > rule hands the request to the entry script. Read once at startup; denials count in oxphp_path_deny_total.
RUNTIME_HOOKS=net — Connects through PHP's stream transports, TLS handshakes and reads, writes waiting for room in the send buffer, and reads on unix://, udp://, udg:// and socket-pair streams suspend the fiber instead of holding the worker thread. Needs streams listed too and runs on Linux only. Not covered: libcurl, libpq, libmemcached, DNS and persistent connects.
TRUSTED_PROXIES cloudflare keyword — Keywords, CIDRs and bare IPs now mix in one list (private,100.64.0.0/10 used to be a startup error). cloudflare trusts Cloudflare's edge ranges and reads the client address from CF-Connecting-IP when the first hop past your own proxies is a Cloudflare edge; private,cloudflare fits Cloudflare → load balancer → OxPHP.
User-Agent in traces and access logs — The root server span carries user_agent.original and each access-log entry a user_agent field, cut to 512 bytes with invalid UTF-8 replaced, so a crawler can be told apart from a browser by its traces alone.
Why a request was shed — The overload log lines now split refusals into refused_queue_full, refused_wait_timeout, refused_waiting_full and refused_waiting_bytes, and the root span of a refused request carries oxphp.shed_reason, so the limit to raise is visible without the INTERNAL_ADDR listener.
OTLP export failures in /metrics — oxphp_otel_export_failures_total, oxphp_otel_export_failed_spans_total and oxphp_otel_export_retries_total count failed exports, the spans they carried and fast-failure retries. A failed export is also logged as OTLP span export failed with the batch size and cause.
CPU count at startup — A CPU count for default sizing line reports cpus, cpu_source (host, affinity, cgroup quota or unknown), host_cpus and the resulting PHP_WORKERS and TOKIO_WORKERS. cpu_source=host on a plan sold as fewer CPUs, as seen on DigitalOcean App Platform under gVisor, means both should be set explicitly.
Debian (glibc) nightly image — :nightly-trixie, :php8.4-nightly-trixie and :php8.5-nightly-trixie run the same server on php:<minor>-zts-trixie and used 14 to 19% less CPU per response than Alpine on a Yii3 app. www-data is UID 33 there and extensions build in a trixie stage; tagged releases stay on Alpine.
Cheaper PHP function calls — The engine observer that counted every call, internal ones included, for the stuck-request classification is gone. On a Yii3 app CPU per request drops 5% in classic mode and 7% in worker mode; a script making 15 000 short calls per request uses 23 to 25% less.
APM: phpredis hGet, hSet, lPush and rPush — With OTEL_APM_ENABLED on, these four returned NULL without reaching Redis: writes were lost and reads came back empty, with no error and no span. The hook now matches method names case-insensitively, as PHP does.
PHP 8.5: memory_limit after an out-of-memory error — A deep-stack OOM in worker mode or an async task left the worker enforcing no limit at all, so the next runaway request grew until the kernel killed the server and every worker in it. The limit is restored as the request ends, and a worker still over it is recycled.
Call-stack overflow no longer segfaults — Recursion reaching PHP's stack limit in worker mode or an async task ends in the usual Error instead of taking the server down; realpath() on the last allowed level was enough. The limit is now the one PHP sets for every fiber.
Worker mode: fatal errors inside a Fiber — A fatal in a fiber the request started, or in a destructor the garbage collector ran, read freed call frames and could crash the process with every connection. The request now ends with a 500 and the worker keeps serving.
Worker mode: timeouts end the right request — The one timer per thread ended whichever request was running and counted the request it was meant for as a handler failure, recycling the worker after three. Each request now has its own deadline; one that passes while suspended ends it with a 504 that does not count toward recycling.
Worker mode: max_input_time after boot — With max_execution_time = 0 and max_input_time set, the timer armed for the worker's boot ended a request max_input_time seconds later (60 under PHP's shipped php.ini templates). The worker now stops it before serving.
Async tasks and the pool thread's deadline — A pool thread's single timer ran out once, about max_execution_time into the process, ending whichever task was running with Maximum execution time of 0 seconds. Pool threads now hold no deadline, so set_time_limit() no longer bounds a task: bound it with the timeout of oxphp_async_await().
Persistent connections closed on worker exit — Every worker-mode recycle and dynamic-pool scale-down left PDO::ATTR_PERSISTENT, mysqli p: and pfsockopen() connections open until the process restarted, piling up until the database refused new clients. They are now closed after the thread's last request.
Worker mode: memory left by cancelled internal calls — A request ended inside an internal function (usort() mid-comparator, say) leaked what that function allocated for the life of the worker. A worker that grew by more than 4 MiB across such a request is now recycled at its next idle moment, with a warning.
Fatal errors inside Shared callables — A fatal in Mutex::withLock(), Once::getOrInit(), a Registry factory or Pool::with() left the mutex locked, the Once pending, the key stuck in creation or the pool one slot short for the life of the process. All of it is given back as the request ends.
RUNTIME_HOOKS=streams with APM — APM's wrappers for PDO, mysqli and Redis skipped the connection guard, so fibers sharing a connection could read each other's Redis replies or get Cannot execute queries while other unbuffered queries are active. The wrappers now call the guarded handler.
RUNTIME_HOOKS=streams: destructors between requests — A destructor run by the worker loop's cycle collection could write into another request's exchange on a shared connection and read its reply. A socket wait on a connection whose holder is parked on it now fails at once, like a timeout, and sends nothing.
Persistent PDO constructors wait per data source — A constructor stuck on a database that never answers held every other persistent constructor on the worker until their wait ran out and they went ahead unserialised. They now wait only for constructors to the same DSN.
Dropping a PDO handle on a shared persistent connection — With RUNTIME_HOOKS=streams, one request releasing its handle rolled back another request's transaction and, with pdo_mysql, could free a result mid-read and segfault. Only the connection's current holder now runs that cleanup, and PDO::rollBack() from another fiber sends nothing.
Planned recycles are not crashes — A worker recycled on WORKER_MAX_MEMORY_MIB or Worker::scheduleExit() is logged at INFO as Worker mode thread recycled, with the reason, instead of a Dead workers detected WARN that read as a crash.
OTLP export over http/protobuf — Not one span was ever exported over that protocol: the export thread panicked and every span logged Spans are being emitted even after Shutdown. Spans now reach /v1/traces under OTEL_EXPORTER_OTLP_ENDPOINT, which is a base URL; drop a /v1/traces already on it.
OTLP over gRPC: connects and dead connections — OTEL_EXPORTER_OTLP_TIMEOUT now bounds connecting too, and on Linux a connection that stops taking data is replaced instead of failing every export for about 15 minutes. An export that fails fast on an idle-dropped connection is sent once more. A set but empty OTEL_EXPORTER_OTLP_ENDPOINT is now refused at startup.
oxphp_worker_stuck_total classification — A PHP loop with no calls in it read as c_call, and a stuck request's first reading could be measured against an earlier request's. The supervisor now asks the engine itself, through the interrupt max_execution_time is delivered by.
UploadedFile::moveTo() without fileinfo — Without mime_content_type(), or under an error handler that turns warnings into exceptions, moveTo() threw before moving and the upload was deleted at the end of the request. It now moves first and leaves type detection to type().
Destructor exceptions as an async task ends — An exception thrown by a destructor freed during task teardown disappeared without a word; it is now reported as uncaught and reaches the error log.
Profiler index.json records user_agent — The documented field was always null.
Documentation: SSE guide — The worker-mode example read events with BRPOP, so each event reached one client; it now reads a Redis Stream and resumes from Last-Event-ID. The guide no longer claims a while (!connection_aborted()) loop ends through its condition, and its example calls ignore_user_abort(true) and set_time_limit(0).
Static files: symlinks repointed after routing — A link inside DOCUMENT_ROOT validated on one request and repointed afterwards, at /etc/passwd say, was followed on the next. Files are now checked after they are opened, against the location the kernel reports for the descriptor; a file still in the in-memory cache keeps its validated bytes.
PHP scripts: symlinks repointed after routing — Outside worker mode such a link could run an attacker's script, print a text file or hang a worker on a FIFO once PHP's realpath cache let go of the old target. The worker now opens the script itself, checks the descriptor and hands PHP that file; a swapped link answers 404. About 3% of throughput on a trivial script.
PHP 8.5: error_get_last() across requests — In worker mode and async tasks, a request paused at a suspension point could read another request's fatal-error backtrace, with the arguments its functions were called with: passwords, tokens, customer records. Each request now keeps its own backtrace and lets go of it as it ends.
TRUSTED_PROXIES: unparseable forwarding entries — The rightmost-non-trusted rule skipped entries it could not parse and read on into what the client wrote, so behind a gateway that writes IP:port the client chose its own address. An unparseable entry or unreadable header line now stops the rule.
Split Cookie headers — Cookies split across several field lines, as HTTP/2 allows, are now joined per RFC 9113 before anything reads them. A plugin's __oxp_* cookie on a later line used to reach the application, and stripping one from the first line dropped the application's own cookies on the rest.
SHARED_ENABLED=false now disables Shared\* — The setting used to be parsed and ignored; now no OxPHP\Shared\* class is registered and the first one constructed throws Error: Class "OxPHP\Shared\Counter" not found. It is also read as OX_SHARED_ENABLED and a bare ENABLED, so check all three before upgrading; class_exists() tells code which way it runs.
Worker mode: a client hang-up no longer ends the request — The handler runs to its end so its finally blocks and cleanup run, as if ignore_user_abort were always on; bounded only by max_execution_time. Call connection_aborted() to stop early. Streaming responses are still ended at their next write.
ignore_user_abort(true) runs to the end in every mode — As under PHP-FPM, output is discarded and connection_aborted() reports the disconnect; a stream loop that relied on its next write to stop must check connection_aborted(). ignore_user_abort = On in php.ini or a worker bootstrap now applies to every stream.
/health answers 503 when the pool is lost or wedged — The body gains pool_stalled and reports "status": "degraded". A Kubernetes liveness probe pointed at the aggregate /health will now restart pods; use /health/liveness, /health/readiness and /health/startup.
ACCESS_LOG is no longer overruled by LOG_LEVEL — ACCESS_LOG=all with LOG_LEVEL=warn used to write nothing; it now writes one line per request, so size the log volume. RUST_LOG=warn,access_log=off still silences the access log alone.
payload() returns form bodies and JSON scalars — Form bodies now parse with SUPERGLOBALS_ENABLED=false, and a JSON body of "hi", 42 or false returns that value instead of null or $default. null now means only an empty body, an unparsed Content-Type, invalid JSON or a literal null.
Client hang-ups counted as cancellations — oxphp_request_cancelled_total{reason="client_abort"} starts climbing while admission_refused_total{reason="wait_timeout"} steps down, and the Connection error ERROR line for a client that hung up is now debug; alerts on either should be revisited.
Profile runs filed by trigger, under their own run id — oxphp_profiler_runs_total{source} now splits into header, cookie, query, sample and the new sdk; run ids take the form <ts_ms>-<req_id_prefix>-<rand4>, so /__profiler/runs/{id} answers 404 for a request id; look it up by request_id in /__profiler/runs instead.
PROFILER_MAX_DEPTH is enforced — It used to apply nowhere. The default is now 0 (no limit); values above 32 are clamped with a startup WARN, and calls below the limit are absent from the profile.
COMPRESSION_LEVEL deprecated — Use COMPRESSION_BROTLI_LEVEL; the old name still works with a startup WARN. A non-zero value no longer means Brotli-only, since Zstandard and gzip are now offered too. /config reports brotli_level, gzip_level and zstd_level instead of compression_level.
SHARED_SOFT_LIMIT_RATIO and SHARED_POISON_STRICT ignored — Neither was ever read; both now log a startup WARN, and limits.soft_ratio and diagnostics.poison_strict are gone from /__ox_shared/summary. Alert on oxphp_shared_capacity_saturation; a terminal Once takes Once\FailureMode::Poison.
oxphp_queue_wait_us reads lower — It now stops when a worker takes the request, about 0.1 ms less per request; sub-millisecond alert thresholds fire less readily. oxphp_request_duration_us is unchanged.
Zstandard compression — Dynamic responses go out as zstd (level 6) to clients that accept it: smaller than Brotli at under half the CPU. COMPRESSION_ENCODINGS (default br,zstd,gzip, or off) picks the codings, with COMPRESSION_BROTLI_LEVEL (5), COMPRESSION_ZSTD_LEVEL (6) and COMPRESSION_GZIP_LEVEL (6).
gzip for clients without Brotli — Plain-HTTP Chromium and most CLI tools used to get uncompressed bodies. Accept-Encoding is now ranked by q-value per RFC 9110, with q=0 honoured as a refusal; as a result compressible files under 3 MiB answer Range requests with a full 200.
Precompressed static cache — A cached static file gets a maximum-quality Brotli copy built once in the background, 8 to 12% smaller than the per-request output, within the same cache budget.
Pool-stall visibility — oxphp_pool_stalled, oxphp_queue_depth, oxphp_queue_capacity and oxphp_admission_slots_available expose a pool that has stopped taking work off its queue, with a WARN while it lasts and an INFO once work moves again (worker mode).
Overload in the log — Requests shed with 529 now produce a WARN per episode, repeated at most once a minute, and an INFO with the total once a minute passes without refusals.
MAX_CONNECTIONS exhaustion reported — A parked accept loop logs a WARN naming MAX_CONNECTIONS and an INFO when it resumes; oxphp_accept_stalled and oxphp_accept_stalls_total export it.
oxphp_admission_wait_wasted_total — Counts wait_timeout refusals during which no worker started any request, the sign that QUEUE_WAIT_TIMEOUT_MS is too long or the app calls back into its own server.
oxphp_worker_request_fibers_active — Request fibers per worker: 256 means the worker has stopped taking work, and a count that does not return to 0 at idle is a fiber leak (worker mode).
RUNTIME_HOOKS visible — /config and oxphp_server_info() report runtime_hooks as the parsed list of installed categories, e.g. ["sleep","streams"], so a misspelled variable reads as [].
Adaptive queue wait budget — QUEUE_WAIT_TIMEOUT_MS is now a ceiling: the server halves the wait while a quarter or more of started work went to clients that had left (floor 10 ms or 1/64 of the ceiling) and restores it once the queue drains. oxphp_admission_wait_budget_us, oxphp_admission_wait_budget_ceiling_us and oxphp_abandoned_work_total expose it.
Brotli per-request quality 5 — Up from 4, which produced larger output than gzip at more CPU; Brotli bodies over 4 KB now compress on the blocking pool. COMPRESSION_BROTLI_LEVEL=4 restores the old behavior.
Connection-end log levels by error type — A client closing the connection is debug, a server-enforced timeout stays warn, and anything else stays error, instead of being decided by the word "timeout" in the message.
Worker mode: sessions leaked between requests — A request admitted while another was suspended started inside its predecessor's session, and after session_write_close() could adopt its id and receive it in Set-Cookie. The session is now written, closed and $_SESSION removed before every request.
Persistent connections answering the wrong request — Outside worker mode a client abort could end a request between sending a command on pfsockopen() or persistent Predis and reading the reply, desyncing the connection until restart. A departed client is now acted on only at the next write, as under PHP-FPM.
Worker mode: pages changing composition after aborts — Aborted requests skipped finally blocks and, when caught at an opcode boundary, disabled every destructor on the worker, so recursion guards stayed set and pages silently lost blocks. Requests now run to their end.
Worker mode: per-request ini values — Changes made with ini_set(), ignore_user_abort() or error_reporting() no longer leak to requests running beside them or outlive a busy worker; they travel with a suspended request and are restored when it ends. A request also no longer starts at the error_reporting level the previous request on its fiber left.
Persistent PDO under RUNTIME_HOOKS=streams — Overlapping PDO::ATTR_PERSISTENT constructors no longer close a connection under the request using it (fatal error or segfault), and a query during another request's liveness ping no longer kills the connection with 2006 MySQL server has gone away.
Worker mode: fatals no longer spin a worker forever — With the profiler or #[Trace] enabled, two fatals on one worker could loop the observer chain at 100% CPU while the pool looked idle. A fatal raised during recovery from another no longer drops every request on the worker either.
Worker mode: uncaught exceptions with __destruct — An exception class with a destructor turned a 500 into a fatal that counted toward recycling (31 requests retired 8 workers), disabled all destructors on the worker and leaked the exception.
Worker mode: client hang-ups no longer recycle workers — Aborts after ignore_user_abort() or during pickup read as fatals and rotated the pool three at a time. Each request the breaker counts now logs a WARN, and a breaker exit is logged at WARN.
Worker mode: streams ended by a write — Such a stream no longer leaks its frames' variables for the life of the worker, and a fatal in a destructor during that cleanup now counts toward the error breaker.
QUEUE_WAIT_TIMEOUT_MS bounds queued requests — The budget was checked only when a worker picked a request up, so a stalled pool never answered queued requests; they now get 529 with Retry-After: 3 at the deadline.
Departed clients no longer queued or refused — Worker mode no longer runs a request whose client left while it was queued, and a budget that runs out after the client left answers 499 as a cancellation rather than 529. client_abort now counts clients leaving before a worker picked them up.
/health/readiness reflects pool health — The executor check always passed; a pool with no live worker threads, or wedged for a minute in worker mode, now answers 503. Overload still does not.
oxphp_apm_trace() runs its callback — It returned null without calling it. The callback now runs in a child span, receives the span id and returns its value, with the return type now mixed.
Error-class messages lost — TypeError, ValueError and the rest of the Error hierarchy reached spans and Once poison reasons without their message and code.
mixed return types under JIT — Functions like oxphp_async_await() and Shared\Map::get() declared a malformed mixed type, so hot loops under the tracing JIT leaked every returned value and Reflection getReturnType() segfaulted.
oxphp_worker_memory_bytes reports memory — It read 0 forever; it now reports the same PHP heap WORKER_MAX_MEMORY_MIB is checked against, sampled at the end of each request.
Worker mode: profiles mixed across requests — Suspended requests on one thread shared profiler and APM span state, producing profiles and #[Trace]/#[SlowThreshold] spans attributed to the wrong request; each request now carries its own.
Profiler truncation and OXPROF — Runs that hit PROFILER_MAX_SPANS are flagged truncated in index.json, /__profiler/stats and oxphp_profiler_truncated_total, and the documented OXPROF cookie now starts a run.
Shared\Mutex::withLock() waits can be ended — A request waiting on a lock now yields to max_execution_time, shutdown and task cancellation every 100 ms, so opposite-order locking no longer holds two workers until restart.
RUNTIME_HOOKS=streams and late-loaded extensions — mysqli and phpredis enabled from an ini file sorting after extension.ini are now guarded like any other; RUNTIME_HOOKS is also read once at startup instead of via an unlocked getenv() per request.
Compression edge cases — Static files between 1 MiB and 3 MiB are compressed; mixed-case Content-Type values compress; identity copies of compressible responses carry Vary: Accept-Encoding; and non-UTF-8 compression settings fail at startup.
Shared\Counter, Atomic and Flag capacity errors — Their constructors now throw Shared\CapacityException past SHARED_MAX_ENTRIES or SHARED_MAX_BYTES, like every other Shared type.
Documentation corrections — SHARED_LOCK_DIAGNOSTICS values are off, warn (default) and strict; worker recycling by request count is no longer advertised; the worker loop also exits on dynamic-pool retirement; 504 comes from max_execution_time; plus graceful-shutdown bounds, the runtime image's ini layout and /config key order.
rustls 0.23.45 — Fixes a TLS 1.3 handshake flaw (RUSTSEC-2026-0285) that accepted plaintext handshake messages across an encryption boundary, affecting the TLS listener and the OTLP and profiler exporters; the transcript stays authenticated.
FRAME_OPTIONS defaults to SAMEORIGIN — Cross-origin framing stays blocked and same-origin embedding (admin previews, dashboard widgets) works out of the box; deployments that relied on the built-in DENY must now set it explicitly.
Worker mode: no event loop inside a request — A worker-mode request is now a fiber, and Revolt refuses to run its loop within one: Revolt\EventLoop::run() in a handler throws. Move it to the worker bootstrap; defer(), delay() and getSuspension() keep working.
Worker mode: request state ends with the request — ini_set(), set_time_limit() and error_reporting() roll back to the bootstrap baseline between requests; filter_input() answers only for the request asking; a php://input handle kept past its request reads as a closed resource instead of another request's body.
query() and cookie() decode their values — Code that compensated with rawurldecode() now decodes twice and must drop the extra call. A cookie sent empty reads as an empty string rather than null, and query() parses at most 1000 parameters.
Strict MAX_CONNECTIONS / QUEUE_CAPACITY parsing — Malformed values abort startup instead of silently falling back to defaults, and QUEUE_CAPACITY=0 now means auto (workers × 128) instead of a zero-capacity queue.
Queue wait budget — A request arriving at a full queue now waits for a slot up to QUEUE_WAIT_TIMEOUT_MS (default 1 s) instead of an instant 529; one deadline stamped on arrival covers the wait for a slot and in the queue. QUEUE_MAX_WAITING and QUEUE_MAX_WAITING_BYTES bound the waiting set in requests and in body bytes.
RUNTIME_HOOKS=streams — Blocking tcp:// socket reads and stream_select() suspend the fiber instead of the worker thread, covering mysqlnd (PDO_MySQL, mysqli), phpredis and the HTTP stream wrappers with no code changes. Connections shared between concurrent fibers are claimed per exchange, so requests cannot read each other's replies.
oxphp_admission_refused_total — Requests shed before reaching a worker now leave a server-side trace, with a reason label separating the four overload reasons (529) from shutting_down (503) and pool_unavailable (500).
Automatic root-span exception capture — An unhandled exception or fatal that answers 5xx attaches an exception event (type, message, stacktrace, file, line) to the request's root span in all four serving modes, with no attribute or oxphp_apm_error() call required.
Database span attributes — PDO and mysqli spans carry db.statement (literal values obfuscated to ?), db.operation, db.system, server.address/port, db.name and oxphp.db.slow; mysqli_stmt::prepare joins the hooked set as function number 34.
oxphp_queue_wait_us reaches one second — Four new buckets (100 ms to 1 s) quantify waits near the default budget instead of lumping them into +Inf; refused requests are excluded from the histogram.
Worker recycling counts what wrecks the worker — Only fatals, out-of-memory and stack overflows count toward the three-consecutive-failures recycle; uncaught exceptions and cancelled requests are neutral. The breaker now trips on the synchronous dispatch path too, where it never fired.
config --check sizing advisory — Warns, without failing, when PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING reaches MAX_CONNECTIONS, the point past which overload answers nothing instead of 529.
Documentation flattened — The repository docs moved from docs/en/ up to docs/; the Russian and Chinese translations moved to oxphp.dev, where French, Polish and Japanese already live.
Worker mode: $_POST and $_FILES populated — Both were empty for every request a worker ever served, so HTML forms and file uploads silently did not work; JSON APIs reading php://input were unaffected, which is why it stayed hidden.
filter_input() cross-request leak — The filter extension's own input copy accumulated for the life of the worker, answering one request with another client's query values, session cookies and body fields; it is now given back at the start of every request.
Sleep timer bursts — More than 32 timers expiring in one scheduler poll left the extra sleepers parked forever; a large burst now wakes in batches of 32.
Closing a shared connection — fclose(), $mysqli->close() or a reconnect helper on a connection another request was parked reading resumed that request into freed memory; it now fails immediately with a 500 naming what happened.
Fatals no longer wipe shared state — Unwinding an aborted request double-released include variables, silently resetting the arrays and objects worker-mode applications keep their cross-request state in.
Async pool pacing — Task handoffs are no longer paced by the fixed 1 ms idle timer: a Shared\Channel transfer dropped from 0.69 ms to 0.14 ms per item, and idle wakeups from 777/s to 92/s.
Paused requests keep their start time — $request->startTime() and oxphp_server_info()[request_time] no longer read 0 or a concurrent request's value after a suspension.
Body-read warnings reach the right request — post_max_size and upload-limit warnings now fire inside the request that sent the body, so error handlers see its superglobals, and one that throws answers 500 instead of closing the connection with nothing.
Privilege drop by default — Started as root where a www-data account exists, the server binds its listeners and drops to www-data before serving any traffic; the official image no longer runs as root. Pass --user=root to keep the old behavior.
Fail-closed TLS config — A half-configured pair (TLS_CERT without TLS_KEY, or vice versa) or a non-UTF-8 cert/key value now aborts startup instead of silently serving plain HTTP, naming the offending variable.
Strict ASYNC_* / SUPERGLOBALS parsing — Garbage in ASYNC_WORKERS, ASYNC_QUEUE_CAPACITY or ASYNC_MAX_FIBERS is a startup error instead of a silent default, and oxphp run no longer ignores an invalid SUPERGLOBALS_ENABLED.
QUERY without Content-Type returns 400, not 415 — The only server-generated 415 is gone; a custom ERROR_PAGES_DIR/415.html for this case must be renamed to 400.html.
Profiler export envelope resolution — PROFILER_EXPORT_XHGUI=false now wins even on a /run/import URL, and envelope auto-detection keys only on each tool's canonical path (xhgui /run/import, Buggregator /api/profiler/store), no longer on a fuzzy "xhgui" substring.
Buggregator profiler export — The profiler's HTTP push wraps xhprof data in the envelope Buggregator's POST /api/profiler/store expects; PROFILER_EXPORT_BUGGREGATOR toggles it, with tags and app_name set via env.
OTLP trace export over TLS — An https:// OTEL_EXPORTER_OTLP_ENDPOINT is now exported encrypted on both gRPC and http/protobuf, verified against the system trust store via rustls; the runtime image must ship a CA bundle (the official one does).
Full exception data on APM spans — The exception span event now carries exception.message and exception.stacktrace alongside exception.type, so New Relic Errors Inbox, Jaeger and Tempo render the message and stack; capped by OTEL_APM_MESSAGE_MAX_BYTES / OTEL_APM_STACKTRACE_MAX_BYTES.
Graceful drain of long-lived connections — On SIGTERM the server sends GOAWAY, closes idle keep-alives and ends open SSE/flush streams promptly; DRAIN_TIMEOUT_SECONDS default lowered 30 → 25 to fit Kubernetes' 30s grace period.
TLS_MIN_VERSION — Minimum accepted protocol version: 1.2 (default) or 1.3. Anything else, including a foreign syntax like "TLSv1.2", is a hard startup error, validated even when TLS is disabled and reported as tls_min_version in /config.
Cheap STATIC_REVALIDATE — stat() at most once every 3 seconds per file; a static hit is served from memory with at most one syscall, and on-disk changes become visible within 3s. Default stays off.
Clearer TLS startup errors — A typo'd path fails with "TLS_CERT: cannot read /etc/ssl/cert.pem: No such file or directory" instead of a bare OS error; PEM-parse and key-mismatch errors are prefixed the same way.
Worker-mode cancellation targeting — A client abort or request timeout could cancel whichever request was most recently accepted on the worker instead of the one actually being cancelled, aborting an unrelated in-flight request.
finish_request() vs multiplexed streams — Once any request on a worker called oxphp_finish_request(), every other stream multiplexed onto that worker silently dropped its subsequent oxphp_stream_flush() output.
Timed-out await cancels the task — An abandoned task is force-cancelled: parked tasks resume into cancellation, CPU-bound ones are interrupted at an opcode boundary. Previously it ran to request end.
Nested oxphp_async() — An async task can dispatch tasks of its own and suspend on await_all / await_any / await_race without blocking its worker thread. The "no nested async" restriction is gone.
In-flight task cap — ASYNC_MAX_FIBERS (default 256) bounds queued + running tasks; dispatch past the cap rejects immediately with AsyncException, so fan-out cannot deadlock.
Internal server allow-list — INTERNAL_ALLOW_IPS: a CIDR allow-list for /metrics and /config; health endpoints stay reachable for orchestrators.
Profiler sampling excludes — PROFILER_EXCLUDE_PATHS keeps framework self-traffic (/_profiler, /_wdt) out of statistical sampling; explicit triggers still profile.
Channel sender wake-up — A fiber send-waiter parked on a full Shared\Channel wakes when recv_blocking frees a slot, so sendTimeout no longer trips spuriously under saturation.
Async edge cases — A graceful-shutdown crash; await on a closed promise stalling instead of rejecting; await_all leaving promises running on early bail-out.
Worker routing contract — Static assets come from disk and everything else goes to the worker ENTRY_FILE. Arbitrary .php files, directory indexes and the root index.php fallback are no longer resolved per-request.
Honest CGI PATH_INFO — In Framework mode PATH_INFO is set only for an explicit /index.php/extra request; PHP_SELF reports the front controller. Route on REQUEST_URI.
Range requests (RFC 9110) — 206 Partial Content for static files: video seeking, resumable downloads, partial PDF loading; If-Range honored with strong validators.
HTTP/2 connection limits — Five env vars bound per-connection resource use; resolved values are logged at startup.
Strong static ETags — "<size>-<mtime>" replaces W/"…" (If-Range requires a strong tag); compressed representations keep a weak tag.
Application headers win — X-Content-Type-Options, X-Frame-Options and CSP set from PHP are no longer overwritten. Server values are insert-if-absent fallbacks.
Streaming vs compression — flush()-streamed responses pass through uncompressed instead of being buffered in full by the brotli layer until script end.
HTTP/2 DoS hardening — Rapid Reset (CVE-2023-44487), HPACK bombs and window-stall attacks are bounded; dead connections reclaimed via PING/PONG keepalive.
remote_ip in access logs — The JSON field remote_addr is renamed remote_ip and carries the IP without a port, as nginx/Apache/Caddy do.
PHP-style CLI — oxphp run script.php executes a file under CLI semantics with the full engine underneath; #!/usr/bin/env oxphp scripts run directly; -d ini overrides accepted.
--user privilege drop — Bind privileged ports as root, then irreversibly drop to an unprivileged user before any request-handling thread spawns.
Forwarded port honored — Behind TRUSTED_PROXIES, SERVER_PORT honors X-Forwarded-Port; REMOTE_PORT is filled from RFC 7239 for= when the proxy supplies one.
Decorator crashes — Two SIGSEGV in the function-decorator path (freed instance cache; dangling zval across resize) are gone; nesting overflow now throws StackOverflowException.
Profiler attributes honor arguments — #[SlowThreshold(ms:)], #[MemoryThreshold(kb:)] and #[Mark(label:)] no longer collapse to shared defaults; APM span events reach OpenTelemetry.
Upload object API wired — Request::file() / files() return UploadedFile objects for every $_FILES shape instead of null.
Trichotomous wait API — Channel and Mutex split every wait into try* / bare verb / *Timeout(int $ms), returning value-typed SendResult / RecvResult instead of mixed|null.
await_any means Promise.any — First fulfilled wins; all-rejected throws AggregateAsyncException. The old "first settled" behavior is now oxphp_async_await_race().
Honest cancel statuses — Timeout → 504, graceful drain → 503 + Retry-After, client disconnect → 499 (log-only). Supervisor kills and userland cancels keep 500.
Renames without shims — Async\Exception → AsyncException, Shared\Exception → SharedException; Worker drops get* prefixes; Counter goes minimal (add/set); Flag mirrors Atomic; Once gains getOrInit()/status().
Shared\Atomic + Ordering — A generic int64 atomic: load/store/swap/CAS/fetch* with explicit memory orderings, one-to-one with std::sync::atomic.
Shared\Registry — Name-keyed process-global handles: every worker converges on the same entry; the factory runs at most once.
One timeout source — REQUEST_TIMEOUT_SECONDS and oxphp_request_heartbeat() are gone; max_execution_time and set_time_limit() remain.
connection_aborted() in SSE — Returns true after a mid-stream disconnect, so while (!connection_aborted()) loops exit cleanly.
Shared\* hot path — Per-op overhead lands within criterion noise of a raw atomic load; ≈4.7× geomean at 8 threads vs the previous shape.
Strict boolean env parsing — Only on/true/1/yes and off/false/0/no; a typo like ture refuses to start instead of silently defaulting.
ENTRY_FILE + WORKER_MODE_ENABLED — One variable selects the routing mode by extension; an explicit toggle enables persistent workers. INDEX_FILE / WORKER_FILE become deprecated aliases.
OxPHP\Server\Worker class — A runtime introspection handle (id, request count, memory, RSS) plus application-driven recycling via scheduleExit().
Static cache renames — STATIC_CACHE_TTL → STATIC_MAX_AGE; STATIC_CACHE → STATIC_REVALIDATE with flipped polarity. Defaults unchanged.
WORKER_MAX_REQUESTS — Parsed and ignored with a startup WARN. Migrate to WORKER_MAX_MEMORY_MIB or scheduleExit().
PHP 8.5 images — :php8.5, :php8.5-alpine and patch-pinned tags ship alongside 8.4; latest stays on 8.4 until the soak window closes.
Chunked streaming across requests — The bridge's per-request context leaked between worker requests, dropping chunked Transfer-Encoding after the first flush.
Shared\* inside user fibers — Channel::recv()/send() inside Fiber::start() no longer take the oxphp-fiber suspend path and crash with fiber_await rc=1.
Named arguments for the plugin API — Plugin-defined classes and functions now advertise real parameter names, so named-argument calls work.
Shared handles in async closures — Channel/Map/Pool captured by an oxphp_async() closure no longer arrive as null on the receiving thread.
Async namespace — Async classes moved under OxPHP\Async\; the functions now ship in plugin-async.
Seven Shared\* primitives — Atomic state shared across workers (Counter, Flag, Once, Mutex, Channel, Map, Pool), with registry observability and a deadlock detector.
Per-request profiler — xhprof / speedscope / pprof / collapsed outputs; cookie, header, query and statistical triggers; HTTP push with an xhgui envelope.
APM auto-instrumentation — plugin-apm with a PHP tracing SDK and error capture; the async and APM subsystems moved into Rust plugins.
Production integrations — TRUSTED_PROXIES (RFC 7239), Kubernetes /readyz + /livez, security headers and per-IP rate limiting, all env-configured.
Pool hot path — Uncontested acquire/release ≈0.9 µs in Docker, with per-thread affinity.
Fiber multiplexing — Concurrent I/O within a single worker thread; distributed tracing with W3C Trace Context and OpenTelemetry export.
oxphp_async() promises — Parallel PHP execution on a dedicated thread pool.
HTTP object API — OxPHP\Http\Request with lazy accessors, plus the attribute-based decorator system.
Port and backpressure — Default port 8080 → 80 (443 with TLS); the backpressure response 503 → 529.
CGI correctness — SERVER_PROTOCOL reflects the real HTTP version; REQUEST_TIME is request start; IPv6 Host parsing; 408 on request timeout.
Async HTTP core — A Hyper + Tokio server with graceful shutdown, a custom oxphp SAPI with full superglobals, a ZTS worker pool with a bounded queue and 529 backpressure.
Three routing modes — Traditional, Framework and SPA; static files with an in-memory cache, ETag/304, brotli, TLS.
Worker mode — Persistent workers via oxphp_worker(), early response completion, SSE streaming, cooperative timeouts.
Observability — /metrics, /health, /config, structured JSON access logs, request IDs.
Docker-first — A multi-stage Alpine build; amd64/arm64 images on GHCR.