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..63122a474d --- /dev/null +++ b/.github/docker/WORKER.md @@ -0,0 +1,148 @@ +# 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. + +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. + +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. + +**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 | +| --- | --- | --- | +| `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..608b6c4f0a --- /dev/null +++ b/.github/docker/ci/worker-routes.php @@ -0,0 +1,117 @@ +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([ + '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'); +}); +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 new file mode 100644 index 0000000000..d79e9953f3 --- /dev/null +++ b/.github/docker/ci/worker-smoke.py @@ -0,0 +1,154 @@ +"""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 +# 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 +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, 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/.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..830557a4f5 100644 --- a/core/bootstrap.php +++ b/core/bootstrap.php @@ -28,8 +28,13 @@ 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()) { +if (! is_cli() && session_status() === PHP_SESSION_NONE && !EvoSessionProxy::disabled() + && !EvoSessionProxy::startsAfterBoot()) { 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..1693fb1aa9 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. @@ -32,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 @@ -125,7 +141,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..a7ea263de0 --- /dev/null +++ b/core/includes/worker.runtime.php @@ -0,0 +1,662 @@ += 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 the models booted before its snapshot; restore() forgets the later ones. + 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. The routes of the router + * forget their controllers, and Eloquent's static boot registry returns to its post-boot value. + */ +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); + } + } + } + } + $this->captureModelBootState(); + 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)]; + } + } + } + } + + /** + * 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) { + $this->resets[] = $service->reset(...); + } 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 { + foreach ($service->getChannels() as $channel) { + if ($channel instanceof \Illuminate\Log\Logger) { + $channel->withoutContext(); + } + } + }; + } + } +} 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/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/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/src/Core.php b/core/src/Core.php index 0a3f5bffae..efd40adf6c 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(); } @@ -281,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')) { @@ -308,7 +317,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 +535,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 +575,7 @@ public function sendRedirect(string $url, int $count_attempts = 0, string $type header($header); } - exit(0); + evo_exit(0); } /** @@ -679,12 +691,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 +767,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 +785,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 +795,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 +828,7 @@ public function sendUnauthorizedPage($noEvent = false) } $this->sendForward(UrlProcessor::getUnAuthorizedPageId(), 'HTTP/1.1 401 Unauthorized'); - exit(); + evo_exit(); } /** @@ -1008,7 +1023,7 @@ public function getDocumentObjectFromCache($id, $loading = false) $this->sendErrorPage(); } - exit; // stop here + evo_exit(); // stop here } } @@ -1263,7 +1278,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 +1371,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 +2285,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 +2364,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 +3230,7 @@ public function processRoutes() $response = $this->router->dispatch($request); } catch (NotFoundHttpException | MethodNotAllowedException $exception) { $this->executeParser(); - exit; + evo_exit(); } $response->send(); @@ -3245,7 +3267,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 +3541,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 +3587,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 +3678,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 +3718,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 +3811,7 @@ function __alertQuit() {

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

'; - exit; + evo_exit(); } /** @@ -5410,7 +5432,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/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/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..5ce62ab5cd --- /dev/null +++ b/core/tests/Mocks/worker_runtime_probe.php @@ -0,0 +1,193 @@ +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 === '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); + 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 === '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); + 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/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)', + ]); +}); 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..08a86e5ca5 --- /dev/null +++ b/core/tests/Unit/WorkerRuntimeTest.php @@ -0,0 +1,258 @@ +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('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); +})->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(), []); + $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