Make WordPress Core

Opened 6 months ago

Last modified 3 days ago

#63987 reopened defect (bug)

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

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

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 (24)

#1 @johnbillion
6 months ago

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

Thanks for the report @codekraft !

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


6 months ago
#2

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

#3 @SergeyBiryukov
6 months ago

  • Milestone changed from Awaiting Review to 6.9

#4 @rollybueno
6 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
6 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 6 months ago by rollybueno (previous) (diff)

#6 @rollybueno
6 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
6 months ago

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

Changes set - adding needs-testing

#8 @mindctrl
6 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.


5 months ago

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


5 months ago

#11 @krupajnanda
5 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
5 months ago

  • Milestone changed from 6.9 to 7.0

#13 @huzaifaalmesbah
7 weeks 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

Last edited 7 weeks ago by huzaifaalmesbah (previous) (diff)

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


2 weeks ago

#15 @juanmaguitar
2 weeks 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
2 weeks 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
2 weeks 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
11 days ago

  • Keywords close added

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


9 days ago

#20 @audrasjb
9 days ago

  • Milestone 7.0 deleted
  • Resolution set to wontfix
  • Status changed from new to closed

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
9 days 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
9 days 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 days ago

  • Keywords needs-patch added; has-patch has-unit-tests 2nd-opinion close removed
  • Resolution wontfix deleted
  • Status changed from closed to reopened

@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
3 days 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 3 days ago by liaison (previous) (diff)
Note: See TracTickets for help on using tickets.