Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [ main ]
pull_request:

permissions:
contents: read

jobs:
test:
name: PHPUnit (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: composer:v2

# The unit suite runs WITHOUT the native ePHPm engine: the Ephpm\Worker\*
# primitives are provided by test shims / the IDE stub, so `composer install`
# + phpunit is all that is needed.
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress

- name: Run PHPUnit
run: vendor/bin/phpunit
50 changes: 32 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ inclusion, not request handling. Handlers on them typically do class wiring,
translation loading, or user-derivation — safe to run once. Re-firing would
double-fire class-wiring code with no request-scoped upside.

The **current user** is handled differently, though: the per-request reset
*unsets* `$current_user` (and the legacy `$user_ID`/`$userdata`/… identity
globals — see `Worker::RESET_GLOBALS`), so the next `wp_get_current_user()`
re-derives the user from **this** request's engine-fresh `$_COOKIE` and re-fires
`set_current_user` lazily. Without that reset a logged-in request would pin its
identity onto the resident worker and the next anonymous visitor would be seen
as that user — a cross-request identity leak.

**Why fire `shutdown` per iteration?** In classic FPM, `shutdown` fires when
PHP tears the script down at request end (via
`register_shutdown_function` → WordPress' `shutdown_action_hook()`). In
Expand Down Expand Up @@ -91,8 +99,8 @@ broken plugin should not take down the worker pool.
`register_post_type(…)` inside its own `init` body will register a fresh
callback / a fresh post-type on every request, growing `$wp_filter` /
`$wp_post_types` unboundedly. Well-behaved plugins register hooks at
plugin-load time (or self-gate with a static flag); the `worker_max_requests`
ceiling bounds the blast radius for the ones that don't.
plugin-load time (or self-gate with a static flag); the `[php.worker]
max_requests` ceiling bounds the blast radius for the ones that don't.
- **Handlers on `shutdown` that call `exit`.** Under FPM, `shutdown` fires
from PHP's tear-down and any `exit`/`die` inside a handler is harmless
(the script was ending anyway). Under the worker, an `exit` inside a
Expand All @@ -111,7 +119,7 @@ broken plugin should not take down the worker pool.
## Requirements

- ePHPm built with worker mode (`[php] mode = "worker"`).
- **`worker_populate_superglobals = true`** — WordPress assumes real
- **`[php.worker] populate_superglobals = true`** — WordPress assumes real
`$_SERVER/$_GET/$_COOKIE` superglobals. This is **not** the default; you must
turn it on. Without it, WordPress routing and `$wpdb`-driven queries misbehave.

Expand Down Expand Up @@ -153,28 +161,34 @@ document_root = "/var/www/html" # the WordPress root (ABSPATH)

[php]
mode = "worker"
worker_script = "worker.php" # your loop; see below
worker_populate_superglobals = true # REQUIRED for WordPress
worker_count = 4 # WordPress ~40 MB/worker — size accordingly
worker_max_requests = 500 # recycle to reclaim slow memory growth
concurrency = 4 # worker-thread pool size — WordPress ~40 MB/worker, size accordingly (0 = auto)

[php.worker]
script = "worker.php" # your loop; see below
populate_superglobals = true # REQUIRED for WordPress
max_requests = 500 # recycle to reclaim slow memory growth
```

The worker knobs live in the `[php.worker]` table (`script`, `max_requests`,
`boot_timeout`, `populate_superglobals`, `stream_threshold`); pool sizing is the
whole-server `[php] concurrency` knob. The old flat `worker_*` keys
(`worker_script`, `worker_count`, `worker_populate_superglobals`,
`worker_max_requests`, `worker_boot_timeout`) were removed and are now a **hard
startup error** — migrate any existing config.

Set `EPHPM_WP_PATH` to the WordPress root (defaults to `document_root` via the
`wp-load.php` walk if omitted):

```
EPHPM_WP_PATH=/var/www/html
```

Your `worker.php` (pointed to by `worker_script`) is a one-liner:

```php
<?php
require __DIR__ . '/vendor/autoload.php';
exit((new Ephpm\WordPress\Worker(getenv('EPHPM_WP_PATH') . '/'))->run());
```

or just use the bundled `bin/ephpm-wp-worker` as your `worker_script`.
WordPress' request cycle must run at **global scope**, so this adapter does not
expose a `run()` method — the request loop lives in the worker entry script. Use
the bundled `bin/ephpm-wp-worker` as your `[php.worker] script` (it boots
WordPress once and runs the loop), or copy it into your project as `worker.php`
and point the config at your copy. See `bin/ephpm-wp-worker` for the canonical
global-scope loop.

## Running WooCommerce

Expand Down Expand Up @@ -271,7 +285,7 @@ before shipping:
global not on the list, or a **dynamically-registered hook** will leak across
requests within a worker. (The bundled mu-plugin demonstrates the hook-leak
trap and how to avoid it — register filters once, self-gate per request.)
Recycling (`worker_max_requests`) bounds the blast radius.
Recycling (`[php.worker] max_requests`) bounds the blast radius.
- **`$_SERVER` is populated by this adapter.** The engine populates `$_GET` /
`$_COOKIE` natively but leaves `$_SERVER` empty in worker mode, so the adapter
fills `$_SERVER` (REQUEST_URI/HTTP_HOST/…) from the Envelope. It deliberately
Expand All @@ -287,7 +301,7 @@ before shipping:
- **Fatals recycle the worker.** An uncatchable fatal in a hook takes down the
worker; ePHPm respawns it and serves the next request from a clean boot. A
single request's fatal never serves stale state, but costs one worker boot.
Run enough workers (`worker_count`) that recycling doesn't starve the pool.
Run enough workers (`[php] concurrency`) that recycling doesn't starve the pool.

## Testing

Expand Down
4 changes: 2 additions & 2 deletions bin/ephpm-wp-worker
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ declare(strict_types=1);
* ephpm-wp-worker — WordPress entrypoint for ePHPm persistent worker mode.
*
* ePHPm launches this script for each persistent worker (point
* `[php] worker_script` at it, or at a copy of it inside your project). It:
* `[php.worker] script` at it, or at a copy of it inside your project). It:
*
* 1. locates and loads this package's Composer autoloader,
* 2. locates the WordPress root (EPHPM_WP_PATH env var, or ABSPATH if already
* defined, or a walk upward looking for wp-load.php),
* 3. boots WordPress ONCE and runs the worker loop via Ephpm\WordPress\Worker.
*
* Requires `[php] worker_populate_superglobals = true` in ephpm.toml — WordPress
* Requires `[php.worker] populate_superglobals = true` in ephpm.toml — WordPress
* assumes real $_SERVER/$_GET/$_COOKIE superglobals. See the package README.
*/

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
},
"extra": {
"ephpm": {
"comment": "ephpm packages are distributed via their GitHub repositories, not Packagist. The `repositories` VCS entry is what makes this package's own ephpm/worker dependency (tag v0.1.0) resolve when running this package's tests. Consumers of ephpm/wordpress-worker must likewise add a Composer vcs repository for EACH ephpm package in the tree (Composer does not resolve VCS deps transitively) — see README. WordPress worker mode REQUIRES `[php] worker_populate_superglobals = true` in ephpm.toml — see README."
"comment": "ephpm packages are distributed via their GitHub repositories, not Packagist. The `repositories` VCS entry is what makes this package's own ephpm/worker dependency (tag v0.1.0) resolve when running this package's tests. Consumers of ephpm/wordpress-worker must likewise add a Composer vcs repository for EACH ephpm package in the tree (Composer does not resolve VCS deps transitively) — see README. WordPress worker mode REQUIRES `[php.worker] populate_superglobals = true` in ephpm.toml — see README."
}
}
}
16 changes: 9 additions & 7 deletions e2e/app.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ document_root = "/var/www/html" # the WordPress root (ABSPATH)

[php]
mode = "worker"
worker_script = "worker.php"
worker_populate_superglobals = true # REQUIRED for WordPress
worker_count = 3 # a few workers so exit-based routes
# (wp-login/REST end in exit -> recycle)
# don't starve the pool
worker_max_requests = 2000
worker_boot_timeout = 120 # generous boot window for WordPress
concurrency = 3 # worker-thread pool size — a few workers so
# exit-based routes (wp-login/REST end in
# exit -> recycle) don't starve the pool

[php.worker]
script = "worker.php"
populate_superglobals = true # REQUIRED for WordPress
max_requests = 2000
boot_timeout = 120 # generous boot window for WordPress
2 changes: 1 addition & 1 deletion e2e/worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* Worker entrypoint for the e2e image. Lives under document_root
* (/var/www/html) so ephpm's worker_script validation accepts it.
* (/var/www/html) so ephpm's [php.worker] script validation accepts it.
*
* WordPress is booted ONCE at GLOBAL scope (a top-level require, not inside a
* function), and the request loop also runs at global scope — wp() + the
Expand Down
2 changes: 1 addition & 1 deletion muplugins/elementor-idempotent-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
* SCOPE: this fixes the redeclaration fatal only. It does NOT audit the
* remaining Elementor codepaths for per-request idempotency (e.g. widget-cache
* clearing, breakpoint state, editor-preview flow). Ship together with a
* conservative `worker_max_requests` and instrument for regressions.
* conservative `[php.worker] max_requests` and instrument for regressions.
*/

add_action(
Expand Down
Loading
Loading