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

Allow running inotify in another process #583

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
15 changes: 15 additions & 0 deletions bin/inotify_watch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

$LOAD_PATH.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
12 changes: 8 additions & 4 deletions lib/listen/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.'

Expand All @@ -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
Expand All @@ -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.find(&:forks?) if options[:prefer_fork]
preferred || usable_adapters.first
end

def _warn_polling_fallback(options)
Expand Down
4 changes: 4 additions & 0 deletions lib/listen/adapter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ class << self
def usable?
const_get('OS_REGEXP') =~ RbConfig::CONFIG['target_os']
end

def forks?
false
end
end
end
end
Expand Down
88 changes: 88 additions & 0 deletions lib/listen/adapter/process_linux.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require 'listen'

module Listen
module Adapter
class ProcessLinux < Base

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/Documentation: Missing top-level class documentation comment.

OS_REGEXP = /linux/i
BIN_PATH = ::File.expand_path("#{__dir__}/../../../bin/inotify_watch")

def self.forks?
true
end

def _configure(dir, &callback)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/EmptyMethod: Put empty method definitions on a single line.

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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [89/80]

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/Documentation: Missing top-level class documentation comment.

def initialize(dirs_to_watch, &block)
@paths = dirs_to_watch
@callback = block
end

def run

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/MethodLength: Method has too many lines. [11/10]

@pipe = IO.popen([BIN_PATH] + @paths)
@running = true

while @running && IO.select([@pipe], nil, nil, nil)
command = @pipe.gets("\0")
next unless command
# remove status (M/A/D) and terminator null byte
dir = command[1..-1].chomp("\0")
@callback.call([dir])
end
rescue Interrupt, IOError, Errno::EBADF

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lint/HandleExceptions: Do not suppress exceptions.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lint/HandleExceptions: Do not suppress exceptions.

ensure
@running = false
end

def process_running?(pid)
Process.kill(0, pid)
true
rescue Errno::ESRCH
false
end
end
end
end
end
3 changes: 2 additions & 1 deletion lib/listen/listener/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Config

# Backend selecting options
force_polling: false,
prefer_fork: false,
polling_fallback_message: nil
}.freeze

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions spec/lib/listen/adapter/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions spec/lib/listen/adapter/process_linux_spec.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 39 additions & 23 deletions spec/lib/listen/adapter_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion spec/lib/listen/listener/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down