PHP の set_error_handler で設定した関数が例外を投げた場合の動作(with 拡張モジュール)

ErrorException という例外クラスがあり set_error_handler で PHP エラーを例外にして送出する方法がよく紹介されていますが、拡張モジュールの1回の関数呼び出しで複数回の PHP エラーが発生した場合にどのような動作になるのか気になったので調べてみました。


拡張モジュールの関数

次のように1回の呼び出して2回の Notice を発生させます。

PHP_FUNCTION(sample_error)
{
    php_printf("EXT fire notice 1\n");
    php_error_docref(NULL TSRMLS_CC, E_NOTICE, "notice 1");

    php_printf("EXT fire notice 2\n");
    php_error_docref(NULL TSRMLS_CC, E_NOTICE, "notice 2");
}

PHPのコード

ユーザ定義のエラーハンドラを設定した後に拡張モジュールの関数を呼び出します。

<?php
fputs(STDERR, "PHP set_error_handler\n");

set_error_handler(function($errno, $errstr, $errfile, $errline){
    fputs(STDERR, "PHP error_handler $errstr\n");
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

try
{
    sample_error();
}
catch (Exception $ex)
{
    $en = get_class($ex);
    fputs(STDERR, "PHP $en: {$ex->getMessage()}\n");
}

実行結果

PHP set_error_handler
EXT fire notice 1
PHP error_handler sample_error(): notice 1
EXT fire notice 2
PHP ErrorException: sample_error(): notice 1


拡張モジュールの方では最初の php_error_docref の後でも例外で処理が飛んだりせずに2番目の php_error_docref が実行されています。
PHP 側ではエラーハンドラは1回しか実行されておらず、例外により処理が飛んだような動作になっています。


PHP のソースを追ってみた

上のような動作になる理由を読み解くために PHP のソースを追ってみました。


php_error_docref からエラーハンドラの呼び出しまでは、概ね次の通り流れになっています。

main.c php_error_docref()
main.c php_verror()
zend.c php_error()
zend_execute_API.c call_user_function_ex()
zend_execute_API.c zend_call_function()


zend_call_function の中で次のような条件分岐がありました。

    if (EG(exception)) {
        return FAILURE; /* we would result in an instable executor otherwise */
    }

どうやらエラーハンドラが例外を送出したあとで再度エラーハンドラを呼びだそうとすると、この条件分岐で return FAILURE となり、エラーハンドラが呼ばれないようです。


call_user_function_ex は拡張モジュールからユーザ定義関数をコールバックなどで呼び出すときに使用します。そのため、1回の関数呼び出しで2回ユーザ定義関数を呼び出すような拡張モジュールでは、最初の1回が例外を送出すると2回目以降の call_user_function_ex は 全て FAILURE で失敗するということですね。

複数回コールバック関数を呼び出す拡張モジュール

PHP_FUNCTION(sample_callback)
{
    zval* function = NULL;
    
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "z", &function) == FAILURE)
    {
        return;
    }

    for (int i=0; i<5; i++)
    {
        zval* retval = NULL;

        int ok = call_user_function_ex(EG(function_table), NULL, function, &retval, 0, NULL, 1, NULL TSRMLS_CC);

        if (ok == FAILURE)
        {
            php_printf("EXT call_user_function_ex FAILURE\n");
        }
        else
        {
            if (EG(exception))
            {
                php_printf("EXT call_user_function_ex throw exception\n");
            }
            else
            {
                php_printf("EXT call_user_function_ex SUCCESS\n");
            }

            if(retval)
            {
                zval_ptr_dtor(&retval);
            }
        }
    }
}

PHP のコード

<?php
try
{
    sample_callback(function(){
        fputs(STDERR, "PHP throw exception\n");
        throw new Exception("error!!!");
    });
}
catch (Exception $ex)
{
    $en = get_class($ex);
    fputs(STDERR, "PHP $en: {$ex->getMessage()}\n");
}

実行結果

PHP throw exception
EXT call_user_function_ex throw exception
EXT retval nothing
EXT call_user_function_ex FAILURE
EXT call_user_function_ex FAILURE
EXT call_user_function_ex FAILURE
EXT call_user_function_ex FAILURE
PHP Exception: error!!!

普通は call_user_function_ex が FAILURE を返した時点で処理を中断すると思うのでこのような結果にはならないと思いますが、次のコードでコールバック関数の呼び出しに失敗した Warning が発生するのも同じ原因ですね。

<?php
$a = array(1,2,3);
array_map(function($v){
    throw new Exception("error!!!");
}, $a);

このコードは次のように Warning と例外になります。

Warning: array_map(): An error occurred while invoking the map callback
Fatal error: Uncaught exception 'Exception' with message 'error!!!'


拡張モジュールで call_user_function_ex を使うときは呼び出し後に例外の有無を EG(exception) でチェックするのが良さそうです。