Make WordPress Core

Opened 7 weeks ago

Last modified 4 weeks ago

#64950 reviewing defect (bug)

wp_using_ext_object_cache can return null, causing type failures, potential fatals

Reported by: ronalfy's profile ronalfy Owned by: westonruter's profile westonruter
Milestone: 7.1 Priority: normal
Severity: normal Version: 3.7
Component: Cache API Keywords: has-patch has-test-info has-unit-tests
Focuses: Cc:

Description

Function wp_using_ext_object_cache has a bool return signature, but it can also return null in certain cases.

If global $_wp_using_ext_object_cache; is not set, then it it assumed null.

Line $current_using = $_wp_using_ext_object_cache; assigns it to return variable.

Line return $current_using; then returns null.

If this function is ran before the global is set, the user must account for null cases instead of a simple bool check. I recommend changing the signature to account for null, or do a null check in the function itself and return a falsey value.

https://core.trac.wordpress.org/browser/tags/6.9.4/src/wp-includes/load.php#L810

Change History (9)

This ticket was mentioned in PR #11356 on WordPress/wordpress-develop by liaisontw.


7 weeks ago
#1

  • Keywords has-patch added

### Description
This PR addresses an inconsistency in the wp_using_ext_object_cache() function where it could return null instead of the documented bool type.

In src/wp-includes/load.php, the global variable $_wp_using_ext_object_cache is used to determine if an external object cache is in use. However, if this function is called early in the WordPress loading hierarchy (before the global is initialized), it currently returns null.

With the ongoing efforts to make WordPress more compatible with PHP 8.x strict typing, ensuring deterministic return types is critical to prevent TypeError or unexpected behavior in downstream logic.

Key Changes:

  • I maintained the original 'get-before-set' return pattern to ensure backward compatibility for developers who rely on the returned value to restore previous states.
  • By casting $current_using to (bool), we resolve the issue where an uninitialized global variable causes the function to leak a null value.

### Testing Instructions

  1. Run an isolated script to call wp_using_ext_object_cache() before the global variable is initialized (e.g., before wp-settings.php is fully loaded).
  2. Before Patch: The function returns NULL.
  3. After Patch: The function returns bool(false).

Trac ticket: https://core.trac.wordpress.org/ticket/64950

## Use of AI Tools

AI assistance: Yes
Tool(s): Google Gemini
Model(s): Gemini 3 Flash
Used for: Authoring the technical description and formatting the testing instructions based on my code implementation. The code logic and testing methodology were reviewed and verified by me.

#2 @liaison
7 weeks ago

I have submitted a PR that addresses the return type inconsistency by casting the value to (bool). This ensures compliance with the documented return type and prevents null leaks in early-loading scenarios.

I’ve verified the fix with an isolated test script, and it correctly returns false instead of null when the global state is uninitialized.

test script: debug-cache-bug.php

<?php
/**
 * Test script for Ticket #64950
 * Purpose: Verify that wp_using_ext_object_cache() returns bool instead of null.
 * * This script isolates the Cache API environment to catch the uninitialized 
 * global variable state.
 */

// 1. Mock environment paths
define( 'ABSPATH', dirname( __FILE__ ) . '/' );
define( 'WPINC', 'wp-includes' );

// 2. Load only the target file where the function is defined
require_once ABSPATH . WPINC . '/load.php';

echo "=== WordPress Core Ticket #64950 Test ===" . PHP_EOL;

// Test 1: Initial state (Global variable is undefined/uninitialized)
echo "1. Initial call (Global is undefined):" . PHP_EOL;
$initial = wp_using_ext_object_cache();
var_dump( $initial );

if ( is_null( $initial ) ) {
    echo "❌ [FAIL] Returned NULL. This violates the @return bool signature." . PHP_EOL;
} elseif ( is_bool( $initial ) ) {
    echo "✅ [PASS] Returned boolean (" . ( $initial ? 'true' : 'false' ) . ")." . PHP_EOL;
}

echo PHP_EOL;

// Test 2: Set new value and check return value (Swap/Toggle pattern)
echo "2. Setting to TRUE (Testing Swap pattern):" . PHP_EOL;

/**
 * Expected behavior: 
 * Returns the current (old) state, then updates the global to the new value.
 */
$old_val = wp_using_ext_object_cache( true );

echo "Returned (Old) Value: ";
var_dump( $old_val );

echo "Current Global State (Should be true): ";
var_dump( wp_using_ext_object_cache() );

echo "=========================================" . PHP_EOL;

#3 @liaison
7 weeks ago

Technical context regarding the impact of return type consistency in wp_using_ext_object_cache()

To provide more context on why ensuring a strict bool return type is critical, I've analyzed the primary callers of this function within WordPress Core. As a foundational "low-level switch," its return value dictates whether data flows to volatile memory or persistent external storage (e.g., Redis/Memcached).

Key Callers and Scenarios:
Bootstrapping Phase (wp_start_object_cache() in load.php):
This is the most critical caller. It checks for object-cache.php. If detected, it calls wp_using_ext_object_cache( true ). If this switch is uninitialized or returns an ambiguous null during early loading, WordPress may default to the non-persistent internal implementation, bypassing the external cache entirely for early-loading logic.

Transient API (set_site_transient() & get_transient() in option.php):
The Transient API relies on this function to decide storage location.

With External Cache: Data goes to wp_cache_set().

Without External Cache: Data falls back to options or sitemeta database tables.

The Risk: If an early call returns null (evaluated as false), the system might trigger redundant database I/O even when a high-performance cache is available.

Multisite Context Switching (ms-settings.php):
During switch_to_blog(), the core verifies if the current cache environment is persistent across the network. Drop-ins like Redis Object Cache often call this function to sync state during environment re-initialization.

