Skip to content
90 changes: 90 additions & 0 deletions classes/ActionScheduler_AdminView.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public static function instance() {
* @codeCoverageIgnore
*/
public function init() {
// Registered unconditionally: the failure is recorded from the queue runner, which usually
// runs outside the admin (cron/async), so the recorder must not sit behind the is_admin() guard.
add_action( 'action_scheduler_unrecognized_schedule_action', array( $this, 'note_unrecognized_schedule_failure' ) );

if ( is_admin() && ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) ) {

if ( class_exists( 'WooCommerce' ) ) {
Expand All @@ -60,6 +64,7 @@ public function init() {

add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_notices', array( $this, 'maybe_check_pastdue_actions' ) );
add_action( 'admin_notices', array( $this, 'maybe_show_unrecognized_schedule_notice' ) );
add_action( 'current_screen', array( $this, 'add_help_tabs' ) );
}
}
Expand Down Expand Up @@ -241,6 +246,91 @@ protected function check_pastdue_actions() {
do_action( 'action_scheduler_pastdue_actions_extra_notices', $query_args );
}

/**
* Option flag set when one or more actions have been failed because their schedule referenced an
* unrecognized class. Drives the review notice below.
*
* @var string
*/
const UNRECOGNIZED_SCHEDULE_NOTICE_OPTION = 'action_scheduler_unrecognized_schedule_failures';

/**
* Record that an action was failed because its schedule referenced an unrecognized class, so an
* admin notice can prompt an operator to review it.
*
* Fired from the queue runner, which usually runs outside the admin (cron/async), so this makes no
* assumptions about admin context. The flag is not autoloaded.
*/
public function note_unrecognized_schedule_failure() {
if ( ! get_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION ) ) {
update_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION, 1, false );
}
}

/**
* Action: admin_notices
*
* Prompt operators to review actions failed because their schedule could not be recognized, and
* render until the operator dismisses the notice.
*/
public function maybe_show_unrecognized_schedule_notice() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}

// Process an explicit dismissal.
if (
isset( $_GET['as_dismiss_unrecognized_schedule'], $_GET['_asnonce'] )
&& wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_asnonce'] ) ), 'as_dismiss_unrecognized_schedule' )
) {
delete_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION );
return;
}

if ( ! get_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION ) ) {
return;
}

$failed_url = add_query_arg(
array(
'page' => 'action-scheduler',
'status' => ActionScheduler_Store::STATUS_FAILED,
'order' => 'asc',
),
admin_url( 'tools.php' )
);

$dismiss_url = wp_nonce_url(
add_query_arg( 'as_dismiss_unrecognized_schedule', '1' ),
'as_dismiss_unrecognized_schedule',
'_asnonce'
);

// The whole sentence is one translatable string, so translators keep full context and natural
// word order. Only the HTML tags are pulled out as placeholders (opening/closing pairs) -- tags
// need no translation. esc_html__() on the sentence neutralizes any markup a rogue translation
// might contain (the %n$s tokens survive it), and each opening tag carries an escaped URL, so
// wp_kses() is unnecessary.
$message = sprintf(
/* translators: 1$s/2$s: opening/closing bold tags around "Action Scheduler:". 3$s/4$s: opening/closing tags of a link (around "failed") to the failed actions screen. 5$s/6$s: opening/closing tags of the dismiss link (around "Dismiss"). */
esc_html__( '%1$sAction Scheduler:%2$s one or more scheduled actions could not be run because their schedule references an unrecognized class, and have been marked %3$sfailed%4$s for your review. If an action is safe to run, use its Run link to force it. %5$sDismiss%6$s', 'action-scheduler' ),
'<strong>',
'</strong>',
'<a href="' . esc_url( $failed_url ) . '">',
'</a>',
'<a href="' . esc_url( $dismiss_url ) . '">',
'</a>'
);

wp_admin_notice(
$message,
array(
'type' => 'warning',
'paragraph_wrap' => true,
)
);
}

