Skip to content

Fix crash on PHP 8.3 when a file is missing #20358

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

XOlegator
Copy link

Q A
Is bugfix? ✔️
New feature?
Breaks BC?
Fixed issues

This fixes a problem I had after migrating Craft CMS (based on Yii2) to PHP 8.3

Copy link

codecov bot commented Apr 21, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 64.44%. Comparing base (7037fd4) to head (dd9bc77).
Report is 39 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #20358      +/-   ##
============================================
- Coverage     64.85%   64.44%   -0.41%     
- Complexity    11445    11572     +127     
============================================
  Files           431      433       +2     
  Lines         37208    37599     +391     
============================================
+ Hits          24132    24232     +100     
- Misses        13076    13367     +291     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@samdark
Copy link
Member

samdark commented Apr 22, 2025

How does the crash looks like? Stacktrace?

@samdark samdark added this to the 2.0.53 milestone Apr 22, 2025
@XOlegator
Copy link
Author

XOlegator commented Apr 22, 2025

Error: filemtime(): stat failed. Backtrace:

2025-04-21 19:24:34 [web.INFO] [yii\db\Connection::open] Opening DB connection: mysql:host=127.0.0.1;dbname=craft_blog;port=3306 {"memory":1163464} 
2025-04-21 19:24:34 [web.INFO] [yii\web\Session::open] Session started {"memory":1730536} 
2025-04-21 19:24:34 [web.INFO] [nystudio107\codeeditor\CodeEditor::bootstrap] CodeEditor module bootstrapped {"memory":1758792} 
2025-04-21 19:24:34 [web.ERROR] [yii\base\ErrorException:2] yii\base\ErrorException: filemtime(): stat failed for /mnt/projects/sites/blog.ekhlakovy.ru/www/storage/runtime/cache/3b/CraftCMS--c2e6b7e4-c13e-492e-9c09-b5252006db833b35b093591572843edcc341f17455a0.bin in /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/caching/FileCache.php:113
Stack trace:
#0 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/ErrorHandler.php(115): craft\web\ErrorHandler->handleError(code: '...', message: '...', file: '...', line: '...')
#1 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/caching/FileCache.php(113): craft\web\ErrorHandler->handleError(code: '...', message: '...', file: '...', line: '...')
#2 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/caching/FileCache.php(113): ::filemtime(filename: '...')
#3 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/caching/Cache.php(134): craft\cache\FileCache->getValue(key: '...')
#4 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/services/ProjectConfig.php(1700): craft\cache\FileCache->get(key: '...')
#5 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/services/ProjectConfig.php(695): craft\services\ProjectConfig->getHadFileWriteIssues()
#6 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/helpers/Cp.php(207): craft\services\ProjectConfig->areChangesPending(path: '...', force: '...')
#7 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/twig/variables/Cp.php(529): craft\helpers\Cp::alerts(path: '...', fetch: '...')
#8 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/twig/twig/src/Extension/CoreExtension.php(1861): craft\web\twig\variables\Cp->getAlerts()
#9 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/helpers/Template.php(148): Twig\Extension\CoreExtension::getAttribute(env: '...', source: '...', object: '...', item: '...', arguments: '...', type: '...', isDefinedTest: '...', ignoreStrictCheck: '...', sandboxed: '...', lineno: '...')
#10 /mnt/projects/sites/blog.ekhlakovy.ru/www/storage/runtime/compiled_templates/11/11779b7006abc2b2e3d49db10de92a46.php(46): craft\helpers\Template::attribute(env: '...', source: '...', object: '...', item: '...', arguments: '...', type: '...', isDefinedTest: '...', ignoreStrictCheck: '...', sandboxed: '...', lineno: '...')
#11 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/twig/twig/src/Template.php(343): __TwigTemplate_e1a825c815db98fb38f1f494a5134d31->doDisplay(context: '...', blocks: '...')
#12 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/twig/twig/src/Template.php(358): __TwigTemplate_909b19cdb5d17880fc3de7ef93bebdc0->display(context: '...', blocks: '...')
#13 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/twig/twig/src/TemplateWrapper.php(35): __TwigTemplate_909b19cdb5d17880fc3de7ef93bebdc0->render(context: '...')
#14 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/twig/twig/src/Environment.php(320): Twig\TemplateWrapper->render(context: '...')
#15 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/View.php(581): craft\web\twig\Environment->render(name: '...', context: '...')
#16 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/View.php(634): craft\web\View->renderTemplate(template: '...', variables: '...', templateMode: '...')
#17 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/TemplateResponseFormatter.php(57): craft\web\View->renderPageTemplate(template: '...', variables: '...', templateMode: '...')
#18 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/web/Response.php(1109): craft\web\TemplateResponseFormatter->format(response: '...')
#19 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/craftcms/cms/src/web/Response.php(341): craft\web\Response->prepare()
#20 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/web/Response.php(340): craft\web\Response->prepare()
#21 /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/base/Application.php(390): craft\web\Response->send()
#22 /mnt/projects/sites/blog.ekhlakovy.ru/www/webhome/index.php(12): craft\web\Application->run()
#23 {main} {"memory":10969160,"exception":"[object] (yii\\base\\ErrorException(code: 2): filemtime(): stat failed for /mnt/projects/sites/blog.ekhlakovy.ru/www/storage/runtime/cache/3b/CraftCMS--c2e6b7e4-c13e-492e-9c09-b5252006db833b35b093591572843edcc341f17455a0.bin at /mnt/projects/sites/blog.ekhlakovy.ru/www/vendor/yiisoft/yii2/caching/FileCache.php:113)"} 

