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の頃よりは発生しにくくなったのだと思います。

fishとzsh - プロセス置換などコマンドの書き方の違いあれこれ

普段はfishを使っていて満足だけど、次のようなケースでbash/zshとの違いに戸惑うことがある。

  • Web上の記事からコピペで実行したい時
    • そういえばfishのコマンドがそのまま載った記事はほとんど見ない気がする
  • チーム開発をしていて、他の人がシェアしたコマンドをコピペ実行したい時
  • チーム開発をしていて、自分が使ったコマンドを他の人にシェアしたい時
    • そのまま載せたらbash/zsh環境では動かない時

ほとんどの場合エラーメッセージに答えがあるけど一応メモしておく。
以下のコマンド例は注釈がなければfishを指す。

変数定義

zshではAAA=111と書けるが、fishではsetを使う。

AAA=111
Unsupported use of '='. In fish, please use 'set AAA 111'.

⟩ set AAA 111echo $AAA
111

参考: set:シェル変数の設定・一覧・消去・確認する6活用

RAILS_ENV=test rails consoleみたいな書き方

コマンド実行時だけ変数定義をしたい時、zshと同じような指定はエラーになる。

RAILS_ENV=test ./bin/rails c
Unsupported use of '='. To run './bin/rails' with a modified environment, please use 'env RAILS_ENV=test ./bin/rails…'

エラーメッセージに書いてある通りenvを付ける。

⟩ env RAILS_ENV=test ./bin/rails c
Running via Spring preloader in process 59475
Loading test environment (Rails 5.0.0.1)

&&や||の書き方

fishとzshの違いで良く見るケース個人的1位。
fishでは&&||をサポートしていない。代わりに;and/orを使う。

echo 1 && echo 2
Unsupported use of '&&'. In fish, please use 'COMMAND; and COMMAND'.
fish: echo 1 && echo 2echo 1 ;and echo 2
1
2

# orの例
⟩ cat aa ;or echo 2
cat: aa: No such file or directory
2

ちなみにandを忘れて;だけにすると、コマンドが失敗しても継続されるので注意。

# andがないと継続される
⟩ cat a ; echo 2
cat: a: No such file or directory
2

# andがあれば継続されない
⟩ cat a ;and echo 2
cat: a: No such file or directory

終了ステータスのとり方

$?ではなくて$statusを使う。

echo $?
$? is not the exit status. In fish, please use $status.
fish: echo $?
            ^

⟩ echo $status
0

$(...) - コマンド置換

コマンドの実行結果を他のコマンドに渡す時の書き方。

⟩ cat $(echo out.txt)
$(...) is not supported. In fish, please use '(echo)'.
fish: cat $(echo out.txt)
          ^

⟩ cat (echo out.txt)
this is out

なおfishではバッククォートをサポートしていない。

⟩ cat `echo out.txt`
cat: `echo: No such file or directory
cat: out.txt`: No such file or directory

<(...) - プロセス置換

例えばディレクトリ内に含まれるファイル一覧の差分を取りたいとき。

/Users/kanno/tmp% tree dir1 dir2
dir1
└── a.txt
dir2
└── b.txt

zshでは<()を使うことで一時ファイルを通すことなく比較できる。
参考: 一時ファイルはもういらない - プロセス置換

# これはzsh
/Users/kanno/tmp% diff -u <(ls dir1) <(ls dir2)
--- /dev/fd/11  2018-12-15 05:17:56.000000000 +0900
+++ /dev/fd/12  2018-12-15 05:17:56.000000000 +0900
@@ -1 +1 @@
-a.txt
+b.txt

普段使わないけど、前職でサーバーに入ってサクッと使っている人がいてカッコいいと思った。

同じことをfishでやるにはpsubを組み合わせる。

⟩ diff -u (ls dir1 | psub) (ls dir2 | psub)
--- /var/folders/n5/z3tch4l11q796wz2m5m_6xd80000gn/T//.psub.eIiVpGBx3M  2018-12-15 05:20:18.000000000 +0900
+++ /var/folders/n5/z3tch4l11q796wz2m5m_6xd80000gn/T//.psub.QhISb6ERoH  2018-12-15 05:20:18.000000000 +0900
@@ -1 +1 @@
-a.txt
+b.txt

参考: psub:fish独自のプロセス置換を行う

リダイレクトの書き方

標準エラー出力^を使う。追記する場合は^^

⟩ cat aaa ^out.txt

⟩ cat out.txt
cat: aaa: No such file or directory

存在しないファイルを上書きしないようにするには>?^?を使う。

