Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
tailwindcss-rails (4.4.0)
tailwindcss-rails (4.5.0)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, running bundle install on main branch was resulting in this. I think from the recent version bump?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's my bad when I cut the last release. Feel free to include this change or ignore it for now, it'll get sorted eventually.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m happy to leave it in if it’s fine for you

railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)

Expand Down
1 change: 1 addition & 0 deletions lib/tailwindcss-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ module Tailwindcss
require_relative "tailwindcss/engines"
require_relative "tailwindcss/engine"
require_relative "tailwindcss/commands"
require_relative "tailwindcss/process_runner"
59 changes: 59 additions & 0 deletions lib/tailwindcss/process_runner.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 2 additions & 3 deletions lib/tasks/build.rake
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
119 changes: 119 additions & 0 deletions test/lib/tailwindcss/process_runner_test.rb
Original file line number Diff line number Diff line change
@@ -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