Skip to content
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

Rebuild --watch event loop to simplify; fix concurrency issues #40

Merged
merged 3 commits into from
Aug 25, 2024
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
20 changes: 9 additions & 11 deletions lib/mighty_test/console.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "io/console"
require "io/wait"

module MightyTest
class Console
Expand All @@ -15,11 +16,14 @@ def clear
true
end

def wait_for_keypress
with_raw_input do
sleep if stdin.eof?
stdin.getc
end
def with_raw_input(&)
return yield unless stdin.respond_to?(:raw) && tty?

stdin.raw(intr: true, &)
end

def read_keypress_nonblock
stdin.getc if stdin.wait_readable(0)
end

def play_sound(name, wait: false)
Expand Down Expand Up @@ -55,11 +59,5 @@ def play_sound(name, wait: false)
def tty?
$stdout.respond_to?(:tty?) && $stdout.tty?
end

def with_raw_input(&)
return yield unless stdin.respond_to?(:raw) && tty?

stdin.raw(intr: true, &)
end
end
end
82 changes: 23 additions & 59 deletions lib/mighty_test/watcher.rb
Original file line number Diff line number Diff line change
@@ -1,58 +1,41 @@
require_relative "watcher/event_queue"

module MightyTest
class Watcher
class ListenerTriggered < StandardError
attr_reader :paths

def initialize(paths)
@paths = paths
super()
end
end

WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "h" for help or "q" to quit.'.freeze

def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system))
def initialize(console: Console.new, extra_args: [], event_queue: nil, file_system: nil, system_proc: nil)
@console = console
@extra_args = extra_args
@file_system = file_system
@system_proc = system_proc
@file_system = file_system || FileSystem.new
@system_proc = system_proc || method(:system)
@event_queue = event_queue || EventQueue.new(console: @console, file_system: @file_system)
end

def run(iterations: :indefinitely) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
started = false
@foreground_thread = Thread.current

loop_for(iterations) do
start_file_system_listener && puts(WATCHING_FOR_CHANGES) unless started
started = true

case console.wait_for_keypress
when "\r", "\n"
run_all_tests
when "a"
run_all_tests(flags: ["--all"])
when "d"
run_matching_test_files_from_git_diff
when "h"
show_help
when "q"
file_system_listener.stop
break
def run
event_queue.start
puts WATCHING_FOR_CHANGES

loop do
case event_queue.pop
in [:file_system_changed, [_, *] => paths] then run_matching_test_files(paths)
in [:keypress, "\r" | "\n"] then run_all_tests
in [:keypress, "a"] then run_all_tests(flags: ["--all"])
in [:keypress, "d"] then run_matching_test_files_from_git_diff
in [:keypress, "h"] then show_help
in [:keypress, "q"] then break
else
nil
end
rescue ListenerTriggered => e
run_matching_test_files(e.paths)
file_system_listener.start if file_system_listener.paused?
rescue Interrupt
file_system_listener&.stop
raise
end
ensure
event_queue.stop
puts "\nExiting."
end

private

attr_reader :console, :extra_args, :file_system, :file_system_listener, :system_proc, :foreground_thread
attr_reader :console, :extra_args, :file_system, :event_queue, :system_proc

def show_help
console.clear
Expand Down Expand Up @@ -109,26 +92,7 @@ def mt(*test_paths, flags: [])
$stdout.flush
rescue Interrupt
# Pressing ctrl-c kills the fs_event background process, so we have to manually restart it.
# Do this in a separate thread to work around odd behavior on Ruby 3.4.
Thread.new { restart_file_system_listener }
end

def start_file_system_listener
file_system_listener.stop if file_system_listener && !file_system_listener.stopped?

@file_system_listener = file_system.listen do |modified, added, _removed|
paths = [*modified, *added].uniq
next if paths.empty?

# Pause listener so that subsequent changes are queued up while we are running the tests
file_system_listener.pause unless file_system_listener.stopped?
foreground_thread.raise ListenerTriggered.new(paths)
end
end
alias restart_file_system_listener start_file_system_listener

def loop_for(iterations, &)
iterations == :indefinitely ? loop(&) : iterations.times(&)
event_queue.restart
end
end
end
78 changes: 78 additions & 0 deletions lib/mighty_test/watcher/event_queue.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
require "io/console"

module MightyTest
class Watcher
class EventQueue
def initialize(console: Console.new, file_system: FileSystem.new)
@console = console
@file_system = file_system
@file_system_queue = Thread::Queue.new
end

def pop
console.with_raw_input do
until stopped?
if (key = console.read_keypress_nonblock)
return [:keypress, key]
end
if (paths = pop_files_changed)
return [:file_system_changed, paths]
end
end
end
end

def start
raise "Already started" unless stopped?

@file_system_listener = file_system.listen do |modified, added, _removed|
paths = [*modified, *added].uniq
file_system_queue.push(paths) unless paths.empty?
end
true
end

def restart
stop
start
end

def stop
file_system_listener&.stop
@file_system_listener = nil
end

def stopped?
!file_system_listener
end

private

attr_reader :console, :file_system, :file_system_listener, :file_system_queue

def pop_files_changed
paths = try_file_system_pop(timeout: 0.2)
return if paths.nil?

paths += file_system_queue.pop until file_system_queue.empty?
paths.uniq
end

if RUBY_VERSION.start_with?("3.1.")
# TODO: Remove once we drop support for Ruby 3.1
require "timeout"
def try_file_system_pop(timeout:)
Timeout.timeout(timeout) do
file_system_queue.pop
end
rescue Timeout::Error
nil
end
else
def try_file_system_pop(timeout:)
file_system_queue.pop(timeout:)
end
end
end
end
end
16 changes: 13 additions & 3 deletions test/mighty_test/console_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ def test_clear_clears_the_screen_and_returns_true_and_if_tty
assert_equal "clear!", stdout
end

def test_wait_for_keypress_returns_the_next_character_on_stdin
console = Console.new(stdin: StringIO.new("hi"))
def test_read_keypress_nonblock_returns_the_next_character_on_stdin
stdin = StringIO.new("hi")
stdin.define_singleton_method(:wait_readable) { |_timeout| true }
console = Console.new(stdin:)

assert_equal "h", console.wait_for_keypress
assert_equal "h", console.read_keypress_nonblock
end

def test_read_keypress_nonblock_returns_nil_if_nothing_is_in_buffer
stdin = StringIO.new
stdin.define_singleton_method(:wait_readable) { |_timeout| false }
console = Console.new(stdin:)

assert_nil console.read_keypress_nonblock
end

def test_play_sound_returns_false_if_not_tty
Expand Down
Loading