From e3ad53b8feafd8c543f48fd37ee8b374acaca8a9 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 31 Oct 2026 01:58:30 +0100 Subject: [PATCH 1/4] add(core): FrankenPHP worker mode --- .github/docker/Dockerfile | 1 + .github/docker/WORKER.md | 140 ++++ .github/docker/ci/external-setting.php | 12 + .github/docker/ci/seed-worker.php | 29 + .github/docker/ci/worker-routes.php | 78 +++ .github/docker/ci/worker-smoke.py | 144 ++++ .github/docker/entrypoint.sh | 10 +- .github/docker/worker.Caddyfile | 41 ++ .github/workflows/build.yml | 46 ++ core/bootstrap.php | 6 +- core/config/session.php | 2 +- core/functions/preload.php | 41 +- core/functions/session_proxy.php | 9 +- core/includes/protect.inc.php | 2 +- core/includes/worker.runtime.php | 623 ++++++++++++++++++ core/src/Bootstrap/EnvCacheLoader.php | 9 + core/src/Core.php | 65 +- core/src/ExceptionHandler.php | 6 +- core/src/Extensions/Router.php | 2 +- core/src/Legacy/Permissions.php | 2 +- core/src/Parser.php | 7 +- core/src/Services/ManagerActivity.php | 2 +- core/src/Support/TvBindingArguments.php | 5 + core/src/Support/TvBindingGuard.php | 5 + core/tests/Mocks/worker_runtime_probe.php | 111 ++++ core/tests/Unit/EvalErrorAttributionTest.php | 81 +++ .../Security/ApacheConfigHardeningTest.php | 3 +- core/tests/Unit/WorkerRuntimeTest.php | 226 +++++++ index.php | 11 +- phpstan.neon | 1 + 30 files changed, 1677 insertions(+), 43 deletions(-) create mode 100644 .github/docker/WORKER.md create mode 100644 .github/docker/ci/external-setting.php create mode 100644 .github/docker/ci/seed-worker.php create mode 100644 .github/docker/ci/worker-routes.php create mode 100644 .github/docker/ci/worker-smoke.py create mode 100644 .github/docker/worker.Caddyfile create mode 100644 core/includes/worker.runtime.php create mode 100644 core/tests/Mocks/worker_runtime_probe.php create mode 100644 core/tests/Unit/EvalErrorAttributionTest.php create mode 100644 core/tests/Unit/WorkerRuntimeTest.php diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile index 331f0b0bf2..49e39ee525 100644 --- a/.github/docker/Dockerfile +++ b/.github/docker/Dockerfile @@ -10,6 +10,7 @@ FROM ${BASE_IMAGE} COPY build/ /var/www/html/ COPY .github/docker/entrypoint.sh /usr/local/bin/evo-entrypoint +COPY .github/docker/worker.Caddyfile /etc/evo/worker.Caddyfile # Friendly URLs: the repository ships the Apache rules as ht.access. The nginx # and FrankenPHP runtimes carry their own rules inside the base image, and diff --git a/.github/docker/WORKER.md b/.github/docker/WORKER.md new file mode 100644 index 0000000000..843065ebe4 --- /dev/null +++ b/.github/docker/WORKER.md @@ -0,0 +1,140 @@ +# Experimental FrankenPHP frontend workers + +The default image still serves each request in classic PHP mode. On a FrankenPHP +image, opt in with: + +```bash +docker.exe run -d --name evo-worker -p 8080:80 \ + -e EVO_WORKER_MODE=1 \ + -e EVO_WORKER_ORIGIN=http://localhost:8080 \ + ghcr.io/evolution-cms/evolution:3.5.x-php8.4-frankenphp +``` + +This command requires an image built with the worker implementation. The site +must already be installed. `EVO_WORKER_ORIGIN` is the exact public scheme, hostname +and port, without a path. Behind TLS termination, configure the trusted proxy to +send the public Host and X-Forwarded-Proto. + +## Hostnames + +The first request of a worker fixes `EVO_SITE_URL` and the constants derived from +it, so a worker serves exactly one origin: + +- A request for `EVO_WORKER_ORIGIN` is served. Its Host header is replaced by the + configured one first, so a client cannot choose the letter case of the site URL + (and of the pages cached from it). +- A request for another entry of `EVO_SITE_HOSTNAMES` with the same scheme receives + a 308 redirect to the same path on `EVO_WORKER_ORIGIN` (`www.example.com` to + `example.com`). Classic PHP serves every listed hostname under its own name; + a worker makes the origin canonical. Another scheme is not redirected: behind + a proxy that drops X-Forwarded-Proto, that would loop. +- Any other origin receives 421. + +When `EVO_SITE_HOSTNAMES` (environment, `.env` or `core/custom/define.php`) does +not list `EVO_WORKER_ORIGIN`, or a defined `EVO_SITE_URL` points elsewhere, the +worker refuses to start: its pages would link to a site it does not serve. Run +separate worker instances for unrelated origins. + +## Application lifetime + +The PHP worker, autoloader and loaded classes survive requests. By default each +request gets a **new Evo container**, providers, settings, parser, authorization +and session. It does not install or require Laravel Octane. + +`EVO_WORKER_KEEP_APP=1` keeps the booted container instead (experimental). Providers +register and boot once per worker; each request starts from that post-boot state +and builds its own settings, parser, authorization and session as before. After +every request the properties of the container, of Core and of every service +resolved while providers booted (and of the objects those services hold directly, +such as the view finder and the route collection) return to their post-boot values, +so whatever a request bound, resolved, registered or set on them is dropped. Kept +services with a public `flushState()` (Laravel's and Livewire's convention) or +Symfony's `ResetInterface` are reset through it. In debug mode, responses carry +`X-Evo-Worker-App: new|kept` and the log lists the kept services. + +A provider that reads the request, the session or the user while booting sees +only the first request of each worker in this mode. An extension service resolved +during boot that keeps request state deeper than one object level must implement +one of the reset hooks. Test the installed extensions with this mode before using +it in production; without it, workers behave as described above. + +Only the root `index.php` is registered as a worker. Manager, installer and other +PHP entry points remain classic. `EVO_WORKER_MODE=1` alone does not activate the +runtime under Apache, FPM, CLI or a classic FrankenPHP request. To disable workers, +remove the setting and restart the container. + +Settings: + +| Environment variable | Default | Meaning | +| --- | --- | --- | +| `EVO_WORKER_COUNT` | `2` | Number of frontend workers in the supplied Caddyfile | +| `EVO_WORKER_MAX_REQUESTS` | `500` | Recycle each worker after this many requests; 1–100000 | +| `EVO_WORKER_KEEP_APP` | off | `1` keeps one booted container per worker (see above) | +| `EVO_WORKER_DEBUG` | off | `1` adds X-Evo-Worker, X-Evo-Request and X-Evo-Worker-App response headers | + +With a custom Caddyfile, register `/var/www/html/index.php` as the worker and +retain the normal PHP handler for the manager. The supplied `worker.Caddyfile` +contains friendly-URL rewrites and blocks private CMS files. Keep equivalent +access rules when adapting it to another document root or HTTPS. + +## Extension compatibility + +Core request exits use `evo_exit($status)` and request shutdown jobs use +`evo_register_shutdown($callback)`. In classic PHP these delegate to `exit` and +`register_shutdown_function`. In a worker, exit raises a request termination +signal; cleanup runs once at the end of that request. Shutdown callbacks run +before returning the response to FrankenPHP, so they must not rely on the +connection already being detached. Core deferred jobs retain their existing +finish-request call where the SAPI supports it. + +A legacy snippet/plugin that calls native `exit` still finishes its response, but +restarts the worker and loses the reuse benefit. Native shutdown handlers execute +when that worker ends, not on each request; migrate per-request jobs to the helper. +An extension must not catch and swallow `EvolutionCMS\Runtime\RequestTerminated` +when catching `Throwable` or `Error`. + +The worker resets CMS singletons, model boot state, session flags, global request +variables, environment superglobal changes, mutable PHP INI settings, error handlers and timezone; +open database transactions are rolled back and connections closed. With a new +container per request, it also empties the static callback lists that providers +append to on every boot (Artisan bootstrappers, the TrimStrings and +ConvertEmptyStringsToNull skip callbacks), whose closures would otherwise keep every +previous container in memory. Exceptions +return a generic 500, are logged server-side and recycle the worker. Project PHP +statics, function statics, constants, custom autoloaders and process-wide changes +made by extensions cannot be reset automatically. Validate installed extensions +before enabling workers on a production site. Restart workers after code, +custom definitions or environment changes; CMS cache/settings changes are read +again on the next request. OPcache revalidates timestamps against the request start +time, which a worker does not advance, so before every request the worker rechecks +the PHP cache files that another process may rewrite (site cache, publishing +schedule, env cache): a change saved through the CLI or another server is seen at +once. Other PHP files, such as custom routes, keep their compiled version until the +worker restarts. A full CMS cache clear also calls `opcache_reset()`; FrankenPHP +versions that restart PHP threads on that call will recycle workers. + +## Measurements + +Local Docker Desktop, PHP 8.4.24, SQLite, one FrankenPHP image with the default +providers (including EvoUI, Livewire, dTui editor, sGallery and sTask) in all three +modes; one keep-alive connection, 30 warmup requests followed by 300 sequential +requests per page. These measure latency, not concurrent throughput: + +| Page | Classic | Worker | Worker, kept app | Kept vs classic | +| --- | ---: | ---: | ---: | ---: | +| Cached homepage | 7.69 ms | 6.87 ms | 3.33 ms | 2.31x | +| Uncached page with a CMS snippet | 9.14 ms | 6.99 ms | 3.90 ms | 2.35x | +| Cached page with a cached snippet | 6.84 ms | 5.69 ms | 2.24 ms | 3.05x | + +Of about 5 ms of PHP time per warm worker request, registering and booting the +providers took 3.4 ms; a kept application skips them. The new-container mode saves +autoloader and class-loading work only. Measure a representative site before +choosing a mode; these results do not predict gains for SQL-heavy pages. + +The FrankenPHP CI legs run `ci/worker-smoke.py` against a disposable seeded +single-worker container, once per application lifetime, including alternating +user/guest sessions, state reset, redirects, forwards, a real 404, transaction +rollback, native exit, exception recovery, a classic manager, a 100-request memory +check, the hostname redirect and a settings change made by a CLI process. A second container +verifies recycling after exactly two requests. Test routes are installed only by +`ci/seed-worker.php` and must never be copied into a production site's custom routes. diff --git a/.github/docker/ci/external-setting.php b/.github/docker/ci/external-setting.php new file mode 100644 index 0000000000..8a314d7f8a --- /dev/null +++ b/.github/docker/ci/external-setting.php @@ -0,0 +1,12 @@ +where('setting_name', 'worker_setting')->update(['setting_value' => $argv[2]]); +evo()->clearCache('full'); diff --git a/.github/docker/ci/seed-worker.php b/.github/docker/ci/seed-worker.php new file mode 100644 index 0000000000..fbc0b7ead6 --- /dev/null +++ b/.github/docker/ci/seed-worker.php @@ -0,0 +1,29 @@ + 'WorkerProbe']); +$snippet->snippet = 'return "worker-demo:" . evo()->documentIdentifier;'; +$snippet->save(); +$page = EvolutionCMS\Models\SiteContent::firstOrNew(['alias' => 'worker-demo']); +$page->fill(['pagetitle' => 'Worker demo', 'published' => 1, 'template' => 0, + 'alias_visible' => 1, 'cacheable' => 0, 'content' => '

[[WorkerProbe]]

']); +$page->save(); +$errorPage = EvolutionCMS\Models\SiteContent::firstOrNew(['alias' => 'worker-error']); +$errorPage->fill(['pagetitle' => 'Worker error page', 'published' => 1, 'template' => 0, + 'cacheable' => 0, 'content' => '

worker-error

