diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6e255..5b60bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # `tailwindcss-rails` Changelog +## next / unreleased + +### Fixed + +* `tailwindcss:watch` now forwards stop signals (`SIGINT`/`SIGTERM`) to the spawned `tailwindcss` process, instead of letting it be orphaned. This happens when a process manager signals the watch task directly rather than the whole process group — most commonly Procfile-based managers like foreman in development. @jordan-brough + + ## v4.5.0 / 2026-06-15 ### Improved diff --git a/Gemfile.lock b/Gemfile.lock index d74bb54..2286af7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - tailwindcss-rails (4.4.0) + tailwindcss-rails (4.5.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) diff --git a/lib/tailwindcss-rails.rb b/lib/tailwindcss-rails.rb index bdfb592..350fb56 100644 --- a/lib/tailwindcss-rails.rb +++ b/lib/tailwindcss-rails.rb @@ -5,3 +5,4 @@ module Tailwindcss require_relative "tailwindcss/engines" require_relative "tailwindcss/engine" require_relative "tailwindcss/commands" +require_relative "tailwindcss/process_runner" diff --git a/lib/tailwindcss/process_runner.rb b/lib/tailwindcss/process_runner.rb new file mode 100644 index 0000000..e594f77 --- /dev/null +++ b/lib/tailwindcss/process_runner.rb @@ -0,0 +1,59 @@ +module Tailwindcss + # Runs a child process and forwards stop signals (INT/TERM) to it, blocking + # until it exits, so a process manager that signals this process directly + # (e.g. foreman) doesn't leave the child orphaned. Shaped like + # +Process.spawn+/+Kernel#system+: it takes an +env+ hash followed by the + # command. + module ProcessRunner + # Signals we forward to the spawned process so it shuts down with us instead + # of being orphaned. + FORWARDED_SIGNALS = %w[INT TERM].freeze + + class << self + # Spawn +command+ with +env+ and block until it exits, forwarding INT + # (Ctrl-C) and TERM (e.g. foreman shutdown) to it so a process manager + # that signals us directly doesn't leave an orphaned child behind. + # Restores the previous signal handlers before returning so the + # process-global traps aren't left changed. Returns the name of the signal + # that was received (e.g. "TERM"), or nil if the child exited on its own. + def spawn_and_wait(env, *command) + pid = nil + received_signal = nil + previous_traps = {} + + forward_signal = ->(signal) do + if pid + Process.kill(signal, pid) + end + rescue Errno::ESRCH + # the child already exited + end + + # Trap immediately before spawning. If a signal lands before pid is + # assigned, remember it and forward it once the child exists. + 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) + # If a signal arrived during spawn (before pid was set), the handler + # couldn't forward it yet, so forward it now. + if received_signal + forward_signal.call(received_signal) + end + Process.wait(pid) + # Drop the pid so a late signal can't kill a process that reused it. + pid = nil + + received_signal + ensure + previous_traps.each do |signal, previous_trap| + trap(signal, previous_trap) + end + end + end + end +end diff --git a/lib/tasks/build.rake b/lib/tasks/build.rake index 991b5b2..d07f80a 100644 --- a/lib/tasks/build.rake +++ b/lib/tasks/build.rake @@ -23,9 +23,8 @@ namespace :tailwindcss do env = Tailwindcss::Commands.command_env(verbose: verbose) puts "Running: #{Shellwords.join(command)}" if verbose - system(env, *command) - rescue Interrupt - puts "Received interrupt, exiting tailwindcss:watch" if args.extras.include?("verbose") + received_signal = Tailwindcss::ProcessRunner.spawn_and_wait(env, *command) + puts "Received #{received_signal}, exiting tailwindcss:watch" if verbose && received_signal end desc "Create Tailwind CSS entry point files for Rails Engines" diff --git a/test/lib/tailwindcss/process_runner_test.rb b/test/lib/tailwindcss/process_runner_test.rb new file mode 100644 index 0000000..61f61a1 --- /dev/null +++ b/test/lib/tailwindcss/process_runner_test.rb @@ -0,0 +1,119 @@ +require "test_helper" +require "minitest/mock" + +class Tailwindcss::ProcessRunnerTest < ActiveSupport::TestCase + test ".spawn_and_wait restores the previous signal handlers when it exits" do + signals = Tailwindcss::ProcessRunner::FORWARDED_SIGNALS + original_handlers = signals.to_h { |signal| [signal, trap(signal, "DEFAULT")] } + + begin + handlers = signals.to_h { |signal| [signal, proc {}] } + handlers.each { |signal, handler| trap(signal, handler) } + + Process.stub(:spawn, ->(*) { 999_999 }) do + Process.stub(:wait, ->(*) {}) do + Tailwindcss::ProcessRunner.spawn_and_wait({}, "tailwindcss") + end + end + + handlers.each do |signal, handler| + # trap returns the currently-installed handler, which should be ours. + restored = trap(signal, handler) + assert_same(handler, restored, "spawn_and_wait did not restore the #{signal} handler") + end + ensure + original_handlers.each { |signal, handler| trap(signal, handler) } + end + end + + test ".spawn_and_wait forwards a stop signal to the spawned process so it isn't orphaned" do + Dir.mktmpdir do |dir| + ready_file = File.join(dir, "ready") # the fake child writes its pid here once running + + # Stand in for the real tailwindcss binary with a process we control: it + # records its pid, then sleeps until a forwarded TERM makes it exit. + fake_watcher = <<~RUBY + Signal.trap("TERM") { exit(0) } + File.write(#{ready_file.inspect}, Process.pid.to_s) + sleep + RUBY + command = [RbConfig.ruby, "-e", fake_watcher] + + watcher_pid = nil + + runner_pid = fork do + Tailwindcss::ProcessRunner.spawn_and_wait({}, *command) + ensure + # skip Minitest's at_exit in this forked child so it can't re-run the suite + exit!(true) + end + + begin + assert(wait_until { File.size?(ready_file) }, "the spawned process never started") + watcher_pid = Integer(File.read(ready_file)) + + # SIGTERM the runner, as a process manager (e.g. foreman) would on shutdown. + Process.kill("TERM", runner_pid) + + # Once the runner is reaped it has waited out its child, so a live child means an orphan. + assert(wait_until { reaped?(runner_pid) }, + "spawn_and_wait did not exit after its child did") + refute(process_alive?(watcher_pid), + "the spawned process #{watcher_pid} was orphaned after TERM") + ensure + # never leak the helper processes, even if an assertion above failed + kill_quietly(watcher_pid) + kill_quietly(runner_pid) + end + end + end + + private + + # Poll a condition until it's truthy or the deadline passes; returns the result. + def wait_until(timeout: 10) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + result = yield + if result + return result + elsif Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + return false + else + sleep(0.05) + end + end + end + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + true + end + + # True once pid has exited (reaping it), or if it was already reaped. + def reaped?(pid) + !!Process.wait(pid, Process::WNOHANG) + rescue Errno::ECHILD + true + end + + # Kill and reap pid, tolerating a process that's already gone or isn't our child. + def kill_quietly(pid) + if pid + begin + Process.kill("KILL", pid) + rescue Errno::ESRCH + # already gone + end + begin + Process.wait(pid) + rescue Errno::ECHILD + # not our child, or already reaped + end + end + end +end