Tailwindcss:watchが停止シグナルを子プロセスへ転送
Tailwindcss:watch タスクがプロセスマネージャからの停止シグナルを子プロセスに転送し、オーファン化を防止します。Foreman が Rails の Rake プロセスに SIGTERM を送ると、Rails 側は正常に終了しますが、内部で spawn された tailwindcss プロセスが残り続けるケースが報告されていました。この PR は、シグナルハンドリングを統一的に扱うユーティリティを導入し、子プロセスのライフサイクルを管理することで問題を解決します。さらに、タスク実行後に元のシグナルハンドラを復元することで、プロセス全体の状態を汚染しない設計となっています。
背景
開発環境で foreman が SIGTERM を送信した際、Rails の Rake プロセスは終了するものの、tailwindcss:watch が spawn した Tailwind CSS のウォッチャーが孤児プロセスとして残る問題がありました。Foreman はプロセス全体にシグナルを送るため、子プロセスはシグナルを受け取れず、OS 上で実行し続けてリソースを消費します。この状態は開発者が手動でプロセスを殺す必要が生じ、作業効率を低下させました。PR はこのシグナル転送の欠如を直接的に修正することを目的としています。
技術的な変更
Tailwindcss::ProcessRunner モジュールが新規に追加され、シグナル転送ロジックをカプセル化しました。モジュール内部で FORWARDED_SIGNALS 定数 ( %w[INT TERM] ) を定義し、転送対象シグナルを明示しています。モジュールは spawn_and_wait メソッドを提供し、子プロセスの生成から終了待機、シグナル転送、元ハンドラの復元までを一貫して行います。
spawn_and_wait はまず対象シグナルを trap で捕捉し、受信したシグナルを変数に保持します。子プロセスが生成されたら保存されたシグナルを即座に転送し、Process.wait で子プロセスが終了するまでブロックします。終了後は trap に保存した元ハンドラを再設定し、プロセス全体のシグナル状態を元に戻します。メソッドは転送されたシグナル名(例: "TERM")または子プロセスが自然終了した場合は nil を返します。
module Tailwindcss
module ProcessRunner
FORWARDED_SIGNALS = %w[INT TERM].freeze
class << self
def spawn_and_wait(env, *command)
pid = nil
received_signal = nil
previous_traps = {}
forward_signal = ->(signal) do
Process.kill(signal, pid) if pid
rescue Errno::ESRCH
# child already exited
end
FORWARDED_SIGNALS.each do |signal|
previous_traps[signal] = trap(signal) do
received_signal ||= signal
forward_signal.call(signal)
end
end
pid = Process.spawn(env, *command)
forward_signal.call(received_signal) if received_signal
Process.wait(pid)
pid = nil
FORWARDED_SIGNALS.each { |s| trap(s, previous_traps[s]) }
received_signal
end
end
end
end
lib/tasks/build.rake の tailwindcss:watch タスクは、従来の system 呼び出しから Tailwindcss::ProcessRunner.spawn_and_wait へ置き換えられました。タスクは戻り値として受信したシグナル名を取得し、verbose オプションが有効な場合にログ出力します。これによりタスクはシグナル処理を自前で実装する必要がなくなり、コードがシンプルかつテスト可能になっています。
namespace :tailwindcss do
task :watch do |t, args|
env = Tailwindcss::Commands.command_env(verbose: verbose)
puts "Running: #{Shellwords.join(command)}" if verbose
received_signal = Tailwindcss::ProcessRunner.spawn_and_wait(env, *command)
puts "Received #{received_signal}, exiting tailwindcss:watch" if verbose && received_signal
end
end
設計判断
シグナル転送機構を専用モジュール Tailwindcss::ProcessRunner に切り出すことで、Rake タスク自体はプロセス実行の呼び出しだけに集中できます。この分離はテスト容易性を高め、ProcessRunner が他のタスクやスクリプトでも再利用できるように設計されたことを示しています。ハンドラ復元ロジックをメソッド内部で完結させたため、タスク実行後にグローバルなシグナル状態が汚染されるリスクを排除しています。設計上のトレードオフとしては、シグナルハンドリングの抽象化に伴う若干のオーバーヘッドが発生しますが、開発環境の安定性向上という利益がそれを上回ります。
まとめ
今回の変更は、tailwindcss:watch がプロセスマネージャからの停止シグナルを正しく子プロセスへ転送し、孤児プロセス化を防止することを実現しました。Tailwindcss::ProcessRunner.spawn_and_wait による一元化されたシグナル管理は、コードの可読性とテスト性を向上させ、将来的な類似タスクへの適用も容易です。開発者は余計な手動クリーンアップなしで安全に開発サイクルを終了できるようになります。