/**
* Provide more information about the screen and its data in the help tab.
*/
Expand Down
12 changes: 11 additions & 1 deletion classes/ActionScheduler_ListTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ protected function get_schedule_display_string( ActionScheduler_Schedule $schedu

$schedule_display_string = '';

if ( is_a( $schedule, 'ActionScheduler_UnrecognizedSchedule' ) ) {
return __( 'Unrecognized schedule', 'action-scheduler' );
}

if ( is_a( $schedule, 'ActionScheduler_NullSchedule' ) ) {
return __( 'async', 'action-scheduler' );
}
Expand Down Expand Up @@ -570,7 +574,13 @@ protected function process_row_action( $action_id, $row_action_type ) {
try {
switch ( $row_action_type ) {
case 'run':
$this->runner->process_action( $action_id, 'Admin List Table' );
// A failed action is not pending, so it cannot be run on the normal path. Force it, so
// an operator can retry a failed action or flush one whose schedule was unrecognized.
if ( ActionScheduler_Store::STATUS_FAILED === $this->store->get_status( $action_id ) ) {
$this->runner->force_run_action( $action_id, 'Admin List Table' );
} else {
$this->runner->process_action( $action_id, 'Admin List Table' );
}
break;
case 'cancel':
$this->store->cancel_action( $action_id );
Expand Down
10 changes: 10 additions & 0 deletions classes/ActionScheduler_ScheduleDeserializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,16 @@ protected function reject( $offending_class, $shadow_eligible ) {
do_action( 'action_scheduler_unexpected_schedule_class', $offending_class, $this->outer_class, $this->potential_offenders, $rejected );

if ( $rejected ) {
// A recoverable ($shadow_eligible) rejection is a structurally-valid schedule that merely
// references an unrecognized class — not evidently dangerous, just unknown. Return it as an
// ActionScheduler_UnrecognizedSchedule placeholder so the action stays visible and can be
// routed to a failed state for operator review / forced re-run, rather than being treated as
// irretrievably corrupt (false) and silently cancelled. Structural rejections (a top-level
// non-schedule or an unwalkable graph) have no legitimate form and remain corrupt.
if ( $shadow_eligible ) {
return new ActionScheduler_UnrecognizedSchedule( $this->potential_offenders );
}

return false;
}

Expand Down
81 changes: 78 additions & 3 deletions classes/abstracts/ActionScheduler_Abstract_QueueRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ public function __construct( ?ActionScheduler_Store $store = null, ?ActionSchedu
* @param int $action_id The action ID to process.
* @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron'
* Generally, this should be capitalised and not localised as it's a proper noun.
* @param bool $force Run the action even if it is not pending, and skip the unrecognized-schedule
* guard. Used by force_run_action() so an operator can flush stuck work.
* @throws \Exception When error running action.
*/
public function process_action( $action_id, $context = '' ) {
public function process_action( $action_id, $context = '', $force = false ) {
// Temporarily override the error handler while we process the current action.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
set_error_handler(
Expand Down Expand Up @@ -89,7 +91,9 @@ function ( $type, $message ) {
try {
do_action( 'action_scheduler_before_execute', $action_id, $context );

if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
// A forced run (e.g. a site operator using the admin "Run" link) executes the action even
// if it is not pending, so it can flush work that is otherwise stuck.
if ( ! $force && ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
$valid_action = false;
do_action( 'action_scheduler_execution_ignored', $action_id, $context );
return;
Expand All @@ -104,6 +108,15 @@ function ( $type, $message ) {
return;
}

// An action whose schedule could not be recognized is not run automatically: we cannot
// know when it was meant to run, so we mark it failed for operator review rather than
// cancelling it. A forced run overrides this and executes the action's callback anyway.
if ( ! $force && $action->get_schedule() instanceof ActionScheduler_UnrecognizedSchedule ) {
$valid_action = false;
$this->fail_unrecognized_action( $action_id );
return;
}

$this->store->log_execution( $action_id );
$action->execute();

Expand All @@ -122,11 +135,45 @@ function ( $type, $message ) {
restore_error_handler();
}

if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {
// A forced run flushes a single occurrence on an operator's request; it must not advance a
// recurring series. The next instance was already scheduled when the action first ran, so
// rescheduling here would create a duplicate.
if ( ! $force && isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {
$this->schedule_next_instance( $action, $action_id );
}
}

/**
* Force an action to run now, regardless of its current status.
*
* Intended for a site operator flushing stuck work from the admin list table — most usefully an
* action that was failed because its schedule references an unrecognized class, which cannot be run
* on the normal (automatic) path. A failed action would otherwise be fetched as a non-executable
* ActionScheduler_FinishedAction, so for the duration of this run we map it back to an executable
* action class. The action's callback runs; its (unreadable or valid) schedule is not consulted, so
* no next instance of a recurring series is scheduled.
*
* This executes an action regardless of status, so callers are responsible for authorizing the
* request (the admin list table gates it behind a per-row nonce and the manage_options capability).
*
* @param int $action_id Action ID.
* @param string $context Context in which the action is being run.
* @return void
*/
public function force_run_action( $action_id, $context = '' ) {
$as_executable = static function ( $action_class, $status ) {
return ActionScheduler_Store::STATUS_FAILED === $status ? 'ActionScheduler_Action' : $action_class;
};

add_filter( 'action_scheduler_stored_action_class', $as_executable, 10, 2 );

try {
$this->process_action( $action_id, $context, true );
} finally {
remove_filter( 'action_scheduler_stored_action_class', $as_executable, 10 );
}
}

/**
* Fetches the action instance. On failure returns null instead of ActionScheduler_NullAction.
*
Expand Down Expand Up @@ -165,6 +212,34 @@ private function cancel_corrupted_action( int $action_id ) {
);
}

/**
* Marks an action failed because its schedule references a class Action Scheduler does not
* recognize. Unlike a corrupt action, this is recoverable: an operator can review it and force it
* to run, or make the referenced class available (via the
* action_scheduler_allowed_nested_schedule_classes filter) and re-run it normally.
*
* @param int $action_id Action ID.
* @return void
*/
private function fail_unrecognized_action( int $action_id ) {
$this->store->mark_failure( $action_id );

/**
* Fires when an action is marked failed because its stored schedule references an unrecognized
* class, rather than being run or cancelled.
*
* @since 4.1.0
*
* @param int $action_id Action ID.
*/
do_action( 'action_scheduler_unrecognized_schedule_action', $action_id );

ActionScheduler_Logger::instance()->log(
$action_id,
__( 'This action references a schedule that could not be recognized, so it has been marked as failed rather than run. Review it and, if it is safe, force it to run.', 'action-scheduler' )
);
}

/**
* Marks actions as either having failed execution or failed validation, as appropriate.
*
Expand Down
46 changes: 46 additions & 0 deletions classes/schedules/ActionScheduler_UnrecognizedSchedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/**
* A placeholder schedule for a stored action whose real schedule could not be safely deserialized
* because it references one or more classes Action Scheduler does not recognize.
*
* Such a schedule is structurally valid (its outermost object implements ActionScheduler_Schedule)
* but nests a class outside the trusted allow-list — for example a support class shipped by an
* extension that is not currently loaded or allow-listed. It is not, in itself, evidently dangerous,
* so rather than silently cancelling the action (which loses it), we substitute this placeholder.
* That keeps the action visible so a site operator can review it, move it to a failed state, and — if
* appropriate — force it to run. @see https://github.com/woocommerce/action-scheduler/issues/1318
*
* It extends ActionScheduler_NullSchedule, so it carries no runnable date and is treated as a one-off
* (non-recurring) for scheduling purposes.
*
* @since 4.1.0
*/
class ActionScheduler_UnrecognizedSchedule extends ActionScheduler_NullSchedule {

/**
* The class names discovered in the stored blob that Action Scheduler did not recognize.
*
* @var string[]
*/
protected $unrecognized_classes = array();

/**
* Construct.
*
* @param string[] $unrecognized_classes Class names from the blob that could not be vetted.
*/
public function __construct( array $unrecognized_classes = array() ) {
parent::__construct();
$this->unrecognized_classes = array_values( array_unique( $unrecognized_classes ) );
}

/**
* The unrecognized class names, for display and logging.
*
* @return string[]
*/
public function get_unrecognized_classes() {
return $this->unrecognized_classes;
}
}
1 change: 1 addition & 0 deletions tests/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<file phpVersion="5.6">./phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php</file>
<file phpVersion="5.6">./phpunit/jobstore/ActionScheduler_DBStore_Test.php</file>
<file phpVersion="5.6">./phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php</file>
<file phpVersion="5.6">./phpunit/jobstore/ActionScheduler_UnrecognizedSchedule_Test.php</file>
<file phpVersion="5.6">./phpunit/jobstore/ActionScheduler_HybridStore_Test.php</file>
<file phpVersion="5.6">./phpunit/logging/ActionScheduler_DBLogger_Test.php</file>
</testsuite>
Expand Down
Loading
Loading