echo aa >? out.txt
The file 'out.txt' already exists

標準出力と標準エラー出力を同時に出すにはこう書く。

echo aa > out.txt ^&1

絶対忘れるが、bash/zsh2>&1も毎回ググっているので問題ない^^

ワイルドカードの解釈

**/*.phpのような指定をすると、zshと違いカレントディレクトリは対象外になる。
カレントディレクトリも対象とするには***.phpとする。

ls **/*.php
php/empty.php

⟩ ls ***.php
algo.php
php/empty.php

iTerm2からTerminal.appに戻ろうとして断念した

最近Vimとターミナル環境をリニューアルしている。
これまでずっとiTerm2を使っていたけど特に理由はなかった。最初にMac環境を色々構築した時に「iTerm2がナウいよ」って記事を読んで、良く分からないまま適当に入れただけだった。
標準のTerminal.appに出来なくてiTerm2じゃないと出来ないこと(使う理由)も良く分かっていない。

「背景画像を設定できる」のと「縦分割ができる」というのを良く見かけるが、僕は背景画像を設定しないし、縦分割はtmuxで出来るので必要ない。

そんなわけでiTerm2をやめてTerminal.appに戻ろうとしたんだけど、日本語を入力した時の挙動が気になってしまった。

f:id:kanno_kanno:20181213035929g:plain

入力中の日本語に応じて右にずれていく。撮り忘れたけど確定すると元の表示位置に戻る。
ちなみにGifでは縦分割だが、1ペインで入力中も時刻など右側に表示しているものがずれていく。
iTerm2だと再現しない。

調査する時間は持てず、結局iTerm2に戻ってきた。

さよならVim

これはVim大好きだったプログラマVim情報を追わなくなり、メインで使わなくなって、また帰ってくるまでの5年間の記録です。

Vimの情報を追わなくなった

それまでVimの情報を追い続けて、面白そうなプラグインは試して、自分でVimプラグインを作っていた僕がその手を止め始めたのは、転職をした2014年だと思う。
友人に誘われてフリークアウトに転職した時の僕は、フリークアウトが扱っているアドテクもPerlも一切知らなかった。

さらにフリークアウトのエンジニアはみんなレベルが高くて、入社してすぐに僕は自分が下から数えた方が早い技術力だと自覚した。
新卒のレベルですら非常に高いことに驚いた。その前の会社では僕を含め、新人といえばプログラミング未経験だったので衝撃は大きかった。
しかも新卒の人たちは広告に興味があって入ってきているわけで、業務知識は彼らの方が遥かにあった。現状に甘えたらすぐに追い抜かれる焦りを感じた。

エディタは自由なので当然Vimを使ったが、Vimをいじりたおす余裕はなかった。
入社してしばらくは自分が望む進捗を出せずに悶々としていた気がする。

唯一の活動としてprevimは使ってくださるユーザーさんがいたので、出来る範囲でサポートを続けてきた。

1年が経つ頃には業務にも慣れてきたけど、同時に「Vimの情報を追わない」ことにも慣れてしまっていた。
入社した段階で「自分のVim環境」は出来上がっていたので、無理に追う必要もなかった。それよりは仕事を進めたり、他の技術的な興味を追っかけたりする方が楽しくなっていた。
広告分野にしろ技術的なことにしろフリークアウトで学べることはたくさんあって、そちらを優先する方が大事に思えた。


フリークアウトに入って2年が経ち、2016年に僕はフリーランスになった。

請け負って一人で開発したのも含めるとRuby, Python, PHPなど色々な環境を経験できた。
直近の仕事を除き全てVimで開発してきた。Vimがあれば十分だった。

でも.vimrcの設定の多くは2014年の状態で止まったままで、少しだけフリークアウト時に修正したものが残っている程度。
プラグインの更新もほとんどしていない。更新して動かなくなることが嫌だから。
僕の中でVimが単なるツールに成り下がっていた。

Vimは楽しい」という感覚はこの頃に失っていたと思う。これまでの習慣で身についた「Vimの操作性」から離れられず、惰性で使っていたに過ぎない。

Vimをメインで使わなくなった

フリーランスになって少しして、僕は自分のサービスを作ることに目覚めた。
僕は本が好きで、本屋が好きだから、今の出版不況を何とかしようと燃えていた。初めて生き甲斐を見つけたと興奮した。
(リトルスタッフ開発者の自己紹介)

自分のサービスを開発するのもVimを使っていた。当時はまだ。
サービス開発に熱中していたし出版業界を知るので精一杯だったから、Vimに限らず技術的なキャッチアップもほとんどしなくなった。開発以外に企画やら営業やら新しい作業は山積みにある。使える時間は限られている以上、何かを切り捨てないといけなかった。

