てきとうなさいと べぇたばん

mb_convert_variables関数における、Cannot handle recursive references in ...のエラーについて

該当のコード。

Cannot handle recursive references

こんなネタがあった。

https://speakerdeck.com/gs3/afalseri-jian-tabaguwopu-da-hamadagong-shi-dokiyumentodejian-renai

PHP 5.6.30 or PHP 7.0.16 or PHP 7.1.1 現在、発生するバグの模様で、修正のプルリクエストが投げられている模様。

https://bugs.php.net/bug.php?id=73322

どういうバグなのか

スライドの通り、mb_convert_variables関数において、以下の特定の条件下で発生するバグである。

  • 第2引数に文字エンコーディングを2種以上指定する(UTF-8,auto)
  • 第3引数に 2つ以上要素のある1次元配列 を指定する

コードで示せば、以下のとおりである。

$ php -r '$h = ["b","ほげ"]; mb_convert_variables("UTF-8", "EUC-JP,auto", $h);'
PHP Warning:  mb_convert_variables(): Cannot handle recursive references in Command line code on line 1

という、本来であれば変換されてほしい値に変換されてほしいのにE_WARNINGが発生するというバグである。

なぜ起こるのか

コレは、以下の該当するコードが原因である。

https://github.com/php/php-src/blob/php-7.0.15/ext/mbstring/mbstring.c#L3768

target_hash->u.v.nApplyCountが1以上のときに、recursion_errorへ呼ばれる。そのrecursion_errorが1となりフラグが立つ。