@@ -110,7 +110,7 @@ protected function getValue($key)
{
$cacheFile = $this->getCacheFile($key);

if (@filemtime($cacheFile) > time()) {
if (file_exists($cacheFile) && @filemtime($cacheFile) > time()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit weird, cause @ should've suppressed the error. Any idea why it doesn't work?

Introducing file_exists make the operation non-atomic which isn't great if we're talking about cache.

A more universal way would've been something like how it is done in Yii3...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into the issue a bit deeper. In my Craft CMS setup, I had DEV_MODE=true enabled. In this mode, a PHP Warning interrupts program execution just like a PHP Error. So, the problem with the missing cache file must be happening at a different level.

Could you clarify what exactly makes the cache file operations non-atomic in this case? Yes, there are now two file operations: checking if the file exists and checking its modification time. But isn’t that the whole point of this method? First, verify the file exists, and only if it does, check its modification time. With the double check, we’d simply exit at the first step if the file isn’t there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you first check if file exist, and then read mtime from it, this means that file could be deleted by other process between these two operations. So file_exists doesn't completely protect you from such errors, it only makes them appear much less frequently (at the cost of additional call, which is not necessary in 99.99% setups).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @XOlegator

I had DEV_MODE=true enabled. In this mode, a PHP Warning interrupts program execution just like a PHP Error

If this is the case, isn't Yii behaving normal here? The method filemtime generates a warning when file does not exist which results in a crash in dev mode. Can you explain where Yii goes wrong?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right that filemtime() itself behaves as expected by emitting a warning when a file doesn't exist. The core issue isn’t with Yii’s behavior, but rather with error handling in contexts like Craft CMS’s DEV mode, where warnings are treated as fatal exceptions.
Is Yii "Wrong"? Not at all! This is more about ecosystem compatibility. Many modern PHP frameworks/libraries treat warnings as strict-mode errors. The change simply makes Yii more resilient in such environments without sacrificing performance.

@xicond
Copy link
Contributor

xicond commented Apr 23, 2025

Warning
Prior to PHP 8.0.0, the error_reporting() called inside the custom error handler always returned 0 if the error was suppressed by the @ operator. As of PHP 8.0.0, it returns the value of this (bitwise) expression: E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE.

As per code yii\base\ErrorHandler::handleError

    public function handleError($code, $message, $file, $line)
    {
        if (error_reporting() & $code) {
            // load ErrorException manually here because autoloading them will not work
            // when error occurs while autoloading a class
            if (!class_exists('yii\\base\\ErrorException', false)) {
                require_once __DIR__ . '/ErrorException.php';
            }
            $exception = new ErrorException($message, $code, $code, $file, $line);

            if (PHP_VERSION_ID < 70400) {
                // prior to PHP 7.4 we can't throw exceptions inside of __toString() - it will result a fatal error
                $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
                array_shift($trace);
                foreach ($trace as $frame) {
                    if ($frame['function'] === '__toString') {
                        $this->handleException($exception);
                        if (defined('HHVM_VERSION')) {
                            flush();
                        }
                        exit(1);
                    }
                }
            }

            throw $exception;
        }

        return false;
    }

So the handleError will show error page at the end for PHP ver >= 8.0.0

for example:

set_error_handler(function($severity, $message, $file, $line) {
    echo "Error handler called! Message: $message\n";
    echo "error_reporting() inside handler: " . error_reporting() . "\n"; // 4437 in PHP 8+
    return true; // Prevent PHP's default error handler from running
});

$filename = "non_existent_file.txt";
$mtime = @filemtime($filename);

if ($mtime === false) {
    echo "filemtime failed (returned false)\n";
}

@rob006
Copy link
Contributor

rob006 commented Apr 23, 2025

As of PHP 8.0.0, it returns the value of this (bitwise) expression: E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE.

filemtime() emits E_WARNING, so this change doesn't affect this call.

@xicond
Copy link
Contributor

xicond commented Apr 25, 2025

set_error_handler(function($severity, $message, $file, $line) {
echo "Error handler called! Message: $message\n";
echo "error_reporting() inside handler: " . error_reporting() . "\n"; // 4437 in PHP 8+
return true; // Prevent PHP's default error handler from running
});

$filename = "non_existent_file.txt";
$mtime = @filemtime($filename);

if ($mtime === false) {
echo "filemtime failed (returned false)\n";
}

I'm not sure, maybe because my error_level still contain E_WARNING

Error handler called! Message: filemtime(): stat failed for non_existent_file.txt
error_reporting() inside handler: 4437
filemtime failed (returned false)

But I tried the simple test on 8.2, it is shown the error

set_error_handler(function($severity, $message, $file, $line) {
    echo "Error handler called! Message: $message\n";
    echo "error_reporting() inside handler: " . error_reporting() . "\n"; // 4437 in PHP 8+
    return true; // Prevent PHP's default error handler from running
});

$filename = "non_existent_file.txt";
$mtime = @filemtime($filename);

if ($mtime === false) {
    echo "filemtime failed (returned false)\n";
}

@lubosdz
Copy link
Contributor

lubosdz commented May 28, 2025

Related #19773 :-)

@XOlegator
Copy link
Author

I’ve updated this PR to address the filemtime() warning issue in a more efficient way. The changes:

  1. Replaced @filemtime with set_error_handler to gracefully catch "file not found" warnings without costly file_exists() checks.
    
  2. Ensured PHP 7.3+ compatibility by using strpos() instead of str_contains().
    
  3. Fixed both occurrences of this pattern in the codebase.
    

Why this matters:

  • In Craft CMS’s DEV mode, even warnings (like missing files) trigger exceptions, breaking execution.
    
  • This solution avoids disk-heavy operations while maintaining silent failure for missing files.
    
  • No performance penalty vs. @, but more explicit and debuggable.
    

Let me know if you’d like any adjustments!

@lubosdz
Copy link
Contributor

lubosdz commented Jun 18, 2025

IMO - setting and restoring error handler for each cached file is a terrible approach. Did you check performance penalty? If you are not OK with extra non-atomic check is_file() then best leave as it is.

To provide perfect solution this can hardly be solved at user-code level ie. with file lock .. or introduce new non-atomic command ie. filemtime_if_exists() :-)

@rob006
Copy link
Contributor

rob006 commented Jun 18, 2025

Can someone explain what specific Craft CMS is doing, that silenced warnings are converted to exceptions?

@samdark
Copy link
Member

samdark commented Jun 19, 2025

@brandonkelly would you please point us to the error handing routine of CraftCMS? Thanks.

@terabytesoftw terabytesoftw modified the milestones: 2.0.53, 2.0.54 Jun 25, 2025
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants