-
Notifications
You must be signed in to change notification settings - Fork 15
ZealPHP — WordPress compatibility issues (v0.3.7) #167
Description
ZealPHP — WordPress compatibility issues (v0.3.7)
Filed after running an unmodified WordPress 7.0 install on a fresh, composer-installed
ZealPHP scaffold (composer create-project sibidharan/zealphp-project) and exercising it over
HTTP across every lifecycle mode (superglobals on/off ×ばつ isolation on/off ×ばつ cgi pool/proc),
with the in-built ext-zealphp isolation engine loaded.
Every claim below was checked twice: a per-issue read of the framework source (file:line
citations) and an empirical HTTP re-test. Two earlier drafts of this report had confounded
examples (a .php test URL that is independently 403-filtered; an "empty for every URL" claim that
was actually a repeat-request artifact) — those are corrected here and called out.
The headline: PHP files are included from inside a function, not at true global scope, so
WordPress's top-level $menu/$submenu become function-locals; global $menu is then null and
wp-admin dies with uksort(): null given. Reproduces in both modes where WordPress renders
(coroutine-legacy + cgi-pool), so it is a framework problem, not a wiring mistake.
Environment
| Component | Version |
|---|---|
| ZealPHP | v0.3.7 (composer create-project sibidharan/zealphp-project) |
| PHP | 8.4.21 (NTS) |
| OpenSwoole (ext) | 26.2.0 |
| ext-zealphp | loaded (in-built) |
| uopz / ext-redis | not loaded |
| WordPress | 7.0 |
| DB | SQLite (official SQLite Database Integration drop-in v1.8.0) — no MySQL on host |
| OS | Linux |
The bugs are not SQLite-related — they are scope / re-entrancy / static-whitelist problems in
the framework, independent of the DB backend.
Mode matrix
Same WordPress install, mode selected via a WP_MODE env var, logged-in admin cookie reused across
restarts. cgi-pool used cgiPoolMaxRequests(1); all modes used ignorePhpExt(false) + an
index.php front-controller fallback + documentRoot('wordpress').
App::mode() |
superglobals | isolation | GET / (1st) |
GET / (repeat) |
logged-in /wp-admin/ |
|---|---|---|---|---|---|
MODE_COROUTINE |
false | coroutine | 200 68 KB ✅ | 200 0 B (empty) | 302 (no dashboard) |
MODE_COROUTINE_LEGACY |
true | coroutine (ext-zealphp) | 200 68 KB ✅ | 200 68 KB ✅ | 500 uksort ... null |
MODE_MIXED |
true | none | 200 68 KB ✅ | 200 0 B (empty) | 302 (no dashboard) |
MODE_LEGACY_CGI (pool, recycle 1) |
true | cgipool | 200 68 KB ✅ | 200 68 KB ✅ | 500 uksort ... null |
MODE_LEGACY_CGI (proc) |
true | cgipool | 000 (hang) | 000 (hang) | 000 (hang) |
Static assets (/wp-includes/css/*.css) are not served in any mode by default — see Issue 4
(it's the static_handler_locations whitelist, fixable by config).
Reading the matrix:
- WordPress renders the homepage on the first request in every non-
procmode.coroutine-legacy
andcgi-poolkeep rendering on repeat;coroutine/mixedgo empty on repeat → Issue 2. - The two modes that render repeatedly (
coroutine-legacy,cgi-pool) both 500 on wp-admin with
uksort ... null→ Issue 1. (coroutine/mixed302 away before reaching it.) cgi-procnever responds → Issue 3.
How to reproduce (full setup)
composer create-project sibidharan/zealphp-project zealphp --ignore-platform-req=ext-redis cd zealphp curl -fsSL https://wordpress.org/latest.tar.gz | tar xz -C . # -> ./wordpress # SQLite drop-in (no MySQL needed) curl -fsSL -o /tmp/s.zip https://downloads.wordpress.org/plugin/sqlite-database-integration.zip unzip -q /tmp/s.zip -d wordpress/wp-content/plugins PLUG="$PWD/wordpress/wp-content/plugins/sqlite-database-integration" sed "s#{SQLITE_IMPLEMENTATION_FOLDER_PATH}#$PLUG#g; s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" \ "$PLUG/db.copy" > wordpress/wp-content/db.php # install curl -fsSL -o wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar php wp-cli.phar --path=wordpress config create --dbname=wp --dbuser=wp --dbpass=wp --dbhost=localhost --skip-check --force php wp-cli.phar --path=wordpress core install --url=http://localhost:8080 --title=WP \ --admin_user=admin --admin_password=admin123 --admin_email=admin@example.com --skip-email # app.php: App::mode(App::MODE_LEGACY_CGI); App::ignorePhpExt(false); App::documentRoot('wordpress'); # + index.php front-controller fallback; then: php app.php start -d
Issue 1 — global vars not in true global scope → uksort(): null given 🔴 Critical
Symptom. Logged-in GET /wp-admin/ → HTTP 500 on every request, in both rendering modes:
coroutine-legacy:TypeError: uksort(): Argument #1 ($array) must be of type array, null givencgi-pool:pool_worker fatal: uksort(): Argument #1 ($array) must be of type array, null given
Where it fires (WordPress side). wp-admin/menu.php builds the admin menu as top-level
variables ($menu, $submenu), relying on global scope. Then wp-admin/includes/menu.php does
global $menu; ... uksort( $menu, ... ). When the parent file was included in function scope, those
top-level assignments became function-locals, so $GLOBALS['menu'] was never set, global $menu
resolves to null, and uksort(null) is a fatal TypeError.
Where it originates (framework side) — verified in source. The include runs inside a PHP
function, not at EG(symbol_table) global scope:
- In-process path (
coroutine,coroutine-legacy,mixed):src/App.php:4776
private static function executeFile(), with$result = include $absPath;at line 4845. - CGI pool path (
cgi-pool):src/pool_worker.php:490function pool_handle_request(), with
$result = include $file;at line 522; thecatch (\Throwable $e)at line 528 emits the
pool_worker fatal:body.
Both inclusions are definitively inside function scope (audited line-by-line). ext-zealphp's
per-coroutine isolation does not help — the problem is include scope, not coroutine state.
Impact. wp-admin is unusable in every mode where WordPress renders. Any legacy/procedural code
relying on a file's top-level variables being global (WordPress core + plugins, WooCommerce) hits it.
Issue 2 — plain in-process modes (coroutine, mixed) aren't re-entrant: render once, then empty 200 🟠 High
Corrected from an earlier draft that said "empty 200 for every URL." That was a
repeat-request artifact: the first request renders fully; repeats go empty.
Symptom (verified). In MODE_COROUTINE and MODE_MIXED, the first request to a path
renders correctly, but every subsequent request to the same path returns 200 with 0 bytes:
MIXED / COROUTINE:
GET / (1st) -> 200 68379 B ✅ full WordPress HTML
GET / (2nd) -> 200 0 B (empty)
GET / (3rd) -> 200 0 B (empty)
A trivial <?php echo "PLAINOK"; file returns PLAINOK (9 B) in mixed, so executeFile()'s
output capture works (ob_start()@4839 → ob_get_clean()@4887 → return@4930–4939, audited). The
empty body is therefore not an output-capture bug — it's lack of per-request state reset: the
long-lived worker reuses the process without resetting WordPress's per-request globals / output
state, so WP's second run produces nothing. cgi-pool (fresh process via recycle=1) and
coroutine-legacy (ext-zealphp per-request reset) both keep rendering; plain coroutine/mixed
don't.
Caveat (per source audit). MODE_COROUTINE additionally has superglobals OFF (App.php:7282–7303
only populates $_SERVER/$_GET when superglobals==true), so it isn't a supported WordPress mode
regardless. The re-entrancy failure above is the one shared by mixed (superglobals ON).
Impact. WordPress is not viable in plain coroutine/mixed; it works only in the two modes that
give a fresh/reset state per request.
Issue 3 — cgiMode('proc') hangs; no response for any request 🔴 Critical
Symptom (verified, non-.php URLs — so not the Issue 6 filter).
PROC mode:
GET / -> code=000 time=12.00s bytes=0 (hung — timed out)
GET /?p=1 -> code=000 time=12.00s bytes=0 (hung — timed out)
POOL mode (same URL, contrast):
GET / -> code=200 time=0.22s bytes=68379 (instant, works)
The .php filter (Issue 6) is an instant 403 (~0.0006s) — a different signature from proc's
12 s timeout. The URLs above aren't .php, yet they hang; the same / is served by POOL in 0.22 s.
Where it originates (verified + audit). The proc backend Dispatcher::cgiSubprocess()
(src/CGI/Dispatcher.php:321–564) runs src/cgi_worker.php (include at line 391). The audit
identifies a stderr pipe deadlock: the subprocess sends JSON metadata via fwrite(STDERR, ...)
(cgi_worker.php:108), but the stderr buffer fills with WordPress's startup warnings, so the
subprocess blocks; the parent's fgets() polling loop (Dispatcher.php:409) waits for that metadata
line / EOF, which never comes → circular wait → kill at the 12 s timeout. (Pool mode uses a separate
FD-3 metadata channel and avoids this.)
Impact. The documented "fresh process per request" CGI mode is unusable on this build — and it's
the natural answer to Issue 1.
Issue 4 — WordPress static assets blocked by the default static_handler_locations whitelist (config) 🟡 Medium
Corrected from an earlier draft that blamed
document_rootnot followingApp::documentRoot().
Source audit + empirical test showdocument_rootis updated correctly; the blocker is the
static-handler whitelist, and it's config-fixable (not a framework bug).
Symptom + proof (verified).
cgi-pool, DEFAULT whitelist: /wp-includes/css/dashicons.min.css -> 301, 0 B
cgi-pool, App::staticHandlerLocations([...wp...]): same URL -> 200, 59004 B, Content-Type: text/css ✅
Where it originates (verified in source). src/App.php:6624 correctly sets
'document_root' => self::resolveDocumentRoot() (reads App::$document_root after
App::documentRoot('wordpress')). But src/App.php:6636–6638 defaults the whitelist to
['/css/','/js/','/img/','/images/','/fonts/','/assets/','/static/','/favicon.ico','/robots.txt'],
which excludes /wp-includes/ and /wp-content/ — so WP asset requests are rejected by the
whitelist before the disk-file check, and fall through to the PHP fallback (→ WP canonical 301). The
moment the whitelist includes those prefixes, the file is served as real text/css — proving the
docroot was right all along.
Impact. Out of the box a WordPress site loads unstyled (no CSS/JS/images). It's a surprising
default for legacy apps, but a one-line config fix — hence Medium, not a hard bug.
Issue 5 — legacy-cgi default cgiPoolMaxRequests = 500 flickers 500s on WordPress 🟡 Medium
Symptom (verified). With the default legacy-cgi pool (no cgiPoolMaxRequests override),
repeated GET / alternates 200 / 500. The 500 body:
WorkerPool: subprocess died mid-request — response not received. Stderr:
PHP Warning: Constant WP_USE_THEMES already defined in .../wordpress/index.php on line 14
PHP Warning: Constant DB_NAME already defined in .../wordpress/wp-config.php on line 24
PHP Warning: Constant WPINC already defined in .../wordpress/wp-settings.php on line 16
... (KB_IN_BYTES, MINUTE_IN_SECONDS, etc.)
Where it originates (verified). src/App.php:580 — public static int $cgi_pool_max_requests = 500;. The pool keeps each subprocess alive for 500 requests (WorkerPool.php reuse confirmed).
WordPress re-runs its unguarded top-level define()/function declarations on every request, which a
reused process can't tolerate → the worker dies mid-request. The audit notes a contributing
stderr-pipe-buffer deadlock (warnings fill the 64 KB stderr buffer; a later fwrite(STDERR)
blocks and the parent reads it as a dead subprocess). A cold worker returns 200; a reused warm one
dies → the flicker.
Impact. WordPress is unstable on the default pool; cgiPoolMaxRequests(1) (fresh process per
request) stabilises it. A better default for the legacy mode would be 1.
Issue 6 — ignore_php_ext = true (default) returns an instant 403 for any .php URL 🟡 Low
Symptom (verified — every endpoint below tested at the default ignore_php_ext=true).
/wp-login.php -> 403 (0.0011s)
/xmlrpc.php -> 403 (0.0009s)
/wp-cron.php -> 403 (0.0008s)
/wp-admin/index.php -> 403 (0.0005s)
/wp-admin/options-general.php -> 403 (0.0005s)
/wp-admin/ (no .php) -> 500 (0.16s) ← NOT this filter; directory URL → hits Issue 1
Every URL ending in .php gets an instant 403 — wp-login.php, xmlrpc.php, wp-cron.php, and
direct .php files under wp-admin (options-general.php, post.php, ...). The dashboard URL
/wp-admin/ (trailing slash, no .php) is a directory URL, so it is not blocked by this
filter; it reaches WordPress and 500s on Issue 1 instead.
Where it originates (verified). src/App.php:488 — public static bool $ignore_php_ext = true;
(docblock: "When true (default), URLs ending in .php get a 403"); enforced in the patternRoute
handler at src/App.php:7043–7058. Fix: App::ignorePhpExt(false). This is the expected, instant-403
default — distinct from the proc hang (Issue 3).
Issue 7 — Documentation: "Mode 1: A, works end-to-end" overstated; example fallback 403s assets/REST/404 🟡 Doc
Verified citations. docs/compatibility-database.md:45 grades WordPress "Mode 1: A";
line 491 says the showcase "demonstrates this working end-to-end"; lines 1131–1133 show the fallback
$app->setFallback(fn() => App::include($_SERVER['REQUEST_URI'])).
The grade is fair for the public site only: wp-admin 500s (Issue 1), the default pool flickers
(Issue 5), and the shown raw-REQUEST_URI fallback 403s/empties static assets, REST, and 404 URLs
(needs the index.php rewrite). The doc should qualify the grade ("A for public site; wp-admin needs
a workaround") and ship the front-controller fallback as the WordPress example.