Skip to content

Commit b3f6bc3

Browse files
committed
Upgrade/Install: Report fatal errors in auto-update emails.
When a plugin auto-update was rolled back because the loopback request found a fatal error, the failure email gave no reason, even though `WP_Automatic_Updater::has_fatal_error()` had already received the error's message, file, and line in the scrape result. These details are now added to the rollback `WP_Error` under a new `plugin_update_fatal_error` code, so the email lists them under the plugin whose update failed. This works regardless of `WP_DEBUG`, and when the loopback request itself fails, its error message is reported instead, so a rollback is no longer left unexplained. The details are carried on the plugin's update result rather than in a transient, so that they cannot expire during a long update run, go stale when no email is sent, or be attributed to the wrong plugin when several fail in the same run. This also leaves the signature of the protected `has_fatal_error()` method unchanged for subclasses. Only the first line of the error message is included, since the message for an uncaught exception continues with a stack trace whose argument values, such as database credentials, would otherwise leave the server by email. The parameters of the `send_plugin_theme_email()` and `after_plugin_theme_update()` methods are also typed with the update result types, so that PHPStan can check the new email code, which resolves a baselined error for a possibly undefined `$subject`. Developed in WordPress#10445. Follow-up to r58128, r64435. Props tlloancy, westonruter, sajib1223, afragen, knutsp, im3dabasia1. See #65817. Fixes #64155. git-svn-id: https://develop.svn.wordpress.org/trunk@64582 602fd350-edb4-49c9-b593-d223f7449a82
1 parent e205f82 commit b3f6bc3

3 files changed

Lines changed: 206 additions & 5 deletions

File tree

