listen 3.0.2 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/listen/change.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'listen/file'
2
4
  require 'listen/directory'
3
5
 
@@ -28,51 +30,40 @@ module Listen
28
30
  end
29
31
 
30
32
  # Invalidate some part of the snapshot/record (dir, file, subtree, etc.)
33
+ # rubocop:disable Metrics/MethodLength
34
+ # rubocop:disable Metrics/CyclomaticComplexity
35
+ # rubocop:disable Metrics/PerceivedComplexity
31
36
  def invalidate(type, rel_path, options)
32
37
  watched_dir = Pathname.new(record.root)
33
38
 
34
39
  change = options[:change]
35
40
  cookie = options[:cookie]
36
41
 
37
- if !cookie && config.silenced?(rel_path, type)
38
- Listen::Logger.debug { "(silenced): #{rel_path.inspect}" }
42
+ if !cookie && @config.silenced?(rel_path, type)
43
+ Listen.logger.debug { "(silenced): #{rel_path.inspect}" }
39
44
  return
40
45
  end
41
46
 
42
47
  path = watched_dir + rel_path
43
48
 
44
- Listen::Logger.debug do
49
+ Listen.logger.debug do
45
50
  log_details = options[:silence] && 'recording' || change || 'unknown'
46
51
  "#{log_details}: #{type}:#{path} (#{options.inspect})"
47
52
  end
48
53
 
49
54
  if change
50
55
  options = cookie ? { cookie: cookie } : {}
51
- config.queue(type, change, watched_dir, rel_path, options)
52
- else
53
- if type == :dir
54
- # NOTE: POSSIBLE RECURSION
55
- # TODO: fix - use a queue instead
56
- Directory.scan(self, rel_path, options)
57
- else
58
- change = File.change(record, rel_path)
59
- return if !change || options[:silence]
60
- config.queue(:file, change, watched_dir, rel_path)
61
- end
56
+ @config.queue(type, change, watched_dir, rel_path, options)
57
+ elsif type == :dir
58
+ # NOTE: POSSIBLE RECURSION
59
+ # TODO: fix - use a queue instead
60
+ Directory.scan(self, rel_path, options)
61
+ elsif (change = File.change(record, rel_path)) && !options[:silence]
62
+ @config.queue(:file, change, watched_dir, rel_path)
62
63
  end
63
- rescue RuntimeError => ex
64
- msg = format(
65
- '%s#%s crashed %s:%s',
66
- self.class,
67
- __method__,
68
- exinspect,
69
- ex.backtrace * "\n")
70
- Listen::Logger.error(msg)
71
- raise
72
64
  end
73
-
74
- private
75
-
76
- attr_reader :config
65
+ # rubocop:enable Metrics/MethodLength
66
+ # rubocop:enable Metrics/CyclomaticComplexity
67
+ # rubocop:enable Metrics/PerceivedComplexity
77
68
  end
78
69
  end
data/lib/listen/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
  require 'listen'
3
5
  require 'logger'
@@ -16,9 +18,9 @@ module Listen
16
18
 
17
19
  class_option :directory,
18
20
  type: :array,
19
- default: '.',
21
+ default: ['.'],
20
22
  aliases: '-d',
21
- banner: 'The directory to listen to'
23
+ banner: 'One or more directories to listen to'
22
24
 
23
25
  class_option :relative,
24
26
  type: :boolean,
@@ -33,15 +35,16 @@ module Listen
33
35
 
34
36
  class Forwarder
35
37
  attr_reader :logger
38
+
36
39
  def initialize(options)
37
40
  @options = options
38
- @logger = ::Logger.new(STDOUT)
39
- @logger.level = ::Logger::INFO
41
+ @logger = ::Logger.new(STDOUT, level: ::Logger::INFO)
40
42
  @logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
41
43
  end
42
44
 
43
45
  def start
44
46
  logger.info 'Starting listen...'
47
+
45
48
  directory = @options[:directory]
46
49
  relative = @options[:relative]
47
50
  callback = proc do |modified, added, removed|
@@ -52,10 +55,7 @@ module Listen
52
55
  end
53
56
  end
54
57
 
55
- listener = Listen.to(
56
- directory,
57
- relative: relative,
58
- &callback)
58
+ listener = Listen.to(*directory, relative: relative, &callback)
59
59
 
60
60
  listener.start
61
61
 
@@ -1,8 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'set'
2
4
 
3
5
  module Listen
4
6
  # TODO: refactor (turn it into a normal object, cache the stat, etc)