一方で生活費が必要な僕はフリーランスの仕事も続けた。友人に誘ってもらって2017年12月からPHPの現場で働き始めた。

そこでPCを支給してもらい開発環境のセットアップを始めた僕は、もちろんVim環境を構築した。
ただここで問題が起きた。まともに開発するには「何か挙動がおかしい」感じだった。
すごく漠然とした書き方になっているのは、ほとんど調査をしなかったからだ。

.vimrcを読み込まずに素のまま立ち上げたら問題なかったので、.vimrcの設定か、プラグインがおかしいことは明らかだった。
そこまで分かったとき僕はVimにイラッとした。(正確にはVimそのものじゃなくて自分の設定やプラグイン側の話だが)
面倒くさいと強く思った。

当時「Vimはツール」という感覚まで冷めていた僕は、「こんなことでハマるくらいならIDEを使う」と気持ちを切り替える。
PhpStormに馴染むには時間がかかったけど、慣れてしまえばすごく便利だった。
(Vimキーバインド以外で開発など無理な体になっているので、もちろんIdeaVimは入れた)

Vimとの比較でいえば「定義ジャンプ」と「ブレークポイントによるデバッグ」がダントツで便利だった。
以前の僕なら「それVimでも出来るよ」とか「別にそこまで必要じゃない」と思っていたけど、体験すると価値観が変わった。
Java + Eclipseで長いこと開発してきた経験もあるからそれらの便利さはもちろん知っていたけど、長らく使っていなかったせいか、こんなに効率違ったかと反省した。今までだったら関数定義をgrepしてファイルを開いていた操作が、PhpStormならショートカットキー1発で正確に飛べる。
特にこの現場での作業は調査寄りのことが多かったので、定義ジャンプとデバッガの有無は大きく影響した。
(それまでの現場のように追加開発がメインだったら、感想は違ったかもしれない)

そして自分のサービス開発もVimからPhpStormに切り替えた。

Vimへの熱がなくなり、自分のサービスに専念したい僕はprevimも有志のメンテナさんへ引き継ぎをお願いした。
快く引き受けてくださる方が数名いて、非常に助けられました。ありがとうございます。
(previmを他の方にお譲りしたい)

僕は開発において、完全にVimを使わなくなった。

それでもVimに帰ってきた

メインでVimを使わなくなって約1年が過ぎたが、最近「Vimをまたいじろうかな」という気持ちが芽生えていた。
きっかけは分からない。技術的なキャッチアップから遠のいている焦りかもしれないし、今の生活へのマンネリかもしれない。

技術的なキャッチアップをしなくなってから、つまりここ数年はTwitterのメインTLを見ていない。
それがここ最近は少しずつリハビリした方がいいかもと思い始めていて、たまにTwitterを開くようになっている。
そんな中、たまたま開いたらVimConf 2018のリアルタイム実況が流れてきた。めちゃくちゃ楽しそうだった。

TLを追いながら、そうだVimは楽しいんだった、ということを思い出していた。
最初にVimへ興味を持ったのは「Vimを覚えたら効率が上がるかもしれない」というツール的側面だったけど、学ぶうちに「Vimを使うこと」や「Vimを使っている自分」が楽しくて、その体験が目的になっていた。それが大事だった。

Vimが楽しいからプログラミングにも意欲的になれる。プログラマとしての自分を楽しめる。僕はそういうタイプのはずだった。

PhpStormの便利さは変わらないから引き続き使い続けるかもしれない。ライセンス更新のタイミングでどうするかはまだ決めていない。

今はただ、Vimの楽しさを思い出そう。僕はVimの情報を追い始めている。

ファイルが変更されたら特定のコマンドを実行するentrというシンプルなツール

あるファイルが変更されたら特定のコマンドを実行したいというケースは良くある。例えばテストの自動実行とかブラウザの自動リロードとかサンプルコードを走らせたいとか。
これらを解決する手段としてguardが有名だしgulpで解決している人も多い。

entrはそんな「ファイルの変更を検知してコマンドを実行する」ツールの一つ。

こちらの動画で使用されているのを見て知った。entrの登場は開始50秒くらいから。


Goライブコーディング: go-switchgen(2倍速)

コンセプトは上記の公式サイトに書いてある。

entr is a zero-configuration tool with no external build or runtime dependencies.

guardやgulpと違い、entrは設定ファイルも依存ライブラリもいらない。実装はC言語で書かれている。

