diff --git a/bin/inotify_watch b/bin/inotify_watch new file mode 100755 index 00000000..2c46de9b --- /dev/null +++ b/bin/inotify_watch @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$:.unshift __dir__ + "/../lib" +require "listen" + +$stdout.sync = true # make sure it works well with the pipe +files = ARGV.to_a +listener = Listen.to(*files, { prefer_fork: false }) do |modified, added, removed| + modified.each { |file| $stdout.write("M", file, "\0") } + added .each { |file| $stdout.write("A", file, "\0") } + removed .each { |file| $stdout.write("D", file, "\0") } +end +listener.start +sleep diff --git a/lib/listen/adapter.rb b/lib/listen/adapter.rb index dce86236..c9c0145b 100644 --- a/lib/listen/adapter.rb +++ b/lib/listen/adapter.rb @@ -4,12 +4,13 @@ require 'listen/adapter/bsd' require 'listen/adapter/darwin' require 'listen/adapter/linux' +require 'listen/adapter/process_linux' require 'listen/adapter/polling' require 'listen/adapter/windows' module Listen module Adapter - OPTIMIZED_ADAPTERS = [Darwin, Linux, BSD, Windows].freeze + OPTIMIZED_ADAPTERS = [Darwin, Linux, BSD, Windows, ProcessLinux].freeze POLLING_FALLBACK_MESSAGE = 'Listen will be polling for changes.'\ 'Learn more at https://github.com/guard/listen#listen-adapters.' @@ -18,7 +19,8 @@ def select(options = {}) Listen.logger.debug 'Adapter: considering polling ...' return Polling if options[:force_polling] Listen.logger.debug 'Adapter: considering optimized backend...' - return _usable_adapter_class if _usable_adapter_class + adapter_class = _usable_adapter_class(options) + return adapter_class if adapter_class Listen.logger.debug 'Adapter: falling back to polling...' _warn_polling_fallback(options) Polling @@ -30,8 +32,10 @@ def select(options = {}) private - def _usable_adapter_class - OPTIMIZED_ADAPTERS.find(&:usable?) + def _usable_adapter_class(options) + usable_adapters = OPTIMIZED_ADAPTERS.select(&:usable?) + preferred = usable_adapters.detect(&:forks?) if options[:prefer_fork] + preferred || usable_adapters.first end def _warn_polling_fallback(options) diff --git a/lib/listen/adapter/base.rb b/lib/listen/adapter/base.rb index b7e10390..d21aad58 100644 --- a/lib/listen/adapter/base.rb +++ b/lib/listen/adapter/base.rb @@ -123,6 +123,10 @@ class << self def usable? const_get('OS_REGEXP') =~ RbConfig::CONFIG['target_os'] end + + def forks? + false + end end end end diff --git a/lib/listen/adapter/process_linux.rb b/lib/listen/adapter/process_linux.rb new file mode 100644 index 00000000..989e4975 --- /dev/null +++ b/lib/listen/adapter/process_linux.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "listen" + +module Listen + module Adapter + class ProcessLinux < Base + OS_REGEXP = /linux/i + BIN_PATH = ::File.expand_path(__dir__ + "/../../../bin/inotify_watch") + + def self.forks? + true + end + + def _configure(dir, &callback) + end + + def _run + dirs_to_watch = @callbacks.keys.map(&:to_s) + worker = Worker.new(dirs_to_watch, &method(:_process_changes)) + @worker_thread = Thread.new("worker_thread") { worker.run } + end + + def _process_changes(dirs) + dirs.each do |dir| + dir = Pathname.new(dir.sub(%r{/$}, "")) + + @callbacks.each do |watched_dir, callback| + if watched_dir.eql?(dir) || Listen::Directory.ascendant_of?(watched_dir, dir) + callback.call(dir) + end + end + end + end + + def _process_event(dir, path) + Listen.logger.debug { "inotify: processing path: #{path.inspect}" } + rel_path = path.relative_path_from(dir).to_s + _queue_change(:dir, dir, rel_path, recursive: true) + end + + def _stop + @worker_thread&.kill + super + end + + class Worker + def initialize(dirs_to_watch, &block) + @paths = dirs_to_watch + @callback = block + end + + def run + @pipe = IO.popen([BIN_PATH] + @paths) + @running = true + + while @running && IO.select([@pipe], nil, nil, nil) + command = @pipe.gets("\0") + next unless command + dir = command[1..-1].chomp("\0") # remove status (M/A/D) and terminator null byte + @callback.call([dir]) + end + rescue Interrupt, IOError, Errno::EBADF + ensure + stop + end + + def stop + unless @pipe.nil? + Process.kill("KILL", @pipe.pid) if process_running?(@pipe.pid) + @pipe.close + end + rescue IOError, Errno::EBADF + ensure + @running = false + end + + def process_running?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end + end + end + end +end diff --git a/lib/listen/listener/config.rb b/lib/listen/listener/config.rb index 56e4b7c9..09201e0e 100644 --- a/lib/listen/listener/config.rb +++ b/lib/listen/listener/config.rb @@ -11,6 +11,7 @@ class Config # Backend selecting options force_polling: false, + prefer_fork: false, polling_fallback_message: nil }.freeze @@ -33,7 +34,7 @@ def adapter_instance_options(klass) end def adapter_select_options - valid_keys = %w[force_polling polling_fallback_message].map(&:to_sym) + valid_keys = %i[force_polling polling_fallback_message prefer_fork] Hash[@options.select { |key, _| valid_keys.include?(key) }] end end diff --git a/spec/lib/listen/adapter/base_spec.rb b/spec/lib/listen/adapter/base_spec.rb index 2b531279..e9c77598 100644 --- a/spec/lib/listen/adapter/base_spec.rb +++ b/spec/lib/listen/adapter/base_spec.rb @@ -98,4 +98,10 @@ def _process_event(dir, event) end end end + + describe '.forks?' do + it 'is false by default' do + expect(described_class.forks?).to be false + end + end end diff --git a/spec/lib/listen/adapter/process_linux_spec.rb b/spec/lib/listen/adapter/process_linux_spec.rb new file mode 100644 index 00000000..4aae1f99 --- /dev/null +++ b/spec/lib/listen/adapter/process_linux_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'listen/adapter/process_linux' + +RSpec.describe Listen::Adapter::ProcessLinux do + describe 'class' do + subject { described_class } + + if linux? + it { should be_usable } + else + it { should_not be_usable } + end + + it '.forks? returns true' do + expect(described_class.forks?).to be true + end + end +end diff --git a/spec/lib/listen/adapter_spec.rb b/spec/lib/listen/adapter_spec.rb index b92c6bd4..e63932b5 100644 --- a/spec/lib/listen/adapter_spec.rb +++ b/spec/lib/listen/adapter_spec.rb @@ -1,42 +1,58 @@ # frozen_string_literal: true RSpec.describe Listen::Adapter do - let(:listener) { instance_double(Listen::Listener, options: {}) } before do allow(Listen::Adapter::BSD).to receive(:usable?) { false } allow(Listen::Adapter::Darwin).to receive(:usable?) { false } allow(Listen::Adapter::Linux).to receive(:usable?) { false } + allow(Listen::Adapter::ProcessLinux).to receive(:usable?) { false } allow(Listen::Adapter::Windows).to receive(:usable?) { false } end describe '.select' do - it 'returns Polling adapter if forced' do - klass = Listen::Adapter.select(force_polling: true) - expect(klass).to eq Listen::Adapter::Polling - end + shared_examples 'chooses usable' do + let(:options) { {} } - it 'returns BSD adapter when usable' do - allow(Listen::Adapter::BSD).to receive(:usable?) { true } - klass = Listen::Adapter.select - expect(klass).to eq Listen::Adapter::BSD - end + it 'returns Polling adapter if forced' do + klass = Listen::Adapter.select(options.merge(force_polling: true)) + expect(klass).to eq Listen::Adapter::Polling + end - it 'returns Darwin adapter when usable' do - allow(Listen::Adapter::Darwin).to receive(:usable?) { true } - klass = Listen::Adapter.select - expect(klass).to eq Listen::Adapter::Darwin - end + it 'returns BSD adapter when usable' do + allow(Listen::Adapter::BSD).to receive(:usable?) { true } + klass = Listen::Adapter.select(options) + expect(klass).to eq Listen::Adapter::BSD + end - it 'returns Linux adapter when usable' do - allow(Listen::Adapter::Linux).to receive(:usable?) { true } - klass = Listen::Adapter.select - expect(klass).to eq Listen::Adapter::Linux + it 'returns Darwin adapter when usable' do + allow(Listen::Adapter::Darwin).to receive(:usable?) { true } + klass = Listen::Adapter.select(options) + expect(klass).to eq Listen::Adapter::Darwin + end + + it 'returns Linux adapter when usable' do + allow(Listen::Adapter::Linux).to receive(:usable?) { true } + klass = Listen::Adapter.select(options) + expect(klass).to eq Listen::Adapter::Linux + end + + it 'returns Windows adapter when usable' do + allow(Listen::Adapter::Windows).to receive(:usable?) { true } + klass = Listen::Adapter.select(options) + expect(klass).to eq Listen::Adapter::Windows + end end - it 'returns Windows adapter when usable' do - allow(Listen::Adapter::Windows).to receive(:usable?) { true } - klass = Listen::Adapter.select - expect(klass).to eq Listen::Adapter::Windows + it_behaves_like 'chooses usable' + it_behaves_like 'chooses usable' do + let(:options) { { prefer_fork: true } } + + it 'returns ProcessLinux adapter when forks are preferred' do + allow(Listen::Adapter::Linux).to receive(:usable?) { true } + allow(Listen::Adapter::ProcessLinux).to receive(:usable?) { true } + klass = Listen::Adapter.select(options) + expect(klass).to eq Listen::Adapter::ProcessLinux + end end context 'no usable adapters' do diff --git a/spec/lib/listen/listener/config_spec.rb b/spec/lib/listen/listener/config_spec.rb index 5136e703..bab899cb 100644 --- a/spec/lib/listen/listener/config_spec.rb +++ b/spec/lib/listen/listener/config_spec.rb @@ -21,7 +21,11 @@ end it 'extract adapter selecting options' do - expected = { force_polling: true, polling_fallback_message: nil } + expected = { + force_polling: true, + polling_fallback_message: nil, + prefer_fork: false + } expect(subject.adapter_select_options).to eq(expected) end end