5
7
  class Directory
8
+ # rubocop:disable Metrics/MethodLength
6
9
  def self.scan(snapshot, rel_path, options)
7
10
  record = snapshot.record
8
11
  dir = Pathname.new(record.root)
@@ -12,40 +15,49 @@ module Listen
12
15
 
13
16
  # TODO: use children(with_directory: false)
14
17
  path = dir + rel_path
15
- current = Set.new(path.children)
18
+ current = Set.new(_children(path))
16
19
 
17
- Listen::Logger.debug do
20
+ Listen.logger.debug do
18
21
  format('%s: %s(%s): %s -> %s',
19
22
  (options[:silence] ? 'Recording' : 'Scanning'),
20
23
  rel_path, options.inspect, previous.inspect, current.inspect)
21
24
  end
22
25
 
23
- current.each do |full_path|
24
- type = detect_type(full_path)
25
- item_rel_path = full_path.relative_path_from(dir).to_s
26
- _change(snapshot, type, item_rel_path, options)
26
+ begin
27
+ current.each do |full_path|
28
+ type = ::File.lstat(full_path.to_s).directory? ? :dir : :file
29
+ item_rel_path = full_path.relative_path_from(dir).to_s
30
+ _change(snapshot, type, item_rel_path, options)
31
+ end
32
+ rescue Errno::ENOENT
33
+ # The directory changed meanwhile, so rescan it
34
+ current = Set.new(_children(path))
35
+ retry
27
36
  end
28
37
 
29
38
  # TODO: this is not tested properly
30
- previous = previous.reject { |entry, _| current.include? path + entry }
39
+ previous = previous.reject { |entry, _| current.include?(path + entry) }
31
40
 
32
41
  _async_changes(snapshot, Pathname.new(rel_path), previous, options)
33
-
34
42
  rescue Errno::ENOENT, Errno::EHOSTDOWN
35
43
  record.unset_path(rel_path)
36
44
  _async_changes(snapshot, Pathname.new(rel_path), previous, options)
37
-
38
45
  rescue Errno::ENOTDIR
39
46
  # TODO: path not tested
40
47
  record.unset_path(rel_path)
41
48
  _async_changes(snapshot, path, previous, options)
42
49
  _change(snapshot, :file, rel_path, options)
43
50
  rescue
44
- Listen::Logger.warn do
45
- format('scan DIED: %s:%s', $ERROR_INFO, $ERROR_POSITION * "\n")
46
- end
51
+ Listen.logger.warn { format('scan DIED: %s:%s', $ERROR_INFO, $ERROR_POSITION * "\n") }
47
52
  raise
48
53
  end
54
+ # rubocop:enable Metrics/MethodLength
55
+
56
+ def self.ascendant_of?(base, other)
57
+ other.ascend do |ascendant|
58
+ break true if base == ascendant
59
+ end
60
+ end
49
61
 
50
62
  def self._async_changes(snapshot, path, previous, options)
51
63
  fail "Not a Pathname: #{path.inspect}" unless path.respond_to?(:children)
@@ -67,14 +79,15 @@ module Listen
67
79
  snapshot.invalidate(type, path, opts)
68
80
  end
69
81
 
70
- def self.detect_type(full_path)
71
- # TODO: should probably check record first
72
- stat = ::File.lstat(full_path.to_s)
73
- stat.directory? ? :dir : :file
74
- rescue Errno::ENOENT
75
- # TODO: ok, it should really check the record here
76
- # report as dir for scanning
77
- :dir
82
+ def self._children(path)
83
+ return path.children unless RUBY_ENGINE == 'jruby'
84
+
85
+ # JRuby inconsistency workaround, see:
86
+ # https://github.com/jruby/jruby/issues/3840
87
+ exists = path.exist?
88
+ directory = path.directory?
89
+ exists && !directory and raise Errno::ENOTDIR, path.to_s
90
+ path.children
78
91
  end
79
92
  end
80
93
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Besides programming error exceptions like ArgumentError,
4
+ # all public interface exceptions should be declared here and inherit from Listen::Error.
5
+ module Listen
6
+ class Error < RuntimeError
7
+ class NotStarted < Error; end
8
+ class SymlinkLoop < Error; end
9
+ class INotifyMaxWatchesExceeded < Error; end
10
+ end
11
+ end
@@ -1,12 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Listen
2
4
  module Event
3
5
  class Config
