Make WordPress Core

Opened 11 months ago

Last modified 8 weeks ago

#63987 reopened defect (bug)

wp_get_scheduled_event() incorrectly returns false for cron events scheduled with a timestamp of 0.

Reported by: codekraft Owned by:
Priority: normal Milestone:
Component: Cron API Version:
Severity: normal Keywords: has-test-info has-patch
Cc: Focuses:

Description

When using the wp_get_scheduled_event() function to retrieve a scheduled cron event, it incorrectly returns false if the event's timestamp is exactly 0 (the Unix epoch).

wp_get_scheduled_event can be found in wp-includes/cron.php:787

The issue

The issue lies in this line:

<?php
if ( ! $timestamp ) {

In PHP, the integer 0 is evaluated as false in a boolean context. This causes the function to enter the "get next event" block, which then fails to find the event at timestamp 0 and ultimately returns false, despite the event existing in the cron array.

How to Reproduce
Schedule a cron event with a timestamp of 0. You can do this by manually adding it to the cron array or by using a function like this:

<?php
// Add an event with a timestamp of 0.
// This is for demonstration purposes. In a real scenario, this might happen due to a bug in a plugin.
$crons = _get_cron_array();
$crons[0]['my_test_hook']['unique_key'] = array(
    'schedule' => false,
    'args'     => array(),
);
_set_cron_array($crons);

Attempt to retrieve this specific event using wp_get_scheduled_event() with the timestamp 0:

<?php
$event = wp_get_scheduled_event( 'my_test_hook', array(), 0 );

Check the result.

<?php
if ( false === $event ) {
    echo "The function returned false. This is the bug.";
} else {
    echo "The function correctly found the event.";
}

Expected behavior: The function should return the event object, as the event with a timestamp of 0 exists.

Current behavior: The function returns false, indicating the event does not exist. So i cannot remove that event from the cron array using wp_unschedule_event

Suggested Fix

A possible fix is to change the if condition to be more explicit, for example:

<?php
if ( null === $timestamp ) {

This would only trigger the "get next event" logic when no timestamp is provided, correctly handling a timestamp of 0 as a valid value.

Change History (25)

#1 @johnbillion
11 months ago

  • Keywords needs-patch needs-unit-tests added
  • Version trunk

Thanks for the report @codekraft !

This ticket was mentioned in PR #9914 on WordPress/wordpress-develop by @rishabhwp.


11 months ago
#2

  • Keywords has-patch has-unit-tests added; needs-patch needs-unit-tests removed

#3 @SergeyBiryukov
11 months ago

  • Milestone Awaiting Review6.9

#4 @rollybueno
11 months ago

  • Keywords dev-feedback changes-requested added

Hey @rishabhwp,

I see that you have this code in wp_unschedule_event but not in wp_schedule_single_event?:

if ( ! is_numeric( $timestamp ) || $timestamp < 0 ) {

When in fact both function shares same validation?

#5 @rollybueno
11 months ago

  • Keywords has-test-info added

Reproduction Report

Description

This report validates whether the issue can be reproduced.

The attached test plugin (Ticket 63987) injects two cron events for the same hook my_test_hook:

  • One at timestamp 0 (bug case).
  • One at a non-zero future timestamp (control case).

When calling wp_get_scheduled_event() with timestamp 0, the function incorrectly returns false.

Environment

  • WordPress: 6.9-alpha-60093-src
  • PHP: 8.2.29
  • Server: nginx/1.29.1
  • Database: mysqli (Server: 8.4.6 / Client: mysqlnd 8.2.29)
  • Browser: Chrome 139.0.0.0
  • OS: Linux
  • Theme: Twenty Twenty-Five 1.3
  • MU Plugins: None activated
  • Plugins:
    • Test Reports 1.2.0
    • Ticket 63987 1.0

Steps to Reproduce

  1. Activate the Ticket 63987 plugin.
  2. Visit any front-end page with ?test-cron=1 appended to the URL.
  3. The plugin injects two events and dumps the results of wp_get_scheduled_event().

Actual Results

  • For timestamp 0:
bool(false)
  • For the future timestamp:
object(stdClass)#311 (4) {
  ["hook"]=>
  string(12) "my_test_hook"
  ["timestamp"]=>
  int(1758456619)
  ["schedule"]=>
  bool(false)
  ["args"]=>
  array(0) {
  }
}

✅ Error condition occurs (reproduced).

Expected Results

  • For timestamp 0, the event should be returned in the same format as the non-zero timestamp event. It should be:
  object(stdClass)[312]
  public 'hook' => string 'my_test_hook' (length=12)
  public 'timestamp' => int 0
  public 'schedule' => boolean false
  public 'args' => 
    array (size=0)

  • Returning false is incorrect since the event exists in the cron array.

Additional Notes

  • This confirms that the falsy check for $timestamp in wp_get_scheduled_event() causes timestamp 0 to be treated as "no timestamp."
  • Non-zero timestamps behave as expected.

Supplemental Artifacts

Inline test plugin code for reproduction:

<?php
/**
 * Plugin Name: Ticket 63987
 * Description: Demonstrates the bug in wp_get_scheduled_event() when timestamp is 0.
 * Version: 1.0
 * Author: Rolly Bueno
 */

add_action( 'init', 'test_cron_zero_timestamp' );

function test_cron_zero_timestamp() {
    if ( ! isset( $_GET['test-cron'] ) ) {
        return;
    }

    // 1. Inject a cron event at timestamp 0 (bug case).
    $crons = _get_cron_array();
    $args  = array();
    $key   = md5( serialize( $args ) );
    $crons[0]['my_test_hook'][ $key ] = array(
        'schedule' => false,
        'args'     => $args,
    );

    // 2. Inject a cron event at a non-zero timestamp (valid case).
    $future = time() + 3600; // 1 hour from now
    $crons[ $future ]['my_test_hook'][ $key ] = array(
        'schedule' => false,
        'args'     => $args,
    );

    // Save back into cron array.
    _set_cron_array( $crons );

    // 3. Try fetching both.
    $event_zero   = wp_get_scheduled_event( 'my_test_hook', array(), 0 );
    $event_future = wp_get_scheduled_event( 'my_test_hook', array(), $future );

    // 4. Output results.
    echo '<pre>';
    echo "Testing wp_get_scheduled_event()\n\n";

    echo "At timestamp 0:\n";
    var_dump( $event_zero );

    echo "\nAt timestamp {$future}:\n";
    var_dump( $event_future );

    echo '</pre>';

    exit;
}

Actual result:
https://i.imgur.com/3nnCxiy.png

(Correct result) If https://github.com/WordPress/WordPress/blob/master/wp-includes/cron.php#L787 updated into if ( null === $timestamp ) {:
https://i.imgur.com/kBZYGx9.png

Last edited 11 months ago by rollybueno (previous) (diff)

#6 @rollybueno
11 months ago

I left a review feedback on https://github.com/WordPress/wordpress-develop/pull/9914, which needs an update before putting this to needs-testing

#7 @rollybueno
11 months ago

  • Keywords needs-testing added; dev-feedback changes-requested removed

Changes set - adding needs-testing

#8 @mindctrl
10 months ago

  • Keywords 2nd-opinion added

It appears this behavior is intentional, since the helper functions for scheduling events return false and/or a WP_Error object when a timestamp of 0 is provided. (wp_schedule_event, wp_schedule_single_event, wp_reschedule_event, wp_unschedule_event). / Example

If using wp_schedule_event() or related, this bug doesn't really exist because the 0 timestamp is considered invalid and it won't be added to the cron array.

0 is a valid timestamp, but is there a use case for scheduling events at 0? What would be the purpose of scheduling at 0, instead of say now() or even 1?

In the original ticket, the example uses _set_cron_array(), which is marked as private in the phpdocs, like many/most functions in WP that begin with the _ character.

This ticket was mentioned in Slack in #core-test by jon_bossenger. View the logs.


10 months ago

This ticket was mentioned in Slack in #core-test by krupajnanda. View the logs.


9 months ago

#11 @krupajnanda
9 months ago

Given the remaining work and the limited time in the 6.9 cycle, moving this to 7.0 will give contributors enough time to properly test and validate the fix.

#12 @wildworks
9 months ago

  • Milestone 6.97.0

#13 @huzaifaalmesbah
6 months ago

  • Keywords needs-testing removed

Patch Testing Report

Patch Tested: https://github.com/WordPress/wordpress-develop/pull/9914

Environment

  • WordPress: 7.0-alpha-61215-src
  • PHP: 8.2.30
  • Server: nginx/1.29.4
  • Database: mysqli (Server: 9.5.0 / Client: mysqlnd 8.2.30)
  • Browser: Chrome 144.0.0.0
  • OS: macOS
  • Theme: Twenty Twenty-Five 1.4
  • MU Plugins:
    • Ticket 63987 1.0 (reproduction plugin)
  • Plugins:
    • Classic Editor 1.6.7
    • Hello Dolly 1.7.2
    • Test Reports 1.2.1

Steps taken

  1. Activated the Ticket 63987 reproduction plugin.
  2. Plugin injected two cron events:
    • timestamp 0 (bug case)
    • future timestamp (control case)
  3. Called wp_get_scheduled_event() for both timestamps.
  4. Before applying the patch, the timestamp 0 event returned bool(false) while the future event returned a valid object.
  5. Applied PR patch.
  6. Re-tested using the same steps; both timestamp 0 and future timestamps now return valid event objects.
  7. ✅ Patch is solving the problem

Expected result

  • wp_get_scheduled_event() should return the scheduled event object for any valid timestamp, including 0.
  • Timestamp 0 should not be treated as missing or falsy.
  • Both events should consistently return an stdClass object.

Additional Notes

  • Root cause was the conditional if ( ! $timestamp ), which treats 0 as false in PHP.
  • Patch updates this to if ( null === $timestamp ), correctly distinguishing between null and 0.
  • Verified no regressions in normal cron behavior.
  • Fix is minimal, safe, and works as expected.

Screenshots/Screencast with results

Before patch and after patch: https://files.catbox.moe/uby6vr.mp4

Version 0, edited 6 months ago by huzaifaalmesbah (next)

This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.


5 months ago

#15 @juanmaguitar
5 months ago

From today's bug scrub

The related PR #9914 is approved and the patch has been properly tested. I'll try to get a Core Commiter to have a look at it. It seems it just need a final push.

I shared this ticket on the #7-0-release-leads channel to try to get more eyes on it

#16 @alexodiy
5 months ago

Hey everyone, I tested PR #9914 on PHP 8.5.1 (Windows) against trunk r61991 and can confirm the fix works as expected.

I injected a cron event at timestamp 0 alongside a regular future event, then tried to retrieve both with wp_get_scheduled_event().

Before the patch: the event at timestamp 0 returns false because if ( ! $timestamp ) treats 0 as falsy. The future event works fine.

After the patch: changing to if ( null === $timestamp ) fixes it. The event at timestamp 0 is now correctly returned as an object, and the "get next event" logic (when no timestamp is passed) still works as before.

I also checked a few edge cases just to be safe:

Scenario Before Patch After Patch
Explicit timestamp 0 false (bug) Event object (correct)
Future timestamp Event object Event object
Non-existent hook at 0 false false (correct)
Null timestamp (get next) Works Works

No regressions on my end. Clean and minimal fix. +1 for commit.

#17 @wildworks
5 months ago

I don't see the current behavior as a bug, so I suggest closing this ticket.

There are two reasons for this.

  • Events with a zero timestamp cannot be registered using public functions. They can only be registered using _get_cron_array and _set_cron_array, which are marked as private. I don't think registering cron events in that way is intended.
  • Cron events are meant to be registered for future dates, not for current or past dates.

#18 @mindctrl
5 months ago

  • Keywords close added

This ticket was mentioned in Slack in #core by audrasjb. View the logs.


5 months ago

#20 @audrasjb
5 months ago

  • Milestone 7.0
  • Resolutionwontfix
  • Status newclosed

As per today's 7.0 pre-RC1 bug scrub:
I'm closing this ticket as wontfix per comment:17. Feel free to reopen it if you disagree with the reasoning.

#21 @codekraft
5 months ago

Hi @mindctrl and @audrasjb,

Thank you both for reviewing this and taking the time to discuss it! And sorry it took me so long to reply... if what I’m saying is nonsense, please don’t even bother taking it seriously. I completely understand the reasoning, but I would kindly ask you to reconsider closing the ticket, as I have just encountered a real-world case where a client's site was broken by this exact issue.

Regarding the point that "Events with a zero timestamp cannot be registered using public functions", this is actually possible through wp_schedule_event() due to a PHP quirk with floating-point numbers.

Here is how it happens using public functions:

  1. If a third-party plugin accidentally passes a float between 0 and 1 (e.g., 0.5, maybe due a wrong calculation), the validation ! is_numeric( 0.5 ) || 0.5 <= 0 evaluates to false. The error check is bypassed.
  2. The event is passed to the core system to be saved.
  3. When WordPress sets this in the multidimensional cron array, PHP automatically truncates the 0.5 float key into an integer 0.

The Real-World Problem:
While cron events are indeed meant for future dates, bugs in third-party plugins happen. When a plugin accidentally triggers the float loophole above, the event gets registered at 0. Once it is stuck at 0, we enter a "cul-de-sac" and it becomes impossible to delete the event using standard public functions like wp_unschedule_event().

The site gets permanently stuck with a ghost cron job that cannot be cleared normally, forcing manual database intervention on the cron option.

Casting the timestamp to an (int) before validation in the core, or using is_int(), would prevent this unrecoverable state from happening in the first place.

Would you be open to reopening the ticket to patch this edge case? thanks!

#22 @wildworks
5 months ago

@codekraft Thank you for explaining it in detail.

Here is how it happens using public functions:

  1. If a third-party plugin accidentally passes a float between 0 and 1 (e.g., 0.5, maybe due a wrong calculation), the validation ! is_numeric( 0.5 ) || 0.5 <= 0 evaluates to false. The error check is bypassed.
  2. The event is passed to the core system to be saved.
  3. When WordPress sets this in the multidimensional cron array, PHP automatically truncates the 0.5 float key into an integer 0.

I think this is certainly a problem that needs to be solved.

#23 @mindctrl
5 months ago

  • Keywords needs-patch added; has-patch has-unit-tests 2nd-opinion close removed
  • Resolution wontfix
  • Status closedreopened

@codekraft thanks for explaining the bug path! I like your proposed fix and think we should reopen and improve the error checking when non-int values are provided.

#24 @liaison
5 months ago

Test Report for Ticket #63987 (PR #9914)

Environment:

OS: Windows 10

Server: XAMPP

PHP: 8.2.x

WordPress: 7.0-beta1-61709-src (trunk)

Testing Process:
I used a standalone script to mock a cron array with an event at timestamp 0 and tested the wp_get_scheduled_event() function. This report also validates edge cases related to PHP's float-to-int truncation for array keys.

Results:

Pre-patch: The function returned false for timestamp 0. This is due to PHP's loose typing where ! 0 evaluates to true, causing the function to incorrectly trigger the fallback logic (finding the next event) instead of retrieving the event at index 0.

Post-patch: After changing the check to if ( null === $timestamp ), the function correctly identified and returned the event object at timestamp 0.

Edge Case (Float 0.5): Confirmed that even if a float is passed (which PHP truncates to integer 0 as an array key), the patched code successfully retrieves the event.

Regression (NULL): Confirmed that passing null still correctly triggers the fallback behavior, ensuring zero breaking changes for existing core logic.

Conclusion:
The fix is verified and works as expected on Windows/PHP 8.2. It correctly distinguishes between an explicit 0 timestamp and a null (missing) value. +1 for commit.

Reproducible Test Script (verify-63987.php):

<?php
/**
 * Test script for Ticket #63987 / PR #9914
 * Verifies that wp_get_scheduled_event() correctly handles timestamp 0.
 */

// Basic WP environment mocks
define( 'ABSPATH', __DIR__ . '/' );
define( 'WPINC', 'wp-includes' );
function apply_filters( $tag, $value ) { return $value; }

function get_option( $option, $default = false ) {
    return isset($GLOBALS['mock_options'][$option]) ? $GLOBALS['mock_options'][$option] : $default;
}
if ( ! defined( 'WP_CRON_LOCK_TIMEOUT' ) ) define( 'WP_CRON_LOCK_TIMEOUT', 60 );

// Load the file under test
require_once ABSPATH . WPINC . '/cron.php';

// Mock a cron event at timestamp 0
$hook = 'test_event';
$args = array( 'key' => 'val' );
$sig  = md5( serialize( $args ) );
$GLOBALS['mock_options']['cron'] = array(
    0 => array( $hook => array( $sig => array( 'schedule' => 'hourly', 'args' => $args ) ) ),
    'version' => 2
);

echo "Testing wp_get_scheduled_event with timestamp 0..." . PHP_EOL;

$event = wp_get_scheduled_event( $hook, $args, 0 );

if ( is_object( $event ) ) {
    echo "RESULT: [PASS] - Event found at timestamp 0." . PHP_EOL;
} else {
    echo "RESULT: [FAIL] - Event NOT found (Bug reproduced)." . PHP_EOL;
}

/**
 * SCENARIO A: Float Truncation (The 0.5 Case)
 * PHP truncates array key 0.5 to integer 0.
 */
echo "Testing Scenario A: Float 0.5 (truncated to 0 internally)..." . PHP_EOL;
$event_float = wp_get_scheduled_event( $hook, $args, 0.5 ); // Passing 0.5

if ( is_object( $event_float ) ) {
    echo "✅ [SUCCESS] Corrected code found the event even when passed as float 0.5." . PHP_EOL;
} else {
    echo "❌ [FAIL] Could not find event with float 0.5." . PHP_EOL;
}

/**
 * SCENARIO B: Regression Test for NULL
 */
echo "Testing Scenario B: NULL value (Should maintain legacy fallback)..." . PHP_EOL;
$event_null = wp_get_scheduled_event( $hook, $args, null );

if ( false === $event_null ) {
    echo "✅ [SUCCESS] NULL correctly triggers fallback (returned false as expected)." . PHP_EOL;
} else {
    echo "❌ [FAIL] NULL behavior changed! Possible regression." . PHP_EOL;
}

Last edited 5 months ago by liaison (previous) (diff)

#25 @naimur444
8 weeks ago

  • Keywords has-patch added; needs-patch removed

I looked into this and was able to reproduce the float -> integer-0 path entirely through the public API (without _set_cron_array()), which I think is the crux of comment:21. Sharing a minimal, database-free reproduction plus a proposed patch (inline below).

Root cause

wp_schedule_single_event(), wp_schedule_event() and wp_reschedule_event() all validate the timestamp with:

if ( ! is_numeric( $timestamp ) || $timestamp <= 0 ) {

A float in the (0, 1) range -- e.g. 0.5, which as @codekraft notes can come from a third-party plugin's miscalculation -- passes this check, because is_numeric( 0.5 ) is true and 0.5 <= 0 is false. The value is then used directly as an array key:

$crons[ $event->timestamp ][ $event->hook ][ $key ] = ...;

PHP truncates the float key 0.5 to integer 0, so the event is stored under key 0. Since wp_get_scheduled_event( ..., 0 ) and wp_unschedule_event( 0, ... ) both treat 0 as falsy/invalid, the event can no longer be read or removed through the public API -- the "cul-de-sac" described in the report.

Reproduction (public API only, no DB)

Current trunk:

wp_schedule_single_event( 0.5, 'hook' )   => true
  cron key 0 (type: integer) => hook        // ghost stored under integer key 0
wp_next_scheduled( 'hook' )               => false
wp_get_scheduled_event( 'hook', [], 0 )   => false
wp_unschedule_event( 0, 'hook' )          => false   // event still present -> stuck

Proposed patch

In the three scheduling functions, cast to int before the <= 0 check (per @codekraft / comment:23) and normalise $timestamp to an integer. A sub-1 float is now rejected up front instead of silently becoming a 0 key, so the unrecoverable state can no longer be created via the public API. Non-numeric and negative values are still rejected exactly as before, and integer timestamps are unaffected.

With the patch, the same reproduction rejects 0.5 and nothing is written to the cron array.

(This is scoped to *prevention*. Making an already-stuck key-0 event removable on sites that hit this before upgrading -- i.e. allowing an explicit 0 in wp_unschedule_event() / wp_get_scheduled_event() -- is a separate concern and could be handled as a follow-up if wanted.)

  • src/wp-includes/cron.php

    a b  
    4646 * @return bool|WP_Error True if event successfully scheduled. False or WP_Error on failure.
    4747 */
    4848function wp_schedule_single_event( $timestamp, $hook, $args = array(), $wp_error = false ) {
    49         // Make sure timestamp is a positive integer.
    50         if ( ! is_numeric( $timestamp ) || $timestamp <= 0 ) {
     49        // Make sure timestamp is a positive integer. Cast to int first so that a
     50        // non-integer numeric value (e.g. a float such as 0.5) cannot pass this
     51        // check and then be truncated to the integer 0 when used as an array key.
     52        if ( ! is_numeric( $timestamp ) || (int) $timestamp <= 0 ) {
    5153                if ( $wp_error ) {
    5254                        return new WP_Error(
    5355                                'invalid_timestamp',
     
    5860                return false;
    5961        }
    6062
     63        $timestamp = (int) $timestamp;
     64
    6165        $event = (object) array(
    6266                'hook'      => $hook,
    6367                'timestamp' => $timestamp,
     
    250254 * @return bool|WP_Error True if event successfully scheduled. False or WP_Error on failure.
    251255 */
    252256function wp_schedule_event( $timestamp, $recurrence, $hook, $args = array(), $wp_error = false ) {
    253         // Make sure timestamp is a positive integer.
    254         if ( ! is_numeric( $timestamp ) || $timestamp <= 0 ) {
     257        // Make sure timestamp is a positive integer. Cast to int first so that a
     258        // non-integer numeric value (e.g. a float such as 0.5) cannot pass this
     259        // check and then be truncated to the integer 0 when used as an array key.
     260        if ( ! is_numeric( $timestamp ) || (int) $timestamp <= 0 ) {
    255261                if ( $wp_error ) {
    256262                        return new WP_Error(
    257263                                'invalid_timestamp',
     
    262268                return false;
    263269        }
    264270
     271        $timestamp = (int) $timestamp;
     272
    265273        $schedules = wp_get_schedules();
    266274
    267275        if ( ! isset( $schedules[ $recurrence ] ) ) {
     
    365373 * @return bool|WP_Error True if event successfully rescheduled. False or WP_Error on failure.
    366374 */
    367375function wp_reschedule_event( $timestamp, $recurrence, $hook, $args = array(), $wp_error = false ) {
    368         // Make sure timestamp is a positive integer.
    369         if ( ! is_numeric( $timestamp ) || $timestamp <= 0 ) {
     376        // Make sure timestamp is a positive integer. Cast to int first so that a
     377        // non-integer numeric value (e.g. a float such as 0.5) cannot pass this
     378        // check and then be truncated to the integer 0 when used as an array key.
     379        if ( ! is_numeric( $timestamp ) || (int) $timestamp <= 0 ) {
    370380                if ( $wp_error ) {
    371381                        return new WP_Error(
    372382                                'invalid_timestamp',
     
    377387                return false;
    378388        }
    379389
     390        $timestamp = (int) $timestamp;
     391
    380392        $schedules = wp_get_schedules();
    381393        $interval  = 0;
    382394

Verification

  • Standalone reproduction through the public API, before/after, plus a regression pass: future-event scheduling, non-numeric and negative rejection, and the null get-next path are all unchanged.
  • php -l clean; phpcs --standard=WordPress-Core reports no issues on the file, and the full WordPress ruleset shows no new warnings introduced by the change.
  • I have not run the full PHPUnit cron suite locally yet -- happy for the test team to confirm, and I can follow up with a unit test covering the float-key rejection.
Note: See TracTickets for help on using tickets.