notify_safely のロック競合を解消し、ワーカー停止ハングを防止
notify_safely が内部ロックと競合し、Puma のクラスターモードでワーカー停止時にプロセスが無限にハングする問題を、IO#syswrite へ置き換えることで根本的に解決した変更を解説します。
背景
Puma のワーカープロセスは、Cluster::Worker#run の終了時に Server#stop を呼び出し、内部で notify_safely が @notify << message により STOP_COMMAND を送信します。この書き込みは Ruby のバッファード I/O 経路を通り、IO#close が取得しようとする内部書き込みロックと競合することがあります。競合が発生すると、IO#close がロック待ちで sleep_forever に陥り、server_thread.join が永久に待ち続け、結果としてクラスターマスターは stop_workers でハングし、外部オーケストレータから SIGKILL される事態になります。
技術的な変更
lib/puma/server.rb の notify_safely 実装を、従来の @notify << message から @notify.syswrite(message) へ置き換えました。syswrite は単一の write(2) システムコールであり、MRI のバッファード I/O ロックをバイパスするため、IO#close とのデッドロックが起きません。また、syswrite は非同期シグナルセーフであるため、シグナルハンドラ(TERM/USR2/INT/HUP)から安全に呼び出すことができます。
@@
- @notify << message
+ notify = @notify
+ notify.syswrite(message) if notify && !notify.closed?
さらに例外ハンドリングを拡張し、MRI 3.3 以降で Puma::IS_MRI && RUBY_VERSION > '3.3' の条件下では @log_writer.unknown_error に渡すようにしました。これは IOError, NoMethodError, Errno::EPIPE, Errno::EBADF などの例外をロギングしつつ、既存の挙動を保持します。
同時に、test/test_puma_server_notify_close_race.rb を追加し、2 つの回帰テストを実装しました。1 つ目はシグナルハンドラから notify_safely を呼び出しても ThreadError が発生しないことを確認し、2 つ目は多数の同時書き込みと停止操作を繰り返してもハングしないことを検証します。
設計判断
ロック競合を回避する手段として Mutex を導入する案も検討されましたが、Puma のシグナルハンドラは「トラップコンテキスト」から呼び出されるため、Mutex の synchronize は "can't be called from trap context" という例外を引き起こします。そのため、非ロックでかつシグナルセーフな IO#syswrite が採用されました。syswrite は低レベルのシステムコールであるため、既存の API 互換性を保ちつつ、内部ロックの取得を回避できます。
変更は 後方互換性 を損なわず、従来通り @notify << message(真偽値 true/false での使用)からも動作します。syswrite の呼び出しは notify が nil もしくはクローズされている場合にスキップされるため、既存コードへの影響はありません。
まとめ
この PR は、notify_safely の実装を IO#syswrite に置き換えることで、Puma のワーカーハンドリング時に発生していたロック競合と無限ハングを根本的に解消しました。シグナルハンドラから安全に呼び出せる設計を維持しつつ、追加テストで回帰を防止することで、クラスターモード運用の信頼性が大幅に向上します。