6
+ attr_reader :listener, :event_queue, :min_delay_between_events
7
+
4
8
  def initialize(
5
9
  listener,
6
10
  event_queue,
7
11
  queue_optimizer,
8
12
  wait_for_delay,
9
- &block)
13
+ &block
14
+ )
10
15
 
11
16
  @listener = listener
12
17
  @event_queue = event_queue
@@ -15,20 +20,12 @@ module Listen
15
20
  @block = block
16
21
  end
17
22
 
18
- def sleep(*args)
19
- Kernel.sleep(*args)
23
+ def sleep(seconds)
24
+ Kernel.sleep(seconds)
20
25
  end
21
26
 
22
27
  def call(*args)
23
- @block.call(*args) if @block
24
- end
25
-
26
- def timestamp
27
- Time.now.to_f
28
- end
29
-
30
- def event_queue
31
- @event_queue
28
+ @block&.call(*args)
32
29
  end
33
30
 
34
31
  def callable?
@@ -38,22 +35,6 @@ module Listen
38
35
  def optimize_changes(changes)
39
36
  @queue_optimizer.smoosh_changes(changes)
40
37
  end
41
-
42
- def min_delay_between_events
43
- @min_delay_between_events
44
- end
45
-
46
- def stopped?
47
- listener.state == :stopped
48
- end
49
-
50
- def paused?
51
- listener.state == :paused
52
- end
53
-
54
- private
55
-
56
- attr_reader :listener
57
38
  end
58
39
  end
59
40
  end
@@ -1,53 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
1
5
  require 'timeout'
2
6
  require 'listen/event/processor'
7
+ require 'listen/thread'
8
+ require 'listen/error'
3
9
 
4
10
  module Listen
5
11
  module Event
6
12
  class Loop
7
- class Error < RuntimeError
8
- class NotStarted < Error
9
- end
10
- end
13
+ include Listen::FSM
14
+
15
+ Error = ::Listen::Error
16
+ NotStarted = ::Listen::Error::NotStarted # for backward compatibility
17
+
18
+ start_state :pre_start
19
+ state :pre_start
20
+ state :starting
21
+ state :started
22
+ state :stopped
11
23
 
12
24
  def initialize(config)
13
25
  @config = config
14
26
  @wait_thread = nil
15
- @state = :paused
16
- @reasons = Thread::Queue.new
27
+ @reasons = ::Queue.new
28
+ initialize_fsm
17
29
  end
18
30
 
19
31
  def wakeup_on_event
20
- return if stopped?
21
- return unless processing?
22
- return unless wait_thread.alive?
23
- _wakeup(:event)
32
+ if started? && @wait_thread&.alive?
33
+ _wakeup(:event)
34
+ end
24
35
  end
25
36
 
26
- def paused?
27
- wait_thread && state == :paused
37
+ def started?
38
+ state == :started
28
39
  end
29
40
 
30
- def processing?
31
- return false if stopped?
32
- return false if paused?
33
- state == :processing
34
- end
41
+ MAX_STARTUP_SECONDS = 5.0
35
42
 
36
- def setup
43
+ # @raises Error::NotStarted if background thread hasn't started in MAX_STARTUP_SECONDS
44
+ def start
37
45
  # TODO: use a Fiber instead?
38
- q = Thread::Queue.new
39
- @wait_thread = Internals::ThreadPool.add do
40
- _wait_for_changes(q, config)
46
+ return unless state == :pre_start
47
+
48
+ transition! :starting
49
+
50
+ @wait_thread = Listen::Thread.new("wait_thread") do
51
+ _process_changes
41
52
  end
42
53
 
43
- Listen::Logger.debug('Waiting for processing to start...')
44
- Timeout.timeout(5) { q.pop }
45
- end
54
+ Listen.logger.debug("Waiting for processing to start...")
46
55
 
47
- def resume
48
- fail Error::NotStarted if stopped?
49
- return unless wait_thread
50
- _wakeup(:resume)
56
+ wait_for_state(:started, timeout: MAX_STARTUP_SECONDS) or
57
+ raise Error::NotStarted, "thread didn't start in #{MAX_STARTUP_SECONDS} seconds (in state: #{state.inspect})"
58
+
59
+ Listen.logger.debug('Processing started.')
51
60
  end
52
61
 
53
62
  def pause
@@ -55,60 +64,30 @@ module Listen
55
64
  # fail NotImplementedError
56
65
  end
57
66
 
58
- def teardown
59
- return unless wait_thread
60
- if wait_thread.alive?
61
- _wakeup(:teardown)
62
- wait_thread.join
63
- end
67
+ def stop
68
+ transition! :stopped
69
+
70
+ @wait_thread&.join
64
71
  @wait_thread = nil
