Build/Test Tools: Check the upgraded site over HTTP. - #13355
Draft
adimoldovan wants to merge 10 commits into
Draft
Build/Test Tools: Check the upgraded site over HTTP.#13355adimoldovan wants to merge 10 commits into
adimoldovan wants to merge 10 commits into
Conversation
Upgrade Tests end at `wp core version`, which reads a string off the filesystem. No step in the job makes an HTTP request. A site that upgrades to the right version and then fatals on every page still reports green. Serve the upgraded site with `wp server` and check what a working install must produce: the version in the generator tag, the default post, a 404 for a missing one, the REST index, the login form, an authentication cookie from a real login, and the admin bar on the dashboard. A check for a missing error string breaks as soon as someone rewords the string. This skips multisite. The network domain lives in `wp-config.php` and in the `site` and `blogs` tables. WordPress strips only `:80` and `:443` from the host before it matches a network, so a move to another port needs more than an option update.
Drop the block above the step. Keep one line on the `if`, which says why the step skips multisite.
Six of 264 upgrade jobs failed. A request hung for 30 seconds and died:
Fatal error: Maximum execution time of 30+2 seconds exceeded (terminated)
in wp-includes/rest-api.php on line 2820
WordPress cron spawns a request back to the same server, and PHP's built-in
server has nothing left to serve it. Four worker processes did not prevent
this, and that mode is experimental: two jobs also died with a segmentation
fault. Turn cron off instead, and drop the workers.
Give every request a 30 second limit, so a hang that slips through fails the
step instead of spending the 20 minute job timeout.
Let the outer shell expand the URL in the wait loop. actionlint reads the
single-quoted form as shellcheck SC2016.
Stop the background server when the step ends, and keep the step's exit code. Print the response body when a check finds the wrong content. Read the port from the site URL. Share the admin credentials with the install step. Match the generator tag without its closing slash. Stop the wait loop when the server dies.
Every marker sat near the top of the response. A fatal in the second half of a page leaves an HTTP 200 with half a document, and the check passed. Each HTML check now also looks for the closing tag, and the REST index must parse as whole JSON. Neither marker depends on the wording of an error message. A failed request printed almost nothing. All requests now go through one helper that keeps the body and the status code. A response that fails prints what the site returned. The login POST fails the same way as the other checks. An empty reply came back as a bare curl error. The step now checks whether the server still runs. A page that kills the PHP process says so and prints the exit status. The step reads the server log at the end. A fatal during shutdown leaves every response whole and the site broken.
The step reported an empty reply as a bare curl error. It now reports two facts: whether the server process is still up, and whether anything still listens on the port. The process that holds the port is a grandchild of the step, so one fact alone cannot tell the two causes apart. The wait for the server to start had no message. A server that never binds now says so, instead of leaving a bare exit 124 in the log. The REST index check read a scratch file that the previous request filled. It now makes its own request and asserts the parsed value, so line order no longer decides what it reads. The 404 page must arrive whole, as every other page must. A missing server log can no longer replace the exit status the step meant to report.
`wp server` wraps `php -S` in a router and puts the server two processes below the step. Every path the step asks for resolves without that router: `/` and `/wp-admin/` reach their `index.php` as directory indexes, and the rest are files on disk. Dropping it removes a layer. The server is now the step's own child, so when a request gets no reply the step reports the status the server exited with, rather than the status of the process that started it.
PHP's built-in server answered the connection and then closed it with no reply, on about four jobs in every run. One process accepts the connection and runs WordPress, so when that process stalls, nothing answers. nginx now owns the listening socket, and PHP-FPM runs the site behind it. A worker that dies mid-request becomes an HTTP 502 the step reports, and the pool starts another. Both programs are on the runner image, and the step runs them as the runner user with its own config in RUNNER_TEMP. The step also blocks external HTTP. The update checks on /wp-admin/ left the runner for api.wordpress.org, which no check asked for.
PHP workers crashed with SIGSEGV on about five percent of cold pools, on PHP 8.4 and 8.5, while they served the first request. A run of 1200 pools on the runner image found the cause: the runner sets opcache.jit to tracing with a 256M buffer. Stock PHP ships the JIT off, so the crash needs a setup no site runs. With the JIT off and opcache still on, none of 400 pools crashed. With the JIT on, 19 of 400 crashed.
A core dump named the crash: every frame sat in opcache.so, and the server that produced it was php -S. opcache counts the built-in server as a web SAPI, not as the CLI, so opcache.enable_cli never kept it out and it ran the JIT the runner turns on. One bug hit both servers. With the JIT off, php -S crashed on none of 1600 cold starts, against 38 with it on. It serves every path the check asks for, so the pool, the server block, the socket and the second process all go.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Trac ticket:
Upgrade Tests end at
wp core version, which reads a string off the filesystem. A site that upgrades to the right version and then fatals on every page reports green.This adds one step at the end of the job. It moves the site to port 8889, serves it with PHP's built-in server, and makes eight requests.
GET /content="WordPress <version>", matched againstwp core version, and</html>GET /?p=1Hello world!and</html>GET /?rest_route=/nameisUpgrade TestGET /wp-admin/upgrade.phpNo Update Requiredand</html>GET /wp-login.phpid="loginform"and</html>GET /?p=99999999POST /wp-login.phpwordpress_logged_in_in the cookie jarGET /wp-admin/id="wpadminbar"and</html>The step then fails if PHP wrote a fatal error to the server log. That catches a fatal during shutdown, which leaves every response whole.
Each check asserts a marker that a working install must produce, and also
</html>. A fatal part way down a page leaves an HTTP 200 that holds half a document, and every other marker sits near the top.On a failure the step prints the response body and the server log. It costs about 5 seconds and uses
jq, which is already on the runner image.The JIT
The server starts with
-d opcache.jit=disable. Without that it crashes.The runner sets
opcache.jitto tracing with a 256M buffer. Stock PHP ships the JIT off.opcache.enable_clidoes not keep the JIT away fromphp -S, because opcache counts the built-in server as a web SAPI rather than as the CLI.Measured on the runner image, one cold server per cycle, PHP 8.4 and 8.5, WordPress 7.1:
php -Snginx with PHP-FPM crashed the same way, 19 of 400. Every core dump put the crash inside
opcache.so:The site under test still runs the configuration a WordPress host runs.
The rest of the setup
The step skips multisite. A network keeps its domain in
wp-config.phpand in thesiteandblogstables, and WordPress strips only:80and:443from the host before it matches a network, so a move to another port needs more than an option update.It sets
DISABLE_WP_CRONandWP_HTTP_BLOCK_EXTERNAL. A cron spawn is a request no check asked for, and the update checks on/wp-admin/leave the runner for api.wordpress.org.WP_Http::block_request()exempts the site's own host, so the site can still talk to itself.Effect on other branches
Branches 6.6 to 7.1 call
reusable-upgrade-testing.yml@trunk, so this step runs on their upgrade jobs too. Branches 6.4 and 6.5 call the olderupgrade-testing-run.ymland do not change.upgrade-develop-testing.ymlcalls this workflow too, and runs on any change tosrc/**.php.All the markers exist in the source of every branch from 6.6 to trunk, and every caller runs Ubuntu.
Testing Instructions
The workflow runs on any pull request that touches
upgrade-testing.ymlorreusable-upgrade-testing.yml, so this one exercises it.To reproduce the job locally:
Start a database:
In an empty directory, install an older version and upgrade it:
Copy the
run:block of the new "Post-upgrade smoke check" step intosmoke.shand run it withRUNNER_TEMP=/tmp SITE_PORT=8889 WP_ADMIN_USER=admin WP_ADMIN_PASSWORD=password bash -e smoke.sh.It prints nine
oklines and exits 0.Confirm it catches a broken site. Each case below is one file in
wp-content/mu-plugins/.Write it, run the script again, then delete it. The
WP_CLIguard keeps WP-CLI working, so thebreak reaches the step over HTTP rather than stopping the first
wpcommand.<?php if ( ! defined( "WP_CLI" ) ) { boom_undefined(); }::error::/ returned HTTP 500<?php add_action( "wp_footer", function () { boom_undefined(); } );::error::/ did not contain: </html><?php if ( ! defined( "WP_CLI" ) ) { register_shutdown_function( function () { boom_undefined(); } ); }::error::the server logged a fatal error<?php add_filter( "template_redirect", function () { if ( is_404() ) { status_header( 200 ); } }, 1 );::error::a missing post returned 200, expected 404The third case leaves every response whole and is caught only by the log check.
Confirm it catches missing content:
wp post delete 1 --force, then run the script again. Itfails with
::error::/?p=1 returned HTTP 404.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Drafting the workflow step and this description, and running the crash measurements quoted above. I ran the local verification described here and reviewed and edited the result.