yii2 icon indicating copy to clipboard operation
yii2 copied to clipboard

Fix crash on PHP 8.3 when a file is missing

Open XOlegator opened this issue 8 months ago • 11 comments

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

XOlegator avatar Apr 21 '25 17:04 XOlegator

Codecov Report

All modified and coverable lines are covered by tests :white_check_mark:

Project coverage is 64.44%. Comparing base (7037fd4) to head (dd9bc77). Report is 57 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     

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

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

codecov[bot] avatar Apr 21 '25 17:04 codecov[bot]

How does the crash looks like? Stacktrace?

samdark avatar Apr 22 '25 06:04 samdark

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)"} 

XOlegator avatar Apr 22 '25 18:04 XOlegator

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";
}

xicond avatar Apr 23 '25 03:04 xicond

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.

rob006 avatar Apr 23 '25 07:04 rob006

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";
}

xicond avatar Apr 25 '25 10:04 xicond

Related #19773 :-)

lubosdz avatar May 28 '25 09:05 lubosdz

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!

XOlegator avatar Jun 18 '25 18:06 XOlegator

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

lubosdz avatar Jun 18 '25 19:06 lubosdz

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

rob006 avatar Jun 18 '25 20:06 rob006

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

samdark avatar Jun 19 '25 21:06 samdark