Opened 10 months ago
Last modified 2 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)
This ticket was mentioned in PR #9914 on WordPress/wordpress-develop by @rishabhwp.
10 months ago
#2
- Keywords has-patch has-unit-tests added; needs-patch needs-unit-tests removed
#4
@
10 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
@
10 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
- Activate the
Ticket 63987plugin. - Visit any front-end page with
?test-cron=1appended to the URL. - 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
falseis incorrect since the event exists in the cron array.
Additional Notes
- This confirms that the falsy check for
$timestampinwp_get_scheduled_event()causes timestamp0to 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;
}
(Correct result) If https://github.com/WordPress/WordPress/blob/master/wp-includes/cron.php#L787 updated into if ( null === $timestamp ) {:

#6
@
10 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
@
10 months ago
- Keywords needs-testing added; dev-feedback changes-requested removed
Changes set - adding needs-testing
#8
@
9 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.
8 months ago
This ticket was mentioned in Slack in #core-test by krupajnanda. View the logs.
8 months ago
#11
@
8 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.
#13
@
5 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
- Activated the Ticket 63987 reproduction plugin.
- Plugin injected two cron events:
- timestamp 0 (bug case)
- future timestamp (control case)
- Called wp_get_scheduled_event() for both timestamps.
- Before applying the patch, the timestamp 0 event returned bool(false) while the future event returned a valid object.
- Applied PR patch.
- Re-tested using the same steps; both timestamp 0 and future timestamps now return valid event objects.
- ✅ 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
This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.
4 months ago
#16
@
4 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
@
4 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_arrayand_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.
This ticket was mentioned in Slack in #core by audrasjb. View the logs.
4 months ago
#20
@
4 months ago
- Milestone 7.0
- Resolution → wontfix
- Status new → 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
@
4 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:
- 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 <= 0evaluates tofalse. The error check is bypassed. - The event is passed to the core system to be saved.
- When WordPress sets this in the multidimensional cron array, PHP automatically truncates the
0.5float key into an integer0.
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
@
4 months ago
@codekraft Thank you for explaining it in detail.
Here is how it happens using public functions:
- 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 <= 0evaluates tofalse. The error check is bypassed.- The event is passed to the core system to be saved.
- When WordPress sets this in the multidimensional cron array, PHP automatically truncates the
0.5float key into an integer0.
I think this is certainly a problem that needs to be solved.
#23
@
4 months ago
- Keywords needs-patch added; has-patch has-unit-tests 2nd-opinion close removed
- Resolution wontfix
- Status closed → 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
@
4 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; }
#25
@
2 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 46 46 * @return bool|WP_Error True if event successfully scheduled. False or WP_Error on failure. 47 47 */ 48 48 function 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 ) { 51 53 if ( $wp_error ) { 52 54 return new WP_Error( 53 55 'invalid_timestamp', … … 58 60 return false; 59 61 } 60 62 63 $timestamp = (int) $timestamp; 64 61 65 $event = (object) array( 62 66 'hook' => $hook, 63 67 'timestamp' => $timestamp, … … 250 254 * @return bool|WP_Error True if event successfully scheduled. False or WP_Error on failure. 251 255 */ 252 256 function 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 ) { 255 261 if ( $wp_error ) { 256 262 return new WP_Error( 257 263 'invalid_timestamp', … … 262 268 return false; 263 269 } 264 270 271 $timestamp = (int) $timestamp; 272 265 273 $schedules = wp_get_schedules(); 266 274 267 275 if ( ! isset( $schedules[ $recurrence ] ) ) { … … 365 373 * @return bool|WP_Error True if event successfully rescheduled. False or WP_Error on failure. 366 374 */ 367 375 function 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 ) { 370 380 if ( $wp_error ) { 371 381 return new WP_Error( 372 382 'invalid_timestamp', … … 377 387 return false; 378 388 } 379 389 390 $timestamp = (int) $timestamp; 391 380 392 $schedules = wp_get_schedules(); 381 393 $interval = 0; 382 394
Verification
- Standalone reproduction through the public API, before/after, plus a regression pass: future-event scheduling, non-numeric and negative rejection, and the
nullget-next path are all unchanged. php -lclean;phpcs --standard=WordPress-Corereports no issues on the file, and the fullWordPressruleset 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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)

Thanks for the report @codekraft !