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) . '