-
Notifications
You must be signed in to change notification settings - Fork 190
Forward stop signals to tailwindcss watcher #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+189
−4
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
dd2e85b
Forward stop signals to tailwindcss watcher
jordan-brough 7dd4600
PR feedback: Extract `Tailwindcss::ProcessRunner` for process-running…
jordan-brough 2aa1ca8
Merge branch 'main' into sigterm-handling
jordan-brough af19f3f
`Gemfile.lock` update from `bundle install`
jordan-brough File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, running
bundle installonmainbranch was resulting in this. I think from the recent version bump?There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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