65
72
  end
66
73
 
67
74
  def stopped?
68
- !wait_thread
75
+ state == :stopped
69
76
  end
70
77
 
71
78
  private
72
79
 
73
- attr_reader :config
74
- attr_reader :wait_thread
75
-
76
- attr_accessor :state
77
-
78
- def _wait_for_changes(ready_queue, config)
79
- processor = Event::Processor.new(config, @reasons)
80
+ def _process_changes
81
+ processor = Event::Processor.new(@config, @reasons)
80
82
 
81
- _wait_until_resumed(ready_queue)
82
- processor.loop_for(config.min_delay_between_events)
83
- rescue StandardError => ex
84
- _nice_error(ex)
85
- end
86
-
87
- def _sleep(*args)
88
- Kernel.sleep(*args)
89
- end
90
-
91
- def _wait_until_resumed(ready_queue)
92
- self.state = :paused
93
- ready_queue << :ready
94
- sleep
95
- self.state = :processing
96
- end
83
+ transition! :started
97
84
 
98
- def _nice_error(ex)
99
- indent = "\n -- "
100
- msg = format(
101
- 'exception while processing events: %s Backtrace:%s%s',
102
- ex,
103
- indent,
104
- ex.backtrace * indent
105
- )
106
- Listen::Logger.error(msg)
85
+ processor.loop_for(@config.min_delay_between_events)
107
86
  end
108
87
 
109
88
  def _wakeup(reason)
110
89
  @reasons << reason
111
- wait_thread.wakeup
90
+ @wait_thread.wakeup
112
91
  end
113
92
  end
114
93
  end
@@ -1,8 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen/monotonic_time'
4
+
1
5
  module Listen
2
6
  module Event
3
7
  class Processor
4
8
  def initialize(config, reasons)
5
9
  @config = config
10
+ @listener = config.listener
6
11
  @reasons = reasons
7
12
  _reset_no_unprocessed_events
8
13
  end
@@ -13,13 +18,14 @@ module Listen
13
18
  @latency = latency
14
19
 
15
20
  loop do
16
- _wait_until_events
21
+ event = _wait_until_events
22
+ _check_stopped
17
23
  _wait_until_events_calm_down
18
24
  _wait_until_no_longer_paused
19
- _process_changes
25
+ _process_changes(event)
20
26
  end
21
27
  rescue Stopped
22
- Listen::Logger.debug('Processing stopped')
28
+ Listen.logger.debug('Processing stopped')
23
29
  end
24
30
 
25
31
  private
@@ -29,7 +35,7 @@ module Listen
29
35
 
30
36
  def _wait_until_events_calm_down
31
37
  loop do
32
- now = _timestamp
38
+ now = MonotonicTime.now
33
39
 
34
40
  # Assure there's at least latency between callbacks to allow
35
41
  # for accumulating changes
@@ -38,82 +44,80 @@ module Listen
38
44
 
39
45
  # give events a bit of time to accumulate so they can be
40
46
  # compressed/optimized
41
- _sleep(:waiting_until_latency, diff)
47
+ _sleep(diff)
42
48
  end
43
49
  end
44
50
 
45
51
  def _wait_until_no_longer_paused
46
- # TODO: may not be a good idea?
47
- _sleep(:waiting_for_unpause) while config.paused?
52
+ @listener.wait_for_state(*(Listener.states.keys - [:paused]))
48
53
  end
49
54
 
50
55
  def _check_stopped
51
- return unless config.stopped?
52
-
53
- _flush_wakeup_reasons
54
- raise Stopped
56
+ if @listener.stopped?
57
+ _flush_wakeup_reasons
58
+ raise Stopped
59
+ end
55
60
  end
56
61
 
57
- def _sleep(_local_reason, *args)
62
+ def _sleep(seconds)
58
63
  _check_stopped
59
- sleep_duration = config.sleep(*args)
64
+ config.sleep(seconds)
60
65
  _check_stopped
61
66
 
62
67
  _flush_wakeup_reasons do |reason|
63
- next unless reason == :event
64
- _remember_time_of_first_unprocessed_event unless config.paused?
68
+ if reason == :event && !@listener.paused?
69
+ _remember_time_of_first_unprocessed_event
70
+ end
65
71
  end
66
-
67
- sleep_duration
68
72
  end
69
73
 
70
74
  def _remember_time_of_first_unprocessed_event