']); +$errorPage->save(); +foreach (['seostrict' => '0', 'error_page' => (string) $errorPage->id, + 'worker_transaction' => 'original', 'worker_setting' => 'original'] as $name => $value) { + Illuminate\Support\Facades\DB::table('system_settings')->updateOrInsert( + ['setting_name' => $name], ['setting_value' => $value] + ); +} +evo()->clearCache('full'); +echo json_encode(['page' => $page->id]) . PHP_EOL; diff --git a/.github/docker/ci/worker-routes.php b/.github/docker/ci/worker-routes.php new file mode 100644 index 0000000000..6e38c8482c --- /dev/null +++ b/.github/docker/ci/worker-routes.php @@ -0,0 +1,78 @@ +json([ + 'counter' => $_SESSION['worker_counter'], + 'user' => app('AuthServices')->id(), + 'placeholder' => evo()->getPlaceholder('worker_marker'), + 'scripts' => evo()->jscripts, + 'core_config' => evo()->getConfig('worker_marker'), + 'config' => config('worker_marker'), + 'global' => $GLOBALS['worker_marker'] ?? null, + 'environment' => $_ENV['worker_marker'] ?? null, + 'locale' => app('translator')->getLocale(), + 'precision' => ini_get('precision'), + 'redirected' => $_SESSION['worker_redirected'] ?? false, + 'transaction' => DB::table('system_settings')->where('setting_name', 'worker_transaction')->value('setting_value'), + 'shutdowns' => (int) @file_get_contents('/tmp/evo-worker-shutdowns'), + 'native_cleanup' => is_file('/tmp/evo-worker-native-cleanup'), + 'memory' => memory_get_usage(true), + 'setting' => evo()->getConfig('worker_setting'), + ]); +}); +Route::get('/__worker/mutate', static function () { + evo()->setPlaceholder('worker_marker', 'dirty'); + evo()->jscripts[] = 'dirty'; + evo()->setConfig('worker_marker', 'dirty'); + app('config')->set('worker_marker', 'dirty'); + app('translator')->setLocale('fr'); + $GLOBALS['worker_marker'] = 'dirty'; + $_ENV['worker_marker'] = 'dirty'; + ini_set('precision', '9'); + date_default_timezone_set('Pacific/Honolulu'); + return 'mutated'; +}); +Route::get('/__worker/login', static function () { + $_SESSION['webValidated'] = true; + $_SESSION['webInternalKey'] = 1; + return 'logged in'; +}); +Route::get('/__worker/redirect', static function () { + $_SESSION['worker_redirected'] = true; + evo()->sendRedirect('/__worker/state'); +}); +Route::get('/__worker/forward', static function () { + $id = EvolutionCMS\Models\SiteContent::where('alias', 'worker-demo')->value('id'); + evo()->sendForward($id); +}); +Route::get('/__worker/shutdown', static function () { + evo_register_shutdown(static function () { + $count = (int) @file_get_contents('/tmp/evo-worker-shutdowns'); + file_put_contents('/tmp/evo-worker-shutdowns', (string) ($count + 1)); + }); + evo_exit('shutdown queued'); +}); +Route::get('/__worker/transaction', static function () { + DB::beginTransaction(); + DB::table('system_settings')->where('setting_name', 'worker_transaction')->update(['setting_value' => 'uncommitted']); + return 'transaction left open'; +}); +Route::get('/__worker/update-setting', static function () { + $value = evo()->getConfig('worker_setting') === 'updated' ? 'original' : 'updated'; + DB::table('system_settings')->where('setting_name', 'worker_setting')->update(['setting_value' => $value]); + evo()->clearCache('full'); + return $value; +}); +Route::post('/__worker/post', static fn () => response()->json(['value' => request()->input('value')])); +Route::get('/__worker/failure', static function () { + throw new RuntimeException('worker-test-private-detail'); +}); +Route::get('/__worker/native-exit', static function () { + $_SESSION['worker_redirected'] = true; + evo_register_shutdown(static fn () => file_put_contents('/tmp/evo-worker-native-cleanup', 'done')); + exit('legacy response'); +}); diff --git a/.github/docker/ci/worker-smoke.py b/.github/docker/ci/worker-smoke.py new file mode 100644 index 0000000000..88f4fa3c35 --- /dev/null +++ b/.github/docker/ci/worker-smoke.py @@ -0,0 +1,144 @@ +"""HTTP isolation checks against an installed, disposable single-worker image.""" +import http.cookiejar +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +import urllib.parse + +base = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8898' +container = sys.argv[sys.argv.index('--container') + 1] if '--container' in sys.argv else None +alias = sys.argv[sys.argv.index('--alias') + 1] if '--alias' in sys.argv else None +keep_app = '--keep-app' in sys.argv + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def client(): + return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + + +def get(path, opener=None, expected=200, headers=None): + request = urllib.request.Request(base + path, headers=headers or {}) + try: + response = (opener or client()).open(request, timeout=15) + except urllib.error.HTTPError as error: + response = error + with response: + body = response.read().decode() + assert response.status == expected, (path, response.status, body[:500]) + return body, response.headers + + +if '--recycle' in sys.argv: + workers = set() + for _ in range(6): + _, headers = get('/') + workers.add(headers['X-Evo-Worker']) + assert 1 <= int(headers['X-Evo-Request']) <= 2 + assert len(workers) == 3, workers + print('PASS: workers recycle after exactly two requests') + sys.exit(0) + + +for attempt in range(30): + try: + get('/') + break + except (OSError, AssertionError): + if attempt == 29: + raise + time.sleep(1) + +alice, bob = client(), client() +first, headers = get('/__worker/state', alice) +first = json.loads(first) +worker = headers['X-Evo-Worker'] +assert worker +get('/__worker/login', alice) +get('/__worker/mutate', alice) +second, headers = get('/__worker/state', alice) +second = json.loads(second) +assert headers['X-Evo-Worker'] == worker +assert second['counter'] == 2 and second['user'] == 1, second +for key in ('placeholder', 'scripts', 'core_config', 'config', 'global', 'environment', 'locale', 'precision'): + assert second[key] == first[key], (key, first, second) +guest, headers = get('/__worker/state', bob) +guest = json.loads(guest) +assert guest['counter'] == 1 and guest['user'] is None, guest +assert headers['X-Evo-Worker'] == worker +assert json.loads(get('/__worker/state', alice)[0])['user'] == 1 +redirected, headers = get('/__worker/redirect', client()) +assert json.loads(redirected)['redirected'] is True +assert headers['X-Evo-Worker'] == worker +assert 'worker-demo:' in get('/worker-demo')[0] +assert 'worker-demo:' in get('/__worker/forward')[0] +assert 'worker-error' in get('/missing-worker-demo', expected=404)[0] +get('/__worker/shutdown') +get('/__worker/transaction') +state, headers = get('/__worker/state') +state = json.loads(state) +assert state['shutdowns'] == first['shutdowns'] + 1, state +assert state['transaction'] == 'original', state +assert headers['X-Evo-Worker'] == worker +updated, _ = get('/__worker/update-setting') +state, headers = get('/__worker/state') +assert json.loads(state)['setting'] == updated +# A full cache clear calls opcache_reset(), which recent FrankenPHP versions use to recycle threads. +worker = headers['X-Evo-Worker'] +for value in ('first payload', 'second payload'): + request = urllib.request.Request(base + '/__worker/post', data=urllib.parse.urlencode({'value': value}).encode()) + with alice.open(request, timeout=15) as response: + assert json.load(response)['value'] == value + assert response.headers['X-Evo-Worker'] == worker +get('/', expected=421, headers={'Host': 'another-site.invalid'}) +if alias: + # Another EVO_SITE_HOSTNAMES entry is sent to the worker origin; the origin in another letter case is served. + try: + urllib.request.build_opener(NoRedirect).open(urllib.request.Request(base + '/worker-demo?x=1', headers={'Host': alias}), timeout=15) + raise AssertionError('the alias hostname was not redirected') + except urllib.error.HTTPError as error: + assert error.status == 308 and error.headers['Location'] == base + '/worker-demo?x=1', (error.status, error.headers) + upper = urllib.parse.urlsplit(base).netloc.upper() + assert 'worker-demo:' in get('/worker-demo', headers={'Host': upper})[0] +get('/core/includes/worker.runtime.php', expected=404) +get('/core/composer.json', expected=404) +get('/assets/files/worker-test.php', expected=404) +_, manager_headers = get('/manager/index.php') +assert manager_headers.get('X-Evo-Worker') is None +error, _ = get('/__worker/failure', expected=500) +assert 'worker-test-private-detail' not in error +_, headers = get('/__worker/state') +assert headers['X-Evo-Worker'] != worker +worker = headers['X-Evo-Worker'] +legacy = client() +assert get('/__worker/native-exit', legacy)[0] == 'legacy response' +state, headers = get('/__worker/state', legacy) +assert json.loads(state)['redirected'] is True +assert json.loads(state)['native_cleanup'] is True +assert headers['X-Evo-Worker'] != worker +worker = headers['X-Evo-Worker'] +memory = json.loads(state)['memory'] +for _ in range(100): + state, headers = get('/__worker/state') + state = json.loads(state) + assert state['user'] is None and state['counter'] == 1, state + assert headers['X-Evo-Worker'] == worker +assert state['memory'] <= memory + 8 * 1024 * 1024, (memory, state['memory']) +if keep_app: + assert headers['X-Evo-Worker-App'] == 'kept', headers +if container: + # A CLI process shares no OPcache with the worker, which must still read the rewritten site cache. + subprocess.run([os.environ.get('DOCKER', 'docker'), 'exec', '-w', '/var/www/html', container, 'php', + '/tmp/worker-ci/external-setting.php', '/var/www/html', 'external'], check=True) + state, headers = get('/__worker/state') + assert json.loads(state)['setting'] == 'external', state + assert headers['X-Evo-Worker'] == worker +print(('PASS (kept application): ' if keep_app else 'PASS: ') + 'persistent worker, session/auth isolation, request state reset, redirects, forwards, 404, cleanup, rollback, host guard, classic manager, errors, native exit, 100-request memory check' + + (', alias redirect' if alias else '') + (', external settings change' if container else '')) diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index 5dba441aa7..87e57e48e8 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -42,7 +42,15 @@ if [ "$#" -eq 0 ]; then elif command -v frankenphp > /dev/null 2>&1; then # FrankenPHP's docker-php-entrypoint turns a leading "-" into # "frankenphp run ...", so the flags are the whole command there. - set -- --config /etc/frankenphp/Caddyfile --adapter caddyfile + if [ "${EVO_WORKER_MODE:-0}" = "1" ]; then + if [ -z "${EVO_WORKER_ORIGIN:-}" ]; then + echo "[evo-entrypoint] EVO_WORKER_ORIGIN is required for worker mode" >&2 + exit 1 + fi + set -- --config /etc/evo/worker.Caddyfile --adapter caddyfile + else + set -- --config /etc/frankenphp/Caddyfile --adapter caddyfile + fi else echo "[evo-entrypoint] no known server command found in this image" >&2 exit 1 diff --git a/.github/docker/worker.Caddyfile b/.github/docker/worker.Caddyfile new file mode 100644 index 0000000000..5b9e7cfed2 --- /dev/null +++ b/.github/docker/worker.Caddyfile @@ -0,0 +1,41 @@ +{ + auto_https off + frankenphp { + worker /var/www/html/index.php {$EVO_WORKER_COUNT:2} + } +} + +:80 { + root * /var/www/html + encode zstd gzip + + @private path_regexp private (?i)^/(core|views|vendor|tmp)(/|$)|^/(composer\.(json|lock)|phpstan\.neon|publiccode\.yml|AGENTS\.md|README\.md|ht\.access|ng\.inx|config\.php(\.example)?)$ + respond @private 404 + @dotfiles { + path_regexp dotfiles (^|/)\. + not path /.well-known/* + } + respond @dotfiles 404 + @fragments path_regexp fragments (?i)\.(sql|sqlite|db|log|bak|old|orig|save|swp|swo|tpl|inc|ini|env|dist|example|yml|yaml|lock|tar)(\.(gz|bz2|xz|zip|tgz))?$ + respond @fragments 404 + @assetScripts path_regexp assetScripts (?i)^/assets/.*\.php(?:/|$) + respond @assetScripts 404 + @generated { + path /assets/cache /assets/cache/* /assets/backup /assets/backup/* /assets/export /assets/export/* /assets/import /assets/import/* + not { + path_regexp thumbnails (?i)^/assets/cache/images/.*\.(jpe?g|png|gif|webp|avif|bmp|ico|wbmp)$ + file + } + } + respond @generated 404 + + @notfile { + not file { + try_files {path} {path}/index.php {path}/index.html + } + } + rewrite @notfile /index.php?q={path}&{query} + + # Only index.php is a worker. manager/index.php and other PHP scripts stay classic. + php_server +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62ff032a91..734ff54ca3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -333,6 +333,52 @@ jobs: exit 1 } + - name: Smoke test FrankenPHP workers + if: matrix.server == 'frankenphp' + run: | + cleanup() { + docker logs evo-worker-smoke 2>&1 | tail -40 || true + docker rm -f evo-worker-smoke > /dev/null 2>&1 || true + } + trap cleanup EXIT + # A fresh container per request, then one booted application kept by the worker. + for keep in 0 1; do + docker run -d --name evo-worker-smoke -p 8898:80 \ + -e EVO_SCHEDULER=0 -e EVO_WORKER_MODE=1 \ + -e EVO_WORKER_ORIGIN=http://localhost:8898 \ + -e EVO_SITE_HOSTNAMES=localhost:8898,127.0.0.1:8898 \ + -e EVO_WORKER_COUNT=1 -e EVO_WORKER_DEBUG=1 \ + -e EVO_WORKER_KEEP_APP="$keep" \ + "${{ steps.image.outputs.ref }}" + docker cp .github/docker/ci evo-worker-smoke:/tmp/worker-ci + docker exec -w /var/www/html evo-worker-smoke \ + php /tmp/worker-ci/seed-worker.php /var/www/html + python3 .github/docker/ci/worker-smoke.py http://localhost:8898 \ + --container evo-worker-smoke --alias 127.0.0.1:8898 \ + $([ "$keep" = 1 ] && echo --keep-app) + docker rm -f evo-worker-smoke > /dev/null + done + + - name: Smoke test FrankenPHP worker recycling + if: matrix.server == 'frankenphp' + run: | + cleanup() { + docker logs evo-worker-recycle 2>&1 | tail -20 + docker rm -f evo-worker-recycle > /dev/null + } + trap cleanup EXIT + docker run -d --name evo-worker-recycle -p 8898:80 \ + -e EVO_SCHEDULER=0 -e EVO_WORKER_MODE=1 \ + -e EVO_WORKER_ORIGIN=http://localhost:8898 \ + -e EVO_WORKER_COUNT=1 -e EVO_WORKER_DEBUG=1 \ + -e EVO_WORKER_MAX_REQUESTS=2 \ + "${{ steps.image.outputs.ref }}" + for _attempt in $(seq 1 30); do + docker logs evo-worker-recycle 2>&1 | grep -q 'FrankenPHP started' && break + sleep 1 + done + python3 .github/docker/ci/worker-smoke.py http://localhost:8898 --recycle + - name: Push run: | image="${{ steps.image.outputs.image }}" diff --git a/core/bootstrap.php b/core/bootstrap.php index 178d780796..d606975b49 100644 --- a/core/bootstrap.php +++ b/core/bootstrap.php @@ -28,7 +28,11 @@ require_once __DIR__ . '/includes/legacy.inc.php'; -require_once __DIR__ . '/includes/protect.inc.php'; // harden it +if (defined('EVO_WORKER_MODE') && EVO_WORKER_MODE) { + require __DIR__ . '/includes/protect.inc.php'; +} else { + require_once __DIR__ . '/includes/protect.inc.php'; +} if (! is_cli() && session_status() === PHP_SESSION_NONE && !EvoSessionProxy::disabled()) { startCMSSession(); // start session diff --git a/core/config/session.php b/core/config/session.php index e59d1819b5..1638b7922e 100644 --- a/core/config/session.php +++ b/core/config/session.php @@ -157,7 +157,7 @@ | */ 'secure' => env('SESSION_SECURE_COOKIE') ?? ( - (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') || (int)($_SERVER['SERVER_PORT'] ?? 0) === 443 ), diff --git a/core/functions/preload.php b/core/functions/preload.php index 2aee8fdb94..a9489172e1 100644 --- a/core/functions/preload.php +++ b/core/functions/preload.php @@ -1,5 +1,34 @@ newInstanceWithoutConstructor()->getInstance(); } catch (ReflectionException $exception) { echo $exception->getMessage(); - exit($exception->getCode()); + evo_exit($exception->getCode()); } } @@ -52,7 +81,7 @@ function rejectManagerReferer($evo): never // every evo() call repeats the check, and shutdown callbacks call evo(): answer once static $refused = false; if ($refused) { - exit; + evo_exit(); } $refused = true; @@ -69,14 +98,14 @@ function rejectManagerReferer($evo): never \EvolutionCMS\Support\CsrfMessage::payload(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); - exit; + evo_exit(); } $evo->webAlertAndQuit( \EvolutionCMS\Support\CsrfMessage::text(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), '/' . MGR_DIR . '/index.php' ); - exit; + evo_exit(); } } @@ -169,7 +198,7 @@ function startCMSSession() // no session started, exit if (session_status() === PHP_SESSION_NONE) { - exit(); + evo_exit(); } $key = "modx.mgr.session.cookie.lifetime"; @@ -232,7 +261,7 @@ function removeInvalidCmsSessionIds($session_name) function modx_sanitize_gpc(&$values, $depth = 0) { if (200 < $depth) { - exit('GPC Array nested too deep!'); + evo_exit('GPC Array nested too deep!'); } if (!is_array($values)) { diff --git a/core/functions/session_proxy.php b/core/functions/session_proxy.php index 0be92f67e3..57feb4ec5f 100644 --- a/core/functions/session_proxy.php +++ b/core/functions/session_proxy.php @@ -22,6 +22,13 @@ class EvoSessionProxy */ private static bool $cookieEmitted = false; + public static function resetForWorker(): void + { + self::$initialized = false; + self::$synced = false; + self::$shutdownRegistered = false; + self::$cookieEmitted = false; + } /** * NO_SESSION (core/custom/define.php) disables the visitor session entirely: no store, no cookie. @@ -125,7 +132,7 @@ public static function init(): void if (!self::$shutdownRegistered) { self::$shutdownRegistered = true; - register_shutdown_function([self::class, 'syncBack']); + evo_register_shutdown([self::class, 'syncBack']); } } diff --git a/core/includes/protect.inc.php b/core/includes/protect.inc.php index bd43c4562c..2eea1ee819 100644 --- a/core/includes/protect.inc.php +++ b/core/includes/protect.inc.php @@ -5,7 +5,7 @@ // Null is evil if (isset($_SERVER['QUERY_STRING']) && strpos(urldecode($_SERVER['QUERY_STRING']), chr(0)) !== false) { - die(); + evo_exit(); } modx_sanitize_gpc($_GET); diff --git a/core/includes/worker.runtime.php b/core/includes/worker.runtime.php new file mode 100644 index 0000000000..3ce2e1091b --- /dev/null +++ b/core/includes/worker.runtime.php @@ -0,0 +1,623 @@ += 1000) { + throw new \RuntimeException('Too many worker request cleanup callbacks.'); + } + $callback = array_shift(self::$callbacks); + try { + $callback(); + } catch (RequestTerminated) { + } catch (\Throwable $exception) { + $failure ??= $exception; + } + } + if ($failure !== null) { + throw $failure; + } + } finally { + self::$callbacks = []; + self::$active = false; + } + } + + public static function origin(array $server): ?string + { + $host = strtolower((string) ($server['HTTP_HOST'] ?? '')); + if (!preg_match('/^(?:[a-z0-9._-]+|\[[a-f0-9:.]+\])(?::[0-9]{1,5})?$/D', $host)) { + return null; + } + $https = strtolower((string) ($server['HTTPS'] ?? '')) === 'on' + || (int) ($server['SERVER_PORT'] ?? 0) === (defined('HTTPS_PORT') ? (int) HTTPS_PORT : 443) + || ($server['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; + $scheme = $https ? 'https' : 'http'; + $parts = parse_url($scheme . '://' . $host); + if ($parts === false || isset($parts['port']) && ($parts['port'] < 1 || $parts['port'] > 65535)) { + return null; + } + $port = $parts['port'] ?? ($https ? 443 : 80); + + return $scheme . '://' . $parts['host'] . ($port === ($https ? 443 : 80) ? '' : ':' . $port); + } + + /** host[:port] of an origin() value, in the form define.inc.php reads from HTTP_HOST. */ + public static function authority(string $origin): string + { + return substr($origin, strpos($origin, '://') + 3); + } + + /** Origins of an EVO_SITE_HOSTNAMES list; as in define.inc.php, an entry without a port means the scheme's default. */ + public static function hostnameOrigins(string $hostnames, string $scheme): array + { + $origins = []; + foreach (explode(',', $hostnames) as $entry) { + $entry = trim($entry); + $origin = $entry === '' ? null : self::origin(['HTTP_HOST' => $entry, 'HTTPS' => $scheme === 'https' ? 'on' : 'off']); + if ($origin !== null) { + $origins[] = $origin; + } + } + + return array_values(array_unique($origins)); + } + + /** + * The other origins of the site, which a worker redirects to its own one. + * + * EVO_SITE_URL and the constants derived from it are fixed by the first request of a worker, + * so EVO_WORKER_ORIGIN must be the site URL that EVO_SITE_HOSTNAMES and custom/define.php yield. + */ + public static function siteAliases(string $expectedOrigin, string $hostnames, ?string $siteUrl): array + { + $origins = self::hostnameOrigins($hostnames, (string) strstr($expectedOrigin, '://', true)); + if ($origins !== [] && !in_array($expectedOrigin, $origins, true)) { + throw new \RuntimeException("EVO_WORKER_ORIGIN {$expectedOrigin} is not listed in EVO_SITE_HOSTNAMES."); + } + if ($siteUrl !== null) { + $url = parse_url($siteUrl); + $origin = is_array($url) && isset($url['scheme'], $url['host']) ? self::origin([ + 'HTTP_HOST' => $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''), + 'HTTPS' => strtolower($url['scheme']) === 'https' ? 'on' : 'off', + ]) : null; + if ($origin !== $expectedOrigin) { + throw new \RuntimeException("EVO_SITE_URL does not belong to EVO_WORKER_ORIGIN {$expectedOrigin}."); + } + } + + return array_values(array_diff($origins, [$expectedOrigin])); + } + + /** + * Null to serve the request, otherwise [status, Location or null]. + * Another hostname of the site is redirected only within the same scheme: behind a proxy that + * drops X-Forwarded-Proto, an http to https redirect would never end. + */ + public static function misdirected(array $server, string $expectedOrigin, array $aliases): ?array + { + $origin = self::origin($server); + if ($origin === $expectedOrigin) { + return null; + } + if ($origin !== null && in_array($origin, $aliases, true) + && strstr($origin, '://', true) === strstr($expectedOrigin, '://', true)) { + $uri = (string) ($server['REQUEST_URI'] ?? '/'); + + return [308, $expectedOrigin . (preg_match('~^/[^\x00-\x20\x7f]*$~D', $uri) ? $uri : '/')]; + } + + return [421, null]; + } + + public static function restoreHandlers(?callable $errorHandler, ?callable $exceptionHandler): void + { + for ($i = 0; $i < 100; $i++) { + $current = set_error_handler($errorHandler); + restore_error_handler(); + if ($current === $errorHandler) { + break; + } + restore_error_handler(); + } + for ($j = 0; $j < 100; $j++) { + $current = set_exception_handler($exceptionHandler); + restore_exception_handler(); + if ($current === $exceptionHandler) { + break; + } + restore_exception_handler(); + } + if ($i === 100 || $j === 100) { + throw new \RuntimeException('Unable to restore worker error handlers.'); + } + } + + public static function keepsApplication(): bool + { + return self::$keepApplication && self::$active; + } + + /** Called by Core once its providers have booted, before the request is initialized. */ + public static function captureApplication(Core $app): void + { + if (self::keepsApplication() && self::$snapshot === null) { + self::$snapshot = new ApplicationSnapshot($app); + if (getenv('EVO_WORKER_DEBUG') === '1') { + error_log('[Evo worker] kept services: ' . implode(', ', self::$snapshot->services())); + } + } + } + + /** The kept application initialized for this request, or null when Core must build one. */ + public static function reuseApplication(string $class): ?Core + { + $app = self::$snapshot?->application(); + if ($app === null || !self::keepsApplication()) { + return null; + } + if (!$app instanceof $class) { + throw new \LogicException("The kept application is not a {$class}."); + } + Container::setInstance($app); + Facade::setFacadeApplication($app); + $app->tstart = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true); + $app->initialize(); + + return $app; + } + + /** Test seam: start (or stop, with false) keeping applications outside run(). */ + public static function keepApplications(bool $keep): void + { + self::$keepApplication = $keep; + self::$snapshot = null; + } + + /** + * OPcache revalidates timestamps against the request start time, which a worker does not advance: + * recheck the PHP cache files that the manager, CLI or cron of another process rewrite. + */ + public static function revalidateRuntimeFiles(string $corePath): void + { + if (!function_exists('opcache_invalidate')) { + return; + } + $storage = rtrim(defined('EVO_STORAGE_PATH') ? EVO_STORAGE_PATH : $corePath . '/storage/', '/') . '/'; + foreach (['bootstrap/siteCache.idx.php', 'bootstrap/sitePublishing.idx.php', 'cache/env.php'] as $file) { + // Without force, only a file newer than its cached script is invalidated. + opcache_invalidate($storage . $file); + } + } + + public static function forgetBootRegistries(): void + { + foreach (self::BOOT_REGISTRIES as [$class, $name]) { + if (class_exists($class, false) && property_exists($class, $name)) { + $property = new \ReflectionProperty($class, $name); + if ($property->isStatic() && $property->hasDefaultValue()) { + $property->setValue(null, $property->getDefaultValue()); + } + } + } + } + + public static function run(string $frontController, string $corePath): void + { + if (!($_SERVER['FRANKENPHP_WORKER'] ?? false) || !function_exists('frankenphp_handle_request')) { + throw new \RuntimeException('FrankenPHP worker mode is required.'); + } + $configuredOrigin = rtrim((string) getenv('EVO_WORKER_ORIGIN'), '/'); + $url = parse_url($configuredOrigin); + if ($url === false || !isset($url['scheme'], $url['host']) + || !in_array($url['scheme'], ['http', 'https'], true) + || array_diff(array_keys($url), ['scheme', 'host', 'port']) !== []) { + throw new \RuntimeException('Set EVO_WORKER_ORIGIN to the canonical http(s) origin of this worker.'); + } + $expectedOrigin = self::origin([ + 'HTTP_HOST' => $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''), + 'HTTPS' => $url['scheme'] === 'https' ? 'on' : 'off', + ]); + if ($expectedOrigin === null) { + throw new \RuntimeException('Invalid EVO_WORKER_ORIGIN.'); + } + $configuredMaxRequests = getenv('EVO_WORKER_MAX_REQUESTS'); + $maxRequests = filter_var($configuredMaxRequests === false || $configuredMaxRequests === '' ? '500' : $configuredMaxRequests, FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1, 'max_range' => 100000]]); + if ($maxRequests === false) { + throw new \RuntimeException('EVO_WORKER_MAX_REQUESTS must be between 1 and 100000.'); + } + self::$keepApplication = getenv('EVO_WORKER_KEEP_APP') === '1'; + define('EVO_WORKER_MODE', true); + require_once $corePath . '/functions/laravel.php'; + require_once $corePath . '/vendor/autoload.php'; + // .env and custom/define.php hold request-independent settings; the guards below need them before the first request. + require_once $corePath . '/includes/custom-define.inc.php'; + $aliases = self::siteAliases( + $expectedOrigin, + defined('EVO_SITE_HOSTNAMES') ? (string) EVO_SITE_HOSTNAMES + : (string) env('REDIRECT_EVO_SITE_HOSTNAMES', env('EVO_SITE_HOSTNAMES', '')), + defined('EVO_SITE_URL') ? (string) EVO_SITE_URL : null + ); + $authority = self::authority($expectedOrigin); + ignore_user_abort(true); + register_shutdown_function(static function (): void { + // A legacy native exit or fatal error bypasses the request's finally block. + if (self::active()) { + try { + if (class_exists('EvoSessionProxy', false)) { + \EvoSessionProxy::saveAndEmitCookie(); + } + } catch (\Throwable $exception) { + error_log('[Evo worker shutdown] ' . $exception); + } finally { + try { + self::terminate(); + } catch (\Throwable $exception) { + error_log('[Evo worker shutdown cleanup] ' . $exception); + } + } + } + }); + + $globals = $GLOBALS; + $environment = $_ENV; + $directory = dirname($frontController); + $timezone = date_default_timezone_get(); + $ini = ini_get_all(); + $errorHandler = set_error_handler(null); + restore_error_handler(); + $exceptionHandler = set_exception_handler(null); + restore_exception_handler(); + $workerId = bin2hex(random_bytes(8)); + $debug = getenv('EVO_WORKER_DEBUG') === '1'; + $retire = false; + $requestNumber = 0; + + $handler = static function () use ($frontController, $corePath, $expectedOrigin, $aliases, $authority, $directory, + $timezone, $ini, $globals, $environment, $workerId, $debug, $errorHandler, $exceptionHandler, + &$retire, &$requestNumber): void { + $requestNumber++; + http_response_code(200); + $misdirected = self::misdirected($_SERVER, $expectedOrigin, $aliases); + if ($misdirected !== null) { + http_response_code($misdirected[0]); + if ($misdirected[1] !== null) { + header('Location: ' . $misdirected[1]); + return; + } + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Misdirected Request'; + return; + } + // The first request defines EVO_SITE_URL for the whole worker: never in the letter case a client chose. + $_SERVER['HTTP_HOST'] = $authority; + $path = rawurldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH)); + $manager = defined('MGR_DIR') ? MGR_DIR : (getenv('MGR_DIR') ?: 'manager'); + if (preg_match('~^/(?:' . preg_quote($manager, '~') . '|install)(?:/|$)~i', $path)) { + http_response_code(404); + if ($debug) { + header('X-Evo-Worker-Guard: frontend-only'); + } + return; + } + + chdir($directory); + $_SESSION = []; + $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); + $_SERVER['REQUEST_TIME'] = time(); + error_clear_last(); + ini_set('display_errors', '0'); + set_time_limit((int) ($ini['max_execution_time']['local_value'] ?? 30)); + self::begin(); + $bufferLevel = ob_get_level(); + ob_start(); + if ($debug) { + header('X-Evo-Worker: ' . $workerId); + header('X-Evo-Request: ' . $requestNumber); + if (self::$keepApplication) { + header('X-Evo-Worker-App: ' . (self::$snapshot === null ? 'new' : 'kept')); + } + } + try { + self::revalidateRuntimeFiles($corePath); + if (class_exists(EnvCacheLoader::class, false)) { + EnvCacheLoader::load($directory); + } + // Includes run in a function scope; request locals never become worker globals. + (static function (string $entry): void { require $entry; })($frontController); + } catch (RequestTerminated) { + // evo_exit() ends this request while leaving the PHP worker alive. + } catch (\Throwable $exception) { + $retire = true; + error_log('[Evo worker] ' . $exception); + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + ob_start(); + if (!headers_sent()) { + header_remove(); + http_response_code(500); + header('Content-Type: text/plain; charset=UTF-8'); + header('Cache-Control: no-store'); + } + echo 'Internal Server Error'; + } finally { + try { + if (class_exists('EvoSessionProxy', false)) { + \EvoSessionProxy::saveAndEmitCookie(); + } + } catch (\Throwable $exception) { + $retire = true; + error_log('[Evo worker cleanup] ' . $exception); + } finally { + try { + self::terminate(); + } catch (\Throwable $exception) { + $retire = true; + error_log('[Evo worker callbacks] ' . $exception); + } + try { + self::releaseApplication(); + self::restoreHandlers($errorHandler, $exceptionHandler); + } catch (\Throwable $exception) { + $retire = true; + error_log('[Evo worker release] ' . $exception); + } + while (ob_get_level() > $bufferLevel) { + ob_end_flush(); + } + $_SESSION = []; + $_ENV = $environment; + foreach (array_keys($GLOBALS) as $key) { + if (!in_array($key, ['GLOBALS', '_SERVER', '_GET', '_POST', '_COOKIE', '_FILES', '_REQUEST', '_SESSION', '_ENV', '__composer_autoload_files'], true) + && !array_key_exists($key, $globals)) { + unset($GLOBALS[$key]); + } + } + foreach ($globals as $key => $value) { + if (!in_array($key, ['GLOBALS', '_SERVER', '_GET', '_POST', '_COOKIE', '_FILES', '_REQUEST', '_SESSION', '_ENV', '__composer_autoload_files'], true)) { + $GLOBALS[$key] = $value; + } + } + date_default_timezone_set($timezone); + foreach ($ini as $key => $setting) { + if (($setting['access'] & INI_USER) && ini_get($key) !== $setting['local_value']) { + @ini_set($key, $setting['local_value']); + } + } + chdir($directory); + gc_collect_cycles(); + } + } + }; + + for ($i = 0; $i < $maxRequests; $i++) { + if (!frankenphp_handle_request($handler) || $retire) { + break; + } + } + } + + public static function releaseApplication(): void + { + // Once captured, the kept application serves every request of this worker, whether or not it reached evo(). + $kept = self::$snapshot !== null; + $app = $kept ? self::$snapshot->application() : ($GLOBALS['evo'] ?? Container::getInstance()); + if ($app instanceof Core) { + if ($app->resolved('ExceptionHandler')) { + $app->getService('ExceptionHandler')->restoreHandlers(); + } + if ($app->resolved('db')) { + foreach ($app['db']->getConnections() as $connection) { + if ($connection->transactionLevel() > 0) { + $connection->rollBack(0); + } + $connection->disconnect(); + if ($kept) { + $connection->flushQueryLog(); + $connection->forgetRecordModificationState(); + $connection->resetTotalQueryDuration(); + $connection->allowQueryDurationHandlersToRunAgain(); + } + } + } + if ($kept) { + self::$snapshot->restore(); + } else { + $app->flush(); + } + } + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + session_id(''); + if (class_exists('EvoSessionProxy', false)) { + \EvoSessionProxy::resetForWorker(); + } + if (class_exists(Parser::class, false)) { + Parser::resetForWorker(); + } + if (class_exists(\EvolutionCMS\Services\ManagerActivity::class, false)) { + \EvolutionCMS\Services\ManagerActivity::reset(); + } + foreach ([\EvolutionCMS\Support\TvBindingArguments::class, \EvolutionCMS\Support\TvBindingGuard::class] as $class) { + if (class_exists($class, false)) { + $class::resetForWorker(); + } + } + if (class_exists(Model::class, false)) { + Model::reguard(); + // A kept application keeps its booted models, observers and connection resolver. + if (!$kept) { + Model::clearBootedModels(); + Model::unsetConnectionResolver(); + Model::unsetEventDispatcher(); + } + } + Facade::clearResolvedInstances(); + if (!$kept) { + Facade::setFacadeApplication(null); + self::forgetBootRegistries(); + } + Container::setInstance(null); + EnvCacheLoader::resetForWorker(); + unset($GLOBALS['evo'], $GLOBALS['modx']); + } +} + +/** + * Post-boot state of a kept application, put back after every request. + * + * The properties of the container, of Core and of every service resolved while providers booted return + * to their post-boot values: whatever a request bound, resolved, registered or set on them is dropped. + * Objects nested inside those services are shared by all requests; their owners reset them through + * flushState() (Laravel's and Livewire's convention) or Symfony's ResetInterface. + */ +final class ApplicationSnapshot +{ + /** @var list */ + private array $properties = []; + /** @var list<\Closure> */ + private array $resets = []; + private array $services = []; + + public function __construct(private readonly Core $app) + { + $this->capture($app); + $this->services = (new \ReflectionProperty(Container::class, 'instances'))->getValue($app); + $captured = [spl_object_id($app) => true]; + $services = []; + foreach ($this->services as $service) { + if (is_object($service) && $service !== $app && !$service instanceof \Closure) { + $services[spl_object_id($service)] = $service; + } + } + foreach ($services as $service) { + $this->captureResets($service); + } + foreach ($services as $id => $service) { + if (!isset($captured[$id])) { + $captured[$id] = true; + $from = count($this->properties); + $this->capture($service); + // One level further: the objects a service holds directly, such as the view finder or the route collection. + foreach (array_slice($this->properties, $from) as [, , $value]) { + if (is_object($value) && !$value instanceof \Closure && !isset($captured[spl_object_id($value)])) { + $captured[spl_object_id($value)] = true; + $this->capture($value); + } + } + } + } + if (class_exists(\Illuminate\Support\Once::class)) { + $this->resets[] = static fn () => \Illuminate\Support\Once::flush(); + } + } + + public function application(): Core + { + return $this->app; + } + + /** @return list */ + public function services(): array + { + return array_keys($this->services); + } + + public function restore(): void + { + foreach ($this->properties as [$property, $object, $value]) { + $property->setValue($object, $value); + } + $this->app->forgetScopedInstances(); + foreach ($this->resets as $reset) { + $reset(); + } + } + + private function capture(object $object): void + { + for ($class = new \ReflectionClass($object); $class !== false; $class = $class->getParentClass()) { + foreach ($class->getProperties() as $property) { + if (!$property->isStatic() && !$property->isReadOnly() + && $property->getDeclaringClass()->getName() === $class->getName() + && $property->isInitialized($object)) { + $this->properties[] = [$property, $object, $property->getValue($object)]; + } + } + } + } + + private function captureResets(object $service): void + { + if ($service instanceof \Symfony\Contracts\Service\ResetInterface) { + $this->resets[] = $service->reset(...); + } elseif (method_exists($service, 'flushState') && (new \ReflectionMethod($service, 'flushState'))->isPublic()) { + $this->resets[] = $service->flushState(...); + } + if ($service instanceof \Illuminate\Log\LogManager) { + // Context set on a channel during a request lives in the Logger object, not in the manager. + $this->resets[] = static function () use ($service): void { + foreach ($service->getChannels() as $channel) { + if ($channel instanceof \Illuminate\Log\Logger) { + $channel->withoutContext(); + } + } + }; + } + } +} diff --git a/core/src/Bootstrap/EnvCacheLoader.php b/core/src/Bootstrap/EnvCacheLoader.php index ceddac205f..3acbd9ef27 100644 --- a/core/src/Bootstrap/EnvCacheLoader.php +++ b/core/src/Bootstrap/EnvCacheLoader.php @@ -70,6 +70,15 @@ public static function isStaticClassDeclaration(string $key, string $path): bool /** @var array */ private static array $environment = []; + /** Forget process memory without deleting the on-disk cache. */ + public static function resetForWorker(): void + { + self::$loadedRoot = null; + self::$loadedCache = null; + self::$runtimeConfiguration = null; + self::$environment = []; + } + /** * Loads environment variables and the optional compiled configuration payload. * diff --git a/core/src/Core.php b/core/src/Core.php index 0a3f5bffae..6d435a8fc6 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -182,6 +182,9 @@ class Core extends AbstractLaravel implements Interfaces\CoreInterface public $aliasListing = []; public $lockedElements = null; public $tmpCache = []; + private array $childIdsCache = []; + private array $childMapCache = []; + private array $templateVarsCache = []; private $version = []; public $extensions = []; public $cacheKey = ''; @@ -274,6 +277,9 @@ public function __construct() }); parent::__construct(); + if (class_exists(\EvolutionCMS\Runtime\WorkerRuntime::class, false)) { + \EvolutionCMS\Runtime\WorkerRuntime::captureApplication($this); + } $this->initialize(); } @@ -308,7 +314,10 @@ public function initialize() public static function getInstance() { if (self::$instance === null) { - self::$instance = new static(); + $kept = class_exists(\EvolutionCMS\Runtime\WorkerRuntime::class, false) + ? \EvolutionCMS\Runtime\WorkerRuntime::reuseApplication(static::class) + : null; + self::$instance = $kept ?? new static(); } return self::$instance; @@ -523,10 +532,10 @@ public function sendRedirect(string $url, int $count_attempts = 0, string $type $header = 'Refresh: 0;URL=' . $url; } elseif ($type === 'REDIRECT_SCRIPT') { echo '' . "\n"; - exit; + evo_exit(); } elseif ($type === 'REDIRECT_META') { echo ''; - exit; + evo_exit(); } else { // default: REDIRECT_HEADER $header = 'Location: ' . $url; } @@ -563,7 +572,7 @@ public function sendRedirect(string $url, int $count_attempts = 0, string $type header($header); } - exit(0); + evo_exit(0); } /** @@ -679,12 +688,12 @@ public function sendForward($id, $responseCode = '') header($responseCode); } $this->prepareResponse(); - exit(); + evo_exit(); } $this->getService('ExceptionHandler')->messageQuit("Internal Server Error id={$id}"); header('HTTP/1.0 500 Internal Server Error'); - die('

