ffmpegのstdin接続を遮断してTTOUシグナルによるRailsハングを修正
ActiveStorageのビデオプレビュー生成時に発生するRailsプロセスのハングアップを、ffmpegのstdinを /dev/null に接続することで解消します。
背景
ffmpegは起動時に tcsetattr を呼び出して制御端末の設定を行い、q キーによる終了などのキー入力を監視します。この挙動が、特定のプロセス管理環境下でRailsを停止させるシグナルを引き起こしていました。
問題は、Railsが自身のプロセスグループを持つ形で起動された場合に顕在化します。overman のように pgroup: true でプロセスを生成するプロセスマネージャを使用している環境では、ffmpegが制御端末に書き込もうとした際にバックグラウンドプロセスグループのメンバーに送られる TTOU(SIGTTOU)シグナル をRailsプロセスが受信します。SIGTTOUのデフォルト動作はプロセスの停止(STOP)であるため、Railsは無期限にハングしてしまいます。
Railsに IO.popen でffmpegを起動する際、stdinをそのまま引き継いでいたことが原因です。ffmpegはstdinが端末であると判断して tcsetattr を呼び出し、バックグラウンドプロセスからの端末制御操作としてSIGTTOUが発火するという連鎖が起きていました。
技術的な変更
activestorage/lib/active_storage/previewer.rb の capture メソッドに対し、IO.popen の呼び出しに in: IO::NULL オプションを追加する1行の変更が加えられました。
変更前:
IO.popen(argv, err: err) { |out| IO.copy_stream(out, to) }
変更後:
IO.popen(argv, in: IO::NULL, err: err) { |out| IO.copy_stream(out, to) }
IO::NULL はRubyの定数で、プラットフォームに応じたnullデバイス(Unixでは /dev/null、Windowsでは NUL)を参照します。stdinをnullデバイスに接続することで、ffmpegはstdinが端末ではないと判断し、tcsetattr による端末設定を試みなくなります。
この修正は Previewer 基底クラスの capture メソッドに適用されているため、VideoPreviewer だけでなく Previewer を継承するすべてのプレビュー処理に影響します。PR内ではffmpegに限らずActiveStorageのPreviewerにstdinを提供する理由がないとの判断から、基底クラスレベルで修正が行われています。なお、IO.popen("ffprobe"...) を使用する箇所については、ffprobeがstdinに対して特殊な操作をしないとの判断から変更対象外とされています。
設計判断
VideoPreviewer に限定せず Previewer 基底クラスで修正する方式 が採用されました。
PRでは -nostdin をffmpegの引数に追加する代替案も言及されています。しかし、その方法はffmpeg固有の対処に留まります。基底クラスで in: IO::NULL を指定する方式はより汎用的で、将来追加されるPreviewerが誤ってstdinを引き継ぐリスクを防ぎます。
ActiveStorageのPreviewerは外部プロセスを起動してその標準出力をファイルにキャプチャする用途に特化しており、stdinへのアクセスを必要とするユースケースが存在しません。この前提に基づき、基底クラスで一律にstdinを遮断することが適切と判断されています。
まとめ
たった1行のオプション追加でありながら、この修正はプロセスグループとシグナルという低レイヤーの挙動に起因するハングアップを根本から解消します。外部プロセスを起動する際にstdinを明示的に制御するという原則が、Previewer 基底クラスへの適用という形で設計に組み込まれた変更です。