Make WordPress Core

Opened 4 weeks ago

Last modified 4 weeks ago

#64653 new defect (bug)

WP_Hook::resort_active_iterations() skips next priority when callback removes itself during execution

Reported by: mrcasual's profile mrcasual Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version: trunk
Component: Plugins Keywords: has-patch has-unit-tests
Focuses: Cc:

Description

When a callback removes itself (or is the only callback at its priority) during
do_action()/apply_filters() execution, the next registered priority is silently skipped. This seems to affect all versions since the introduction of WP_Hook.

To reproduce, run this as an mu-plugin:

<?php

function wp_hook_bug_self_removing() {
    remove_action( 'wp_hook_bug_test', 'wp_hook_bug_self_removing', 50 );
    error_log( 'Priority 50 executed (removed itself).' );
}

add_action( 'init', function () {
    add_action( 'wp_hook_bug_test', function () {
            error_log( 'Priority 10 executed.' );
    }, 10 );

    add_action( 'wp_hook_bug_test', 'wp_hook_bug_self_removing', 50 );

    add_action( 'wp_hook_bug_test', function () {
            error_log( 'Priority 100 executed.' );
    }, 100 );

    do_action( 'wp_hook_bug_test' );
} );

Expected: all three priorities execute (10, 50, 100).
Actual: priority 100 is silently skipped. Only 10 and 50 execute.

The bug requires all three:

  1. A callback removes itself (or its entire priority group) during hook execution.
  2. It is the only callback at that priority (so the priority is fully removed from the array).
  3. There is at least one priority lower than the removed one. If the removed priority is the lowest, the array_unshift branch in resort_active_iterations() handles it correctly.

In WP_Hook::resort_active_iterations() (wp-includes/class-wp-hook.php), when a priority is removed during iteration, the method rebuilds the iteration array and repositions the internal pointer:

while ( current( $iteration ) < $current ) {
    if ( false === next( $iteration ) ) {
        break;
    }
}

This positions the pointer at the first remaining priority >= $current (the removed priority). Since $current no longer exists, the pointer lands on the next priority (e.g., 100).

Back in apply_filters(), the do...while loop calls next() at the bottom of each iteration, which advances past 100. The loop ends, and priority 100 never executes.

Change History (2)

This ticket was mentioned in PR #10949 on WordPress/wordpress-develop by mrcasual.


4 weeks ago
#1

  • Keywords has-unit-tests added

Tac ticket: https://core.trac.wordpress.org/ticket/64653

When a callback removes itself during do_action() / apply_filters() and is the sole callback at its priority, resort_active_iterations() positions the internal array pointer one step too far. The main loop's next() call then skips the following priority entirely.

## Steps to reproduce

<?php

add_action( 'test_hook', '__return_true', 10 );

add_action( 'test_hook', function () {
    remove_action( 'test_hook', __FUNCTION__, 50 );
}, 50 );

add_action( 'test_hook', function () {
    error_log( 'This never runs' );
}, 100 );

do_action( 'test_hook' ); // Priority 100 is silently skipped.

The bug requires: (1) a lower priority exists, (2) the removed callback is the only one at its priority.

#2 @siliconforks
4 weeks ago

Possibly related: #61263

Note: See TracTickets for help on using tickets.