ERROR: Too many forward attempts!

The request could not be completed due to too many unsuccessful forward attempts.

'); + evo_exit('

ERROR: Too many forward attempts!

The request could not be completed due to too many unsuccessful forward attempts.

'); } /** @@ -755,8 +764,11 @@ protected function handleForwardLoop($target, string $responseCode = '', string $this->documentIdentifier = $errorPageId; $this->documentMethod = 'id'; $this->prepareResponse(); - exit; + evo_exit(); } catch (\Throwable $e) { + if ($e instanceof \EvolutionCMS\Runtime\RequestTerminated) { + throw $e; + } Log::warning('Forward loop fallback to error_page failed', [ 'loop_type' => $loopType, 'request_uri' => $_SERVER['REQUEST_URI'] ?? null, @@ -770,7 +782,7 @@ protected function handleForwardLoop($target, string $responseCode = '', string if (file_exists(EVO_BASE_PATH . 'views/' . (int)$status . '.html')) { header('HTTP/1.0 404 Not Found', true, (int)$status); echo file_get_contents(EVO_BASE_PATH . 'views/' . (int)$status . '.html'); - exit; + evo_exit(); } echo '

ERROR: Forward loop detected

'; @@ -780,7 +792,7 @@ protected function handleForwardLoop($target, string $responseCode = '', string if (is_array($docInfo)) { echo '

Target document: ' . htmlspecialchars((string)($docInfo['status'] ?? 'unknown'), ENT_QUOTES) . '

'; } - exit; + evo_exit(); } /** @@ -813,7 +825,7 @@ public function sendUnauthorizedPage($noEvent = false) } $this->sendForward(UrlProcessor::getUnAuthorizedPageId(), 'HTTP/1.1 401 Unauthorized'); - exit(); + evo_exit(); } /** @@ -1008,7 +1020,7 @@ public function getDocumentObjectFromCache($id, $loading = false) $this->sendErrorPage(); } - exit; // stop here + evo_exit(); // stop here } } @@ -1263,7 +1275,7 @@ public function setConditional() if ($HTTP_IF_MODIFIED_SINCE == $last_modified || Str::contains($HTTP_IF_NONE_MATCH, $etag)) { header('HTTP/1.1 304 Not Modified'); header('Content-Length: 0'); - exit; + evo_exit(); } header('Last-Modified: ' . $last_modified); @@ -1356,7 +1368,7 @@ protected function runPubStatusSync($timeNow) */ public function deferAfterResponse(callable $callback) { - register_shutdown_function(function () use ($callback) { + evo_register_shutdown(function () use ($callback) { $this->finishRequest(); try { $callback(); @@ -2270,10 +2282,15 @@ public function evalPlugin($pluginCode, $params) if (is_array($params)) { extract($params, EXTR_SKIP); } + // error_get_last() below must only see what the plugin raised, not an earlier (even @-suppressed) warning. + error_clear_last(); ob_start(); try { eval($pluginCode); } catch (\Error $e) { + if ($e instanceof \EvolutionCMS\Runtime\RequestTerminated) { + throw $e; + } ob_end_clean(); $pluginName = $modx->event->activePlugin; SitePlugin::where('name', $pluginName)->update(['disabled' => 1]); @@ -2344,6 +2361,8 @@ public function evalSnippet($phpcode, $params) if (is_array($params)) { extract($params, EXTR_SKIP); } + // error_get_last() below must only see what the snippet raised, not an earlier (even @-suppressed) warning. + error_clear_last(); ob_start(); if (is_scalar($phpcode) && Str::contains($phpcode, ';')) { if (substr($phpcode, 0, 5) === 'where('disabled', '=', 0) ->get(); if ($snippetModelCollection->count() > 1) { - exit('Error $modx->getSnippetObject()' . $snip_name); + evo_exit('Error $modx->getSnippetObject()' . $snip_name); } if ($snippetModelCollection->count() === 1) { @@ -3208,7 +3227,7 @@ public function processRoutes() $response = $this->router->dispatch($request); } catch (NotFoundHttpException | MethodNotAllowedException $exception) { $this->executeParser(); - exit; + evo_exit(); } $response->send(); @@ -3245,7 +3264,7 @@ public function executeParser() // display offline message $this->documentContent = $this->getConfig('site_unavailable_message'); $this->outputContent(); - exit; // stop processing here, as the site's offline + evo_exit(); // stop processing here, as the site's offline } // setup offline page document settings @@ -3519,7 +3538,7 @@ public function prepareResponse() } if (!$template) { - register_shutdown_function([ + evo_register_shutdown([ &$this, 'postProcess' ]); // tell PHP to call postProcess when it shuts down @@ -3565,7 +3584,7 @@ public function _sendRedirectForRefPage($url) $url = $this->resolveWeblinkPath($url); // The resource's stored link is trusted configuration, not a request return URL. $this->sendRedirect($url, 0, '', 302, true); - exit; + evo_exit(); } /** @@ -3656,7 +3675,7 @@ public function getUltimateParentId($id, $top = 0) */ public function getChildIds($id, $depth = 10, $children = []) { - static $cached = []; + $cached = &$this->childIdsCache; $cacheKey = md5(print_r(func_get_args(), true)); if (isset($cached[$cacheKey])) { @@ -3696,7 +3715,7 @@ public function getChildIds($id, $depth = 10, $children = []) } // Initialise a static array to index parents->children - static $documentMap_cache = []; + $documentMap_cache = &$this->childMapCache; if (!$documentMap_cache) { foreach ($this->documentMap as $document) { foreach ($document as $p => $c) { @@ -3789,7 +3808,7 @@ function __alertQuit() {

