From dd2e85b054081530ebf834be6c0a8a812c80938f Mon Sep 17 00:00:00 2001 From: Jordan Brough Date: Tue, 26 May 2026 22:39:05 -0600 Subject: [PATCH 1/3] Forward stop signals to tailwindcss watcher I was getting orphaned tailwind processes in dev when foreman got a sigterm. Foreman stops the Rails/rake process, but the tailwindcss watcher it spawned could keep running as an orphan. This updates to have tailwindcss:watch spawn the CLI directly, trap INT and TERM, forward them to the child, and wait for the child to exit. It also restores the previous signal handlers afterward so the task doesn't leave process-global traps changed. --- CHANGELOG.md | 8 +- lib/tailwindcss/commands.rb | 55 ++++++++++++ lib/tasks/build.rake | 16 ++-- test/lib/tailwindcss/commands_test.rb | 119 ++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1252a2..5db8670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # `tailwindcss-rails` Changelog +## next / unreleased + +### Fixed + +* `tailwindcss:watch` now handles `SIGTERM` from process supervisors by forwarding stop signals to the spawned `tailwindcss` process and waiting for it to exit, preventing orphaned watchers when foreman, systemd, or Docker stops the rake task. @jordan-brough + + ## v4.4.0 / 2025-10-27 ### Changed @@ -470,4 +477,3 @@ Nothing. But we're promote 0.5.4 to 1.0.0 to go along with the final release of * Match button label for destroy with text used by regular Rails templates by [@dhh](https://github.com/dhh) - diff --git a/lib/tailwindcss/commands.rb b/lib/tailwindcss/commands.rb index 9c910de..844d3dd 100644 --- a/lib/tailwindcss/commands.rb +++ b/lib/tailwindcss/commands.rb @@ -1,7 +1,12 @@ +require "shellwords" require "tailwindcss/ruby" module Tailwindcss module Commands + # Signals we forward to the spawned tailwindcss process so it shuts down + # with us instead of being orphaned. + FORWARDED_SIGNALS = %w[INT TERM].freeze + class << self def compile_command(debug: false, **kwargs) debug = ENV["TAILWINDCSS_DEBUG"].present? if ENV.key?("TAILWINDCSS_DEBUG") @@ -28,6 +33,56 @@ def watch_command(always: false, **kwargs) end end + # Spawn the tailwindcss watcher and block until it exits, forwarding INT + # (Ctrl-C) and TERM (foreman/systemd/Docker shutdown) to it so a stopping + # supervisor doesn't leave an orphaned watcher behind. + def watch(always: false, debug: false, verbose: false) + pid = nil + received_signal = nil + previous_traps = {} + + command = watch_command(always: always, debug: debug) + env = command_env(verbose: verbose) + if verbose + puts "Running: #{Shellwords.join(command)}" + end + + forward_signal = ->(signal) do + if pid + Process.kill(signal, pid) + end + rescue Errno::ESRCH + # tailwindcss 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 + + if verbose && received_signal + puts "Received #{received_signal}, exiting tailwindcss:watch" + end + ensure + previous_traps.each do |signal, previous_trap| + trap(signal, previous_trap) + end + end + def command_env(verbose:) {}.tap do |env| env["DEBUG"] = "1" if verbose diff --git a/lib/tasks/build.rake b/lib/tasks/build.rake index 539a8df..050baad 100644 --- a/lib/tasks/build.rake +++ b/lib/tasks/build.rake @@ -13,17 +13,11 @@ namespace :tailwindcss do desc "Watch and build your Tailwind CSS on file changes" task watch: [:environment, :engines] do |_, args| - debug = args.extras.include?("debug") - always = args.extras.include?("always") - verbose = args.extras.include?("verbose") - - command = Tailwindcss::Commands.watch_command(always: always, debug: debug) - 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") + Tailwindcss::Commands.watch( + always: args.extras.include?("always"), + debug: args.extras.include?("debug"), + verbose: args.extras.include?("verbose"), + ) end desc "Create Tailwind CSS entry point files for Rails Engines" diff --git a/test/lib/tailwindcss/commands_test.rb b/test/lib/tailwindcss/commands_test.rb index 62d1e68..0489786 100644 --- a/test/lib/tailwindcss/commands_test.rb +++ b/test/lib/tailwindcss/commands_test.rb @@ -118,4 +118,123 @@ def setup assert_includes(actual, "always") end end + + test ".watch restores the previous signal handlers when it exits" do + signals = Tailwindcss::Commands::FORWARDED_SIGNALS + original_handlers = signals.to_h { |signal| [signal, trap(signal, "DEFAULT")] } + + Rails.stub(:root, File) do # Rails.root won't work in this test suite + 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::Commands.watch + 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, "watch did not restore the #{signal} handler") + end + ensure + original_handlers.each { |signal, handler| trap(signal, handler) } + end + end + + test ".watch 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 watcher 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 + + # The stub must stay active across the fork so the forked watch picks up + # our fake command instead of the real CLI. + Tailwindcss::Commands.stub(:watch_command, ->(*) { command }) do + watch_pid = fork do + Tailwindcss::Commands.watch + 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 rake task, as a supervisor (foreman, systemd, Docker) would on shutdown. + Process.kill("TERM", watch_pid) + + # Once watch is reaped it has waited out its child, so a live watcher means an orphan. + assert(wait_until { reaped?(watch_pid) }, + "watch 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(watch_pid) + end + 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 From 7dd46004ed609d10431c003d8f9d3d3904a83840 Mon Sep 17 00:00:00 2001 From: Jordan Brough Date: Mon, 15 Jun 2026 09:00:21 -0600 Subject: [PATCH 2/3] PR feedback: Extract `Tailwindcss::ProcessRunner` for process-running logic --- CHANGELOG.md | 3 +- lib/tailwindcss-rails.rb | 1 + lib/tailwindcss/commands.rb | 55 --------- lib/tailwindcss/process_runner.rb | 59 ++++++++++ lib/tasks/build.rake | 15 ++- test/lib/tailwindcss/commands_test.rb | 119 -------------------- test/lib/tailwindcss/process_runner_test.rb | 119 ++++++++++++++++++++ 7 files changed, 191 insertions(+), 180 deletions(-) create mode 100644 lib/tailwindcss/process_runner.rb create mode 100644 test/lib/tailwindcss/process_runner_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db8670..f95bdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -* `tailwindcss:watch` now handles `SIGTERM` from process supervisors by forwarding stop signals to the spawned `tailwindcss` process and waiting for it to exit, preventing orphaned watchers when foreman, systemd, or Docker stops the rake task. @jordan-brough +* `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.4.0 / 2025-10-27 @@ -477,3 +477,4 @@ Nothing. But we're promote 0.5.4 to 1.0.0 to go along with the final release of * Match button label for destroy with text used by regular Rails templates by [@dhh](https://github.com/dhh) + 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/commands.rb b/lib/tailwindcss/commands.rb index 844d3dd..9c910de 100644 --- a/lib/tailwindcss/commands.rb +++ b/lib/tailwindcss/commands.rb @@ -1,12 +1,7 @@ -require "shellwords" require "tailwindcss/ruby" module Tailwindcss module Commands - # Signals we forward to the spawned tailwindcss process so it shuts down - # with us instead of being orphaned. - FORWARDED_SIGNALS = %w[INT TERM].freeze - class << self def compile_command(debug: false, **kwargs) debug = ENV["TAILWINDCSS_DEBUG"].present? if ENV.key?("TAILWINDCSS_DEBUG") @@ -33,56 +28,6 @@ def watch_command(always: false, **kwargs) end end - # Spawn the tailwindcss watcher and block until it exits, forwarding INT - # (Ctrl-C) and TERM (foreman/systemd/Docker shutdown) to it so a stopping - # supervisor doesn't leave an orphaned watcher behind. - def watch(always: false, debug: false, verbose: false) - pid = nil - received_signal = nil - previous_traps = {} - - command = watch_command(always: always, debug: debug) - env = command_env(verbose: verbose) - if verbose - puts "Running: #{Shellwords.join(command)}" - end - - forward_signal = ->(signal) do - if pid - Process.kill(signal, pid) - end - rescue Errno::ESRCH - # tailwindcss 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 - - if verbose && received_signal - puts "Received #{received_signal}, exiting tailwindcss:watch" - end - ensure - previous_traps.each do |signal, previous_trap| - trap(signal, previous_trap) - end - end - def command_env(verbose:) {}.tap do |env| env["DEBUG"] = "1" if verbose 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 050baad..6138353 100644 --- a/lib/tasks/build.rake +++ b/lib/tasks/build.rake @@ -13,11 +13,16 @@ namespace :tailwindcss do desc "Watch and build your Tailwind CSS on file changes" task watch: [:environment, :engines] do |_, args| - Tailwindcss::Commands.watch( - always: args.extras.include?("always"), - debug: args.extras.include?("debug"), - verbose: args.extras.include?("verbose"), - ) + debug = args.extras.include?("debug") + always = args.extras.include?("always") + verbose = args.extras.include?("verbose") + + command = Tailwindcss::Commands.watch_command(always: always, debug: debug) + 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 desc "Create Tailwind CSS entry point files for Rails Engines" diff --git a/test/lib/tailwindcss/commands_test.rb b/test/lib/tailwindcss/commands_test.rb index 0489786..62d1e68 100644 --- a/test/lib/tailwindcss/commands_test.rb +++ b/test/lib/tailwindcss/commands_test.rb @@ -118,123 +118,4 @@ def setup assert_includes(actual, "always") end end - - test ".watch restores the previous signal handlers when it exits" do - signals = Tailwindcss::Commands::FORWARDED_SIGNALS - original_handlers = signals.to_h { |signal| [signal, trap(signal, "DEFAULT")] } - - Rails.stub(:root, File) do # Rails.root won't work in this test suite - 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::Commands.watch - 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, "watch did not restore the #{signal} handler") - end - ensure - original_handlers.each { |signal, handler| trap(signal, handler) } - end - end - - test ".watch 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 watcher 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 - - # The stub must stay active across the fork so the forked watch picks up - # our fake command instead of the real CLI. - Tailwindcss::Commands.stub(:watch_command, ->(*) { command }) do - watch_pid = fork do - Tailwindcss::Commands.watch - 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 rake task, as a supervisor (foreman, systemd, Docker) would on shutdown. - Process.kill("TERM", watch_pid) - - # Once watch is reaped it has waited out its child, so a live watcher means an orphan. - assert(wait_until { reaped?(watch_pid) }, - "watch 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(watch_pid) - end - 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 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 From af19f3fb3e9de70b761547e18c19d1a71e95f063 Mon Sep 17 00:00:00 2001 From: Jordan Brough Date: Mon, 15 Jun 2026 09:14:18 -0600 Subject: [PATCH 3/3] `Gemfile.lock` update from `bundle install` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)