История изменений
Каждое значимое изменение сервера, PHP API и образов — избранные моменты по каждому релизу. Полная, неурезанная история — в CHANGELOG.md.
Режим воркеров: max_execution_time на каждый запрос — Запрос, который воркер принимает, уже обслуживая другие, теперь получает собственный лимит, отсчитываемый с момента, когда он был принят, а set_time_limit() влияет только на вызвавший его запрос. Поток SSE или long polling, державшийся на общем таймере, должен сам вызвать set_time_limit(0); релизный образ поставляется без php.ini, поэтому лимит составляет 30 с.
RUNTIME_HOOKS=1, true и all включают net — После обновления установка соединений, TLS-рукопожатия и чтения по TLS, записи в ожидании места в буфере, а также чтения unix://, udp:// и udg:// паркуют файбер без единой правки с вашей стороны. runtime_hooks теперь сообщает ["sleep","streams","net"], а поток, разделяемый между файберами, защищён независимо от его транспорта. RUNTIME_HOOKS=sleep,streams сохраняет прежний набор.
Shareable зарезервирован за типами Shared — PHP-класс больше не может реализовать OxPHP\Shared\Shareable или расширить тип Shared, теперь объявленный final; и то и другое приводит к фатальной ошибке при объявлении. Передача такого объекта в другой поток раньше роняла сервер. createMock() из PHPUnit для типа Shared или для Shareable теперь завершается ошибкой; используйте собственный интерфейс-маркер.
Удалены псевдонимы наблюдаемости Shared\* — oxphp_shared_channel_pending и oxphp_shared_pool_size исчезают из /metrics, а pending/size — из type_specific в /__ox_shared/entry, вместе с предупреждавшим о них WARN при запуске. Дашборды и алерты должны перейти на oxphp_shared_channel_count, oxphp_shared_pool_count и count.
TRUSTED_PROXIES читает записи IP:port — За балансировщиком нагрузки, который пишет IP:port в X-Forwarded-For (Azure Application Gateway), REMOTE_ADDR теперь содержит адрес клиента, а REMOTE_PORT — его порт. Если CDN перед таким шлюзом тоже дописывает свою запись, адресом теперь оказывается адрес самой CDN; внесите в список и её диапазоны (для Cloudflare это ключевое слово cloudflare).
DOCUMENT_ROOT за симлинком релиза — Корень разрешается один раз при запуске, и каждый статический файл, читаемый с диска, и каждый PHP-скрипт сверяются с ним при открытии, поэтому после переключения /app/current они отвечают 404 до перезапуска сервера. Направьте DOCUMENT_ROOT на путь, который не меняется от релиза к релизу, или перезапускайте сервер в рамках переключения.
Скрипты, которые сервер не может открыть — Вне режима воркеров удалённый скрипт отвечает 404, даже если OPcache держит его при выключенном validate_timestamps, поэтому у развёртывания, которое удаляет и пересоздаёт файлы на месте (git checkout, rsync --delete), появляется окно 404; записывайте новые файлы рядом со старыми и переименовывайте их поверх. Нечитаемый скрипт отвечает 500 и пишет в лог Cannot open script.
Скрипты за симлинками выполняются как их цель — Вне режима воркеров скрипт, доступный через ссылку в другой каталог, выполняется с этим каталогом в качестве рабочего, а то, считает ли PHP его phar-архивом, зависит от разрешённого имени файла, а не от URL. Стройте относительные пути от __DIR__.
fiber.stack_size должен превышать zend.reserved_stack_size — В режиме воркеров и в задачах ASYNC_WORKERS предел стека файбера теперь оставляет свободным резерв PHP (48 KiB в релизной сборке), поэтому при fiber.stack_size, не превышающем его, каждый запрос завершается ошибкой Maximum call stack size of 0 bytes, а глубокая рекурсия останавливается на 44 KiB раньше, чем прежде.
Profile\start() ничего не делает при отключённом профилировщике — При выключенном PROFILER_ENABLED он больше не заставляет Profile\is_active() возвращать true, поэтому тесту, который запускает профиль и проверяет is_active(), нужен включённый профилировщик. При OTEL_APM_ENABLED спаны APM, которые этот вызов раньше отбрасывал, теперь сохраняются в трассе.
UploadedFile::type() после moveTo() читает файл назначения — Определение типа больше не выполняется до перемещения, поэтому для файла, который к первому вызову type() перемещён дальше, удалён или перезаписан, сообщается то, что там лежит сейчас, либо application/octet-stream с предупреждением. Вызывайте type() до moveTo(), если перемещённый файл не остаётся на месте или находится за обёрткой потока.
Правила запрета путей .oxphpdeny — Файл с синтаксисом .gitignore в корне DOCUMENT_ROOT не позволяет отдавать перечисленные в нём пути: в любом режиме маршрутизации, как статические файлы, так и PHP, ещё до какого-либо обращения к диску. Запрещённые пути отвечают согласно PHP_DENY_FALLBACK, а правило > передаёт запрос входному скрипту. Файл читается один раз при запуске; запреты учитываются в oxphp_path_deny_total.
RUNTIME_HOOKS=net — Установка соединений через потоковые транспорты PHP, TLS-рукопожатия и чтения по TLS, записи в ожидании места в буфере отправки, а также чтения из потоков unix://, udp://, udg:// и пар сокетов приостанавливают файбер вместо того, чтобы удерживать поток воркера. Требует, чтобы в списке была указана и категория streams, и работает только в Linux. Не покрываются: libcurl, libpq, libmemcached, DNS и установка постоянных соединений.
Ключевое слово cloudflare в TRUSTED_PROXIES — Ключевые слова, CIDR и одиночные IP теперь смешиваются в одном списке (private,100.64.0.0/10 раньше было ошибкой запуска). cloudflare доверяет диапазонам edge-узлов Cloudflare и берёт адрес клиента из CF-Connecting-IP, когда первым узлом за вашими собственными прокси оказывается edge-узел Cloudflare; private,cloudflare подходит для схемы Cloudflare → балансировщик нагрузки → OxPHP.
User-Agent в трассах и журналах доступа — Корневой серверный спан несёт user_agent.original, а каждая запись журнала доступа — поле user_agent; оба обрезаются до 512 байт с заменой некорректного UTF-8, так что краулера можно отличить от браузера по одним лишь трассам.
Почему запрос был сброшен — Строки лога о перегрузке теперь разделяют отказы на refused_queue_full, refused_wait_timeout, refused_waiting_full и refused_waiting_bytes, а корневой спан отклонённого запроса несёт oxphp.shed_reason, так что предел, который нужно поднять, виден без слушателя INTERNAL_ADDR.
Сбои экспорта OTLP в /metrics — oxphp_otel_export_failures_total, oxphp_otel_export_failed_spans_total и oxphp_otel_export_retries_total считают неудавшиеся экспорты, переносившиеся в них спаны и повторы после быстрых сбоев. Неудавшийся экспорт также пишется в лог как OTLP span export failed с размером пакета и причиной.
Число CPU при запуске — Строка CPU count for default sizing сообщает cpus, cpu_source (host, affinity, cgroup quota или unknown), host_cpus и получившиеся PHP_WORKERS и TOKIO_WORKERS. cpu_source=host на тарифе, продаваемом как меньшее число CPU (так бывает на DigitalOcean App Platform под gVisor), означает, что обе переменные следует задать явно.
Ночной образ на Debian (glibc) — :nightly-trixie, :php8.4-nightly-trixie и :php8.5-nightly-trixie запускают тот же сервер на php:<minor>-zts-trixie и на приложении Yii3 тратили на 14–19% меньше CPU на ответ, чем Alpine. www-data там имеет UID 33, а расширения собираются в стадии trixie; тегированные релизы остаются на Alpine.
Более дешёвые вызовы функций PHP — Наблюдатель движка, который считал каждый вызов, включая внутренние, для классификации зависших запросов, удалён. На приложении Yii3 затраты CPU на запрос снижаются на 5% в классическом режиме и на 7% в режиме воркеров; скрипт, делающий 15 000 коротких вызовов на запрос, расходует на 23–25% меньше.
APM: hGet, hSet, lPush и rPush в phpredis — При включённом OTEL_APM_ENABLED эти четыре метода возвращали NULL, не обращаясь к Redis: записи терялись, а чтения возвращались пустыми, без ошибки и без спана. Теперь хук сопоставляет имена методов без учёта регистра, как это делает PHP.
PHP 8.5: memory_limit после ошибки нехватки памяти — OOM на глубоком стеке в режиме воркеров или в асинхронной задаче оставлял воркер вовсе без лимита, так что следующий вышедший из-под контроля запрос рос, пока ядро не убивало сервер вместе со всеми его воркерами. Теперь лимит восстанавливается при завершении запроса, а воркер, всё ещё превышающий его, перезапускается.
Переполнение стека вызовов больше не приводит к segfault — Рекурсия, достигающая предела стека PHP в режиме воркеров или в асинхронной задаче, заканчивается обычным Error, а не падением сервера; раньше для падения хватало вызова realpath() на последнем допустимом уровне. Теперь действует тот предел, который PHP устанавливает для каждого файбера.
Режим воркеров: фатальные ошибки внутри Fiber — Фатал в файбере, запущенном запросом, или в деструкторе, вызванном сборщиком мусора, читал освобождённые фреймы вызовов и мог обрушить процесс вместе со всеми соединениями. Теперь запрос завершается с 500, а воркер продолжает обслуживание.
Режим воркеров: таймауты завершают нужный запрос — Единственный таймер на поток завершал тот запрос, который выполнялся в данный момент, а запрос, для которого он предназначался, засчитывал как сбой обработчика, перезапуская воркер после трёх таких случаев. Теперь у каждого запроса свой дедлайн; если он истекает, пока запрос приостановлен, запрос завершается с 504, который не засчитывается в перезапуск.
Режим воркеров: max_input_time после старта — При max_execution_time = 0 и заданном max_input_time таймер, взведённый на время старта воркера, завершал запрос через max_input_time секунд (60 в шаблонах php.ini, поставляемых с PHP). Теперь воркер останавливает его до начала обслуживания.
Асинхронные задачи и дедлайн потока пула — Единственный таймер потока пула срабатывал один раз, примерно через max_execution_time после старта процесса, завершая выполнявшуюся в тот момент задачу с Maximum execution time of 0 seconds. Теперь у потоков пула нет дедлайна, поэтому set_time_limit() больше не ограничивает задачу: ограничивайте её таймаутом oxphp_async_await().
Постоянные соединения закрываются при выходе воркера — Каждый перезапуск воркера в режиме воркеров и каждое сокращение динамического пула оставляли соединения PDO::ATTR_PERSISTENT, mysqli p: и pfsockopen() открытыми до перезапуска процесса, и они копились, пока база данных не начинала отказывать новым клиентам. Теперь они закрываются после последнего запроса потока.
Режим воркеров: память, оставленная отменёнными внутренними вызовами — Запрос, завершённый внутри внутренней функции (скажем, usort() посреди компаратора), допускал утечку выделенной этой функцией памяти на всю жизнь воркера. Воркер, выросший больше чем на 4 MiB за такой запрос, теперь перезапускается в ближайший момент простоя с предупреждением.
Фатальные ошибки внутри вызываемых объектов Shared — Фатал в Mutex::withLock(), Once::getOrInit(), фабрике Registry или Pool::with() оставлял мьютекс заблокированным, Once — в ожидании, ключ — застрявшим на стадии создания, а пул — без одного слота на всю жизнь процесса. Теперь всё это возвращается при завершении запроса.
RUNTIME_HOOKS=streams вместе с APM — Обёртки APM для PDO, mysqli и Redis обходили защиту соединения, поэтому файберы, разделяющие соединение, могли читать ответы Redis друг друга или получать Cannot execute queries while other unbuffered queries are active. Теперь обёртки вызывают защищённый обработчик.
RUNTIME_HOOKS=streams: деструкторы между запросами — Деструктор, запущенный сборкой циклических ссылок в цикле воркера, мог записать данные в обмен другого запроса по общему соединению и прочитать его ответ. Ожидание сокета на соединении, владелец которого на нём припаркован, теперь сразу завершается неудачей, как по таймауту, и ничего не отправляет.
Конструкторы постоянного PDO ждут только свой источник данных — Конструктор, зависший на базе данных, которая так и не отвечает, задерживал все остальные постоянные конструкторы на воркере, пока их ожидание не истекало и они не продолжали работу уже без сериализации. Теперь они ждут только конструкторы, подключающиеся к тому же DSN.
Освобождение дескриптора PDO на общем постоянном соединении — При RUNTIME_HOOKS=streams запрос, освобождавший свой дескриптор, откатывал транзакцию другого запроса, а с pdo_mysql мог освободить результат посреди чтения и вызвать segfault. Теперь такую очистку выполняет только текущий владелец соединения, а PDO::rollBack() из другого файбера ничего не отправляет.
Плановые перезапуски не являются сбоями — Воркер, перезапущенный по WORKER_MAX_MEMORY_MIB или Worker::scheduleExit(), логируется на уровне INFO как Worker mode thread recycled с указанием причины, а не как WARN Dead workers detected, который читался как сбой.
Экспорт OTLP по http/protobuf — По этому протоколу ни разу не был экспортирован ни один спан: поток экспорта паниковал, и каждый спан писал в лог Spans are being emitted even after Shutdown. Теперь спаны доходят до /v1/traces под OTEL_EXPORTER_OTLP_ENDPOINT, который является базовым URL; уберите /v1/traces, если он там уже есть.
OTLP по gRPC: установка соединения и мёртвые соединения — OTEL_EXPORTER_OTLP_TIMEOUT теперь ограничивает и установку соединения, а в Linux соединение, переставшее принимать данные, заменяется, вместо того чтобы около 15 минут проваливать каждый экспорт. Экспорт, быстро завершившийся неудачей на соединении, сброшенном из-за простоя, отправляется ещё раз. Заданный, но пустой OTEL_EXPORTER_OTLP_ENDPOINT теперь отклоняется при запуске.
Классификация oxphp_worker_stuck_total — Цикл PHP без единого вызова внутри читался как c_call, а первое измерение зависшего запроса могло сравниваться с измерением более раннего запроса. Теперь супервизор опрашивает сам движок через то же прерывание, которым доставляется max_execution_time.
UploadedFile::moveTo() без fileinfo — Без mime_content_type() или под обработчиком ошибок, превращающим предупреждения в исключения, moveTo() выбрасывал исключение до перемещения, и загруженный файл удалялся в конце запроса. Теперь он сначала перемещает файл, а определение типа оставляет type().
Исключения деструкторов при завершении асинхронной задачи — Исключение, выброшенное деструктором объекта, освобождаемого при разборе задачи, исчезало бесследно; теперь оно сообщается как необработанное и попадает в журнал ошибок.
index.json профилировщика записывает user_agent — Задокументированное поле всегда было null.
Документация: руководство по SSE — Пример для режима воркеров читал события через BRPOP, поэтому каждое событие доходило только до одного клиента; теперь он читает Redis Stream и возобновляет чтение с Last-Event-ID. Руководство больше не утверждает, что цикл while (!connection_aborted()) завершается по своему условию, а его пример вызывает ignore_user_abort(true) и set_time_limit(0).
Статические файлы: симлинки, перенаправленные после маршрутизации — Ссылка внутри DOCUMENT_ROOT, проверенная на одном запросе и затем перенаправленная, скажем, на /etc/passwd, на следующем запросе сервер переходил по ней. Теперь файлы проверяются после открытия по расположению, которое ядро сообщает для дескриптора; файл, всё ещё находящийся в кэше в памяти, сохраняет свои проверенные байты.
PHP-скрипты: симлинки, перенаправленные после маршрутизации — Вне режима воркеров такая ссылка, как только кэш realpath в PHP отпускал старую цель, могла запустить скрипт злоумышленника, вывести текстовый файл или подвесить воркер на FIFO. Теперь воркер сам открывает скрипт, проверяет дескриптор и передаёт PHP именно этот файл; подменённая ссылка отвечает 404. Это стоит около 3% пропускной способности на тривиальном скрипте.
PHP 8.5: error_get_last() между запросами — В режиме воркеров и в асинхронных задачах запрос, остановленный в точке приостановки, мог прочитать трассировку стека фатальной ошибки другого запроса вместе с аргументами, с которыми вызывались его функции: паролями, токенами, записями клиентов. Теперь каждый запрос хранит собственную трассировку и освобождает её при завершении.
TRUSTED_PROXIES: неразбираемые записи пересылки — Правило «крайний справа недоверенный» пропускало записи, которые не могло разобрать, и читало дальше, вплоть до того, что написал клиент, поэтому за шлюзом, пишущим IP:port, клиент сам выбирал свой адрес. Теперь неразбираемая запись или нечитаемая строка заголовка останавливает правило.
Разделённые заголовки Cookie — Cookie, разнесённые по нескольким строкам поля, как допускает HTTP/2, теперь объединяются согласно RFC 9113 до того, как их что-либо прочитает. Cookie плагина __oxp_* на одной из последующих строк раньше доходила до приложения, а удаление такой cookie из первой строки отбрасывало собственные cookie приложения на остальных строках.
SHARED_ENABLED=false теперь отключает Shared\* — Раньше настройка разбиралась и игнорировалась; теперь не регистрируется ни один класс OxPHP\Shared\*, и первый же созданный выбрасывает Error: Class "OxPHP\Shared\Counter" not found. Она также читается как OX_SHARED_ENABLED и как голый ENABLED, поэтому перед обновлением проверьте все три; class_exists() подскажет коду, в каком режиме он работает.
Режим воркеров: обрыв со стороны клиента больше не завершает запрос — Обработчик выполняется до конца, поэтому его блоки finally и очистка отрабатывают, как если бы ignore_user_abort был всегда включён; ограничивает его только max_execution_time. Чтобы остановиться раньше, вызывайте connection_aborted(). Потоковые ответы по-прежнему завершаются на следующей записи.
ignore_user_abort(true) выполняется до конца во всех режимах — Как и под PHP-FPM, вывод отбрасывается, а connection_aborted() сообщает об отключении; цикл потоковой передачи, полагавшийся на остановку при следующей записи, должен проверять connection_aborted(). ignore_user_abort = On в php.ini или в бутстрапе воркера теперь применяется к каждому потоку.
/health отвечает 503, когда пул потерян или завис — Тело получает поле pool_stalled и сообщает "status": "degraded". Liveness-проба Kubernetes, направленная на агрегированный /health, теперь будет перезапускать поды; используйте /health/liveness, /health/readiness и /health/startup.
LOG_LEVEL больше не перекрывает ACCESS_LOG — ACCESS_LOG=all при LOG_LEVEL=warn раньше не писал ничего; теперь он пишет по строке на запрос, так что рассчитайте объём логов. RUST_LOG=warn,access_log=off по-прежнему отключает только журнал доступа.
payload() возвращает тела форм и скаляры JSON — Тела форм теперь разбираются при SUPERGLOBALS_ENABLED=false, а JSON-тело "hi", 42 или false возвращает это значение вместо null или $default. null теперь означает только пустое тело, неразобранный Content-Type, некорректный JSON или литеральный null.
Обрывы со стороны клиента считаются отменами — oxphp_request_cancelled_total{reason="client_abort"} начинает расти, а admission_refused_total{reason="wait_timeout"} снижается, и строка ERROR Connection error для ушедшего клиента теперь пишется на уровне debug; алерты по любой из них стоит пересмотреть.
Запуски профилировщика учитываются по триггеру и под собственным id — oxphp_profiler_runs_total{source} теперь делится на header, cookie, query, sample и новый sdk; id запусков имеют вид <ts_ms>-<req_id_prefix>-<rand4>, поэтому /__profiler/runs/{id} отвечает 404 на id запроса — ищите его по request_id в /__profiler/runs.
PROFILER_MAX_DEPTH соблюдается — Раньше он не применялся нигде. Значение по умолчанию теперь 0 (без ограничения); значения больше 32 урезаются с WARN при запуске, а вызовы ниже предела отсутствуют в профиле.
COMPRESSION_LEVEL устарел — Используйте COMPRESSION_BROTLI_LEVEL; старое имя по-прежнему работает с WARN при запуске. Ненулевое значение больше не означает «только Brotli», поскольку теперь предлагаются также Zstandard и gzip. /config сообщает brotli_level, gzip_level и zstd_level вместо compression_level.
SHARED_SOFT_LIMIT_RATIO и SHARED_POISON_STRICT игнорируются — Ни одна из них никогда не читалась; обе теперь пишут WARN при запуске, а limits.soft_ratio и diagnostics.poison_strict исчезли из /__ox_shared/summary. Ставьте алерты на oxphp_shared_capacity_saturation; терминальный Once принимает Once\FailureMode::Poison.
oxphp_queue_wait_us показывает меньше — Теперь отсчёт останавливается, когда воркер забирает запрос, — примерно на 0,1 мс меньше на запрос; субмиллисекундные пороги алертов срабатывают реже. oxphp_request_duration_us не изменился.
Сжатие Zstandard — Динамические ответы уходят в zstd (уровень 6) клиентам, которые его принимают: меньше, чем Brotli, при менее чем половине затрат CPU. COMPRESSION_ENCODINGS (по умолчанию br,zstd,gzip, или off) выбирает кодировки, вместе с COMPRESSION_BROTLI_LEVEL (5), COMPRESSION_ZSTD_LEVEL (6) и COMPRESSION_GZIP_LEVEL (6).
gzip для клиентов без Brotli — Chromium по обычному HTTP и большинство CLI-инструментов раньше получали несжатые тела. Accept-Encoding теперь ранжируется по q-value согласно RFC 9110, а q=0 соблюдается как отказ; как следствие, сжимаемые файлы до 3 MiB отвечают на Range-запросы полным 200.
Предварительно сжатый кэш статики — Для закэшированного статического файла однократно в фоне строится копия Brotli максимального качества, на 8–12% меньше вывода, сжимаемого на каждый запрос, в пределах того же бюджета кэша.
Видимость зависания пула — oxphp_pool_stalled, oxphp_queue_depth, oxphp_queue_capacity и oxphp_admission_slots_available показывают пул, переставший забирать работу из очереди, с WARN, пока это длится, и INFO, как только работа снова движется (режим воркеров).
Перегрузка в логе — Запросы, сброшенные с 529, теперь дают по WARN на эпизод, повторяемый не чаще раза в минуту, и INFO с итогом, как только проходит минута без отказов.
Сообщение об исчерпании MAX_CONNECTIONS — Припаркованный цикл accept пишет WARN с упоминанием MAX_CONNECTIONS и INFO при возобновлении; oxphp_accept_stalled и oxphp_accept_stalls_total экспортируют это.
oxphp_admission_wait_wasted_total — Считает отказы wait_timeout, в течение которых ни один воркер не начал ни одного запроса, — признак того, что QUEUE_WAIT_TIMEOUT_MS слишком велик или приложение обращается к собственному серверу.
oxphp_worker_request_fibers_active — Файберы запросов на воркер: 256 означает, что воркер перестал принимать работу, а счётчик, не возвращающийся к 0 в простое, — утечка файберов (режим воркеров).
RUNTIME_HOOKS на виду — /config и oxphp_server_info() сообщают runtime_hooks как разобранный список установленных категорий, например ["sleep","streams"], так что переменная с опечаткой читается как [].
Адаптивный бюджет ожидания очереди — QUEUE_WAIT_TIMEOUT_MS теперь потолок: сервер вдвое сокращает ожидание, пока четверть или больше начатой работы уходит клиентам, которые уже ушли (нижняя граница 10 мс или 1/64 потолка), и восстанавливает его, когда очередь опустеет. oxphp_admission_wait_budget_us, oxphp_admission_wait_budget_ceiling_us и oxphp_abandoned_work_total показывают это.
Brotli с качеством 5 на запрос — Вместо 4, которое давало вывод больше, чем gzip, при больших затратах CPU; тела Brotli больше 4 KB теперь сжимаются в блокирующем пуле. COMPRESSION_BROTLI_LEVEL=4 возвращает прежнее поведение.
Уровни логирования завершения соединения по типу ошибки — Закрытие соединения клиентом — debug, навязанный сервером таймаут остаётся warn, а всё остальное — error, вместо решения по слову "timeout" в сообщении.
Режим воркеров: утечка сессий между запросами — Запрос, принятый, пока другой был приостановлен, начинался внутри сессии предшественника и после session_write_close() мог перенять её id и получить его в Set-Cookie. Теперь перед каждым запросом сессия записывается, закрывается, а $_SESSION удаляется.
Постоянные соединения отвечали не тому запросу — Вне режима воркеров обрыв со стороны клиента мог завершить запрос между отправкой команды по pfsockopen() или постоянному Predis и чтением ответа, рассинхронизируя соединение до перезапуска. Теперь на ушедшего клиента реагируют только при следующей записи, как под PHP-FPM.
Режим воркеров: страницы меняли состав после обрывов — Прерванные запросы пропускали блоки finally и, будучи перехвачены на границе опкода, отключали все деструкторы на воркере, так что защиты от рекурсии оставались взведёнными, а страницы молча теряли блоки. Теперь запросы выполняются до конца.
Режим воркеров: ini-значения на запрос — Изменения через ini_set(), ignore_user_abort() или error_reporting() больше не утекают в запросы, выполняющиеся рядом, и не переживают загруженный воркер; они путешествуют вместе с приостановленным запросом и восстанавливаются, когда он завершается. Запрос также больше не начинается с уровнем error_reporting, оставленным предыдущим запросом на его файбере.
Постоянный PDO под RUNTIME_HOOKS=streams — Перекрывающиеся конструкторы PDO::ATTR_PERSISTENT больше не закрывают соединение под использующим его запросом (фатальная ошибка или segfault), а запрос во время проверки живости соединения другим запросом больше не убивает соединение с 2006 MySQL server has gone away.
Режим воркеров: фаталы больше не зацикливают воркер навсегда — При включённом профилировщике или #[Trace] два фатала на одном воркере могли зациклить цепочку наблюдателей на 100% CPU, пока пул выглядел простаивающим. Фатал, возникший при восстановлении после другого, тоже больше не сбрасывает все запросы на воркере.
Режим воркеров: необработанные исключения с __destruct — Класс исключения с деструктором превращал 500 в фатал, засчитывавшийся в перезапуск (31 запрос вывел из строя 8 воркеров), отключал все деструкторы на воркере и допускал утечку исключения.
Режим воркеров: обрывы со стороны клиента больше не перезапускают воркеры — Обрывы после ignore_user_abort() или во время подхвата читались как фаталы и прокручивали пул по три воркера за раз. Каждый запрос, который считает предохранитель, теперь пишет WARN, а выход по предохранителю логируется на уровне WARN.
Режим воркеров: потоки, завершённые записью — Такой поток больше не допускает утечку переменных своих фреймов на всю жизнь воркера, а фатал в деструкторе во время этой очистки теперь засчитывается предохранителю ошибок.
QUEUE_WAIT_TIMEOUT_MS ограничивает запросы в очереди — Бюджет проверялся только когда воркер забирал запрос, поэтому зависший пул никогда не отвечал на запросы в очереди; теперь по дедлайну они получают 529 с Retry-After: 3.
Ушедшие клиенты больше не ставятся в очередь и не получают отказ — Режим воркеров больше не выполняет запрос, клиент которого ушёл, пока тот был в очереди, а бюджет, исчерпанный после ухода клиента, отвечает 499 как отмена, а не 529. client_abort теперь учитывает клиентов, ушедших до того, как воркер их забрал.
/health/readiness отражает здоровье пула — Проверка исполнителя проходила всегда; пул без живых потоков воркеров или зависший на минуту в режиме воркеров теперь отвечает 503. Перегрузка по-прежнему нет.
oxphp_apm_trace() выполняет свой колбэк — Функция возвращала null, не вызывая его. Теперь колбэк выполняется в дочернем спане, получает id спана и возвращает своё значение, а тип возврата теперь mixed.
Потеря сообщений классов Error — TypeError, ValueError и остальная иерархия Error доходили до спанов и причин отравления Once без своих сообщения и кода.
Типы возврата mixed под JIT — Функции вроде oxphp_async_await() и Shared\Map::get() объявляли некорректный тип mixed, поэтому горячие циклы под трассирующим JIT допускали утечку каждого возвращённого значения, а Reflection getReturnType() падал с segfault.
oxphp_worker_memory_bytes сообщает память — Метрика всегда показывала 0; теперь она сообщает ту же кучу PHP, с которой сверяется WORKER_MAX_MEMORY_MIB, замеряя её в конце каждого запроса.
Режим воркеров: профили смешивались между запросами — Приостановленные запросы на одном потоке делили состояние профилировщика и спанов APM, порождая профили и спаны #[Trace]/#[SlowThreshold], приписанные не тому запросу; теперь у каждого запроса своё.
Усечение профилировщика и OXPROF — Запуски, упёршиеся в PROFILER_MAX_SPANS, помечаются как truncated в index.json, /__profiler/stats и oxphp_profiler_truncated_total, а задокументированная cookie OXPROF теперь запускает профилирование.
Ожидание в Shared\Mutex::withLock() можно прервать — Запрос, ждущий блокировки, теперь каждые 100 мс уступает max_execution_time, завершению работы и отмене задачи, поэтому блокировка в противоположном порядке больше не удерживает два воркера до перезапуска.
RUNTIME_HOOKS=streams и поздно загружаемые расширения — mysqli и phpredis, включённые из ini-файла, который сортируется после extension.ini, теперь защищены, как и любые другие; RUNTIME_HOOKS также читается один раз при запуске, а не через незаблокированный getenv() на каждый запрос.
Граничные случаи сжатия — Статические файлы от 1 MiB до 3 MiB сжимаются; значения Content-Type в смешанном регистре сжимаются; identity-копии сжимаемых ответов несут Vary: Accept-Encoding; а настройки сжатия не в UTF-8 приводят к ошибке запуска.
Ошибки ёмкости Shared\Counter, Atomic и Flag — Их конструкторы теперь выбрасывают Shared\CapacityException при превышении SHARED_MAX_ENTRIES или SHARED_MAX_BYTES, как и все остальные типы Shared.
Исправления документации — Значения SHARED_LOCK_DIAGNOSTICS: off, warn (по умолчанию) и strict; перезапуск воркеров по числу запросов больше не заявляется; цикл воркера также завершается при выводе из динамического пула; 504 исходит от max_execution_time; а также границы корректного завершения, раскладка ini в образе среды выполнения и порядок ключей /config.
rustls 0.23.45 — Исправляет уязвимость рукопожатия TLS 1.3 (RUSTSEC-2026-0285), при которой принимались открытые сообщения рукопожатия через границу шифрования; затронуты слушатель TLS и экспортёры OTLP и профилировщика; транскрипт остаётся аутентифицированным.
FRAME_OPTIONS по умолчанию SAMEORIGIN — Межсайтовое встраивание остаётся заблокированным, а встраивание в рамках того же источника (предпросмотры в админке, виджеты дашбордов) работает из коробки; развёртывания, полагавшиеся на встроенный DENY, теперь должны задавать его явно.
Режим воркеров: без цикла событий внутри запроса — Запрос режима воркеров теперь является файбером, и Revolt отказывается запускать свой цикл внутри него: Revolt\EventLoop::run() в обработчике выбрасывает исключение. Перенесите его в бутстрап воркера; defer(), delay() и getSuspension() продолжают работать.
Режим воркеров: состояние запроса заканчивается вместе с запросом — ini_set(), set_time_limit() и error_reporting() откатываются к базовым значениям бутстрапа между запросами; filter_input() отвечает только за спрашивающий запрос; дескриптор php://input, удержанный дольше своего запроса, читается как закрытый ресурс, а не как тело другого запроса.
query() и cookie() декодируют свои значения — Код, компенсировавший это через rawurldecode(), теперь декодирует дважды и должен убрать лишний вызов. Cookie, отправленная пустой, читается как пустая строка, а не null, а query() разбирает не более 1000 параметров.
Строгий разбор MAX_CONNECTIONS / QUEUE_CAPACITY — Некорректные значения прерывают запуск вместо молчаливого отката к умолчаниям, а QUEUE_CAPACITY=0 теперь означает авто (воркеры × 128), а не очередь нулевой ёмкости.
Бюджет ожидания очереди — Запрос, пришедший в заполненную очередь, теперь ждёт слота до QUEUE_WAIT_TIMEOUT_MS (по умолчанию 1 с) вместо мгновенного 529; один дедлайн, проставленный при прибытии, покрывает ожидание слота и ожидание в очереди. QUEUE_MAX_WAITING и QUEUE_MAX_WAITING_BYTES ограничивают множество ожидающих в запросах и в байтах тел.
RUNTIME_HOOKS=streams — Блокирующие чтения сокетов tcp:// и stream_select() приостанавливают файбер вместо потока воркера, покрывая mysqlnd (PDO_MySQL, mysqli), phpredis и HTTP-обёртки потоков без изменений кода. Соединения, разделяемые конкурентными файберами, занимаются на время обмена, поэтому запросы не могут читать ответы друг друга.
oxphp_admission_refused_total — Запросы, сброшенные до достижения воркера, теперь оставляют след на стороне сервера, а метка reason отделяет четыре причины перегрузки (529) от shutting_down (503) и pool_unavailable (500).
Автоматический захват исключений на корневом спане — Необработанное исключение или фатальная ошибка, отвечающая 5xx, прикрепляет событие exception (type, message, stacktrace, file, line) к корневому спану запроса во всех четырёх режимах обслуживания — без атрибута и без вызова oxphp_apm_error().
Атрибуты спанов базы данных — Спаны PDO и mysqli несут db.statement (литеральные значения обфусцированы в ?), db.operation, db.system, server.address/port, db.name и oxphp.db.slow; mysqli_stmt::prepare присоединяется к перехватываемому набору как функция номер 34.
oxphp_queue_wait_us достигает одной секунды — Четыре новых бакета (от 100 мс до 1 с) дают численную оценку ожиданиям около бюджета по умолчанию вместо сваливания их в +Inf; отклонённые запросы исключены из гистограммы.
Перезапуск воркеров считает то, что ломает воркер — Только фаталы, нехватка памяти и переполнения стека засчитываются в перезапуск по трём последовательным сбоям; необработанные исключения и отменённые запросы нейтральны. Предохранитель теперь срабатывает и на синхронном пути диспетчеризации, где он раньше не срабатывал никогда.
Рекомендация по размерам в config --check — Предупреждает, не завершаясь ошибкой, когда PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING достигает MAX_CONNECTIONS — точки, за которой перегрузка отвечает ничем вместо 529.
Документация выровнена — Документация репозитория переехала из docs/en/ на уровень docs/; русский и китайский переводы переехали на oxphp.dev, где уже живут французский, польский и японский.
Режим воркеров: $_POST и $_FILES заполняются — Оба были пустыми для каждого запроса, который когда-либо обслуживал воркер, поэтому HTML-формы и загрузки файлов молча не работали; JSON-API, читающие php://input, затронуты не были — потому это и оставалось незамеченным.
Межзапросная утечка filter_input() — Собственная копия ввода расширения filter накапливалась всю жизнь воркера, отвечая одному запросу значениями строки запроса, сессионными cookies и полями тела другого клиента; теперь она возвращается в начале каждого запроса.
Всплески таймеров sleep — Более 32 таймеров, истекающих за один опрос планировщика, оставляли лишних спящих припаркованными навсегда; большой всплеск теперь пробуждается партиями по 32.
Закрытие общего соединения — fclose(), $mysqli->close() или помощник переподключения на соединении, на чтении которого стоял другой запрос, возобновляли тот запрос в освобождённую память; теперь он немедленно завершается с 500, называющим произошедшее.
Фаталы больше не стирают разделяемое состояние — Размотка прерванного запроса дважды освобождала include-переменные, молча сбрасывая массивы и объекты, в которых приложения режима воркеров хранят межзапросное состояние.
Темп асинхронного пула — Передача задач больше не задаётся фиксированным таймером простоя в 1 мс: передача через Shared\Channel упала с 0,69 мс до 0,14 мс на элемент, а пробуждения при простое — с 777/с до 92/с.
Приостановленные запросы сохраняют своё время старта — $request->startTime() и oxphp_server_info()[request_time] больше не читают 0 или значение конкурентного запроса после приостановки.
Предупреждения чтения тела достигают правильного запроса — Предупреждения post_max_size и лимитов загрузки теперь срабатывают внутри запроса, приславшего тело, поэтому обработчики ошибок видят его суперглобальные переменные, а обработчик, выбросивший исключение, отвечает 500 вместо закрытия соединения ни с чем.
Сброс привилегий по умолчанию — Запущенный от root при наличии учётной записи www-data, сервер привязывает свои слушающие сокеты и сбрасывает привилегии до www-data перед тем, как начать обслуживать трафик; официальный образ больше не работает от root. Передайте --user=root, чтобы сохранить прежнее поведение.
Fail-closed конфигурация TLS — Наполовину настроенная пара (TLS_CERT без TLS_KEY или наоборот) либо значение сертификата/ключа не в UTF-8 теперь прерывает запуск вместо того, чтобы молча обслуживать обычный HTTP, называя проблемную переменную.
Строгий разбор ASYNC_* / SUPERGLOBALS — Мусор в ASYNC_WORKERS, ASYNC_QUEUE_CAPACITY или ASYNC_MAX_FIBERS теперь приводит к ошибке запуска вместо молчаливого значения по умолчанию, а oxphp run больше не игнорирует некорректный SUPERGLOBALS_ENABLED.
QUERY без Content-Type возвращает 400, а не 415 — Единственный генерируемый сервером 415 исчез; пользовательский ERROR_PAGES_DIR/415.html для этого случая нужно переименовать в 400.html.
Определение конверта экспорта профилировщика — PROFILER_EXPORT_XHGUI=false теперь имеет приоритет даже на URL /run/import, а автоопределение конверта опирается только на канонический путь каждого инструмента (xhgui /run/import, Buggregator /api/profiler/store), а не на нечёткое вхождение подстроки "xhgui".
Экспорт профилировщика в Buggregator — HTTP-push профилировщика оборачивает данные xhprof в конверт, который ожидает POST /api/profiler/store в Buggregator; PROFILER_EXPORT_BUGGREGATOR включает это, а tags и app_name задаются через переменные окружения.
Экспорт трассировки OTLP по TLS — https:// OTEL_EXPORTER_OTLP_ENDPOINT теперь экспортируется в зашифрованном виде как по gRPC, так и по http/protobuf, с проверкой по системному хранилищу доверенных сертификатов через rustls; образ среды выполнения должен содержать CA-бандл (официальный содержит).
Полные данные об исключении в спанах APM — Событие исключения в спане теперь несёт exception.message и exception.stacktrace наряду с exception.type, так что New Relic Errors Inbox, Jaeger и Tempo отображают сообщение и стек; ограничивается OTEL_APM_MESSAGE_MAX_BYTES / OTEL_APM_STACKTRACE_MAX_BYTES.
Корректный слив долгоживущих соединений — По SIGTERM сервер отправляет GOAWAY, закрывает простаивающие keep-alive-соединения и оперативно завершает открытые потоки SSE/flush; значение по умолчанию DRAIN_TIMEOUT_SECONDS снижено с 30 → 25, чтобы уложиться в 30-секундный grace-период Kubernetes.
TLS_MIN_VERSION — Минимальная допустимая версия протокола: 1.2 (по умолчанию) или 1.3. Всё остальное — включая чужеродный синтаксис вроде "TLSv1.2" — приводит к жёсткой ошибке запуска, проверяется даже при отключённом TLS и сообщается как tls_min_version в /config.
Дешёвый STATIC_REVALIDATE — stat() не чаще одного раза в 3 секунды на файл; попадание по статике обслуживается из памяти максимум одним системным вызовом, а изменения на диске становятся видны в течение 3 с. По умолчанию остаётся выключенным.
Более понятные ошибки запуска TLS — Путь с опечаткой завершается сообщением "TLS_CERT: cannot read /etc/ssl/cert.pem: No such file or directory" вместо голой ошибки ОС; ошибки разбора PEM и несоответствия ключа снабжаются таким же префиксом.
Нацеливание отмены в режиме воркеров — Обрыв со стороны клиента или таймаут запроса мог отменить тот запрос, который был принят воркером последним, вместо того, который действительно отменялся, прерывая посторонний обрабатываемый запрос.
finish_request() против мультиплексированных потоков — Как только любой запрос на воркере вызывал oxphp_finish_request(), каждый другой поток, мультиплексированный на этот воркер, молча терял свой последующий вывод oxphp_stream_flush().
await по таймауту отменяет задачу — Брошенная задача принудительно отменяется: припаркованные задачи возобновляются уже в состоянии отмены, а задачи с нагрузкой на CPU прерываются на границе опкода. Раньше она выполнялась до конца запроса.
Вложенный oxphp_async() — Асинхронная задача может порождать собственные задачи и приостанавливаться на await_all / await_any / await_race, не блокируя свой поток-воркер. Ограничение «без вложенного async» снято.
Лимит задач в обработке — ASYNC_MAX_FIBERS (по умолчанию 256) ограничивает число задач в очереди + выполняющихся; отправка сверх лимита немедленно отклоняется с AsyncException, так что fan-out не может привести к взаимоблокировке.
Список разрешений для внутреннего сервера — INTERNAL_ALLOW_IPS: список разрешений в формате CIDR для /metrics и /config; эндпоинты проверки работоспособности остаются доступными для оркестраторов.
Исключения для семплирования профилировщика — PROFILER_EXCLUDE_PATHS удерживает собственный трафик фреймворка (/_profiler, /_wdt) вне статистического семплирования; явные триггеры по-прежнему запускают профилирование.
Пробуждение отправителя в Channel — Файбер, ожидающий отправки и припаркованный на заполненном Shared\Channel, пробуждается, когда recv_blocking освобождает слот — sendTimeout больше не срабатывает ложно при насыщении.
Крайние случаи async — Падение при корректном завершении работы; await на закрытом промисе зависал вместо отклонения; await_all оставлял промисы выполняться при раннем выходе.
Контракт маршрутизации воркеров — Статические ресурсы с диска, всё остальное — во входной файл воркера ENTRY_FILE; произвольные .php-файлы, индексы каталогов и запасной вариант с корневым index.php больше не разрешаются на каждый запрос.
Честный CGI PATH_INFO — В режиме Framework PATH_INFO задаётся только для явного запроса /index.php/extra; PHP_SELF сообщает фронт-контроллер. Маршрутизируйте по REQUEST_URI.
Range-запросы (RFC 9110) — 206 Partial Content для статических файлов: перемотка видео, возобновляемые загрузки, частичная загрузка PDF; If-Range учитывается со строгими валидаторами.
Лимиты соединений HTTP/2 — Пять переменных окружения ограничивают использование ресурсов на соединение; итоговые значения логируются при запуске.
Строгие ETag для статики — "<size>-<mtime>" заменяет W/"…" — этого требует If-Range; сжатые представления сохраняют слабый тег.
Заголовки приложения побеждают — X-Content-Type-Options, X-Frame-Options и CSP, заданные из PHP, больше не перезаписываются — значения сервера являются запасными и вставляются, только если заголовок отсутствует.
Стриминг против сжатия — Ответы, передаваемые через flush(), проходят без сжатия, а не буферизуются целиком слоем brotli до конца скрипта.
Усиление защиты HTTP/2 от DoS — Rapid Reset (CVE-2023-44487), HPACK-бомбы и атаки на зависание окна ограничены; мёртвые соединения высвобождаются через keepalive на PING/PONG.
remote_ip в логах доступа — JSON-поле remote_addr переименовано в remote_ip и содержит IP без порта — как в nginx/Apache/Caddy.
CLI в стиле PHP — oxphp run script.php выполняет файл с семантикой CLI и полноценным движком под капотом; скрипты с #!/usr/bin/env oxphp запускаются напрямую; переопределения ini через -d принимаются.
Сброс привилегий через --user — Привязать привилегированные порты от root, затем необратимо сбросить привилегии до непривилегированного пользователя до того, как появится любой поток обработки запросов.
Проброшенный порт учитывается — За TRUSTED_PROXIES, SERVER_PORT учитывает X-Forwarded-Port; REMOTE_PORT заполняется из for= в RFC 7239, когда прокси его предоставляет.
Падения декораторов — Два SIGSEGV в пути функциональных декораторов (освобождённый кэш экземпляров; висячий zval при изменении размера) устранены; переполнение вложенности теперь бросает StackOverflowException.
Атрибуты профилировщика учитывают аргументы — #[SlowThreshold(ms:)], #[MemoryThreshold(kb:)] и #[Mark(label:)] больше не схлопываются к общим значениям по умолчанию; события спанов APM доходят до OpenTelemetry.
API объектов загрузки подключён — Request::file() / files() возвращают объекты UploadedFile для любой формы $_FILES вместо null.
Трихотомический API ожидания — Channel и Mutex разбивают каждое ожидание на try* / голый глагол / *Timeout(int $ms), возвращая значения типов SendResult / RecvResult вместо mixed|null.
await_any означает Promise.any — Побеждает первый выполненный; при отклонении всех бросается AggregateAsyncException. Прежнее поведение «первый завершившийся» теперь называется oxphp_async_await_race().
Честные статусы отмены — Таймаут → 504, корректный слив → 503 + Retry-After, отключение клиента → 499 (только в лог). Убийства супервизором и отмены из пользовательского кода сохраняют 500.
Переименования без прослоек — Async\Exception → AsyncException, Shared\Exception → SharedException; Worker убирает префиксы get*; Counter становится минимальным (add/set); Flag повторяет Atomic; Once получает getOrInit()/status().
Shared\Atomic + Ordering — Обобщённый int64-атомик: load/store/swap/CAS/fetch* с явными упорядочениями памяти, один в один с std::sync::atomic.
Shared\Registry — Процессно-глобальные хендлы с ключом по имени: каждый воркер сходится на одной и той же записи; фабрика выполняется максимум один раз.
Единый источник таймаута — REQUEST_TIMEOUT_SECONDS и oxphp_request_heartbeat() удалены — max_execution_time и set_time_limit() остаются.
connection_aborted() в SSE — Возвращает true после разрыва посреди потока, так что циклы while (!connection_aborted()) завершаются корректно.
Горячий путь Shared\* — Накладные расходы на операцию укладываются в шум criterion относительно чистой атомарной загрузки; ≈4.7× среднегеометрического на 8 потоках против прежней формы.
Строгий разбор булевых переменных окружения — Только on/true/1/yes и off/false/0/no; опечатка вроде ture приводит к отказу запуска вместо молчаливого значения по умолчанию.
ENTRY_FILE + WORKER_MODE_ENABLED — Одна переменная выбирает режим маршрутизации по расширению; отдельный переключатель включает постоянные воркеры. INDEX_FILE / WORKER_FILE становятся устаревшими псевдонимами.
Класс OxPHP\Server\Worker — Хендл для интроспекции во время выполнения: id, число запросов, память, RSS — плюс управляемое приложением пересоздание через scheduleExit().
Переименования кэша статики — STATIC_CACHE_TTL → STATIC_MAX_AGE; STATIC_CACHE → STATIC_REVALIDATE с инвертированной полярностью. Значения по умолчанию не изменились.
WORKER_MAX_REQUESTS — Разбирается и игнорируется с предупреждением WARN при запуске — переходите на WORKER_MAX_MEMORY_MIB или scheduleExit().
Образы PHP 8.5 — :php8.5, :php8.5-alpine и теги с фиксацией патча поставляются наряду с 8.4; latest остаётся на 8.4, пока не закроется окно обкатки.
Чанкованный стриминг между запросами — Контекст моста, привязанный к запросу, утекал между запросами воркера, теряя чанкованный Transfer-Encoding после первого flush.
Shared\* внутри пользовательских файберов — Channel::recv()/send() внутри Fiber::start() больше не идут по пути приостановки oxphp-fiber и не падают с fiber_await rc=1.
Именованные аргументы для API плагинов — Классы и функции, определённые плагинами, теперь объявляют реальные имена параметров — вызовы с именованными аргументами работают.
Разделяемые хендлы в асинхронных замыканиях — Channel/Map/Pool, захваченные замыканием oxphp_async(), больше не приходят как null в принимающий поток.
Пространство имён Async — Async-классы перемещены в OxPHP\Async\; функции теперь поставляются в plugin-async.
Семь примитивов Shared\* — Counter, Flag, Once, Mutex, Channel, Map, Pool — атомарное состояние, разделяемое между воркерами, с наблюдаемостью через реестр и детектором взаимоблокировок.
Профилировщик на каждый запрос — Вывод в xhprof / speedscope / pprof / collapsed; триггеры по cookie, заголовку, query-параметру и статистические; HTTP-push с конвертом xhgui.
Автоинструментирование APM — plugin-apm с SDK трассировки для PHP и захватом ошибок; подсистемы async и APM перенесены в плагины на Rust.
Интеграции для продакшена — TRUSTED_PROXIES (RFC 7239), Kubernetes /readyz + /livez, заголовки безопасности, ограничение частоты запросов на IP — всё настраивается через переменные окружения.
Горячий путь Pool — Неконкурентные acquire/release ≈0.9 мкс в Docker, с привязкой к потоку.
Мультиплексирование файберов — Конкурентный ввод-вывод в рамках одного потока-воркера; распределённая трассировка с W3C Trace Context и экспортом в OpenTelemetry.
Промисы oxphp_async() — Параллельное выполнение PHP на выделенном пуле потоков.
Объектный API HTTP — OxPHP\Http\Request с ленивыми аксессорами, плюс система декораторов на основе атрибутов.
Порт и противодавление (backpressure) — Порт по умолчанию 8080 → 80 (443 при TLS); ответ противодавления 503 → 529.
Корректность CGI — SERVER_PROTOCOL отражает реальную версию HTTP; REQUEST_TIME — это начало запроса; разбор IPv6 в Host; 408 при таймауте запроса.
Асинхронное ядро HTTP — Сервер на Hyper + Tokio с корректным завершением работы, собственный SAPI oxphp с полными суперглобальными переменными, пул воркеров ZTS с ограниченной очередью и противодавлением 529.
Три режима маршрутизации — Traditional, Framework и SPA; статические файлы с кэшем в памяти, ETag/304, brotli, TLS.
Режим воркеров — Постоянные воркеры через oxphp_worker(), раннее завершение ответа, стриминг SSE, кооперативные таймауты.
Наблюдаемость — /metrics, /health, /config, структурированные JSON-логи доступа, идентификаторы запросов.
Docker-first — Многоэтапная сборка на Alpine; образы amd64/arm64 на GHCR.