71
- @first_unprocessed_event_time ||= _timestamp
75
+ @_remember_time_of_first_unprocessed_event ||= MonotonicTime.now
72
76
  end
73
77
 
74
78
  def _reset_no_unprocessed_events
75
- @first_unprocessed_event_time = nil
79
+ @_remember_time_of_first_unprocessed_event = nil
76
80
  end
77
81
 
78
82
  def _deadline
79
- @first_unprocessed_event_time + @latency
83
+ @_remember_time_of_first_unprocessed_event + @latency
80
84
  end
81
85
 
86
+ # blocks until event is popped
87
+ # returns the event or `nil` when the event_queue is closed
82
88
  def _wait_until_events
83
- # TODO: long sleep may not be a good idea?
84
- _sleep(:waiting_for_events) while config.event_queue.empty?
85
- @first_unprocessed_event_time ||= _timestamp
89
+ config.event_queue.pop.tap do |_event|
90
+ @_remember_time_of_first_unprocessed_event ||= MonotonicTime.now
91
+ end
86
92
  end
87
93
 
88
94
  def _flush_wakeup_reasons
89
- reasons = @reasons
90
- until reasons.empty?
91
- reason = reasons.pop
95
+ until @reasons.empty?
96
+ reason = @reasons.pop
92
97
  yield reason if block_given?
93
98
  end
94
99
  end
95
100
 
96
- def _timestamp
97
- config.timestamp
98
- end
99
-
100
101
  # for easier testing without sleep loop
101
- def _process_changes
102
+ def _process_changes(event)
102
103
  _reset_no_unprocessed_events
103
104
 
104
- changes = []
105
+ changes = [event]
105
106
  changes << config.event_queue.pop until config.event_queue.empty?
106
107
 
107
- callable = config.callable?
108
- return unless callable
108
+ return unless config.callable?
109
109
 
110
110
  hash = config.optimize_changes(changes)
111
111
  result = [hash[:modified], hash[:added], hash[:removed]]
112
112
  return if result.all?(&:empty?)
113
113
 
114
- block_start = _timestamp
115
- config.call(*result)
116
- Listen::Logger.debug "Callback took #{_timestamp - block_start} sec"
114
+ block_start = MonotonicTime.now
115
+ exception_note = " (exception)"
116
+ ::Listen::Thread.rescue_and_log('_process_changes') do
117
+ config.call(*result)
118
+ exception_note = nil
119
+ end
120
+ Listen.logger.debug "Callback#{exception_note} took #{MonotonicTime.now - block_start} sec"
117
121
  end
118
122
 
119
123
  attr_reader :config
@@ -1,6 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ require 'forwardable'
6
+
1
7
  module Listen
2
8
  module Event
3
9
  class Queue
10
+ extend Forwardable
11
+
4
12
  class Config
5
13
  def initialize(relative)
6
14
  @relative = relative
@@ -11,9 +19,8 @@ module Listen
11
19
  end
12
20
  end
13
21
 
14
- def initialize(config, &block)
15
- @event_queue = Thread::Queue.new
16
- @block = block
22
+ def initialize(config)
23
+ @event_queue = ::Queue.new
17
24
  @config = config
18
25
  end
19
26
 
@@ -23,28 +30,21 @@ module Listen
23
30
  fail "Invalid change: #{change.inspect}" unless change.is_a?(Symbol)
24
31
  fail "Invalid path: #{path.inspect}" unless path.is_a?(String)
25
32
 
26
- dir = _safe_relative_from_cwd(dir)
27
- event_queue.public_send(:<<, [type, change, dir, path, options])
28
-
29
- block.call(args) if block
33
+ dir = if @config.relative?
34
+ _safe_relative_from_cwd(dir)
35
+ else
36
+ dir
37
+ end
38
+ @event_queue << [type, change, dir, path, options]
30
39
  end
31
40
 
32
- def empty?
33
- event_queue.empty?
34
- end
35
-
36
- def pop
37
- event_queue.pop
38
- end
41
+ delegate empty?: :@event_queue
42
+ delegate pop: :@event_queue
43
+ delegate close: :@event_queue
39
44
 
40
45
  private
41
46
 
42
- attr_reader :event_queue
43
- attr_reader :block
44
- attr_reader :config
45
-
46
47
  def _safe_relative_from_cwd(dir)
47
- return dir unless config.relative?
48
48
  dir.relative_path_from(Pathname.pwd)
49
49
  rescue ArgumentError
50
50
  dir