macならhomebrewで簡単に入る。他のOSはREADMEを読む限り普通にmake installだと思う。

$ brew install entr

説明が不要なくらい使い方はシンプル。変更を監視したいファイルをパイプで渡してあげるだけ。/_は変更があったファイルを指す。

f:id:kanno_kanno:20181208144744p:plain
(1ファイルなのでfindじゃなくechoで十分だった)


キャプチャでは1ファイルだけ指定しているけど、もちろんgrepによる複数指定が可能。ファイル一覧を渡せばいいのでgrepじゃなくてagとかackとかでももちろんOK。

// 公式サイトより
$ ag -l | entr make

使い方 - オプション

man entrで確認できるオプションについて僕なりの理解。

-c
デフォルトだと実行結果は標準出力にどんどん追加されていく。
f:id:kanno_kanno:20181208145848p:plain
-cを付けると実行のたびに画面がclearされる。watchコマンドの実行結果みたいなもの。

-d
指定したディレクトに新しい追加があった時にexitされる。
fooディレクトリを作ってfind foo | entr -d go run /_を実行したあと、新しくファイルを作ったら「entr: directory altered」というメッセージとともに確かにexitされた。
ちなみに追加ではなく削除してみたら「entr: cannot open 'foo/a.go': No child processes」というメッセージとともにexitされた。(落ちた)

-n
このオプションを付けると「非インタラクティブモード」で動く。デフォルトだと後述するようにentr実行中にキーボード入力を受け付けるが、-nだと無反応になる。どういう時に使うのかは分からない。

-p
デフォルトだとentrを実行した段階でまずコマンドを実行する。-pを付けるとファイルに変更があるまで実行しない。Postpone(後回し)のpだ。

-r
コマンド実行の度に子プロセスを作り直す。
デフォルトではプロセスツリーはこうなっていて、コマンド実行は同じプロセスで実行される。
f:id:kanno_kanno:20181208160324p:plain

それが-rを付けると子プロセスを作って実行され、再実行のたびに子プロセスは生まれ変わる。
f:id:kanno_kanno:20181208160452p:plain
examplesだとentr -r node app.jsのようなサーバー再起動などで使われているので、リソースをちゃんと解放させたいとかそういう時に指定する感じかな。

-s
引数で指定したコマンドを$SHELLに対して実行する。簡単に言えば指定したコマンドを実行するだけ。
公式の例にある$ ag -l | entr -s 'make && make test'のように、&&とかをちゃんと解釈させて動かしたい時に使うのだと思う。

使い方 - コマンド

entr実行中に受け付けているキーボード操作は2つ。

スペース
即座にコマンドを実行する。

q
entrから抜け出す。

例: SQLファイルを更新するたびにMySQLへ即実行

man entrに載っているexamplesを見ていたらこんなのがあった。

Clear the screen and run a query after the SQL script is updated:

      $ echo my.sql | entr -p psql -f /_

sqlファイルを編集したら即座にPostgreSQLに実行するというサンプル。面白いなと思った。
psqlだとサンプルそのままなので、MySQLで試してみた。

$ echo sample.sql | entr -c -p -s "mysql sample < sample.sql"
  • -cで実行の度に画面をクリア
  • -pで初回は実行しない。何か怖いので
  • -sでコマンドを指定
    • リダイレクト(<)を使うせいか、デフォルトのままでは動かなかった
    • -sを使っているため/_(変更があったファイル)の記述を使えなかった
      • コードを読む限り-sを使っていると無理っぽい

一応これで実行できた。Vimでファイルを編集したら即座に結果が出力される。ただ普段はGUIツールを使っているので実際に使うことはないだろう。

監視対象のファイルが多いとエラー

何も考えずディレクトリを指定したらエラーが発生。

Too many files listed; the hard limit for your login class is 4864

扱えるファイルディスクリプタのソフトリミットを超えたので当然。特に意識したことないけど、guardとか他のツールも同様の制御があるんだろうか。制御がなくてもソフトリミット超えたら落ちるはずだから一緒か。

おまけ

動画を見たのは少し前だったので今日導入したいと思った時にentrという名前を思い出せず、当初の目的はgoで書かれたcespare/reflexを入れて解決した。
そのあとに元動画を思い出して、entrのmanやリポジトリを見たら小さかったので勉強のために出来る範囲で読み解いた。C言語、雰囲気でしか分からないな。ファイルやプロセス周りだから、C言語というよりシステムプログラミングが分かっていないのか。でも「分からないけどちょっと分かる」みたいなレベルを追いかけている時って面白い。