[rails/spring] シグナルによるプロセス終了時の終了コードを正しくクライアントに伝播
背景
Springは、Railsアプリケーションの起動時間を短縮するために、アプリケーションをバックグラウンドでプリロードしておくツールです。しかし、子プロセスがクリーンに終了しなかった場合(シグナルで終了した場合など)、Spring経由で実行すると本来の終了コードがマスクされ、常に 0 が返される問題がありました。
これは、Rubyプロセスがクラッシュした際に、実際には異常終了しているにも関わらず、呼び出し元には正常終了として扱われてしまうという深刻な問題です。Issue #676 で報告されていました。
技術的な変更内容
Process::Status の正しい取り扱い
問題の核心は Process::Status#exitstatus の挙動にあります。このメソッドは、プロセスが exit() で正常終了した場合は終了コードを返しますが、シグナルで終了した場合は nil を返します。
変更前:
_, status = Process.wait2 pid
log "#{pid} exited with #{status.exitstatus}"
streams.each(&:close)
client.puts(status.exitstatus)
client.close
このコードでは、status.exitstatus が nil の場合、client.puts(nil) が実行され、クライアント側で空文字列として読み取られていました。
変更後:
_, status = Process.wait2 pid
log "#{pid} exited with #{status.exitstatus || status.inspect}"
streams.each(&:close)
client.puts(status.exitstatus || status.to_i)
client.close
status.to_i メソッドは、Process::Status オブジェクトに対して呼び出すと、以下のように動作します:
- 正常終了の場合:
exitstatusの値を返す(例:0) - シグナル終了の場合:
128 + シグナル番号を返す(例: SIGTERM(15)なら143) - 停止の場合:
128 + 停止シグナル番号を返す
これにより、どのような終了方法でも適切な終了コードがクライアントに伝わるようになります。
クライアント側のエラーハンドリング強化
status = application.read
log "got exit status #{status.inspect}"
# Status should always be an integer. If it is empty, something unexpected must have happened to the server.
if status.to_s.strip.empty?
log "unexpected empty exit status, app crashed?"
exit 1
end
exit status.to_i
クライアント側では、サーバーから受け取った終了ステータスが空の場合(サーバーが予期せずクラッシュした場合など)を検出し、明示的に終了コード 1 で終了するようになりました。これにより、サーバー側の異常をより確実に検出できます。
テストケースの追加
test "passes exit code from exit and signal" do
artifacts = app.run("bin/rails runner 'Process.exit(7)'")
code = artifacts[:status].exitstatus || artifacts[:status].termsig
assert_equal 7, code, "Expected exit status to be 7, but was #{code}"
end
明示的に終了コード 7 で終了するテストを追加し、その終了コードが正しく伝播されることを確認しています。
影響範囲
この変更により、以下のようなケースで正しい終了コードが返されるようになります:
-
Process.exit(n)で明示的に指定した終了コード - セグメンテーションフォルト(SIGSEGV)によるクラッシュ
-
killコマンドなどによるシグナル送信 - その他の異常終了
CI/CDパイプラインやスクリプトで Spring を使用している場合、これまで見逃されていたエラーが正しく検出されるようになるため、潜在的な問題の早期発見につながります。