Skip to content

Commit

Permalink
Exit current run (#243)
Browse files Browse the repository at this point in the history
* Switch from Kernel.system to Kernel.spawn
* Convert Listen into its own executable to run in separate process
* Run Watchexec and Listen is a new process group so they keep running after INT signal
  • Loading branch information
AlexB52 authored Feb 21, 2025
1 parent 5efbf1d commit 2863a1d
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 55 deletions.
18 changes: 11 additions & 7 deletions exe/retest
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@ require 'retest'

$stdout.sync = true
listen_rd, listen_wr = IO.pipe
Signal.trap(:INT) do
puts "Goodbye"
listen_rd.close
listen_wr.close
exit
end

options = Retest::Options.new(ARGV)

Expand Down Expand Up @@ -50,6 +44,16 @@ end

puts "Command: '#{command}'"

# === TRAP INTERRUPTION ===
Signal.trap(:INT) do
if !runner.interrupt_run
puts "Goodbye"
listen_rd.close
listen_wr.close
exit
end
end

# === DIFF ACTION ===
if options.params[:diff]
program.diff(options.params[:diff])
Expand Down Expand Up @@ -98,7 +102,7 @@ def run_command(input:, program:)
Process.kill("INT", 0)
when 'r', 'reset'
program.reset_selection
puts "command reset to '#{program.runner.command.to_s}'"
puts "command reset to '#{program.runner.command}'"
when 'f', 'force'
require 'tty-prompt'
prompt = TTY::Prompt.new
Expand Down
4 changes: 0 additions & 4 deletions lib/retest.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require 'listen'

require 'string/similarity'
require 'observer'

Expand All @@ -17,8 +15,6 @@
require "retest/sounds"
require "retest/watcher"

Listen.adapter_warn_behavior = :log

module Retest
class Error < StandardError; end
class FileNotFound < StandardError; end
Expand Down
14 changes: 13 additions & 1 deletion lib/retest/runner.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'forwardable'
require_relative "runner/cached_test_file"

module Retest
Expand All @@ -17,6 +18,14 @@ def initialize(command, stdout: $stdout)
end
end

def interrupt_run
return false unless @pid

Process.kill('INT', @pid)
rescue Errno::ESRCH
false
end

def run_last_command
unless last_command
return log('Error - Not enough information to run a command. Please trigger a run first.')
Expand Down Expand Up @@ -89,7 +98,10 @@ def sync(added:, removed:)

def system_run(command)
log("\n")
result = system(command) ? :tests_pass : :tests_fail
@pid = spawn(command)
Process.wait
@pid = nil
result = $?.exitstatus&.zero? ? :tests_pass : :tests_fail
changed
notify_observers(result)
end
Expand Down
95 changes: 52 additions & 43 deletions lib/retest/watcher.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'pathname'

module Retest
module Watcher
def self.for(watcher)
Expand All @@ -24,15 +26,49 @@ def self.installed?
true
end

def self.watch(dir:, extensions:, polling: false)
Listen.to(dir, only: extensions_regex(extensions), relative: true, polling: polling) do |modified, added, removed|
yield modified, added, removed
end.start
end

def self.extensions_regex(extensions)
Regexp.new("\\.(?:#{extensions.join("|")})$")
end

def self.watch(dir:, extensions:, polling: false)
executable = File.expand_path("../../scripts/listen", __FILE__)
command = "#{executable} --exts #{extensions.join(',')} -w #{dir} --polling #{polling}"

watch_rd, watch_wr = IO.pipe
# Process needs its own process group otherwise the process gets killed on INT signal
# We need the process to still run when trying to stop the current test run
# Maybe there is another way to prevent killing these but for now a new process groups works
# Process group created with: pgroup: true
pid = Process.spawn(command, out: watch_wr, pgroup: true)

at_exit do
Process.kill("TERM", pid) if pid
watch_rd.close
watch_wr.close
end

Thread.new do
loop do
ready = IO.select([watch_rd])
readable_connections = ready[0]
readable_connections.each do |conn|
data = conn.readpartial(4096)
change = /^(?<action>create|remove|modify):(?<path>.*)/.match(data.strip)

next unless change

modified, added, removed = result = [[], [], []]
case change[:action]
when 'modify' then modified << change[:path]
when 'create' then added << change[:path]
when 'remove' then removed << change[:path]
end

yield result
end
end
end
end
end

module Watchexec
Expand All @@ -42,22 +78,30 @@ def self.installed?

def self.watch(dir:, extensions:, polling: false)
command = "watchexec --exts #{extensions.join(',')} -w #{dir} --emit-events-to stdio --no-meta --only-emit-events"
files = VersionControl.files(extensions: extensions).zip([]).to_h

watch_rd, watch_wr = IO.pipe
pid = Process.spawn(command, out: watch_wr)
# Process needs its own process group otherwise the process gets killed on INT signal
# We need the process to still run when trying to stop the current test run
# Maybe there is another way to prevent killing these but for now a new process groups works
# Process group created with: pgroup: true
pid = Process.spawn(command, out: watch_wr, pgroup: true)

at_exit do
Process.kill("TERM", pid) if pid
watch_rd.close
watch_wr.close
end

Thread.new do
files = VersionControl.files(extensions: extensions).zip([]).to_h

loop do
ready = IO.select([watch_rd])
readable_connections = ready[0]
readable_connections.each do |conn|
data = conn.readpartial(4096)
# Watchexec is not great at figuring out whether a file has been deleted and comes as an update.
# This is why we're not looking at the action like we do with Listen.
change = /^(?:create|remove|rename|modify):(?<path>.*)/.match(data.strip)

next unless change
Expand All @@ -81,41 +125,6 @@ def self.watch(dir:, extensions:, polling: false)
end
end
end

# require 'open3'
# Thread.new do
# files = VersionControl.files(extensions: extensions).zip([]).to_h

# Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
# loop do
# ready = IO.select([stdout])
# readable_connections = ready[0]
# readable_connections.each do |conn|
# data = conn.readpartial(4096)
# change = /^(?:create|remove|rename|modify):(?<path>.*)/.match(data.strip)

# next unless change

# path = Pathname(change[:path]).relative_path_from(Dir.pwd).to_s
# file_exist = File.exist?(path)
# file_cached = files.key?(path)

# modified, added, removed = result = [[], [], []]
# if file_exist && file_cached
# modified << path
# elsif file_exist && !file_cached
# added << path
# files[path] = nil
# elsif !file_exist && file_cached
# removed << path
# files.delete(path)
# end

# yield result
# end
# end
# end
# end
end
end
end
Expand Down
57 changes: 57 additions & 0 deletions lib/scripts/listen
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env ruby

$stdout.sync = true

require 'listen'
require 'optparse'

options = {}
OptionParser.new do |opts|
opts.banner = "Usage: scripts/listen.rb [options]"

opts.on("--exts rb,js,ts", Array, "Extensions to watch for") do |list|
options[:extensions] = list
end

opts.on("--polling BOOLEAN", "Force Listen to use polling") do |value|
options[:polling] = value
end

opts.on("-w", "--watch .", "Directory to listen to") do |value|
options[:dir] = value
end

opts.on("-h", "--help", "Prints help") do
puts opts
exit
end
end.parse!

unless options.key?(:extensions)
raise ArgumentError, 'must provide the files extensions to watch for'
end

unless options.key?(:polling)
raise ArgumentError, 'must provide the polling option'
end

unless options.key?(:dir)
raise ArgumentError, 'must provide the directory path to watch'
end

def extensions_regex(extensions)
Regexp.new("\\.(?:#{extensions.join("|")})$")
end

Listen.adapter_warn_behavior = :log

Listen.to(options[:dir], only: extensions_regex(options[:extensions]), relative: true, polling: options[:polling]) do |modified, added, removed|
if modified.any?
$stdout.puts "modify:#{modified.first}"
elsif added.any?
$stdout.puts "create:#{added.first}"
elsif removed.any?
$stdout.puts "remove:#{removed.first}"
end
end.start
sleep

0 comments on commit 2863a1d

Please sign in to comment.