Opened 4 weeks ago
Last modified 3 weeks ago
#65911 new defect (bug)
wp_is_maintenance_mode() can require a deleted .maintenance file with opcache.enable_file_override=1
| Reported by: | cbratschi | Owned by: | |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.2 |
| Component: | Upgrade/Install | Version: | 7.0.4 |
| Severity: | normal | Keywords: | has-test-info has-patch has-unit-tests |
| Cc: | Focuses: |
Description
Summary
After an update has otherwise completed successfully, WordPress can fail during shutdown with:
Uncaught Error: Failed opening required '<wordpress-root>/.maintenance' in <wordpress-root>/wp-includes/load.php wp_is_maintenance_mode() WP_Fatal_Error_Handler::handle()
The error disappears on reload, the update is installed, and the .maintenance file is no longer present.
This has occurred intermittently on several installations since 2025. It is reproducible when PHP-FPM uses OPcache with opcache.enable_file_override=1.
Why no earlier fatal is required
WP_Fatal_Error_Handler::handle() calls wp_is_maintenance_mode() before detect_error():
The handler runs at every shutdown. Reaching this stack therefore does not prove that an earlier fatal error occurred.
wp_is_maintenance_mode() performs a separate file_exists() check followed by require:
if ( ! file_exists( ABSPATH . '.maintenance' ) || wp_installing() ) {
return false;
}
require ABSPATH . '.maintenance';
With opcache.enable_file_override=1, file_exists() can return true from a cached script entry after another FPM request has deleted the physical file. require then reaches the filesystem and fails.
Deterministic reproduction
A development-only two-request probe used a unique harmless PHP file, not WordPress's real .maintenance file.
Environment:
PHP 8.3.32 FPM opcache.enable_file_override = 1 opcache.validate_timestamps = 1 opcache.revalidate_freq = 2 opcache.file_update_protection = 2 realpath_cache_size = 4096K realpath_cache_ttl = 120
Steps:
- Request A creates and requires the temporary PHP file so it is compiled in shared OPcache.
- Request A calls Request B.
- Request B deletes the file and confirms deletion.
- Request A checks and opens the deleted path.
The initial run and five confirmation runs all produced:
opcache_is_script_cached before deletion = true second request deleted the file = true file_exists after deletion = true fopen after deletion = false file_exists after clearstatcache = true opcache_invalidate = true file_exists after opcache_invalidate = false
Twenty control runs that warmed only file_exists(), without compiling the file, did not produce a stale result.
This demonstrates the exact contradiction required for the Core failure: file_exists() passes, but the subsequent open/require cannot find the file. clearstatcache() is not sufficient for this OPcache configuration.
Real WordPress update confirmation
A manual Core update from WordPress 7.0.4 to 7.1 was run on the same development environment with a temporary fatal-error-handler.php drop-in that recorded state immediately before delegating to Core's unmodified WP_Fatal_Error_Handler.
Observed:
- The initiating response stopped after “Unpacking the update...”.
- A fresh administration request confirmed WordPress 7.1 was installed successfully.
- Immediately before Core's shutdown handler, file_exists(.maintenance) returned true and opcache_is_script_cached(.maintenance) returned true.
- error_get_last() contained only an unrelated E_WARNING from plugin enumeration, not a fatal error.
- Core's subsequent maintenance check logged require(<wordpress-root>/.maintenance): Failed to open stream: No such file or directory in wp-includes/load.php.
- No earlier fatal error, memory exhaustion, or execution timeout was captured.
The same missing-file warning/fatal has also followed successful theme and translation updates. In several historical incidents, “Disabling Maintenance mode”, the successful update record, and the missing-file failure have the same timestamp.
Increasing PHP/WP memory limits and max_execution_time did not prevent the issue. Equivalent updates on a comparison host have not reproduced it so far.
Expected result
A deleted maintenance file should not interrupt shutdown after a successful update. wp_is_maintenance_mode() should defensively handle the file disappearing or becoming unreadable between its existence check and load.
A host-level mitigation is to disable opcache.enable_file_override, but Core should not assume that the result of file_exists() guarantees that a later require will succeed.
Related reports
#62176 describes related maintenance-file races in the automatic updater, but focuses on the file being empty while it is created and on fatal-error loopback detection. This report concerns a deleted file that remains visible to file_exists() through OPcache and can occur during an otherwise successful manual update.
An independent support report contains the same missing .maintenance shutdown stack:
https://de.wordpress.org/support/topic/plugin-aktualisierung-fehlgeschlagen-successtruedata/
Change History (8)
This ticket was mentioned in PR #13180 on WordPress/wordpress-develop by @khokansardar.
4 weeks ago
#3
- Keywords has-patch has-unit-tests added
After a successful update, WordPress can end the request with Uncaught Error: Failed opening required '.maintenance' from wp-includes/load.php.
wp_is_maintenance_mode() and WP_Automatic_Updater::has_fatal_error() both check for the file with file_exists() and then load it with require. A concurrent request can delete it in between, and with opcache.enable_file_override=1 the cached script entry keeps file_exists() returning true after the deletion, so require fails fatally. Both now load the file with include and bail when it returns false.
The file_exists() fast path is kept deliberately: suppressed warnings are still dispatched to custom error handlers, so loading unconditionally would add two per request on every site.
Trac ticket: https://core.trac.wordpress.org/ticket/65911
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Reproducing the failure shape, drafting the patch, and adding a regression test. All changes were reviewed and validated by me.
#4
follow-up:
↓ 5
@
4 weeks ago
This has occurred intermittently on several installations since 2025. It is reproducible when PHP-FPM uses OPcache with opcache.enable_file_override=1.
Based on this, I'm not sure if this is in scope for 7.1.1 since this appears to be a long-standing issue.
@cbratschi you put 7.0.4 as the version, is this when you think it was introduced or when you discovered it?
#5
in reply to: ↑ 4
@
4 weeks ago
Replying to jorbin:
This has occurred intermittently on several installations since 2025. It is reproducible when PHP-FPM uses OPcache with opcache.enable_file_override=1.
Based on this, I'm not sure if this is in scope for 7.1.1 since this appears to be a long-standing issue.
@cbratschi you put 7.0.4 as the version, is this when you think it was introduced or when you discovered it?
I saw it first end of 2025 and this was version 6.9 or earlier. Could not find the root cause at that time but tracked this issue in our wiki.
#6
@
4 weeks ago
- Milestone 7.1.1 → 7.2
Thanks for investigating! Based on this, I'm moving this to the 7.2 milestone instead.
#7
@
3 weeks ago
Additional production recurrence (2026-08-24)
Another production installation on the same affected hosting platform reproduced the user-facing failure.
Simple History recorded two successful plugin updates at 21:31:23 and 21:31:24 UTC, followed by a translation update at 21:31:29 UTC. The updater response then ended with the same missing .maintenance fatal from wp_is_maintenance_mode().
A read-only check at 21:37:10 UTC showed no remaining plugin updates, and the site plus its authenticated API were operational. This is another instance where the updates completed successfully but the shutdown maintenance check produced a transient fatal response.
WP_DEBUG_LOG was disabled on this installation, so this occurrence does not provide an additional pre-Core error state or server-log warning/fatal pair. It adds recurrence and user-impact evidence, not a different failure mechanism.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
Proposed Core fix
The consumer of the temporary file should tolerate it disappearing. Replacing
file_exists()withis_file()oris_readable()would not fix the issue becauseopcache.enable_file_overrideapplies the cached-script shortcut to all three checks.The smallest defensive change in
wp_is_maintenance_mode()is to remove the separate existence check, reset the global, load the file non-fatally, and validate the timestamp before using it:function wp_is_maintenance_mode() { global $upgrading; if ( wp_installing() ) { return false; } $upgrading = null; // The file may be removed by an update before this request can load it. $maintenance_file_loaded = @include ABSPATH . '.maintenance'; if ( false === $maintenance_file_loaded || ! is_int( $upgrading ) ) { return false; } // Existing age, scrape-key, filter, and return logic follows. }PHP's
includereturnsfalseinstead of terminating the request if the file cannot be loaded. Suppressing that expected warning is limited to this temporary-file race. Resetting and validating$upgradingalso prevents an empty or partially written file from reusing a value left by an earlier maintenance-mode check.The independent
file_exists()/requiresequence inWP_Automatic_Updater::has_fatal_error()should use the same defensive pattern.I tested the proposed
wp_is_maintenance_mode()change in an isolated current-trunk copy. PHP syntax validation passed, followed by focused checks for:.maintenancefile;$upgradingvalue;The focused checks passed. A Core PHPUnit test should additionally retain coverage for the fatal-error scrape-key bypass.
Interim host workaround
Set the affected PHP-FPM pool to:
This disables only the risky file-existence override, not OPcache. PHP classifies the directive as
INI_SYSTEM, so WordPress,ini_set(), and per-directory.user.inifiles cannot change it. The controlled two-request probe should then be repeated to confirm thatfile_exists()returnsfalseafter cross-request deletion.clearstatcache(), higher memory/execution limits, and another existence check are not effective workarounds for the reproduced configuration.