From 2863a1d2262e37233b51574404efc3b404d08388 Mon Sep 17 00:00:00 2001 From: Alexandre Barret Date: Sat, 22 Feb 2025 11:17:06 +1300 Subject: [PATCH] Exit current run (#243) * 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 --- exe/retest | 18 ++++---- lib/retest.rb | 4 -- lib/retest/runner.rb | 14 ++++++- lib/retest/watcher.rb | 95 +++++++++++++++++++++++-------------------- lib/scripts/listen | 57 ++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 55 deletions(-) create mode 100755 lib/scripts/listen diff --git a/exe/retest b/exe/retest index 54ecf98..4d05c67 100755 --- a/exe/retest +++ b/exe/retest @@ -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) @@ -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]) @@ -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 diff --git a/lib/retest.rb b/lib/retest.rb index 91fb3b8..e2576d5 100644 --- a/lib/retest.rb +++ b/lib/retest.rb @@ -1,5 +1,3 @@ -require 'listen' - require 'string/similarity' require 'observer' @@ -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 diff --git a/lib/retest/runner.rb b/lib/retest/runner.rb index 146c731..e02c6f9 100644 --- a/lib/retest/runner.rb +++ b/lib/retest/runner.rb @@ -1,3 +1,4 @@ +require 'forwardable' require_relative "runner/cached_test_file" module Retest @@ -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.') @@ -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 diff --git a/lib/retest/watcher.rb b/lib/retest/watcher.rb index 95a923d..d640be1 100644 --- a/lib/retest/watcher.rb +++ b/lib/retest/watcher.rb @@ -1,3 +1,5 @@ +require 'pathname' + module Retest module Watcher def self.for(watcher) @@ -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 = /^(?create|remove|modify):(?.*)/.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 @@ -42,10 +78,14 @@ 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 @@ -53,11 +93,15 @@ def self.watch(dir:, extensions:, polling: false) 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):(?.*)/.match(data.strip) next unless change @@ -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):(?.*)/.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 diff --git a/lib/scripts/listen b/lib/scripts/listen new file mode 100755 index 0000000..08bcdd6 --- /dev/null +++ b/lib/scripts/listen @@ -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