Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

ZealPHP — WordPress compatibility issues (v0.3.7) #167

Open

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-proc mode. coroutine-legacy
    and cgi-pool keep rendering on repeat; coroutine/mixed go empty on repeatIssue 2.
  • The two modes that render repeatedly (coroutine-legacy, cgi-pool) both 500 on wp-admin with
    uksort ... nullIssue 1. (coroutine/mixed 302 away before reaching it.)
  • cgi-proc never 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 given
  • cgi-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:490 function pool_handle_request(), with
    $result = include $file; at line 522; the catch (\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()@4839ob_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_root not following App::documentRoot().
Source audit + empirical test show document_root is 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:580public 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:488public 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.


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

        AltStyle によって変換されたページ (->オリジナル) /