phpcon2018のLTで見たPREG_JIT_STACKLIMIT_ERRORについて調べた

先日のPHP Conference 2018でこのようなLTがありました。
RegExp Error caused by PHP upgrade 5.6 to 7.2

スライドはLT用にシンプルなので、同じ発表者さんのQitta記事を見た方が伝わるかもしれません。
PHPを7.2にバージョンアップしたら正規表現でマッチしない現象に出くわした

上記の背景は「5.6から7.2へのバージョンアップで発生した」わけなので、5.6と同じ挙動になるならpcre.jit=0で問題ないという判断かと思います。
(Qiitaにも書いてある通り本来はロジックを変えるのが理想だが、それが困難な状況ではJITを無効にするしかない)

でも、何か気になったので調べました。先に述べておくと、pcre.jit=0にするという結論に違いはありません。

簡潔なまとめ

安定のstackoverflow。ベストアンサーで簡潔にまとまっています。
PHP PREG_JIT_STACKLIMIT_ERROR - inefficient regex
ちなみにPHP7.2であれば、この質問者のコードをそのままコピペして実行すれば再現できます。
最後にvar_dump(preg_last_error());を追加すれば6(PREG_JIT_STACKLIMIT_ERROR)が表示されます。

後述するようにテキスト量ではなく正規表現の複雑さが問題になるので、自分で再現するコードを書くのは面倒なため助かります。
ただしこの記事の最後に書いた通り、PHP7.3だと正常終了します。

詳細なまとめ

PCRE JITの仕様書から

https://www.pcre.org/original/doc/html/pcrejit.html よりいくつか抜粋します。

JIT support applies only to the traditional Perl-compatible matching function. It does not apply when the DFA matching function is being used. The code for this support was written by Zoltan Herczeg.

いきなりですが、ここは良くわからなかった。
JITPerl互換のあるマッチングの場合のみ利用するよ。DFAマッチングの場合はJITは効かないからね」とありますがPCREはNFAなのでは?DFAで動くケースがあるんだろうか。

JIT support is an optional feature of PCRE. The "configure" option --enable-jit (or equivalent CMake option) must be set when PCRE is built if you want to use JIT

「pcreをインストールする時に--enable-jitオプションを付ければJITが有効になるよ」
PHP7からPCRE JITはデフォルト有効なので強制的に--enable-jitを付けているのかな。(未確認)

When a pattern is matched using JIT execution, the return values are the same as those given by the interpretive pcre_exec() code, with the addition of one new error code: PCRE_ERROR_JIT_STACKLIMIT. This means that the memory used for the JIT stack was insufficient

本題。
JITによる実行ではpcre_exec()と同じ実行結果に加え、PCRE_ERROR_JIT_STACKLIMITという新しいエラーコードを追加します。このエラーコードはJITスタックのメモリ確保が不十分という意味です」
PCRE_ERROR_JIT_STACKLIMITは冒頭のPREG_JIT_STACKLIMIT_ERRORと違う定数ですが、これは後述のコードリーディングにて。

When the compiled JIT code runs, it needs a block of memory to use as a stack. By default, it uses 32K on the machine stack. However, some large or complicated patterns need more than this. The error PCRE_ERROR_JIT_STACKLIMIT is given when there is not enough stack.

JITの実行にはスタック用にメモリを確保します。これはデフォルトでは32KBです。もしこのサイズで不十分な場合はPCRE_ERROR_JIT_STACKLIMITを発生させます」
デフォルトでは、とあるものの設定値をいじれるのはPCREのソースレベル(コンパイル前のC言語レベル)なので、PHP側から変更できる手段はないはず。

PCRE (and JIT) is a recursive, depth-first engine, so it needs a stack where the local data of the current node is pushed before checking its child nodes

「PCRE(とJIT)は深さ優先探索なので、ノードを確保するためにスタックが必要なんだ」

つまり↓のような理解です。

  • PCRE(とJIT)は正規表現のロジックとして深さ優先探索であり、各ノードを読み込むためにメモリ確保が必要
  • この処理のために使われるメモリは32KBで、これを超えるとエラーが発生する
    • この上限値はPHP側では変更できない
  • 深さ優先探索用のノードなので、テキスト量ではなく正規表現の書き方に依存する

PCREのソースコードから

PHP7.2.13のソースコードを読んでいます。
まず冒頭にあった発端のPREG_JIT_STACKLIMIT_ERRORの定義から。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/php_pcre.c#L222

REGISTER_LONG_CONSTANT("PREG_JIT_STACKLIMIT_ERROR", PHP_PCRE_JIT_STACKLIMIT_ERROR, CONST_CS | CONST_PERSISTENT);

PHP_PCRE_JIT_STACKLIMIT_ERRORという値を使って定義されていて、この定数はここで利用されている。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/php_pcre.c#L105-L109

#ifdef HAVE_PCRE_JIT_SUPPORT
  case PCRE_ERROR_JIT_STACKLIMIT:
    preg_code = PHP_PCRE_JIT_STACKLIMIT_ERROR;
    break;
#endif

エラーコードがPCRE_ERROR_JIT_STACKLIMITだったら代入していて、このエラーコードはたぶんこの辺りの処理。たぶん。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/pcrelib/pcre_jit_compile.c#L11249-L11250

/* Allocating stack, returns with PCRE_ERROR_JIT_STACKLIMIT if fails. */
/* This is a (really) rare case. */

「この例外はレアなケースさ(マジで)」というコメントから始まり

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/pcrelib/pcre_jit_compile.c#L11271-L11272

/* We break the return address cache here, but this is a really rare case. */
OP1(SLJIT_MOV, SLJIT_RETURN_REG, 0, SLJIT_IMM, PCRE_ERROR_JIT_STACKLIMIT);

「でもコレはマジでレアなケースだから」というコメントに終わる。
ここの処理はC言語力が無くて良く分からなかった。雰囲気でC言語を読んでいる僕はここが限界。

コメントにある通り実装者はレアなケースだと想定したんだろうけど、ググってみるとそれなりにハマっている人はいるようだった。

PHP7.3でスタックサイズの確保が増えた

「動いていた正規表現が動かなくなった」というバグ報告により、スタックサイズが多めに確保されるようになりました。
(php-src側に変更が入った)

そのため、冒頭のstackoverflowのサンプルコードではエラーが再現しません。
結局サイズを超えたら同じ現象になりますが、7.2の頃よりは発生しにくくなったのだと思います。