‎src/wp-admin/includes/class-wp-automatic-updater.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ class WP_Automatic_Updater {
8686
*/
8787
protected $update_results = array();
8888

89+
/**
90+
* Details of the fatal error detected by the last call to has_fatal_error().
91+
*
92+
* This is the fatal error, or the reason the loopback request to check for one failed.
93+
* It is null when no fatal error was detected.
94+
*
95+
* @since 7.2.0
96+
*/
97+
private ?string $fatal_error_message = null;
98+
8999
/**
90100
* Determines whether the entire automatic updater is disabled.
91101
*
@@ -710,6 +720,10 @@ public function update( $type, $item ) {
710720
}
711721
}
712722

723+
if ( null !== $this->fatal_error_message ) {
724+
$upgrade_result->add( 'plugin_update_fatal_error', $this->fatal_error_message );
725+
}
726+
713727
/*
714728
* Should emails not be working, log the message(s) so that
715729
* the log file contains context for the fatal error,
@@ -1268,6 +1282,13 @@ protected function send_email( $type, $core_update, $result = null ) {
12681282
* @since 5.5.0
12691283
*
12701284
* @param array $update_results The results of update tasks.
1285+
*
1286+
* @phpstan-param array{
1287+
* core?: list<Core_Update_Result>,
1288+
* plugin?: list<Plugin_Update_Result>,
1289+
* theme?: list<Theme_Update_Result>,
1290+
* translation?: list<Translation_Update_Result>,
1291+
* } $update_results
12711292
*/
12721293
protected function after_plugin_theme_update( $update_results ) {
12731294
$successful_updates = array();
@@ -1340,6 +1361,10 @@ protected function after_plugin_theme_update( $update_results ) {
13401361
* @param string $type The type of email to send. Can be one of 'success', 'fail', 'mixed'.
13411362
* @param array $successful_updates A list of updates that succeeded.
13421363
* @param array $failed_updates A list of updates that failed.
1364+
*
1365+
* @phpstan-param 'success'|'fail'|'mixed' $type
1366+
* @phpstan-param array{ plugin?: list<Plugin_Update_Result>, theme?: list<Theme_Update_Result> } $successful_updates
1367+
* @phpstan-param array{ plugin?: list<Plugin_Update_Result>, theme?: list<Theme_Update_Result> } $failed_updates
13431368
*/
13441369
protected function send_plugin_theme_email( $type, $successful_updates, $failed_updates ) {
13451370
// No updates were attempted.
@@ -1487,6 +1512,13 @@ protected function send_plugin_theme_email( $type, $successful_updates, $failed_
14871512

14881513
$body[] = $body_message;
14891514

1515+
if ( isset( $item->result ) && is_wp_error( $item->result ) ) {
1516+
$fatal_error = $item->result->get_error_message( 'plugin_update_fatal_error' );
1517+
if ( '' !== $fatal_error ) {
1518+
$body[] = ' ' . $fatal_error;
1519+
}
1520+
}
1521+
14901522
$past_failure_emails[ $item->item->plugin ] = $item->item->new_version;
14911523
}
14921524

@@ -1862,6 +1894,7 @@ protected function send_debug_email() {
18621894
* Fatal errors cannot be detected unless maintenance mode is enabled.
18631895
*
18641896
* @since 6.6.0
1897+
* @since 7.2.0 Details of a detected fatal error are recorded for the update result.
18651898
*
18661899
* @global int $upgrading The Unix timestamp marking when upgrading WordPress began.
18671900
*
@@ -1870,6 +1903,8 @@ protected function send_debug_email() {
18701903
protected function has_fatal_error() {
18711904
global $upgrading;
18721905

1906+
$this->fatal_error_message = null;
1907+
18731908
$maintenance_file = ABSPATH . '.maintenance';
18741909
if ( ! file_exists( $maintenance_file ) ) {
18751910
return false;
@@ -1916,6 +1951,12 @@ protected function has_fatal_error() {
19161951
$response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
19171952

19181953
if ( is_wp_error( $response ) ) {
1954+
$this->fatal_error_message = sprintf(
1955+
/* translators: %s: Error message. */
1956+
__( 'The loopback request to check for fatal errors failed: %s' ),
1957+
$response->get_error_message()
1958+
);
1959+
19191960
if ( $is_debug ) {
19201961
error_log( 'Loopback request failed: ' . $response->get_error_message() );
19211962
}
@@ -1937,6 +1978,19 @@ protected function has_fatal_error() {
19371978
$result = json_decode( trim( $error_output ), true );
19381979
}
19391980

1981+
if ( is_array( $result ) && isset( $result['type'] ) && ! empty( $result['message'] ) && is_string( $result['message'] ) ) {
1982+
/*
1983+
* Only the first line of the message is kept, as the message for an uncaught exception
1984+
* continues with a stack trace, which can include the values of function arguments.
1985+
*/
1986+
$this->fatal_error_message = sprintf(
1987+
'PHP Fatal error: %s in %s on line %d',
1988+
explode( "\n", $result['message'], 2 )[0],
1989+
isset( $result['file'] ) && is_string( $result['file'] ) ? $result['file'] : 'unknown',
1990+
isset( $result['line'] ) && is_numeric( $result['line'] ) ? (int) $result['line'] : 0
1991+
);
1992+
}
1993+
19401994
delete_transient( $transient );
19411995

19421996
// Only fatal errors will result in a 'type' key.

‎tests/phpstan/baselines/variable.undefined.neon‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,6 @@ parameters:
158158
identifier: variable.undefined
159159
count: 9
160160
path: ../../../src/wp-admin/includes/class-core-upgrader.php
161-
-
162-
message: '#^Variable \$subject might not be defined\.$#'
163-
identifier: variable.undefined
164-
count: 1
165-
path: ../../../src/wp-admin/includes/class-wp-automatic-updater.php
166161
-
167162
message: '#^Variable \$wp_version might not be defined\.$#'
168163
identifier: variable.undefined

‎tests/phpunit/tests/admin/wpAutomaticUpdater.php‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,4 +756,156 @@ public function test_is_vcs_checkout_should_return_false_when_no_directories_are
756756

757757
$this->assertFalse( $updater_mock->is_vcs_checkout( get_temp_dir() ) );
758758
}
759+
760+
/**
761+
* Tests that {@see WP_Automatic_Updater::send_plugin_theme_email()} lists the details of a
762+
* fatal error under the plugin whose update was rolled back.
763+
*
764+
* @ticket 64155
765+
*
766+
* @covers WP_Automatic_Updater::send_plugin_theme_email
767+
*/
768+
public function test_send_plugin_theme_email_should_include_fatal_error_details(): void {
769+
$filter = new MockAction();
770+
add_filter( 'wp_mail', array( $filter, 'filter' ) );
771+
772+
$fatal_error = 'PHP Fatal error: Uncaught Error: Call to undefined function failed_plugin_init() in wp-content/plugins/failed-plugin/failed-plugin.php on line 12';
773+
774+
$result = new WP_Error( 'plugin_update_fatal_error_rollback_successful', "The update for 'failed-plugin' contained a fatal error. The previously installed version has been restored." );
775+
$result->add( 'plugin_update_fatal_error', $fatal_error );
776+
777+
$failed = array(
778+
(object) array(
779+
'name' => 'Failed Plugin',
780+
'result' => $result,
781+
'item' => (object) array(
782+
'current_version' => '1.0.0',
783+
'new_version' => '2.0.0',
784+
'plugin' => 'failed-plugin/failed-plugin.php',
785+
),
786+
),
787+
);
788+
789+
self::$send_plugin_theme_email->invokeArgs( self::$updater, array( 'fail', array(), array( 'plugin' => $failed ) ) );
790+
791+
$this->assertSame( 1, $filter->get_call_count(), 'An email should have been sent.' );
792+
793+
$mail_args = $filter->get_args()[0][0];
794+
$this->assertIsArray( $mail_args );
795+
$this->assertArrayHasKey( 'message', $mail_args );
796+
$this->assertIsString( $mail_args['message'] );
797+
$this->assertStringContainsString(
798+
"- Failed Plugin (from version 1.0.0 to 2.0.0)\n {$fatal_error}\n",
799+
$mail_args['message'],
800+
'The fatal error should be listed under the plugin that failed to update.'
801+
);
802+
}
803+
804+
/**
805+
* Tests that {@see WP_Automatic_Updater::has_fatal_error()} records the details of the fatal
806+
* error found by the loopback request, without any stack trace.
807+
*
808+
* @ticket 64155
809+
*
810+
* @covers WP_Automatic_Updater::has_fatal_error
811+
*
812+
* @dataProvider data_has_fatal_error_should_record_fatal_error_details
813+
*
814+
* @param array<string, string>|true|WP_Error $scrape_result The result of the loopback request: the error
815+
* found by the scrape, `true` for none, or a
816+
* `WP_Error` for a failed request.
817+
* @param bool $expected_return The expected return value.
818+
* @param string|null $expected_message The expected details of the fatal error.
819+
*/
820+
public function test_has_fatal_error_should_record_fatal_error_details( $scrape_result, bool $expected_return, ?string $expected_message ): void {
821+
$upgrading = 1700000000;
822+
823+
$maintenance_file = ABSPATH . '.maintenance';
824+
$this->assertFileDoesNotExist( $maintenance_file, 'The test requires that no maintenance file exists.' );
825+
file_put_contents( $maintenance_file, "<?php \$upgrading = {$upgrading};" );
826+
827+
add_filter(
828+
'pre_http_request',
829+
static function () use ( $scrape_result, $upgrading ) {
830+
if ( $scrape_result instanceof WP_Error ) {
831+
return $scrape_result;
832+
}
833+
834+
$scrape_key = md5( (string) $upgrading );
835+
836+
return array(
837+
'headers' => array(),
838+
'body' => "<html></html>\n###### wp_scraping_result_start:{$scrape_key} ######\n" . wp_json_encode( $scrape_result ) . "\n###### wp_scraping_result_end:{$scrape_key} ######\n",
839+
'response' => array(
840+
'code' => 200,
841+
'message' => 'OK',
842+
),
843+
'cookies' => array(),
844+
'filename' => null,
845+
);
846+
}
847+
);
848+
849+
$has_fatal_error = new ReflectionMethod( self::$updater, 'has_fatal_error' );
850+
$message = new ReflectionProperty( self::$updater, 'fatal_error_message' );
851+
if ( PHP_VERSION_ID < 80100 ) {
852+
$has_fatal_error->setAccessible( true );
853+
$message->setAccessible( true );
854+
}
855+
856+
try {
857+
$this->assertSame( $expected_return, $has_fatal_error->invoke( self::$updater ), 'The return value should indicate whether a fatal error was detected.' );
858+
} finally {
859+
unlink( $maintenance_file );
860+
}
861+
862+
$this->assertSame( $expected_message, $message->getValue( self::$updater ), 'The recorded details of the fatal error do not match.' );
863+
}
864+
865+
/**
866+
* Data provider.
867+
*
868+
* The scrape results mirror those of {@see wp_finalize_scraping_edited_file_errors()}, which
869+
* passes the error through `str_replace()`, so that every value is a string.
870+
*
871+
* @return array<non-falsy-string, array{
872+
* scrape_result: array<string, string>|true|WP_Error,
873+
* expected_return: bool,
874+
* expected_message: string|null,
875+
* }>
876+
*/
877+
public function data_has_fatal_error_should_record_fatal_error_details(): array {
878+
return array(
879+
'fatal error with a stack trace' => array(
880+
'scrape_result' => array(
881+
'type' => '1',
882+
'message' => "Uncaught Exception: Could not connect to the database in wp-content/plugins/failed-plugin/failed-plugin.php:3\nStack trace:\n#0 wp-content/plugins/failed-plugin/failed-plugin.php(7): failed_plugin_connect('db_user', 'secret_password')\n#1 {main}\n thrown",
883+
'file' => 'wp-content/plugins/failed-plugin/failed-plugin.php',
884+
'line' => '3',
885+
),
886+
'expected_return' => true,
887+
'expected_message' => 'PHP Fatal error: Uncaught Exception: Could not connect to the database in wp-content/plugins/failed-plugin/failed-plugin.php:3 in wp-content/plugins/failed-plugin/failed-plugin.php on line 3',
888+
),
889+
'fatal error without a stack trace' => array(
890+
'scrape_result' => array(
891+
'type' => '64',
892+
'message' => 'Cannot redeclare failed_plugin_init()',
893+
'file' => 'wp-content/plugins/failed-plugin/failed-plugin.php',
894+
'line' => '12',
895+
),
896+
'expected_return' => true,
897+
'expected_message' => 'PHP Fatal error: Cannot redeclare failed_plugin_init() in wp-content/plugins/failed-plugin/failed-plugin.php on line 12',
898+
),
899+
'no fatal error' => array(
900+
'scrape_result' => true,
901+
'expected_return' => false,
902+
'expected_message' => null,
903+
),
904+
'failed loopback request' => array(
905+
'scrape_result' => new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ),
906+
'expected_return' => true,
907+
'expected_message' => 'The loopback request to check for fatal errors failed: cURL error 28: Operation timed out',
908+
),
909+
);
910+
}
759911
}

0 commit comments

Comments
 (0)