CLI and Compatibility Layers:
Tools like WP-CLI and functions like wp_cache_flush_runtime() frequently trigger this function during simulated loading sequences to determine environmental capabilities.

Why this matters: Eliminating the "Early-Loading Race Condition"
Since wp_start_object_cache() is invoked at the very beginning of the WordPress lifecycle, any null leakage—especially when triggered by MU-Plugins or early db.php logic—creates a race condition where the system misidentifies the cache state.

Ensuring a deterministic boolean return type eliminates this ambiguity at the foundational level of load.php.

Timeline Event Return Value (Current) Resulting Behavior
$T_1$ db.php loads, queries cache state null (evaluated as false) Triggers slow DB-level fallbacks.
$T_2$ wp_start_object_cache() runs (Initializing...) State is in transition.
$T_3$ Initialization complete true Subsequent code follows the fast cache path.

#4 follow-up: @westonruter
6 weeks ago

  • Milestone changed from Awaiting Review to 7.1
  • Owner set to westonruter
  • Status changed from new to reviewing

@liaison Inside wp_using_ext_object_cache(), wouldn't it be helpful if it emitted a _doing_it_wrong() if $_wp_using_ext_object_cache is null?

I'm trying to reproduce the issue here. In the wordpress-develop environment with LOCAL_PHP_MEMCACHED=true added to .env, I'm seeing that wp_using_ext_object_cache() is returning true even when when an mu-plugin is added with:

<?php
var_dump( wp_using_ext_object_cache() );

However, I do see it output NULL if added to db.php. So it seems this is specifically an issue with db.php.

Nevertheless, if I have LOCAL_PHP_MEMCACHED=false in my .env, then wp_using_ext_object_cache() is returning NULL even very late in the execution cycle.

<?php
add_action( 'template_redirect', function () {
        var_dump( wp_using_ext_object_cache() );
} );

So it seems the function has always returned null if there is no object-cache.php drop-in? Apparently its return values are null|true?

#5 in reply to: ↑ 4 @liaison
6 weeks ago

Replying to westonruter:

@liaison Inside wp_using_ext_object_cache(), wouldn't it be helpful if it emitted a _doing_it_wrong() if $_wp_using_ext_object_cache is null?

I'm trying to reproduce the issue here. In the wordpress-develop environment with LOCAL_PHP_MEMCACHED=true added to .env, I'm seeing that wp_using_ext_object_cache() is returning true even when when an mu-plugin is added with:

<?php
var_dump( wp_using_ext_object_cache() );

However, I do see it output NULL if added to db.php. So it seems this is specifically an issue with db.php.

Nevertheless, if I have LOCAL_PHP_MEMCACHED=false in my .env, then wp_using_ext_object_cache() is returning NULL even very late in the execution cycle.

<?php
add_action( 'template_redirect', function () {
        var_dump( wp_using_ext_object_cache() );
} );

So it seems the function has always returned null if there is no object-cache.php drop-in? Apparently its return values are null|true?

I've refined the patch to ensure wp_using_ext_object_cache() strictly returns a boolean as defined in its @return contract.

In cases where the function is called before the object cache is initialized (e.g., during wp_start_object_cache()), $_wp_using_ext_object_cache remains null. The updated logic now correctly returns false in this state.

Note on _doing_it_wrong():
I explored adding a _doing_it_wrong() check for these early calls. However, because this function is invoked at the very beginning of the bootstrap process, triggering any output at this stage (even via trigger_error) leads to stability issues in the test environment (missing __(), wp_kses(), specifically causing REST API discovery failures in Multisite E2E tests).
That destabilize the bootstrap process and fail Multisite E2E tests.

To maintain maximum compatibility and zero dependencies during the early load phase, I've focused this PR on ensuring type safety without adding bootstrap-level warnings.

Tests:
All 238 tests (including Multisite) are now passing with this implementation.

<?php
function wp_using_ext_object_cache( $using = null ) {
        global $_wp_using_ext_object_cache;

        // Save the current state to return later.
        $current_using = $_wp_using_ext_object_cache;

        if ( null !== $using ) {
                $_wp_using_ext_object_cache = (bool) $using;
        }

        if ( null === $_wp_using_ext_object_cache ) {
                // If the global is uninitialized, the value would be null, which violates the type signature.
                return false;
        }

        return (bool) $current_using;
}
Last edited 6 weeks ago by liaison (previous) (diff)

#6 @ronalfy
6 weeks ago

@westonruter I'm not using a standard WP docker setup, but I can confirm our code is running in an activated plugin's constructor. As you mentioned, the result is null, which is the confusing part of this.

If the function has returned bool|null all along, intentionally or not, then for backwards compatibility, I recommend updating the diff to remove the casting (essentially leave the function as-is) and update the return type to bool|null so a user consuming it can expect both types. We'll also want to update the docs because expecting a boolean and then checking that var will trigger a fatal.

#7 @liaison
4 weeks ago

  • Keywords has-test-info has-unit-tests added

#8 @westonruter
4 weeks ago

@liaison See feedback about how this function has actually returned bool|null.

#9 @liaison
4 weeks ago

Maintaining bool|null is significant for backwards compatibility. It respects the 'de facto' behavior of WordPress over the years.

This proposed patch aims to align the implementation with the long-standing documentation, providing type safety for modern PHP 8.x environments. An unexpected null can lead to TypeError.

The added test case is designed to complete the patch's intent.

Under PHP 8.x standards, a strict implementation would typically utilize Union Types, such as:

PHP

<?php
function wp_using_ext_object_cache( bool|null $using = null ): bool|null
Last edited 4 weeks ago by liaison (previous) (diff)
Note: See TracTickets for help on using tickets.