Skip to content

Commit

Permalink
Allow running inotify in another process
Browse files Browse the repository at this point in the history
  • Loading branch information
etiennebarrie committed Jun 13, 2024
1 parent f186b2f commit cdc3548
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 29 deletions.
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

$:.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.detect(&: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
87 changes: 87 additions & 0 deletions lib/listen/adapter/process_linux.rb
Original file line number Diff line number Diff line change
@@ -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(directory, &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..].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
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 = %w[force_polling polling_fallback_message prefer_fork].map(&:to_sym)
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
2 changes: 1 addition & 1 deletion spec/lib/listen/listener/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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

0 comments on commit cdc3548

Please sign in to comment.