while ((hash_entry = zend_hash_get_current_data(target_hash)) != NULL) {
    if (!Z_IMMUTABLE_P(var)) {
        if (++target_hash->u.v.nApplyCount > 1) {
            --target_hash->u.v.nApplyCount;
            recursion_error = 1;
            goto detect_end;
        }
    }

結果、以下のブロックへ呼ばれてしまい、E_WARNINGが発生してしまうというわけである。

if (recursion_error) {
    while(stack_level-- && (var = &stack[stack_level])) {
        if (!Z_IMMUTABLE_P(var)) {
            if (HASH_OF(var)->u.v.nApplyCount > 1) {
                HASH_OF(var)->u.v.nApplyCount--;
            }
        }
    }
    efree(stack);
    if (elist != NULL) {
        efree((void *)elist);
    }
    php_error_docref(NULL, E_WARNING, "Cannot handle recursive references");
    RETURN_FALSE;
}

nApplyCountとは何なのか

ここで純粋な疑問を持つ。nApplyCountとは何か。これが1より大きいとエラーになるのはわかったけど、なぜ1より大きくなるのか。そもそもこれは何なのか。

PHP 5系では、HashTableという構造体にて管理されていた。これは、単純に言えば配列を管理するための構造体で、「次への要素」「前への要素」「サイズ」などが管理されている。

typedef struct _hashtable {
    uint nTableSize;
    uint nTableMask;
    uint nNumOfElements;
    ulong nNextFreeElement;
    Bucket *pInternalPointer;
    Bucket *pListHead;
    Bucket *pListTail;
    Bucket **arBuckets;
    dtor_func_t pDestructor;
    zend_bool persistent;
    unsigned char nApplyCount;
    zend_bool bApplyProtection;
#if ZEND_DEBUG
    int inconsistent;
#endif
} HashTable;

http://www.phpinternalsbook.com/hashtables/basic_structure.html#the-hashtable-and-bucket-structures

この中にある、「nApplyCount」が今回注目するべき要素であるのだけど、このページにはnApplyCountについてもお話している。

Recursion protection will throw an error if the recursion depth (stored in nApplyCount) reaches a certain level. The protection is used for hashtable comparisons and for the zend_hash_apply functions.

(拙訳)再帰の深さが特定の深さまで達すると保護のためエラーを投げます(nApplyCountはその深さを管理しています)。この保護はhashtableを比較するときやzend_hash_apply関数で使われます。

つまり、「再帰の深さ」がどのくらいなのか、n次元配列のどの次元にいるのかというのを管理しているのである。試しに、PHPのarray_merge_recursive関数を見てみると以下のような再帰する際に出くわす。

if (Z_TYPE_P(src_zval) == IS_ARRAY) { // この値は配列である
    if (thash && ZEND_HASH_APPLY_PROTECTION(thash)) {
        thash->u.v.nApplyCount++; // 再帰するので、nApplyCountを1増やす
    }
    ret = php_array_merge_recursive(Z_ARRVAL_P(dest_zval), Z_ARRVAL_P(src_zval));
    if (thash && ZEND_HASH_APPLY_PROTECTION(thash)) {
        thash->u.v.nApplyCount--; // 再帰が終わったので、nApplyCountを1減らす
    }
    if (!ret) {
        return 0;
    }

https://github.com/php/php-src/blob/php-7.0.15/ext/standard/array.c#L2930

本来の使い方はこういうものなのである。mb_convert_variables関数は「1次元配列は変換するけど、2次元以上の配列は変換できないよ」ということで制限を加えたかったのである。

実際にはどうなっていたのか

それでは、なぜエラーが出たか。もう一度以下のコードを読んで見る。

https://github.com/php/php-src/blob/php-7.0.15/ext/mbstring/mbstring.c#L3768

target_hash->u.v.nApplyCountが1以上のときに、recursion_errorへ呼ばれる。そのrecursion_errorが1となりフラグが立つ。

while ((hash_entry = zend_hash_get_current_data(target_hash)) != NULL) {
    if (!Z_IMMUTABLE_P(var)) {
        if (++target_hash->u.v.nApplyCount > 1) {
            --target_hash->u.v.nApplyCount;
            recursion_error = 1;
            goto detect_end;
        }
    }

zend_hash_get_current_data関数というのは、簡単に言えばイテレーターのようなものである。現在の配列の要素を取り出してくれる。その上で、target_hash->u.v.nApplyCountを「インクリメント」している。

["a", "b"]

このブロックはイテレーターのようなものと言ったけれども、この現状では 要素の数が2つ以上あるときには、nApplyCountが1より大きい値になってしまう ということになる。上記の変数を持った引数では"b"のときにrecursion_errorが発生することになる。

何故起こったのか

もともと、多次元配列に対する処理が入っていないために起こったバグを修正したら発生してしまったバグであるようだ。

https://bugs.php.net/bug.php?id=66964

確認してみる

ちょっとgdb入れてみましょう。

(gdb) b ext/mbstring/mbstring.c:3690
Breakpoint 1 at 0x67e35e: file /home/tekitoh/docker/php-src/ext/mbstring/mbstring.c, line 3690.
(gdb) run -r '$e = ["a", "s"]; mb_convert_variables("UTF-8", "EUC-JP,auto", $e);'
Starting program: /home/tekitoh/docker/php-src/sapi/cli/php -r '$e = ["a", "s"]; mb_convert_variables("UTF-8", "EUC-JP,auto", $e);'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, zif_mb_convert_variables (execute_data=0x7ffff60130c0, return_value=0x7ffff60130b0) at /home/tekitoh/docker/php-src/ext/mbstring/mbstring.c:3704

ブレークポイントを入れる。

(gdb) n
3768                            while ((hash_entry = zend_hash_get_current_data(target_hash)) != NULL) {
(gdb) n
3769                                if (++target_hash->u.v.nApplyCount > 1) {
(gdb) printzv hash_entry
[0x7ffff605da08] (refcount=2) string: a
(gdb) target_hash->u.v.nApplyCount
Undefined command: "target_hash->u".  Try "help".
(gdb) print target_hash->u.v.nApplyCount
$1 = 0 '\000'
(gdb) n
3774                                zend_hash_move_forward(target_hash);
(gdb) print target_hash->u.v.nApplyCount
$2 = 1 '\001'
(gdb)

該当の行以降になると、0から1へ変化しているのがわかる。

どうするべきか

かんたんにどうするべきか考えてみる。以下の3種類になるだろうか。

  • mb_convert_variablesの第2引数をひとつのみ(UTF-8,SJIS,EUC-JP,など)に決め打ちする
    • autoも同じく複数していすることなるので使えない
  • スライドの通り、バグの出る前のバージョンPHP 5.6.26へ戻す
    • ただし、引数には1次元配列であることを確認するコードを入れるべきである
  • mb_convert_variablesを使わない

恐らく、変換する文字の文字コードは知っておくべきで、それを1種類に決め打ちしておくことが望ましいと考える。

感想

すごくくるしい戦いだとおもいました。

変更履歴

  • 2017年2月23日 PHP 7.1.1でも再現できることがわかったことを追記
  • 2017年2月23日 考えてみると、第二引数に入れる変更元文字エンコーディングを1つにすれば回避できるので追記