" . htmlspecialchars((string)$msg, ENT_QUOTES, $manager_charset) . '

'; - exit; + evo_exit(); } /** @@ -5410,7 +5429,7 @@ public function getTemplateVar($idname = "", $fields = "*", $docid = "", $publis */ public function getTemplateVars($idnames = [], $fields = '*', $docid = '', $published = 1, $sort = 'rank', $dir = 'ASC', $checkAccess = true) { - static $cached = []; + $cached = &$this->templateVarsCache; $cacheKey = md5(print_r(func_get_args(), true)); if (isset($cached[$cacheKey])) { return $cached[$cacheKey]; diff --git a/core/src/ExceptionHandler.php b/core/src/ExceptionHandler.php index 5be4632e8d..bd8ad091aa 100644 --- a/core/src/ExceptionHandler.php +++ b/core/src/ExceptionHandler.php @@ -44,7 +44,7 @@ public function __construct(Container $container) protected function registerHandlers(): void { - register_shutdown_function([$this, 'handleShutdown']); + evo_register_shutdown([$this, 'handleShutdown']); set_exception_handler([$this, 'handleException']); set_error_handler([$this, 'phpError']); $this->registered = true; @@ -484,7 +484,7 @@ public function messageQuit( if (!is_cli()) { ob_end_flush(); } - exit; + evo_exit(); } protected function shouldDisplay() @@ -661,7 +661,7 @@ public function handleException(\Throwable $exception) ) ) { echo $exception->getMessage(); - exit; + evo_exit(); } if ($exception instanceof ViewException) { diff --git a/core/src/Extensions/Router.php b/core/src/Extensions/Router.php index e9a1593e44..20be3666c2 100644 --- a/core/src/Extensions/Router.php +++ b/core/src/Extensions/Router.php @@ -23,7 +23,7 @@ public function fallbackToParser() if (!$this->hasFallbackToParser) { $this->addRoute(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], '{any}', function () { EvolutionCMS()->executeParser(); - exit(); + evo_exit(); })->where('any', '.*')->fallback(); $this->hasFallbackToParser = true; diff --git a/core/src/Legacy/Permissions.php b/core/src/Legacy/Permissions.php index 1be905fe92..d50382e04f 100644 --- a/core/src/Legacy/Permissions.php +++ b/core/src/Legacy/Permissions.php @@ -102,7 +102,7 @@ public static function getManagerDocumentGroups() */ public static function getAccessibleDocumentIds() { - static $documents = null; + $documents = &evo()->tmpCache[__METHOD__]; if ($documents === null) { $docgrp = static::getManagerDocumentGroups(); diff --git a/core/src/Parser.php b/core/src/Parser.php index eeb983eb1f..e66a17c707 100644 --- a/core/src/Parser.php +++ b/core/src/Parser.php @@ -73,13 +73,18 @@ class Parser public static function getInstance (Core $modx) { - if (null === self::$instance) { + if (null === self::$instance || self::$instance->modx !== $modx) { self::$instance = new self($modx); } return self::$instance; } + public static function resetForWorker(): void + { + self::$instance = null; + } + /** * is not allowed to call from outside: private! * diff --git a/core/src/Services/ManagerActivity.php b/core/src/Services/ManagerActivity.php index 7f98ff6bb1..4f9655c9bd 100644 --- a/core/src/Services/ManagerActivity.php +++ b/core/src/Services/ManagerActivity.php @@ -50,7 +50,7 @@ public function defer(callable $job): void if (!$this->shutdownRegistered) { $this->shutdownRegistered = true; - register_shutdown_function(function (): void { + evo_register_shutdown(function (): void { $this->flush(); }); } diff --git a/core/src/Support/TvBindingArguments.php b/core/src/Support/TvBindingArguments.php index c132139cf1..30b3d7f82c 100644 --- a/core/src/Support/TvBindingArguments.php +++ b/core/src/Support/TvBindingArguments.php @@ -29,6 +29,11 @@ final class TvBindingArguments /** @var array refusals already written to the event log in this request */ private static array $loggedRefusals = []; + public static function resetForWorker(): void + { + self::$loggedRefusals = []; + } + /** * The field values a placeholder may name: the given TV rows or values over the current * document's fields, resolved the way parseTvValues() resolves them. diff --git a/core/src/Support/TvBindingGuard.php b/core/src/Support/TvBindingGuard.php index 9cc50b8cb7..7c01a0fea6 100644 --- a/core/src/Support/TvBindingGuard.php +++ b/core/src/Support/TvBindingGuard.php @@ -34,6 +34,11 @@ final class TvBindingGuard /** @var array refusals already written to the event log in this request */ private static array $loggedRefusals = []; + public static function resetForWorker(): void + { + self::$loggedRefusals = []; + } + public static function runsCode(?string $command): bool { return in_array(strtoupper(trim((string) $command)), self::CODE_BINDINGS, true); diff --git a/core/tests/Mocks/worker_runtime_probe.php b/core/tests/Mocks/worker_runtime_probe.php new file mode 100644 index 0000000000..15d46bdae6 --- /dev/null +++ b/core/tests/Mocks/worker_runtime_probe.php @@ -0,0 +1,111 @@ +instance('path.storage', sys_get_temp_dir()); + $results = []; + foreach (['', 'off', 'OFF', 'on'] as $https) { + $_SERVER['HTTPS'] = $https; + $_SERVER['SERVER_PORT'] = 80; + $config = require dirname(__DIR__, 2) . '/config/session.php'; + $results[] = $config['secure']; + } + echo json_encode($results); +} +if ($mode === 'classic-string' || $mode === 'classic-code') { + evo_register_shutdown(static fn () => print('|shutdown')); + evo_exit($mode === 'classic-string' ? 'body' : 7); + echo 'unreachable'; +} + +if ($mode === 'keep-app') { + define('EVO_CLASS', 'KeepProbeCore'); + class KeepProbeCore extends EvolutionCMS\Core + { + public int $initialized = 0; + public function initialize() + { + $this->initialized++; + } + } + WorkerRuntime::keepApplications(true); + $boot = (new ReflectionClass(KeepProbeCore::class))->newInstanceWithoutConstructor(); + $boot->instance('config', new Repository(['app' => ['name' => 'boot']])); + $boot->instance('events', new Illuminate\Events\Dispatcher($boot)); + $boot['events']->listen('boot.event', static fn () => 'boot'); + $results = []; + WorkerRuntime::begin(); + WorkerRuntime::captureApplication($boot); + foreach (['first', 'second'] as $request) { + if ($request === 'second') { + WorkerRuntime::begin(); + } + $app = KeepProbeCore::getInstance(); + $results[] = [ + 'same' => $app === $boot, + 'initialized' => $app->initialized, + 'container' => Container::getInstance() === $app, + 'config' => $app['config']->get('app.name'), + 'placeholder' => $app->placeholders['marker'] ?? null, + 'bound' => $app->bound('request.only'), + 'listeners' => count($app['events']->getListeners('boot.event')), + ]; + $app['config']->set('app.name', 'dirty'); + $app->placeholders['marker'] = 'dirty'; + $app->instance('request.only', new stdClass()); + $app['events']->listen('boot.event', static fn () => 'request'); + WorkerRuntime::terminate(); + WorkerRuntime::releaseApplication(); + } + echo json_encode($results); +} + +if ($mode === 'session') { + define('IN_MANAGER_MODE', false); + define('IN_INSTALL_MODE', false); + define('EVO_API_MODE', true); + define('EVO_SESSION', true); + define('EVO_CLASS', Container::class); + require dirname(__DIR__, 2) . '/functions/session_proxy.php'; + class WorkerSessionStore + { + public array $data; + public int $saves = 0; + public function __construct(string $name) { $this->data = ['user' => $name]; } + public function isStarted(): bool { return true; } + public function getId(): string { return 'test-session'; } + public function all(): array { return $this->data; } + public function put($key, $value): void { $this->data[$key] = $value; } + public function forget($key): void { unset($this->data[$key]); } + public function save(): void { $this->saves++; } + } + $results = []; + foreach (['alice', 'bob'] as $user) { + WorkerRuntime::begin(); + $_COOKIE = []; + $_SESSION = []; + $store = new WorkerSessionStore($user); + $GLOBALS['evo'] = $app = new Container(); + Container::setInstance($app); + $app->instance('config', new Repository(['session' => ['cookie' => 'evo_session']])); + $app->instance('session', $store); + EvoSessionProxy::init(); + $_SESSION['changed'] = $user; + WorkerRuntime::terminate(); + $results[] = ['session' => $_SESSION['user'], 'saved' => $store->data['changed'], 'saves' => $store->saves]; + WorkerRuntime::releaseApplication(); + } + echo json_encode($results); +} diff --git a/core/tests/Unit/EvalErrorAttributionTest.php b/core/tests/Unit/EvalErrorAttributionTest.php new file mode 100644 index 0000000000..f25cf64b97 --- /dev/null +++ b/core/tests/Unit/EvalErrorAttributionTest.php @@ -0,0 +1,81 @@ +core->quits[] = $arguments[6]; + } + }; + } + + public function isBackend() + { + return false; + } +} + +/** Runs $callback under PHP's own error handling, which is what fills error_get_last(). */ +function withNativeErrorHandling(callable $callback): void +{ + set_error_handler(static fn () => false); + try { + $callback(); + } finally { + restore_error_handler(); + } +} + +function evalErrorAttributionCore(): EvalErrorAttributionCore +{ + $core = (new ReflectionClass(EvalErrorAttributionCore::class))->newInstanceWithoutConstructor(); + $core->event = new EvolutionCMS\Event(); + + return $core; +} + +test('a warning raised before a snippet is not reported as the snippet error', function () { + $core = evalErrorAttributionCore(); + withNativeErrorHandling(function () use ($core, &$output) { + // e.g. a provider re-running class_alias() in a persistent worker, or a suppressed @fileperms() + @trigger_error('raised before the snippet', E_USER_WARNING); + expect(error_get_last()['message'] ?? null)->toBe('raised before the snippet'); + $output = $core->evalSnippet('return "snippet output";', []); + }); + + expect($output)->toBe('snippet output')->and($core->quits)->toBe([]); +}); + +test('a warning raised before a plugin is not reported as the plugin error', function () { + $core = evalErrorAttributionCore(); + ob_start(); + withNativeErrorHandling(function () use ($core) { + @trigger_error('raised before the plugin', E_USER_WARNING); + $core->evalPlugin('echo "plugin output";', []); + }); + + expect(ob_get_clean())->toBe('plugin output')->and($core->quits)->toBe([]); +}); + +test('a warning raised by the snippet itself is still reported', function () { + $core = evalErrorAttributionCore(); + withNativeErrorHandling(fn () => $core->evalSnippet('@trigger_error("snippet warning", E_USER_WARNING); return "x";', [])); + + expect($core->quits)->toBe(['snippet warning']); +}); diff --git a/core/tests/Unit/Security/ApacheConfigHardeningTest.php b/core/tests/Unit/Security/ApacheConfigHardeningTest.php index 74d579036b..502a4702a4 100644 --- a/core/tests/Unit/Security/ApacheConfigHardeningTest.php +++ b/core/tests/Unit/Security/ApacheConfigHardeningTest.php @@ -165,7 +165,8 @@ static function (SplFileInfo $item) use ($relative, $skipNames, $skipPaths) { expect($source) ->toContain("'secure' => env('SESSION_SECURE_COOKIE') ?? (") - ->toContain("\$_SERVER['HTTPS'] !== 'off'") + ->toContain("!empty(\$_SERVER['HTTPS'])") + ->toContain("strtolower(\$_SERVER['HTTPS']) !== 'off'") ->and($source)->not->toContain("env('SESSION_SECURE_COOKIE', false)"); }); diff --git a/core/tests/Unit/WorkerRuntimeTest.php b/core/tests/Unit/WorkerRuntimeTest.php new file mode 100644 index 0000000000..6cd2c59b61 --- /dev/null +++ b/core/tests/Unit/WorkerRuntimeTest.php @@ -0,0 +1,226 @@ +toBe('body|shutdown') + ->and($code)->toBe(0) + ->and(evoRunPhp($probe, ['classic-code'], $code))->toBe('|shutdown') + ->and($code)->toBe(7); +}); + +test('worker termination stops execution without ending the process', function () { + WorkerRuntime::begin(); + ob_start(); + try { + evo_exit('body'); + echo 'unreachable'; + } catch (RequestTerminated) { + $body = ob_get_clean(); + } finally { + WorkerRuntime::terminate(); + } + expect($body)->toBe('body')->and(WorkerRuntime::active())->toBeFalse(); +}); + +test('worker cleanup runs once per request, including callbacks appended during termination', function () { + $calls = []; + for ($i = 0; $i < 2; $i++) { + WorkerRuntime::begin(); + evo_register_shutdown(function () use (&$calls, $i) { + $calls[] = 'first' . $i; + evo_register_shutdown(function () use (&$calls, $i) { $calls[] = 'last' . $i; }); + }); + WorkerRuntime::terminate(); + } + expect($calls)->toBe(['first0', 'last0', 'first1', 'last1']); +}); + +test('failed request cleanup does not skip subsequent cleanup or leak callbacks', function () { + WorkerRuntime::begin(); + $ran = false; + evo_register_shutdown(static function () { throw new RuntimeException('cleanup failed'); }); + evo_register_shutdown(function () use (&$ran) { $ran = true; }); + try { + WorkerRuntime::terminate(); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toBe('cleanup failed'); + } + expect($ran)->toBeTrue()->and(WorkerRuntime::active())->toBeFalse(); + WorkerRuntime::begin(); + WorkerRuntime::terminate(); +}); + +test('successive worker sessions load and save the correct user independently', function () { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['session'], $code); + expect($code)->toBe(0)->and(json_decode($output, true))->toBe([ + ['session' => 'alice', 'saved' => 'alice', 'saves' => 1], + ['session' => 'bob', 'saved' => 'bob', 'saves' => 1], + ]); +}); + +test('the parser follows a new request core and releases its old reference', function () { + $reflection = new ReflectionClass(Core::class); + $first = $reflection->newInstanceWithoutConstructor(); + $second = $reflection->newInstanceWithoutConstructor(); + $one = Parser::getInstance($first); + expect(Parser::getInstance($second))->not->toBe($one); + $two = Parser::getInstance($second); + Parser::resetForWorker(); + expect(Parser::getInstance($second))->not->toBe($two); + Parser::resetForWorker(); +}); + +test('plugin request termination is not treated as a fatal plugin error', function () { + $core = (new ReflectionClass(Core::class))->newInstanceWithoutConstructor(); + $core->event = new EvolutionCMS\Event(); + WorkerRuntime::begin(); + $level = ob_get_level(); + try { + $core->evalPlugin('evo_exit();', []); + test()->fail('The plugin termination signal must propagate.'); + } catch (RequestTerminated) { + expect($core->event)->toBeInstanceOf(EvolutionCMS\Event::class); + } finally { + while (ob_get_level() > $level) { ob_end_clean(); } + WorkerRuntime::terminate(); + } +}); + +test('worker origins include scheme and non-default ports and reject malformed hosts', function () { + expect(WorkerRuntime::origin(['HTTP_HOST' => 'EXAMPLE.com:80']))->toBe('http://example.com') + ->and(WorkerRuntime::origin(['HTTP_HOST' => 'example.com:8443', 'HTTPS' => 'on']))->toBe('https://example.com:8443') + ->and(WorkerRuntime::origin(['HTTP_HOST' => '[::1]:8080']))->toBe('http://[::1]:8080') + ->and(WorkerRuntime::origin(['HTTP_HOST' => 'example.com:65536']))->toBeNull() + ->and(WorkerRuntime::origin(['HTTP_HOST' => 'example.com@evil.test']))->toBeNull() + ->and(WorkerRuntime::origin(['HTTP_HOST' => "example.com\r\nX: injected"]))->toBeNull(); +}); + +test('empty HTTPS values from FrankenPHP keep HTTP session cookies usable', function () { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['secure-cookie'], $code); + expect($code)->toBe(0)->and(json_decode($output, true))->toBe([false, false, false, true]); +}); + +test('document tree caches do not survive a new core instance', function () { + if (!class_exists('UrlProcessor')) { + class_alias(EvolutionCMS\Facades\UrlProcessor::class, 'UrlProcessor'); + } + $reflection = new ReflectionClass(Core::class); + $old = EvolutionCMS\Facades\UrlProcessor::getFacadeRoot(); + try { + $results = []; + foreach ([2, 3] as $child) { + $core = $reflection->newInstanceWithoutConstructor(); + $core->instance('config', new Illuminate\Config\Repository()); + $core->documentMap = [[0 => $child]]; + EvolutionCMS\Facades\UrlProcessor::swap((object) ['aliasListing' => [ + $child => ['path' => '', 'alias' => 'child-' . $child], + ]]); + $results[] = $core->getChildIds(0, 1); + } + expect($results)->toBe([['child-2' => 2], ['child-3' => 3]]); + } finally { + EvolutionCMS\Facades\UrlProcessor::swap($old); + } +}); + +test('request error handlers are unwound to the worker baseline', function () { + $error = set_error_handler(null); + restore_error_handler(); + $exception = set_exception_handler(null); + restore_exception_handler(); + set_error_handler(static fn () => true); + set_error_handler(static fn () => false); + set_exception_handler(static function (Throwable $exception): void {}); + WorkerRuntime::restoreHandlers($error, $exception); + $restoredError = set_error_handler(null); + restore_error_handler(); + $restoredException = set_exception_handler(null); + restore_exception_handler(); + expect($restoredError)->toBe($error)->and($restoredException)->toBe($exception); +}); + +test('worker host guard redirects other site hostnames of the same scheme and refuses the rest', function () { + $aliases = WorkerRuntime::siteAliases('https://example.com', 'www.example.com, example.com', null); + $request = fn (string $host, array $server = []) => WorkerRuntime::misdirected( + $server + ['HTTP_HOST' => $host, 'HTTPS' => 'on', 'REQUEST_URI' => '/page?x=1'], 'https://example.com', $aliases + ); + + expect($aliases)->toBe(['https://www.example.com']) + ->and($request('example.com'))->toBeNull() + ->and($request('EXAMPLE.COM:443'))->toBeNull() + ->and($request('www.example.com'))->toBe([308, 'https://example.com/page?x=1']) + ->and($request('www.example.com', ['REQUEST_URI' => "/\r\nX: y"]))->toBe([308, 'https://example.com/']) + // a proxy that drops X-Forwarded-Proto must not loop between http and https + ->and($request('www.example.com', ['HTTPS' => 'off']))->toBe([421, null]) + ->and($request('evil.test'))->toBe([421, null]) + ->and(WorkerRuntime::authority('https://example.com'))->toBe('example.com') + ->and(WorkerRuntime::authority('http://[::1]:8080'))->toBe('[::1]:8080'); +}); + +test('site hostnames without a port stand for the default port of the worker scheme', function () { + expect(WorkerRuntime::hostnameOrigins('a.test, a.test:443, b.test:8443,, bad host', 'https')) + ->toBe(['https://a.test', 'https://b.test:8443']) + ->and(WorkerRuntime::hostnameOrigins('a.test:443', 'http'))->toBe(['http://a.test:443']); +}); + +test('a worker origin that the site URL settings would not produce is refused at startup', function (string $hostnames, ?string $siteUrl) { + expect(fn () => WorkerRuntime::siteAliases('http://a.test:8080', $hostnames, $siteUrl))->toThrow(RuntimeException::class); +})->with([ + 'not in EVO_SITE_HOSTNAMES' => ['b.test:8080', null], + 'other port' => ['a.test', null], + 'EVO_SITE_URL of another host' => ['', 'http://b.test:8080/'], + 'EVO_SITE_URL of another scheme' => ['', 'https://a.test:8080/'], +]); + +test('a matching EVO_SITE_URL and an empty hostname list are accepted', function () { + expect(WorkerRuntime::siteAliases('http://a.test:8080', '', 'http://A.test:8080/sub/'))->toBe([]); +}); + +test('kept application serves each request from its post-boot state', function () { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['keep-app'], $code); + $state = ['same' => true, 'initialized' => 1, 'container' => true, 'config' => 'boot', + 'placeholder' => null, 'bound' => false, 'listeners' => 1]; + expect($code)->toBe(0)->and(json_decode($output, true))->toBe([$state, $state]); +}); + +test('the snapshot resets nested service state and calls the reset hooks of kept services', function () { + $app = (new ReflectionClass(Core::class))->newInstanceWithoutConstructor(); + $finder = new Illuminate\View\FileViewFinder(new Illuminate\Filesystem\Filesystem(), []); + $finder->addNamespace('manager', '/boot'); + $view = new class ($finder) { + public array $shared = ['boot' => true]; + public int $flushes = 0; + public function __construct(public Illuminate\View\FileViewFinder $finder) {} + public function flushState(): void { $this->flushes++; } + }; + $resettable = new class implements Symfony\Contracts\Service\ResetInterface { + public int $resets = 0; + public function reset(): void { $this->resets++; } + }; + $app->instance('view', $view); + $app->instance('resettable', $resettable); + $snapshot = new EvolutionCMS\Runtime\ApplicationSnapshot($app); + + $view->shared['request'] = true; + $finder->addNamespace('manager', '/request'); + $snapshot->restore(); + + expect($view->shared)->toBe(['boot' => true]) + ->and($finder->getHints())->toBe(['manager' => ['/boot']]) + ->and($view->flushes)->toBe(1) + ->and($resettable->resets)->toBe(1) + ->and($snapshot->services())->toBe(['view', 'resettable']); +}); + +test('fresh containers forget the static callbacks that every boot registers again', function () { + Illuminate\Console\Application::starting(static fn () => null); + WorkerRuntime::forgetBootRegistries(); + expect((new ReflectionProperty(Illuminate\Console\Application::class, 'bootstrappers'))->getValue())->toBe([]); +}); diff --git a/index.php b/index.php index e3ffe87a75..d038a1146b 100644 --- a/index.php +++ b/index.php @@ -59,6 +59,13 @@ } unset($configPath); +if (!defined('EVO_WORKER_MODE') && ($_SERVER['FRANKENPHP_WORKER'] ?? false) + && getenv('EVO_WORKER_MODE') === '1') { + require_once $config['core'] . '/includes/worker.runtime.php'; + \EvolutionCMS\Runtime\WorkerRuntime::run(__FILE__, $config['core']); + return; +} + // A POST over post_max_size arrives empty after a PHP startup warning. Keep the warnings that // follow from it (headers already sent, with server paths) off the page; the request goes on as before. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && is_file($config['core'] . '/src/Support/UploadLimits.php')) { @@ -112,7 +119,9 @@ * Function: This file loads and executes the parser. * */ -define('IN_PARSER_MODE', true); +if (!defined('IN_PARSER_MODE')) { + define('IN_PARSER_MODE', true); +} if (!defined('IN_MANAGER_MODE')) { define('IN_MANAGER_MODE', false); } diff --git a/phpstan.neon b/phpstan.neon index d844bdfcba..fadfcc1b93 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,4 +9,5 @@ parameters: - core/includes/aliases.inc.php - core/includes/legacy.inc.php scanFiles: + - core/includes/worker.runtime.php - core/tests/phpstan/stask-symbols.php From 4469fc734d8551acc4585636802390486058f96d Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 31 Oct 2026 03:05:12 +0100 Subject: [PATCH 2/4] add(core): FrankenPHP worker mode --- .github/docker/WORKER.md | 8 ++++++-- core/bootstrap.php | 3 ++- core/functions/session_proxy.php | 9 +++++++++ core/src/Core.php | 3 +++ core/src/Providers/TracyServiceProvider.php | 3 ++- core/tests/Mocks/worker_runtime_probe.php | 8 ++++++++ core/tests/Unit/WorkerRuntimeTest.php | 18 ++++++++++++++++++ 7 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/docker/WORKER.md b/.github/docker/WORKER.md index 843065ebe4..c3a5bc40f5 100644 --- a/.github/docker/WORKER.md +++ b/.github/docker/WORKER.md @@ -52,8 +52,12 @@ services with a public `flushState()` (Laravel's and Livewire's convention) or Symfony's `ResetInterface` are reset through it. In debug mode, responses carry `X-Evo-Worker-App: new|kept` and the log lists the kept services. -A provider that reads the request, the session or the user while booting sees -only the first request of each worker in this mode. An extension service resolved +Providers boot before the session starts, in both lifetimes and with either +`EVO_SESSION` setting (a worker starts the native session of `EVO_SESSION=false` in +`Core::initialize()`), so while booting they always see an empty session and a +guest. The query string, cookies and request headers they could read there are +those of the first request of each worker in this mode: read them per request, +in middleware, a plugin event or a binding resolved during the request. An extension service resolved during boot that keeps request state deeper than one object level must implement one of the reset hooks. Test the installed extensions with this mode before using it in production; without it, workers behave as described above. diff --git a/core/bootstrap.php b/core/bootstrap.php index d606975b49..830557a4f5 100644 --- a/core/bootstrap.php +++ b/core/bootstrap.php @@ -34,6 +34,7 @@ require_once __DIR__ . '/includes/protect.inc.php'; } -if (! is_cli() && session_status() === PHP_SESSION_NONE && !EvoSessionProxy::disabled()) { +if (! is_cli() && session_status() === PHP_SESSION_NONE && !EvoSessionProxy::disabled() + && !EvoSessionProxy::startsAfterBoot()) { startCMSSession(); // start session } diff --git a/core/functions/session_proxy.php b/core/functions/session_proxy.php index 57feb4ec5f..1693fb1aa9 100644 --- a/core/functions/session_proxy.php +++ b/core/functions/session_proxy.php @@ -39,6 +39,15 @@ public static function disabled(): bool return defined('NO_SESSION') && NO_SESSION && !(defined('IN_MANAGER_MODE') && IN_MANAGER_MODE); } + /** + * A worker starts the native session (EVO_SESSION=false) in Core::initialize(), once the providers + * have booted: a kept application would otherwise keep what its first visitor's session held. + */ + public static function startsAfterBoot(): bool + { + return defined('EVO_WORKER_MODE') && EVO_WORKER_MODE && !(defined('EVO_SESSION') && EVO_SESSION); + } + /** * Drops the session middleware from a front-end group while sessions are disabled, * and with it what reads the store: ShareErrorsFromSession would throw diff --git a/core/src/Core.php b/core/src/Core.php index 6d435a8fc6..efd40adf6c 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -287,6 +287,9 @@ public function initialize() { if (defined('EVO_SESSION') && EVO_SESSION) { \EvoSessionProxy::init(); + } elseif (class_exists('EvoSessionProxy', false) && \EvoSessionProxy::startsAfterBoot() + && !is_cli() && session_status() === PHP_SESSION_NONE && !\EvoSessionProxy::disabled()) { + startCMSSession(); } if ($this->isLoggedIn('mgr')) { diff --git a/core/src/Providers/TracyServiceProvider.php b/core/src/Providers/TracyServiceProvider.php index d318122ab0..755aca991a 100644 --- a/core/src/Providers/TracyServiceProvider.php +++ b/core/src/Providers/TracyServiceProvider.php @@ -6,7 +6,8 @@ use EvolutionCMS\Interfaces\TracyPanel; use Tracy\IBarPanel; -if (session_status() == PHP_SESSION_NONE && (!defined('EVO_SESSION') || !EVO_SESSION) && !(class_exists('EvoSessionProxy', false) && \EvoSessionProxy::disabled())) { +if (session_status() == PHP_SESSION_NONE && (!defined('EVO_SESSION') || !EVO_SESSION) && !(class_exists('EvoSessionProxy', false) + && (\EvoSessionProxy::disabled() || \EvoSessionProxy::startsAfterBoot()))) { session_start(); } diff --git a/core/tests/Mocks/worker_runtime_probe.php b/core/tests/Mocks/worker_runtime_probe.php index 15d46bdae6..45c7e6461d 100644 --- a/core/tests/Mocks/worker_runtime_probe.php +++ b/core/tests/Mocks/worker_runtime_probe.php @@ -24,6 +24,14 @@ } echo json_encode($results); } +if ($mode === 'session-start') { + if ($argv[2] === 'worker') { + define('EVO_WORKER_MODE', true); + } + define('EVO_SESSION', $argv[3] === 'proxy'); + require dirname(__DIR__, 2) . '/functions/session_proxy.php'; + echo json_encode(EvoSessionProxy::startsAfterBoot()); +} if ($mode === 'classic-string' || $mode === 'classic-code') { evo_register_shutdown(static fn () => print('|shutdown')); evo_exit($mode === 'classic-string' ? 'body' : 7); diff --git a/core/tests/Unit/WorkerRuntimeTest.php b/core/tests/Unit/WorkerRuntimeTest.php index 6cd2c59b61..126e339f3f 100644 --- a/core/tests/Unit/WorkerRuntimeTest.php +++ b/core/tests/Unit/WorkerRuntimeTest.php @@ -190,6 +190,24 @@ class_alias(EvolutionCMS\Facades\UrlProcessor::class, 'UrlProcessor'); expect($code)->toBe(0)->and(json_decode($output, true))->toBe([$state, $state]); }); +test('only a worker defers the native session until the providers have booted', function (string $runtime, string $session, bool $deferred) { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['session-start', $runtime, $session], $code); + expect($code)->toBe(0)->and(json_decode($output, true))->toBe($deferred); +})->with([ + 'worker, native session' => ['worker', 'native', true], + 'worker, session proxy' => ['worker', 'proxy', false], + 'classic, native session' => ['classic', 'native', false], + 'classic, session proxy' => ['classic', 'proxy', false], +]); + +test('bootstrap, Tracy and Core start the native session on the same condition', function () { + $core = dirname(__DIR__, 2); + expect(file_get_contents($core . '/bootstrap.php'))->toContain('!EvoSessionProxy::startsAfterBoot()') + ->and(file_get_contents($core . '/src/Providers/TracyServiceProvider.php'))->toContain('\EvoSessionProxy::startsAfterBoot()') + ->and(file_get_contents($core . '/src/Core.php')) + ->toMatch('~\\\\EvoSessionProxy::startsAfterBoot\(\)[^}]*startCMSSession\(\);~s'); +}); + test('the snapshot resets nested service state and calls the reset hooks of kept services', function () { $app = (new ReflectionClass(Core::class))->newInstanceWithoutConstructor(); $finder = new Illuminate\View\FileViewFinder(new Illuminate\Filesystem\Filesystem(), []); From d4aed69f82445ffe7202b0ee850cdb59780cdeac Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 31 Oct 2026 13:45:42 +0100 Subject: [PATCH 3/4] add(core): FrankenPHP worker mode --- .github/docker/ci/worker-routes.php | 39 ++++++++++++ .github/docker/ci/worker-smoke.py | 12 +++- core/includes/worker.runtime.php | 43 ++++++++++++- core/tests/Mocks/worker_runtime_probe.php | 74 +++++++++++++++++++++++ core/tests/Unit/WorkerRuntimeTest.php | 14 +++++ 5 files changed, 179 insertions(+), 3 deletions(-) diff --git a/.github/docker/ci/worker-routes.php b/.github/docker/ci/worker-routes.php index 6e38c8482c..608b6c4f0a 100644 --- a/.github/docker/ci/worker-routes.php +++ b/.github/docker/ci/worker-routes.php @@ -3,6 +3,39 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Route; +// The routes file runs again for every fresh container; a class is declared only once per worker. +if (!class_exists('WorkerProbeController', false)) { + final class WorkerProbeController + { + private ?int $user; + + public function __construct(private Illuminate\Http\Request $request) + { + $this->user = app('AuthServices')->id(); + } + + public function __invoke() + { + return response()->json(['value' => $this->request->query('value'), 'user' => $this->user]); + } + } + + // First instantiated by a request, after a kept application took its snapshot. + final class WorkerProbeSetting extends EvolutionCMS\Models\SystemSetting + { + public static array $events = []; + + protected static function booted(): void + { + foreach (['saving', 'creating'] as $event) { + static::registerModelEvent($event, static function () use ($event) { + self::$events[] = $event; + }); + } + } + } +} + Route::get('/__worker/state', static function () { $_SESSION['worker_counter'] = ($_SESSION['worker_counter'] ?? 0) + 1; return response()->json([ @@ -76,3 +109,9 @@ evo_register_shutdown(static fn () => file_put_contents('/tmp/evo-worker-native-cleanup', 'done')); exit('legacy response'); }); +Route::get('/__worker/controller', WorkerProbeController::class); +Route::get('/__worker/model', static function () { + WorkerProbeSetting::$events = []; + WorkerProbeSetting::create(['setting_name' => 'worker_model', 'setting_value' => '1'])->delete(); + return response()->json(WorkerProbeSetting::$events); +}); diff --git a/.github/docker/ci/worker-smoke.py b/.github/docker/ci/worker-smoke.py index 88f4fa3c35..d79e9953f3 100644 --- a/.github/docker/ci/worker-smoke.py +++ b/.github/docker/ci/worker-smoke.py @@ -74,6 +74,16 @@ def get(path, opener=None, expected=200, headers=None): assert guest['counter'] == 1 and guest['user'] is None, guest assert headers['X-Evo-Worker'] == worker assert json.loads(get('/__worker/state', alice)[0])['user'] == 1 +# A route controller is built for each request, with that request and user, never one kept from an earlier one. +for opener, value, user in ((alice, 'alice', 1), (bob, 'bob', None), (alice, 'alice-again', 1)): + body, headers = get('/__worker/controller?value=' + value, opener) + assert json.loads(body) == {'value': value, 'user': user}, (value, body) + assert headers['X-Evo-Worker'] == worker +# A model first booted by a request keeps its listeners in the next one, and runs each of them once. +for _ in range(2): + body, headers = get('/__worker/model') + assert json.loads(body) == ['saving', 'creating'], body + assert headers['X-Evo-Worker'] == worker redirected, headers = get('/__worker/redirect', client()) assert json.loads(redirected)['redirected'] is True assert headers['X-Evo-Worker'] == worker @@ -140,5 +150,5 @@ def get(path, opener=None, expected=200, headers=None): state, headers = get('/__worker/state') assert json.loads(state)['setting'] == 'external', state assert headers['X-Evo-Worker'] == worker -print(('PASS (kept application): ' if keep_app else 'PASS: ') + 'persistent worker, session/auth isolation, request state reset, redirects, forwards, 404, cleanup, rollback, host guard, classic manager, errors, native exit, 100-request memory check' +print(('PASS (kept application): ' if keep_app else 'PASS: ') + 'persistent worker, session/auth isolation, request state reset, route controllers, late-booted model events, redirects, forwards, 404, cleanup, rollback, host guard, classic manager, errors, native exit, 100-request memory check' + (', alias redirect' if alias else '') + (', external settings change' if container else '')) diff --git a/core/includes/worker.runtime.php b/core/includes/worker.runtime.php index 3ce2e1091b..a7ea263de0 100644 --- a/core/includes/worker.runtime.php +++ b/core/includes/worker.runtime.php @@ -500,7 +500,7 @@ public static function releaseApplication(): void } if (class_exists(Model::class, false)) { Model::reguard(); - // A kept application keeps its booted models, observers and connection resolver. + // A kept application keeps the models booted before its snapshot; restore() forgets the later ones. if (!$kept) { Model::clearBootedModels(); Model::unsetConnectionResolver(); @@ -524,7 +524,8 @@ public static function releaseApplication(): void * The properties of the container, of Core and of every service resolved while providers booted return * to their post-boot values: whatever a request bound, resolved, registered or set on them is dropped. * Objects nested inside those services are shared by all requests; their owners reset them through - * flushState() (Laravel's and Livewire's convention) or Symfony's ResetInterface. + * flushState() (Laravel's and Livewire's convention) or Symfony's ResetInterface. The routes of the router + * forget their controllers, and Eloquent's static boot registry returns to its post-boot value. */ final class ApplicationSnapshot { @@ -562,6 +563,7 @@ public function __construct(private readonly Core $app) } } } + $this->captureModelBootState(); if (class_exists(\Illuminate\Support\Once::class)) { $this->resets[] = static fn () => \Illuminate\Support\Once::flush(); } @@ -602,6 +604,26 @@ private function capture(object $object): void } } + /** + * A model boots once per process and registers its listeners on the shared dispatcher, whose listeners + * restore() puts back: a model first booted by a request must boot again in the next one, and only such a model. + */ + private function captureModelBootState(): void + { + $state = []; + foreach (['booting', 'booted', 'bootedCallbacks', 'traitInitializers', 'globalScopes', 'classAttributes', 'dispatcher', 'resolver'] as $name) { + if (property_exists(Model::class, $name)) { + $property = new \ReflectionProperty(Model::class, $name); + $state[] = [$property, $property->getValue()]; + } + } + $this->resets[] = static function () use ($state): void { + foreach ($state as [$property, $value]) { + $property->setValue(null, $value); + } + }; + } + private function captureResets(object $service): void { if ($service instanceof \Symfony\Contracts\Service\ResetInterface) { @@ -609,6 +631,23 @@ private function captureResets(object $service): void } elseif (method_exists($service, 'flushState') && (new \ReflectionMethod($service, 'flushState'))->isPublic()) { $this->resets[] = $service->flushState(...); } + if ($service instanceof \Illuminate\Routing\Router) { + // Routes live in arrays of the route collection, out of reach of capture(); each one caches the + // controller it built, together with the request or user that controller took in its constructor. + $forget = \Closure::bind(static function (\Illuminate\Routing\Route $route): void { + $route->flushController(); + $route->parameters = null; + $route->originalParameters = null; + }, null, \Illuminate\Routing\Route::class); + $this->resets[] = static function () use ($service, $forget): void { + $routes = $service->getRoutes(); + if ($routes instanceof \Illuminate\Routing\RouteCollection) { + foreach ($routes->getRoutes() as $route) { + $forget($route); + } + } + }; + } if ($service instanceof \Illuminate\Log\LogManager) { // Context set on a channel during a request lives in the Logger object, not in the manager. $this->resets[] = static function () use ($service): void { diff --git a/core/tests/Mocks/worker_runtime_probe.php b/core/tests/Mocks/worker_runtime_probe.php index 45c7e6461d..5ce62ab5cd 100644 --- a/core/tests/Mocks/worker_runtime_probe.php +++ b/core/tests/Mocks/worker_runtime_probe.php @@ -80,6 +80,80 @@ public function initialize() echo json_encode($results); } +if ($mode === 'keep-app-controller' || $mode === 'keep-app-model') { + define('EVO_CLASS', 'KeepProbeCore'); + class KeepProbeCore extends EvolutionCMS\Core + { + public function initialize() {} + } + final class KeepProbeController + { + public static int $instances = 0; + public function __construct(private Illuminate\Http\Request $request) + { + self::$instances++; + } + public function __invoke(): string + { + return $this->request->query('user') . ':' . $this->request->route('page'); + } + } + final class KeepProbeEvents + { + public static array $fired = []; + } + abstract class KeepProbeModel extends Illuminate\Database\Eloquent\Model + { + protected static function booted(): void + { + foreach (['creating', 'saving'] as $event) { + static::registerModelEvent($event, static fn () => KeepProbeEvents::$fired[] = static::class . '.' . $event); + } + static::addGlobalScope('probe', static fn () => null); + } + public function fire(string $event): void + { + $this->fireModelEvent($event, false); + } + } + final class KeepProbeBootModel extends KeepProbeModel {} + final class KeepProbeLateModel extends KeepProbeModel {} + + WorkerRuntime::keepApplications(true); + $boot = (new ReflectionClass(KeepProbeCore::class))->newInstanceWithoutConstructor(); + $boot->instance('events', new Illuminate\Events\Dispatcher($boot)); + $router = new Illuminate\Routing\Router($boot['events'], $boot); + $router->get('/page/{page}', KeepProbeController::class); + $boot->instance('router', $router); + Illuminate\Database\Eloquent\Model::setEventDispatcher($boot['events']); + new KeepProbeBootModel(); + WorkerRuntime::begin(); + WorkerRuntime::captureApplication($boot); + $results = []; + foreach (['alice', 'bob'] as $i => $user) { + if ($i > 0) { + WorkerRuntime::begin(); + } + $app = KeepProbeCore::getInstance(); + if ($mode === 'keep-app-controller') { + $request = Illuminate\Http\Request::create('/page/' . ($i + 1) . '?user=' . $user); + $app->instance('request', $request); + $app->alias('request', Illuminate\Http\Request::class); + $results[] = [$app['router']->dispatch($request)->getContent(), KeepProbeController::$instances]; + } else { + KeepProbeEvents::$fired = []; + foreach ([new KeepProbeBootModel(), new KeepProbeLateModel()] as $model) { + $model->fire('saving'); + $model->fire('creating'); + } + $results[] = [KeepProbeEvents::$fired, array_keys((new KeepProbeLateModel())->getGlobalScopes())]; + } + WorkerRuntime::terminate(); + WorkerRuntime::releaseApplication(); + } + echo json_encode($results); +} + if ($mode === 'session') { define('IN_MANAGER_MODE', false); define('IN_INSTALL_MODE', false); diff --git a/core/tests/Unit/WorkerRuntimeTest.php b/core/tests/Unit/WorkerRuntimeTest.php index 126e339f3f..08a86e5ca5 100644 --- a/core/tests/Unit/WorkerRuntimeTest.php +++ b/core/tests/Unit/WorkerRuntimeTest.php @@ -190,6 +190,20 @@ class_alias(EvolutionCMS\Facades\UrlProcessor::class, 'UrlProcessor'); expect($code)->toBe(0)->and(json_decode($output, true))->toBe([$state, $state]); }); +test('a kept application builds route controllers again, with the request of their own request', function () { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['keep-app-controller'], $code); + expect($code)->toBe(0, $output)->and(json_decode($output, true))->toBe([['alice:1', 1], ['bob:2', 2]]); +}); + +test('models first booted by a kept-application request boot again and fire their listeners once', function () { + $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['keep-app-model'], $code); + $request = [[ + 'KeepProbeBootModel.saving', 'KeepProbeBootModel.creating', + 'KeepProbeLateModel.saving', 'KeepProbeLateModel.creating', + ], ['probe']]; + expect($code)->toBe(0, $output)->and(json_decode($output, true))->toBe([$request, $request]); +}); + test('only a worker defers the native session until the providers have booted', function (string $runtime, string $session, bool $deferred) { $output = evoRunPhp(dirname(__DIR__) . '/Mocks/worker_runtime_probe.php', ['session-start', $runtime, $session], $code); expect($code)->toBe(0)->and(json_decode($output, true))->toBe($deferred); From 32e4d450b87e106a90ac2308ed1f15e8a5bd59b8 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 31 Oct 2026 16:20:09 +0100 Subject: [PATCH 4/4] add(core): FrankenPHP worker mode --- .github/docker/WORKER.md | 4 ++ core/lang/az/global.php | 8 +++ core/lang/be/global.php | 8 +++ core/lang/bg/global.php | 8 +++ core/lang/cs/global.php | 8 +++ core/lang/da/global.php | 8 +++ core/lang/de/global.php | 8 +++ core/lang/en/global.php | 8 +++ core/lang/es/global.php | 8 +++ core/lang/et/global.php | 8 +++ core/lang/fa/global.php | 8 +++ core/lang/fi/global.php | 8 +++ core/lang/fr/global.php | 8 +++ core/lang/he/global.php | 8 +++ core/lang/hy/global.php | 8 +++ core/lang/it/global.php | 8 +++ core/lang/ja/global.php | 8 +++ core/lang/ka/global.php | 8 +++ core/lang/kk/global.php | 8 +++ core/lang/ky/global.php | 8 +++ core/lang/lt/global.php | 8 +++ core/lang/lv/global.php | 8 +++ core/lang/nl/global.php | 8 +++ core/lang/nn/global.php | 8 +++ core/lang/pl/global.php | 8 +++ core/lang/pt/global.php | 8 +++ core/lang/ro/global.php | 8 +++ core/lang/sk/global.php | 8 +++ core/lang/sv/global.php | 8 +++ core/lang/tg/global.php | 8 +++ core/lang/tk/global.php | 8 +++ core/lang/uk/global.php | 8 +++ core/lang/uz/global.php | 8 +++ core/lang/zh/global.php | 8 +++ core/src/Controllers/SystemInfo.php | 53 +++++++++++++++++++ .../Unit/Manager/SystemInfoActionsTest.php | 46 ++++++++++++++++ 36 files changed, 367 insertions(+) diff --git a/.github/docker/WORKER.md b/.github/docker/WORKER.md index c3a5bc40f5..63122a474d 100644 --- a/.github/docker/WORKER.md +++ b/.github/docker/WORKER.md @@ -67,6 +67,10 @@ PHP entry points remain classic. `EVO_WORKER_MODE=1` alone does not activate the runtime under Apache, FPM, CLI or a classic FrankenPHP request. To disable workers, remove the setting and restart the container. +**System info** in the manager has a *Worker mode* row: unavailable outside FrankenPHP, +available but off, or the configured lifetime and recycle limit. The manager runs +classic, so the row reflects the environment the workers start with, not a live worker. + Settings: | Environment variable | Default | Meaning | diff --git a/core/lang/az/global.php b/core/lang/az/global.php index 32ce5314a8..8de3c0d0f5 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -680,6 +680,14 @@ $_lang["opcache_reset_unavailable"] = 'sıfırlama mümkün deyil'; $_lang["opcache_reset_complete"] = 'sıfırlama tamamlandı'; $_lang["opcache_reset_failed"] = 'sıfırlama alınmadı'; +$_lang["worker_mode"] = 'Worker rejimi'; +$_lang["sysinfo_worker_unavailable"] = 'FrankenPHP tələb olunur'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 FrankenPHP xaricində təsir etmir'; +$_lang["sysinfo_worker_available"] = 'mövcuddur: EVO_WORKER_MODE=1 və EVO_WORKER_ORIGIN təyin edin, sonra serveri yenidən başladın'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN təyin edilməyib, worker-lər başlamayacaq'; +$_lang["sysinfo_worker_new_app"] = 'hər sorğu üçün yeni tətbiq'; +$_lang["sysinfo_worker_kept_app"] = 'saxlanılan tətbiq, eksperimental'; +$_lang["sysinfo_worker_recycle"] = '%s sorğudan sonra yenidən başladılır'; $_lang["database_name"] = 'Verilənlər bazasının adı'; $_lang["database_overhead"] = 'Qeyd: overhead MySQL tərəfindən ayrılmış, lakin istifadə olunmayan boş sahədir. Bu sahəni azad etmək üçün müvafiq cədvəlin overhead dəyərinə klikləyin.'; $_lang["database_server"] = 'Verilənlər bazası serveri'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index 159eb3f171..2ac2aa2046 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -668,6 +668,14 @@ $_lang["opcache_reset_unavailable"] = 'скід недаступны'; $_lang["opcache_reset_complete"] = 'скід завершаны'; $_lang["opcache_reset_failed"] = 'скід не атрымаўся'; +$_lang["worker_mode"] = 'Рэжым worker'; +$_lang["sysinfo_worker_unavailable"] = 'патрабуецца FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 не дзейнічае па-за FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'даступна: задайце EVO_WORKER_MODE=1 і EVO_WORKER_ORIGIN, затым перазапусціце сервер'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN не зададзены, worker-працэсы не запусцяцца'; +$_lang["sysinfo_worker_new_app"] = 'новае прыкладанне на кожны запыт'; +$_lang["sysinfo_worker_kept_app"] = 'захаванае прыкладанне, эксперыментальна'; +$_lang["sysinfo_worker_recycle"] = 'перазапускаецца пасля %s запытаў'; $_lang["database_name"] = 'Імя базы дадзеных'; $_lang["database_overhead"] = 'Заўвага: лішняя прастора — гэта невыкарыстанае месца, зарэзерваванае MySQL. Каб вызваліць яго, націсніце значэнне лішняй прасторы для адпаведнай табліцы.'; $_lang["database_server"] = 'Сервер базы дадзеных'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 4d526f5427..cf30f5bf05 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -207,6 +207,14 @@ $_lang["opcache_reset_unavailable"] = 'нулирането не е достъпно'; $_lang["opcache_reset_complete"] = 'нулирането е завършено'; $_lang["opcache_reset_failed"] = 'нулирането е неуспешно'; +$_lang["worker_mode"] = 'Режим worker'; +$_lang["sysinfo_worker_unavailable"] = 'изисква FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 няма ефект извън FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'налично: задайте EVO_WORKER_MODE=1 и EVO_WORKER_ORIGIN, след това рестартирайте сървъра'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN не е зададен, worker процесите няма да стартират'; +$_lang["sysinfo_worker_new_app"] = 'ново приложение за всяка заявка'; +$_lang["sysinfo_worker_kept_app"] = 'запазено приложение, експериментално'; +$_lang["sysinfo_worker_recycle"] = 'рестартира се след %s заявки'; $_lang["database_name"] = 'Име на базата от данни'; $_lang["database_overhead"] = 'Забележка: излишното пространство е неизползвано място, резервирано от MySQL. За да го освободите, щракнете върху стойността на излишното пространство за съответната таблица.'; $_lang["database_server"] = 'Сървъра, където е базата от данни'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 976b7055b5..b797c300e7 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -207,6 +207,14 @@ $_lang["opcache_reset_unavailable"] = 'reset není dostupný'; $_lang["opcache_reset_complete"] = 'reset dokončen'; $_lang["opcache_reset_failed"] = 'reset selhal'; +$_lang["worker_mode"] = 'Režim workeru'; +$_lang["sysinfo_worker_unavailable"] = 'vyžaduje FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 nemá mimo FrankenPHP žádný účinek'; +$_lang["sysinfo_worker_available"] = 'k dispozici: nastavte EVO_WORKER_MODE=1 a EVO_WORKER_ORIGIN, poté restartujte server'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN není nastaveno, workery se nespustí'; +$_lang["sysinfo_worker_new_app"] = 'nová aplikace pro každý požadavek'; +$_lang["sysinfo_worker_kept_app"] = 'zachovaná aplikace, experimentální'; +$_lang["sysinfo_worker_recycle"] = 'restartován po %s požadavcích'; $_lang["database_name"] = 'Jméno databáze'; $_lang["database_overhead"] = 'Poznámka: Režie je nevyužité místo rezervované MySQL. Chcete-li ho uvolnit, klikněte na hodnotu režie u příslušné tabulky.'; $_lang["database_server"] = 'Databázový server'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 0ebf91ba09..9c24632a2d 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -208,6 +208,14 @@ $_lang["opcache_reset_unavailable"] = 'nulstilling ikke tilgængelig'; $_lang["opcache_reset_complete"] = 'nulstilling fuldført'; $_lang["opcache_reset_failed"] = 'nulstilling mislykkedes'; +$_lang["worker_mode"] = 'Worker-tilstand'; +$_lang["sysinfo_worker_unavailable"] = 'kræver FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 har ingen effekt uden for FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'tilgængelig: sæt EVO_WORKER_MODE=1 og EVO_WORKER_ORIGIN, og genstart derefter serveren'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN er ikke sat, workers starter ikke'; +$_lang["sysinfo_worker_new_app"] = 'ny applikation pr. forespørgsel'; +$_lang["sysinfo_worker_kept_app"] = 'bevaret applikation, eksperimentel'; +$_lang["sysinfo_worker_recycle"] = 'genstartes efter %s forespørgsler'; $_lang["database_name"] = 'Database navn'; $_lang["database_overhead"] = 'Bemærk: overhead er ubrugt plads reserveret af MySQL. Klik på overheadværdien for den relevante tabel for at frigøre pladsen.'; $_lang["database_server"] = 'Database server'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index dd0ee243ad..2964d9c479 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -219,6 +219,14 @@ $_lang["opcache_reset_unavailable"] = 'Zurücksetzen nicht verfügbar'; $_lang["opcache_reset_complete"] = 'Zurücksetzen abgeschlossen'; $_lang["opcache_reset_failed"] = 'Zurücksetzen fehlgeschlagen'; +$_lang["worker_mode"] = 'Worker-Modus'; +$_lang["sysinfo_worker_unavailable"] = 'erfordert FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 hat außerhalb von FrankenPHP keine Wirkung'; +$_lang["sysinfo_worker_available"] = 'verfügbar: EVO_WORKER_MODE=1 und EVO_WORKER_ORIGIN setzen, dann den Server neu starten'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN ist nicht gesetzt, Worker werden nicht gestartet'; +$_lang["sysinfo_worker_new_app"] = 'neue Anwendung pro Anfrage'; +$_lang["sysinfo_worker_kept_app"] = 'beibehaltene Anwendung, experimentell'; +$_lang["sysinfo_worker_recycle"] = 'nach %s Anfragen neu gestartet'; $_lang["database_name"] = 'Datenbank-Name'; $_lang["database_overhead"] = 'Hinweis: Overhead ist ungenutzter Speicherplatz, der von MySQL reserviert wurde. Um ihn freizugeben, klicken Sie auf den Overhead-Wert der entsprechenden Tabelle.'; $_lang["database_server"] = 'Datenbank-Server'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 237b8036ab..c4295e1281 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Copy failed'; $_lang['sysinfo_opcache_hit_rate'] = '%s hits'; $_lang['sysinfo_opcache_memory'] = '%s used / %s total'; +$_lang['worker_mode'] = 'Worker mode'; +$_lang['sysinfo_worker_unavailable'] = 'requires FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 has no effect outside FrankenPHP'; +$_lang['sysinfo_worker_available'] = 'available: set EVO_WORKER_MODE=1 and EVO_WORKER_ORIGIN, then restart the server'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN is not set, workers will not start'; +$_lang['sysinfo_worker_new_app'] = 'new application per request'; +$_lang['sysinfo_worker_kept_app'] = 'kept application, experimental'; +$_lang['sysinfo_worker_recycle'] = 'recycled after %s requests'; $_lang["database_name"] = 'Database name'; $_lang["database_overhead"] = 'Note: overhead is unused space reserved by MySQL. To free it, click the overhead value for the corresponding table.'; $_lang["database_server"] = 'Database server'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index cbe68a35a0..58210f0b5e 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -219,6 +219,14 @@ $_lang["opcache_reset_unavailable"] = 'restablecimiento no disponible'; $_lang["opcache_reset_complete"] = 'restablecimiento completado'; $_lang["opcache_reset_failed"] = 'restablecimiento fallido'; +$_lang["worker_mode"] = 'Modo worker'; +$_lang["sysinfo_worker_unavailable"] = 'requiere FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 no tiene efecto fuera de FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'disponible: establezca EVO_WORKER_MODE=1 y EVO_WORKER_ORIGIN y reinicie el servidor'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN no está definido, los workers no se iniciarán'; +$_lang["sysinfo_worker_new_app"] = 'nueva aplicación por petición'; +$_lang["sysinfo_worker_kept_app"] = 'aplicación conservada, experimental'; +$_lang["sysinfo_worker_recycle"] = 'reciclado tras %s peticiones'; $_lang["database_name"] = 'Nombre de la base de datos'; $_lang["database_overhead"] = 'Nota: la sobrecarga es espacio no utilizado reservado por MySQL. Para liberarlo, haz clic en el valor de sobrecarga de la tabla correspondiente.'; $_lang["database_server"] = 'Servidor de BD'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 02aad6f3d7..4d62442115 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Kopeerimine ebaõnnestus'; $_lang['sysinfo_opcache_hit_rate'] = '%s tabamust'; $_lang['sysinfo_opcache_memory'] = '%s kasutatud / kokku %s'; +$_lang['worker_mode'] = 'Workeri režiim'; +$_lang['sysinfo_worker_unavailable'] = 'nõuab FrankenPHP-d'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 ei mõju väljaspool FrankenPHP-d'; +$_lang['sysinfo_worker_available'] = 'saadaval: määrake EVO_WORKER_MODE=1 ja EVO_WORKER_ORIGIN, seejärel taaskäivitage server'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN pole määratud, workerid ei käivitu'; +$_lang['sysinfo_worker_new_app'] = 'uus rakendus iga päringu jaoks'; +$_lang['sysinfo_worker_kept_app'] = 'säilitatud rakendus, eksperimentaalne'; +$_lang['sysinfo_worker_recycle'] = 'taaskäivitatakse pärast %s päringut'; $_lang["database_name"] = 'Andmebaasi nimi'; $_lang["database_overhead"] = 'Märkus: lisaruum on MySQL-i reserveeritud kasutamata ruum. Selle vabastamiseks klõpsa vastava tabeli lisaruumi väärtusel.'; $_lang["database_server"] = 'Andmebaasiserver'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index dadca76c12..6df87e1347 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -208,6 +208,14 @@ $_lang["opcache_reset_unavailable"] = 'بازنشانی در دسترس نیست'; $_lang["opcache_reset_complete"] = 'بازنشانی کامل شد'; $_lang["opcache_reset_failed"] = 'بازنشانی ناموفق بود'; +$_lang["worker_mode"] = 'حالت Worker'; +$_lang["sysinfo_worker_unavailable"] = 'نیازمند FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 خارج از FrankenPHP تأثیری ندارد'; +$_lang["sysinfo_worker_available"] = 'در دسترس: EVO_WORKER_MODE=1 و EVO_WORKER_ORIGIN را تنظیم کنید، سپس سرور را دوباره راه‌اندازی کنید'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN تنظیم نشده است، workerها اجرا نخواهند شد'; +$_lang["sysinfo_worker_new_app"] = 'برنامه جدید برای هر درخواست'; +$_lang["sysinfo_worker_kept_app"] = 'برنامه نگه‌داشته‌شده، آزمایشی'; +$_lang["sysinfo_worker_recycle"] = 'پس از %s درخواست دوباره راه‌اندازی می‌شود'; $_lang["database_name"] = 'نام دیتابیس'; $_lang["database_overhead"] = 'تذکر: سربار فضای استفاده‌نشده‌ای است که توسط MySQL رزرو شده است. برای آزاد کردن آن، روی مقدار سربار جدول مربوطه کلیک کنید.'; $_lang["database_server"] = 'سرور دیتابیس'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index 3e22f21b43..746d819611 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -207,6 +207,14 @@ $_lang["opcache_reset_unavailable"] = 'nollaus ei ole käytettävissä'; $_lang["opcache_reset_complete"] = 'nollaus valmis'; $_lang["opcache_reset_failed"] = 'nollaus epäonnistui'; +$_lang["worker_mode"] = 'Worker-tila'; +$_lang["sysinfo_worker_unavailable"] = 'vaatii FrankenPHP:n'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 ei vaikuta FrankenPHP:n ulkopuolella'; +$_lang["sysinfo_worker_available"] = 'saatavilla: aseta EVO_WORKER_MODE=1 ja EVO_WORKER_ORIGIN ja käynnistä palvelin uudelleen'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN ei ole asetettu, workerit eivät käynnisty'; +$_lang["sysinfo_worker_new_app"] = 'uusi sovellus jokaiselle pyynnölle'; +$_lang["sysinfo_worker_kept_app"] = 'säilytetty sovellus, kokeellinen'; +$_lang["sysinfo_worker_recycle"] = 'käynnistetään uudelleen %s pyynnön jälkeen'; $_lang["database_name"] = 'Tietokannan nimi'; $_lang["database_overhead"] = 'Huomaa: hukkatila on MySQL:n varaamaa käyttämätöntä tilaa. Vapauta tila napsauttamalla kyseisen taulun hukkatilan arvoa.'; $_lang["database_server"] = 'Tietokantapalvelin'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 20e6b30996..c3b0954f70 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -210,6 +210,14 @@ $_lang["opcache_reset_unavailable"] = 'réinitialisation indisponible'; $_lang["opcache_reset_complete"] = 'réinitialisation terminée'; $_lang["opcache_reset_failed"] = 'échec de la réinitialisation'; +$_lang["worker_mode"] = 'Mode worker'; +$_lang["sysinfo_worker_unavailable"] = 'nécessite FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 n\'a aucun effet hors de FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'disponible : définissez EVO_WORKER_MODE=1 et EVO_WORKER_ORIGIN, puis redémarrez le serveur'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN n\'est pas défini, les workers ne démarreront pas'; +$_lang["sysinfo_worker_new_app"] = 'nouvelle application par requête'; +$_lang["sysinfo_worker_kept_app"] = 'application conservée, expérimental'; +$_lang["sysinfo_worker_recycle"] = 'recyclé après %s requêtes'; $_lang["database_name"] = 'Nom de la base de données'; $_lang["database_overhead"] = 'Note : l\'espace excédentaire est un espace inutilisé réservé par MySQL. Pour le libérer, cliquez sur la valeur d\'espace excédentaire de la table concernée.'; $_lang["database_server"] = 'Serveur de la base de données'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index ddfc0f3d2e..eb7effc4fe 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -208,6 +208,14 @@ $_lang["opcache_reset_unavailable"] = 'איפוס לא זמין'; $_lang["opcache_reset_complete"] = 'האיפוס הושלם'; $_lang["opcache_reset_failed"] = 'האיפוס נכשל'; +$_lang["worker_mode"] = 'מצב Worker'; +$_lang["sysinfo_worker_unavailable"] = 'דורש FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'ל-EVO_WORKER_MODE=1 אין השפעה מחוץ ל-FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'זמין: הגדר EVO_WORKER_MODE=1 ו-EVO_WORKER_ORIGIN, ואז הפעל מחדש את השרת'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN אינו מוגדר, ה-workers לא יופעלו'; +$_lang["sysinfo_worker_new_app"] = 'יישום חדש לכל בקשה'; +$_lang["sysinfo_worker_kept_app"] = 'יישום נשמר, ניסיוני'; +$_lang["sysinfo_worker_recycle"] = 'מופעל מחדש לאחר %s בקשות'; $_lang["database_name"] = 'שם מסד הנתונים'; $_lang["database_overhead"] = 'הערה: תקורה היא שטח לא בשימוש ש-MySQL שמר. כדי לשחרר אותו, לחץ על ערך התקורה של הטבלה המתאימה.'; $_lang["database_server"] = 'שרת מסד הנתונים'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 824dfe1831..818b3c3dce 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Պատճենումը ձախողվեց'; $_lang['sysinfo_opcache_hit_rate'] = '%s համընկնում'; $_lang['sysinfo_opcache_memory'] = '%s օգտագործված / ընդամենը %s'; +$_lang['worker_mode'] = 'Worker ռեժիմ'; +$_lang['sysinfo_worker_unavailable'] = 'պահանջում է FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1-ը FrankenPHP-ից դուրս ազդեցություն չունի'; +$_lang['sysinfo_worker_available'] = 'հասանելի է՝ սահմանեք EVO_WORKER_MODE=1 և EVO_WORKER_ORIGIN, ապա վերագործարկեք սերվերը'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN-ը սահմանված չէ, worker-ները չեն գործարկվի'; +$_lang['sysinfo_worker_new_app'] = 'նոր հավելված յուրաքանչյուր հարցման համար'; +$_lang['sysinfo_worker_kept_app'] = 'պահպանված հավելված, փորձնական'; +$_lang['sysinfo_worker_recycle'] = 'վերագործարկվում է %s հարցումից հետո'; $_lang["database_name"] = 'Տվյալների բազայի անուն'; $_lang["database_overhead"] = 'Նշում. ավելցուկը MySQL-ի կողմից պահուստավորված չօգտագործվող տարածքն է։ Այն ազատելու համար սեղմեք համապատասխան աղյուսակի ավելցուկի արժեքը։'; $_lang["database_server"] = 'Տվյալների բազայի սերվեր'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 27fc4b9384..4a0ddc44f4 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -219,6 +219,14 @@ $_lang["opcache_reset_unavailable"] = 'reset non disponibile'; $_lang["opcache_reset_complete"] = 'reset completato'; $_lang["opcache_reset_failed"] = 'reset non riuscito'; +$_lang["worker_mode"] = 'Modalità worker'; +$_lang["sysinfo_worker_unavailable"] = 'richiede FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 non ha effetto al di fuori di FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'disponibile: imposta EVO_WORKER_MODE=1 e EVO_WORKER_ORIGIN, poi riavvia il server'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN non è impostato, i worker non verranno avviati'; +$_lang["sysinfo_worker_new_app"] = 'nuova applicazione per ogni richiesta'; +$_lang["sysinfo_worker_kept_app"] = 'applicazione mantenuta, sperimentale'; +$_lang["sysinfo_worker_recycle"] = 'riciclato dopo %s richieste'; $_lang["database_name"] = 'Nome del Database'; $_lang["database_overhead"] = 'Nota: l\'overhead è spazio inutilizzato riservato da MySQL. Per liberarlo, fai clic sul valore di overhead della tabella corrispondente.'; $_lang["database_server"] = 'Server del Database'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 520e2a3b75..35ed3cc131 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -227,6 +227,14 @@ $_lang["opcache_reset_unavailable"] = 'リセットは利用できません'; $_lang["opcache_reset_complete"] = 'リセットが完了しました'; $_lang["opcache_reset_failed"] = 'リセットに失敗しました'; +$_lang["worker_mode"] = 'ワーカーモード'; +$_lang["sysinfo_worker_unavailable"] = 'FrankenPHP が必要です'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 は FrankenPHP 以外では効果がありません'; +$_lang["sysinfo_worker_available"] = '利用可能: EVO_WORKER_MODE=1 と EVO_WORKER_ORIGIN を設定してからサーバーを再起動してください'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN が設定されていないため、ワーカーは起動しません'; +$_lang["sysinfo_worker_new_app"] = 'リクエストごとに新しいアプリケーション'; +$_lang["sysinfo_worker_kept_app"] = 'アプリケーションを保持、実験的'; +$_lang["sysinfo_worker_recycle"] = '%s リクエスト後に再起動'; $_lang["database_name"] = 'データベース名'; $_lang["database_overhead"] = '注: オーバーヘッドは MySQL が予約している未使用領域です。解放するには、対象テーブルのオーバーヘッド値をクリックしてください。'; $_lang["database_server"] = 'データベースサーバー'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index ebe23f6875..1e67f5eb1d 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'კოპირება ვერ შესრულდა'; $_lang['sysinfo_opcache_hit_rate'] = '%s დამთხვევა'; $_lang['sysinfo_opcache_memory'] = 'გამოყენებულია %s / სულ %s'; +$_lang['worker_mode'] = 'Worker რეჟიმი'; +$_lang['sysinfo_worker_unavailable'] = 'საჭიროებს FrankenPHP-ს'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 FrankenPHP-ის გარეთ არ მოქმედებს'; +$_lang['sysinfo_worker_available'] = 'ხელმისაწვდომია: დააყენეთ EVO_WORKER_MODE=1 და EVO_WORKER_ORIGIN, შემდეგ გადატვირთეთ სერვერი'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN არ არის დაყენებული, worker-ები არ გაეშვება'; +$_lang['sysinfo_worker_new_app'] = 'ახალი აპლიკაცია ყოველ მოთხოვნაზე'; +$_lang['sysinfo_worker_kept_app'] = 'შენახული აპლიკაცია, ექსპერიმენტული'; +$_lang['sysinfo_worker_recycle'] = 'გადაიტვირთება %s მოთხოვნის შემდეგ'; $_lang["database_name"] = 'მონაცემთა ბაზის სახელი'; $_lang["database_overhead"] = 'შენიშვნა: ჭარბი სივრცე არის MySQL-ის მიერ რეზერვირებული გამოუყენებელი ადგილი. მის გასათავისუფლებლად დააჭირეთ შესაბამისი ცხრილის ჭარბი სივრცის მნიშვნელობას.'; $_lang["database_server"] = 'მონაცემთა ბაზის სერვერი'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index 6245f7a8b7..81489782b1 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -251,6 +251,14 @@ $_lang['sysinfo_copy_failed'] = 'Көшіру сәтсіз'; $_lang['sysinfo_opcache_hit_rate'] = '%s сәйкестік'; $_lang['sysinfo_opcache_memory'] = '%s қолданылған / барлығы %s'; +$_lang['worker_mode'] = 'Worker режимі'; +$_lang['sysinfo_worker_unavailable'] = 'FrankenPHP қажет'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 FrankenPHP сыртында әсер етпейді'; +$_lang['sysinfo_worker_available'] = 'қолжетімді: EVO_WORKER_MODE=1 және EVO_WORKER_ORIGIN орнатып, серверді қайта іске қосыңыз'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN орнатылмаған, worker процестері іске қосылмайды'; +$_lang['sysinfo_worker_new_app'] = 'әр сұрауға жаңа қолданба'; +$_lang['sysinfo_worker_kept_app'] = 'сақталған қолданба, эксперименттік'; +$_lang['sysinfo_worker_recycle'] = '%s сұраудан кейін қайта іске қосылады'; $_lang["database_name"] = 'Дерекқор атауы'; $_lang["database_overhead"] = 'Ескерту: артық орын — MySQL резервтеген пайдаланылмайтын кеңістік. Оны босату үшін тиісті кестенің артық орын мәнін басыңыз.'; $_lang["database_server"] = 'Дерекқор сервері'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index fbbcc6b327..7529241c0c 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Көчүрүү ишке ашкан жок'; $_lang['sysinfo_opcache_hit_rate'] = '%s дал келүү'; $_lang['sysinfo_opcache_memory'] = '%s колдонулду / бардыгы %s'; +$_lang['worker_mode'] = 'Worker режими'; +$_lang['sysinfo_worker_unavailable'] = 'FrankenPHP талап кылынат'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 FrankenPHP сыртында таасир этпейт'; +$_lang['sysinfo_worker_available'] = 'жеткиликтүү: EVO_WORKER_MODE=1 жана EVO_WORKER_ORIGIN орнотуп, серверди кайра иштетиңиз'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN орнотулган эмес, worker процесстери иштебейт'; +$_lang['sysinfo_worker_new_app'] = 'ар бир суроо үчүн жаңы тиркеме'; +$_lang['sysinfo_worker_kept_app'] = 'сакталган тиркеме, эксперименталдык'; +$_lang['sysinfo_worker_recycle'] = '%s суроодон кийин кайра иштетилет'; $_lang["database_name"] = 'Маалымат базасынын аты'; $_lang["database_overhead"] = 'Эскертүү: ашыкча орун — MySQL ээлеп койгон колдонулбаган орун. Бошотуу үчүн тиешелүү таблицанын ашыкча орун маанисин басыңыз.'; $_lang["database_server"] = 'Маалымат базасынын сервери'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index aece009f6d..4ea5c411c8 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Nukopijuoti nepavyko'; $_lang['sysinfo_opcache_hit_rate'] = '%s pataikymų'; $_lang['sysinfo_opcache_memory'] = '%s naudojama / iš viso %s'; +$_lang['worker_mode'] = 'Worker režimas'; +$_lang['sysinfo_worker_unavailable'] = 'reikia FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 neturi poveikio ne FrankenPHP aplinkoje'; +$_lang['sysinfo_worker_available'] = 'prieinama: nustatykite EVO_WORKER_MODE=1 ir EVO_WORKER_ORIGIN, tada paleiskite serverį iš naujo'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN nenustatytas, workeriai nebus paleisti'; +$_lang['sysinfo_worker_new_app'] = 'nauja programa kiekvienai užklausai'; +$_lang['sysinfo_worker_kept_app'] = 'išlaikoma programa, eksperimentinė'; +$_lang['sysinfo_worker_recycle'] = 'paleidžiamas iš naujo po %s užklausų'; $_lang["database_name"] = 'Duomenų bazės pavadinimas'; $_lang["database_overhead"] = 'Pastaba: perteklinė vieta yra MySQL rezervuota nenaudojama vieta. Norėdami ją atlaisvinti, spustelėkite atitinkamos lentelės perteklinės vietos reikšmę.'; $_lang["database_server"] = 'Duomenų bazės serveris'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index c48a0013ff..1c408f8ece 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Kopēšana neizdevās'; $_lang['sysinfo_opcache_hit_rate'] = '%s trāpījumi'; $_lang['sysinfo_opcache_memory'] = '%s izmantoti / kopā %s'; +$_lang['worker_mode'] = 'Worker režīms'; +$_lang['sysinfo_worker_unavailable'] = 'nepieciešams FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 nedarbojas ārpus FrankenPHP'; +$_lang['sysinfo_worker_available'] = 'pieejams: iestatiet EVO_WORKER_MODE=1 un EVO_WORKER_ORIGIN, pēc tam restartējiet serveri'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN nav iestatīts, workeri netiks palaisti'; +$_lang['sysinfo_worker_new_app'] = 'jauna lietotne katram pieprasījumam'; +$_lang['sysinfo_worker_kept_app'] = 'saglabāta lietotne, eksperimentāli'; +$_lang['sysinfo_worker_recycle'] = 'restartēts pēc %s pieprasījumiem'; $_lang["database_name"] = 'Datubāzes nosaukums'; $_lang["database_overhead"] = 'Piezīme: liekā vieta ir MySQL rezervēta, neizmantota vieta. Lai to atbrīvotu, nospiediet attiecīgās tabulas liekās vietas vērtību.'; $_lang["database_server"] = 'Datubāzes serveris'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 3284a25af2..64ff363d3a 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -219,6 +219,14 @@ $_lang["opcache_reset_unavailable"] = 'reset niet beschikbaar'; $_lang["opcache_reset_complete"] = 'reset voltooid'; $_lang["opcache_reset_failed"] = 'reset mislukt'; +$_lang["worker_mode"] = 'Worker-modus'; +$_lang["sysinfo_worker_unavailable"] = 'vereist FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 heeft geen effect buiten FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'beschikbaar: stel EVO_WORKER_MODE=1 en EVO_WORKER_ORIGIN in en herstart daarna de server'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN is niet ingesteld, workers starten niet'; +$_lang["sysinfo_worker_new_app"] = 'nieuwe applicatie per verzoek'; +$_lang["sysinfo_worker_kept_app"] = 'behouden applicatie, experimenteel'; +$_lang["sysinfo_worker_recycle"] = 'herstart na %s verzoeken'; $_lang["database_name"] = 'Database naam'; $_lang["database_overhead"] = 'Opmerking: overhead is ongebruikte ruimte die door MySQL is gereserveerd. Klik op de overheadwaarde van de betreffende tabel om deze ruimte vrij te maken.'; $_lang["database_server"] = 'Database server'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 9053e916b7..eddf84a93a 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -207,6 +207,14 @@ $_lang["opcache_reset_unavailable"] = 'nullstilling ikkje tilgjengeleg'; $_lang["opcache_reset_complete"] = 'nullstilling fullført'; $_lang["opcache_reset_failed"] = 'nullstilling mislukkast'; +$_lang["worker_mode"] = 'Worker-modus'; +$_lang["sysinfo_worker_unavailable"] = 'krev FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 har ingen verknad utanfor FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'tilgjengeleg: set EVO_WORKER_MODE=1 og EVO_WORKER_ORIGIN, og start så tenaren på nytt'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN er ikkje sett, workerar vil ikkje starte'; +$_lang["sysinfo_worker_new_app"] = 'ny applikasjon per førespurnad'; +$_lang["sysinfo_worker_kept_app"] = 'bevart applikasjon, eksperimentell'; +$_lang["sysinfo_worker_recycle"] = 'starta på nytt etter %s førespurnader'; $_lang["database_name"] = 'Database name'; $_lang["database_overhead"] = 'Merk: overhead er ubrukt plass reservert av MySQL. Klikk på overheadverdien for den aktuelle tabellen for å frigjere plassen.'; $_lang["database_server"] = 'Database server'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index 766206f1d8..0b398ed9c4 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -220,6 +220,14 @@ $_lang["opcache_reset_unavailable"] = 'reset niedostępny'; $_lang["opcache_reset_complete"] = 'reset zakończony'; $_lang["opcache_reset_failed"] = 'reset nieudany'; +$_lang["worker_mode"] = 'Tryb workera'; +$_lang["sysinfo_worker_unavailable"] = 'wymaga FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 nie działa poza FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'dostępny: ustaw EVO_WORKER_MODE=1 i EVO_WORKER_ORIGIN, a następnie uruchom ponownie serwer'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN nie jest ustawiony, workery nie zostaną uruchomione'; +$_lang["sysinfo_worker_new_app"] = 'nowa aplikacja na każde żądanie'; +$_lang["sysinfo_worker_kept_app"] = 'zachowana aplikacja, eksperymentalne'; +$_lang["sysinfo_worker_recycle"] = 'restartowany po %s żądaniach'; $_lang["database_name"] = 'Baza danych - nazwa bazy'; $_lang["database_overhead"] = 'Wyjaśnienie: nadmiar to nieużywane miejsce zarezerwowane przez MySQL. Aby je zwolnić, kliknij wartość nadmiaru dla odpowiedniej tabeli.'; $_lang["database_server"] = 'Baza danych - serwer'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d9f1c4d908..4b9b848b43 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -207,6 +207,14 @@ $_lang["opcache_reset_unavailable"] = 'reset indisponível'; $_lang["opcache_reset_complete"] = 'reset concluído'; $_lang["opcache_reset_failed"] = 'reset falhou'; +$_lang["worker_mode"] = 'Modo worker'; +$_lang["sysinfo_worker_unavailable"] = 'requer FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 não tem efeito fora do FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'disponível: defina EVO_WORKER_MODE=1 e EVO_WORKER_ORIGIN e reinicie o servidor'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN não está definido, os workers não serão iniciados'; +$_lang["sysinfo_worker_new_app"] = 'nova aplicação por pedido'; +$_lang["sysinfo_worker_kept_app"] = 'aplicação mantida, experimental'; +$_lang["sysinfo_worker_recycle"] = 'reciclado após %s pedidos'; $_lang["database_name"] = 'Nome da Base de Dados'; $_lang["database_overhead"] = 'Nota: a folga (overhead) é espaço não utilizado reservado pelo MySQL. Para libertá-la, clique no valor de folga da tabela correspondente.'; $_lang["database_server"] = 'Servidor da Base de Dados'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 3710e88c63..621b061607 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Copiere eșuată'; $_lang['sysinfo_opcache_hit_rate'] = '%s accesări reușite'; $_lang['sysinfo_opcache_memory'] = '%s utilizați / %s în total'; +$_lang['worker_mode'] = 'Mod worker'; +$_lang['sysinfo_worker_unavailable'] = 'necesită FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 nu are efect în afara FrankenPHP'; +$_lang['sysinfo_worker_available'] = 'disponibil: setați EVO_WORKER_MODE=1 și EVO_WORKER_ORIGIN, apoi reporniți serverul'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN nu este setat, workerii nu vor porni'; +$_lang['sysinfo_worker_new_app'] = 'aplicație nouă la fiecare cerere'; +$_lang['sysinfo_worker_kept_app'] = 'aplicație păstrată, experimental'; +$_lang['sysinfo_worker_recycle'] = 'repornit după %s cereri'; $_lang["database_name"] = 'Numele bazei de date'; $_lang["database_overhead"] = 'Notă: spațiul suplimentar este spațiu neutilizat rezervat de MySQL. Pentru a-l elibera, apăsați valoarea spațiului suplimentar pentru tabelul respectiv.'; $_lang["database_server"] = 'Serverul bazei de date'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 7bb6529fb5..5390728154 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -225,6 +225,14 @@ $_lang["opcache_reset_unavailable"] = 'reset nie je dostupný'; $_lang["opcache_reset_complete"] = 'reset vykonaný'; $_lang["opcache_reset_failed"] = 'reset zlyhal'; +$_lang["worker_mode"] = 'Režim workera'; +$_lang["sysinfo_worker_unavailable"] = 'vyžaduje FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 nemá mimo FrankenPHP žiadny účinok'; +$_lang["sysinfo_worker_available"] = 'dostupné: nastavte EVO_WORKER_MODE=1 a EVO_WORKER_ORIGIN, potom reštartujte server'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN nie je nastavené, workery sa nespustia'; +$_lang["sysinfo_worker_new_app"] = 'nová aplikácia pre každú požiadavku'; +$_lang["sysinfo_worker_kept_app"] = 'zachovaná aplikácia, experimentálne'; +$_lang["sysinfo_worker_recycle"] = 'reštartovaný po %s požiadavkách'; $_lang["database_name"] = 'Názov databázy'; $_lang["database_overhead"] = 'Poznámka: nadbytočný priestor je nevyužité miesto rezervované MySQL. Ak ho chcete uvoľniť, kliknite na hodnotu nadbytočného priestoru pri príslušnej tabuľke.'; $_lang["database_server"] = 'Databázový server'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index 731639f975..fc0f26719b 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -208,6 +208,14 @@ $_lang["opcache_reset_unavailable"] = 'återställning inte tillgänglig'; $_lang["opcache_reset_complete"] = 'återställning klar'; $_lang["opcache_reset_failed"] = 'återställning misslyckades'; +$_lang["worker_mode"] = 'Worker-läge'; +$_lang["sysinfo_worker_unavailable"] = 'kräver FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 har ingen effekt utanför FrankenPHP'; +$_lang["sysinfo_worker_available"] = 'tillgängligt: ange EVO_WORKER_MODE=1 och EVO_WORKER_ORIGIN och starta sedan om servern'; +$_lang["sysinfo_worker_no_origin"] = 'EVO_WORKER_ORIGIN är inte angiven, workers startas inte'; +$_lang["sysinfo_worker_new_app"] = 'ny applikation per förfrågan'; +$_lang["sysinfo_worker_kept_app"] = 'bevarad applikation, experimentellt'; +$_lang["sysinfo_worker_recycle"] = 'startas om efter %s förfrågningar'; $_lang["database_name"] = 'Databasens namn'; $_lang["database_overhead"] = 'Obs: overhead är oanvänt utrymme som reserverats av MySQL. Klicka på overheadvärdet för motsvarande tabell för att frigöra utrymmet.'; $_lang["database_server"] = 'Databasens server'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 2e417c6fa5..3b58836824 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Нусхабардорӣ муяссар нашуд'; $_lang['sysinfo_opcache_hit_rate'] = '%s бархӯрд'; $_lang['sysinfo_opcache_memory'] = '%s истифода шуд / ҳамагӣ %s'; +$_lang['worker_mode'] = 'Реҷаи worker'; +$_lang['sysinfo_worker_unavailable'] = 'FrankenPHP лозим аст'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 берун аз FrankenPHP таъсир надорад'; +$_lang['sysinfo_worker_available'] = 'дастрас: EVO_WORKER_MODE=1 ва EVO_WORKER_ORIGIN-ро танзим кунед, баъд серверро аз нав оғоз кунед'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN танзим нашудааст, worker-ҳо оғоз намешаванд'; +$_lang['sysinfo_worker_new_app'] = 'барномаи нав барои ҳар дархост'; +$_lang['sysinfo_worker_kept_app'] = 'барномаи нигоҳдошташуда, таҷрибавӣ'; +$_lang['sysinfo_worker_recycle'] = 'пас аз %s дархост аз нав оғоз мешавад'; $_lang["database_name"] = 'Номи пойгоҳи додаҳо'; $_lang["database_overhead"] = 'Эзоҳ: фазои зиёдатӣ ҷойи истифоданашудаи захиракардаи MySQL аст. Барои озод кардани он қимати мувофиқи ҷадвалро пахш кунед.'; $_lang["database_server"] = 'Сервери пойгоҳи додаҳо'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index a06e3cd682..531f867eca 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Göçürip bolmady'; $_lang['sysinfo_opcache_hit_rate'] = '%s gabat gelme'; $_lang['sysinfo_opcache_memory'] = '%s ulanyldy / jemi %s'; +$_lang['worker_mode'] = 'Worker tertibi'; +$_lang['sysinfo_worker_unavailable'] = 'FrankenPHP talap edilýär'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 FrankenPHP-den daşarda täsir etmeýär'; +$_lang['sysinfo_worker_available'] = 'elýeterli: EVO_WORKER_MODE=1 we EVO_WORKER_ORIGIN belläň, soňra serweri täzeden işlediň'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN bellenmedik, workerler işe girizilmez'; +$_lang['sysinfo_worker_new_app'] = 'her sorag üçin täze programma'; +$_lang['sysinfo_worker_kept_app'] = 'saklanýan programma, synag görnüşinde'; +$_lang['sysinfo_worker_recycle'] = '%s soragdan soň täzeden işledilýär'; $_lang["database_name"] = 'Maglumatlar binýadynyň ady'; $_lang["database_overhead"] = 'Bellik: artykmaç ýer MySQL tarapyndan bölünen, ulanylmaýan ýerdir. Boşatmak üçin degişli tablisanyň artykmaç ýer bahasyna basyň.'; $_lang["database_server"] = 'Maglumatlar binýadynyň serweri'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index e6c614d4e3..e141b1eaf4 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -237,6 +237,14 @@ $_lang['sysinfo_copy_failed'] = 'Не вдалося скопіювати'; $_lang['sysinfo_opcache_hit_rate'] = '%s влучань'; $_lang['sysinfo_opcache_memory'] = '%s використано / %s загалом'; +$_lang['worker_mode'] = 'Режим worker'; +$_lang['sysinfo_worker_unavailable'] = 'потребує FrankenPHP'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 не діє поза FrankenPHP'; +$_lang['sysinfo_worker_available'] = 'доступно: задайте EVO_WORKER_MODE=1 та EVO_WORKER_ORIGIN, потім перезапустіть сервер'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN не задано, worker-процеси не запустяться'; +$_lang['sysinfo_worker_new_app'] = 'новий застосунок на кожен запит'; +$_lang['sysinfo_worker_kept_app'] = 'збережений застосунок, експериментально'; +$_lang['sysinfo_worker_recycle'] = 'перезапускається після %s запитів'; $_lang["database_name"] = 'Ім\'я бази даних'; $_lang["database_overhead"] = 'Примітка: надлишок — це невикористаний простір, зарезервований MySQL. Щоб звільнити його, натисніть значення надлишку для відповідної таблиці.'; $_lang["database_server"] = 'Сервер бази даних'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index 887e0e9067..807080f594 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -230,6 +230,14 @@ $_lang['sysinfo_copy_failed'] = 'Nusxalab boʻlmadi'; $_lang['sysinfo_opcache_hit_rate'] = '%s topilgan'; $_lang['sysinfo_opcache_memory'] = '%s ishlatilgan / jami %s'; +$_lang['worker_mode'] = 'Worker rejimi'; +$_lang['sysinfo_worker_unavailable'] = 'FrankenPHP talab qilinadi'; +$_lang['sysinfo_worker_ignored'] = 'EVO_WORKER_MODE=1 FrankenPHP tashqarisida taʼsir qilmaydi'; +$_lang['sysinfo_worker_available'] = 'mavjud: EVO_WORKER_MODE=1 va EVO_WORKER_ORIGIN ni oʻrnating, soʻng serverni qayta ishga tushiring'; +$_lang['sysinfo_worker_no_origin'] = 'EVO_WORKER_ORIGIN oʻrnatilmagan, workerlar ishga tushmaydi'; +$_lang['sysinfo_worker_new_app'] = 'har bir soʻrov uchun yangi ilova'; +$_lang['sysinfo_worker_kept_app'] = 'saqlangan ilova, eksperimental'; +$_lang['sysinfo_worker_recycle'] = '%s ta soʻrovdan keyin qayta ishga tushiriladi'; $_lang["database_name"] = 'Maʼlumotlar bazasi nomi'; $_lang["database_overhead"] = 'Eslatma: ortiqcha joy — MySQL tomonidan ajratilgan, ishlatilmayotgan joy. Uni boʻshatish uchun tegishli jadvalning ortiqcha joy qiymatini bosing.'; $_lang["database_server"] = 'Maʼlumotlar bazasi serveri'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 14e9d97c3f..d461aaa929 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -208,6 +208,14 @@ $_lang["opcache_reset_unavailable"] = '重置不可用'; $_lang["opcache_reset_complete"] = '重置完成'; $_lang["opcache_reset_failed"] = '重置失败'; +$_lang["worker_mode"] = 'Worker 模式'; +$_lang["sysinfo_worker_unavailable"] = '需要 FrankenPHP'; +$_lang["sysinfo_worker_ignored"] = 'EVO_WORKER_MODE=1 在 FrankenPHP 之外无效'; +$_lang["sysinfo_worker_available"] = '可用:设置 EVO_WORKER_MODE=1 和 EVO_WORKER_ORIGIN,然后重启服务器'; +$_lang["sysinfo_worker_no_origin"] = '未设置 EVO_WORKER_ORIGIN,工作进程将不会启动'; +$_lang["sysinfo_worker_new_app"] = '每个请求使用新的应用实例'; +$_lang["sysinfo_worker_kept_app"] = '保留应用实例,实验性'; +$_lang["sysinfo_worker_recycle"] = '处理 %s 个请求后回收'; $_lang["database_name"] = 'Database name'; $_lang["database_overhead"] = '注意:开销是 MySQL 保留但未使用的空间。要释放该空间,请点击相应表的开销值。'; $_lang["database_table_overhead"] = '开销'; diff --git a/core/src/Controllers/SystemInfo.php b/core/src/Controllers/SystemInfo.php index 444fc12f25..11cd5b2991 100644 --- a/core/src/Controllers/SystemInfo.php +++ b/core/src/Controllers/SystemInfo.php @@ -125,6 +125,7 @@ protected function parameterServerArr(bool $allowReset = true): Collection 'status' => $jit['enabled'], 'details' => '(' . $jit['details'] . ')' ], + 'worker_mode' => $this->workerModeRow(), 'access_permissions' => [ 'is_lexicon' => true, 'data' => $this->managerTheme->getLexicon( @@ -253,6 +254,58 @@ protected function jitInfo(): array return ['enabled' => $enabled, 'details' => $this->formatBytes($bufferSize)]; } + protected function workerModeRow(): array + { + $info = $this->workerModeInfo(); + $details = implode(', ', array_map( + fn (array $hint) => vsprintf($this->managerTheme->getLexicon($hint[0]), array_slice($hint, 1)), + $info['hints'] + )); + + return [ + 'is_lexicon' => true, + 'data' => $this->managerTheme->getLexicon($info['enabled'] ? 'enabled' : 'disabled') . ' (' . $details . ')', + 'status' => $info['enabled'], + 'details' => '(' . $details . ')', + ]; + } + + /** + * The manager itself always runs classic, so this reports the configuration the front-end workers + * start with (index.php and the image entrypoint read the same variables), not a live worker. + * + * @return array{enabled: bool, hints: list} + */ + public function workerModeInfo(): array + { + $requested = $this->environment('EVO_WORKER_MODE') === '1'; + if ($this->serverApi() !== 'frankenphp') { + return ['enabled' => false, 'hints' => [[$requested ? 'sysinfo_worker_ignored' : 'sysinfo_worker_unavailable']]]; + } + if (!$requested) { + return ['enabled' => false, 'hints' => [['sysinfo_worker_available']]]; + } + if (trim((string) $this->environment('EVO_WORKER_ORIGIN')) === '') { + return ['enabled' => false, 'hints' => [['sysinfo_worker_no_origin']]]; + } + $maxRequests = (string) $this->environment('EVO_WORKER_MAX_REQUESTS'); + + return ['enabled' => true, 'hints' => [ + [$this->environment('EVO_WORKER_KEEP_APP') === '1' ? 'sysinfo_worker_kept_app' : 'sysinfo_worker_new_app'], + ['sysinfo_worker_recycle', $maxRequests === '' ? '500' : $maxRequests], + ]]; + } + + protected function serverApi(): string + { + return PHP_SAPI; + } + + protected function environment(string $name): string|false + { + return getenv($name); + } + protected function resetOpcacheIfRequested(): string { if (!request()->boolean('opcache_reset')) { diff --git a/core/tests/Unit/Manager/SystemInfoActionsTest.php b/core/tests/Unit/Manager/SystemInfoActionsTest.php index fb647122b0..e4cb1ddfe6 100644 --- a/core/tests/Unit/Manager/SystemInfoActionsTest.php +++ b/core/tests/Unit/Manager/SystemInfoActionsTest.php @@ -72,3 +72,49 @@ protected function controller(): SystemInfo { return $this->fixture; } expect($component->phpInfoOpen)->toBeFalse() ->and($controller->resets)->toBe(0); }); + +function systemInfoWorkerFixture(string $sapi, array $environment): SystemInfo +{ + $core = Mockery::mock(\EvolutionCMS\Interfaces\CoreInterface::class); + $core->shouldReceive('getDatabase')->andReturn(Mockery::mock(\EvolutionCMS\Database::class)); + $theme = Mockery::mock(\EvolutionCMS\Interfaces\ManagerThemeInterface::class); + $theme->shouldReceive('getCore')->andReturn($core); + $theme->shouldReceive('getLexicon')->andReturnUsing(fn (string $key) => $key === 'sysinfo_worker_recycle' ? '%s requests' : $key); + $controller = new class($theme) extends SystemInfo { + public string $sapi = 'cli'; + public array $environment = []; + protected function serverApi(): string { return $this->sapi; } + protected function environment(string $name): string|false { return $this->environment[$name] ?? false; } + public function row(): array { return $this->workerModeRow(); } + }; + $controller->sapi = $sapi; + $controller->environment = $environment; + return $controller; +} + +test('worker mode hint follows the server API and the worker environment', function (string $sapi, array $environment, bool $enabled, array $hints) { + expect(systemInfoWorkerFixture($sapi, $environment)->workerModeInfo()) + ->toBe(['enabled' => $enabled, 'hints' => $hints]); +})->with([ + 'apache' => ['apache2handler', [], false, [['sysinfo_worker_unavailable']]], + 'fpm with the variable set' => ['fpm-fcgi', ['EVO_WORKER_MODE' => '1', 'EVO_WORKER_ORIGIN' => 'http://a'], false, [['sysinfo_worker_ignored']]], + 'frankenphp, not requested' => ['frankenphp', ['EVO_WORKER_MODE' => '0'], false, [['sysinfo_worker_available']]], + 'frankenphp without origin' => ['frankenphp', ['EVO_WORKER_MODE' => '1', 'EVO_WORKER_ORIGIN' => ' '], false, [['sysinfo_worker_no_origin']]], + 'frankenphp, new application' => ['frankenphp', ['EVO_WORKER_MODE' => '1', 'EVO_WORKER_ORIGIN' => 'http://a'], true, + [['sysinfo_worker_new_app'], ['sysinfo_worker_recycle', '500']]], + 'frankenphp, kept application' => ['frankenphp', ['EVO_WORKER_MODE' => '1', 'EVO_WORKER_ORIGIN' => 'http://a', + 'EVO_WORKER_KEEP_APP' => '1', 'EVO_WORKER_MAX_REQUESTS' => '20'], true, + [['sysinfo_worker_kept_app'], ['sysinfo_worker_recycle', '20']]], +]); + +test('worker mode row renders the status with its hints for both manager themes', function () { + $row = systemInfoWorkerFixture('frankenphp', ['EVO_WORKER_MODE' => '1', 'EVO_WORKER_ORIGIN' => 'http://a', + 'EVO_WORKER_KEEP_APP' => '1', 'EVO_WORKER_MAX_REQUESTS' => '20'])->row(); + + expect($row)->toBe([ + 'is_lexicon' => true, + 'data' => 'enabled (sysinfo_worker_kept_app, 20 requests)', + 'status' => true, + 'details' => '(sysinfo_worker_kept_app, 20 requests)', + ]); +});