listen 2.7.5 → 2.7.6

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -0
  3. data/.rspec +0 -0
  4. data/.rubocop.yml +0 -0
  5. data/.travis.yml +0 -1
  6. data/.yardopts +0 -0
  7. data/CHANGELOG.md +0 -0
  8. data/CONTRIBUTING.md +0 -0
  9. data/Gemfile +25 -4
  10. data/Guardfile +0 -0
  11. data/LICENSE.txt +0 -0
  12. data/README.md +18 -10
  13. data/Rakefile +0 -0
  14. data/lib/listen.rb +2 -4
  15. data/lib/listen/adapter.rb +13 -4
  16. data/lib/listen/adapter/base.rb +33 -16
  17. data/lib/listen/adapter/bsd.rb +21 -38
  18. data/lib/listen/adapter/darwin.rb +17 -25
  19. data/lib/listen/adapter/linux.rb +34 -52
  20. data/lib/listen/adapter/polling.rb +9 -25
  21. data/lib/listen/adapter/tcp.rb +27 -14
  22. data/lib/listen/adapter/windows.rb +67 -23
  23. data/lib/listen/change.rb +26 -23
  24. data/lib/listen/cli.rb +0 -0
  25. data/lib/listen/directory.rb +47 -58
  26. data/lib/listen/file.rb +66 -101
  27. data/lib/listen/listener.rb +214 -155
  28. data/lib/listen/queue_optimizer.rb +104 -0
  29. data/lib/listen/record.rb +15 -5
  30. data/lib/listen/silencer.rb +14 -10
  31. data/lib/listen/tcp.rb +0 -1
  32. data/lib/listen/tcp/broadcaster.rb +31 -26
  33. data/lib/listen/tcp/message.rb +2 -2
  34. data/lib/listen/version.rb +1 -1
  35. data/listen.gemspec +1 -1
  36. data/spec/acceptance/listen_spec.rb +151 -239
  37. data/spec/acceptance/tcp_spec.rb +125 -134
  38. data/spec/lib/listen/adapter/base_spec.rb +13 -30
  39. data/spec/lib/listen/adapter/bsd_spec.rb +7 -35
  40. data/spec/lib/listen/adapter/darwin_spec.rb +18 -30
  41. data/spec/lib/listen/adapter/linux_spec.rb +49 -55
  42. data/spec/lib/listen/adapter/polling_spec.rb +20 -35
  43. data/spec/lib/listen/adapter/tcp_spec.rb +25 -27
  44. data/spec/lib/listen/adapter/windows_spec.rb +7 -33
  45. data/spec/lib/listen/adapter_spec.rb +10 -10
  46. data/spec/lib/listen/change_spec.rb +55 -57
  47. data/spec/lib/listen/directory_spec.rb +105 -155
  48. data/spec/lib/listen/file_spec.rb +186 -73
  49. data/spec/lib/listen/listener_spec.rb +233 -216
  50. data/spec/lib/listen/record_spec.rb +60 -22
  51. data/spec/lib/listen/silencer_spec.rb +48 -75
  52. data/spec/lib/listen/tcp/broadcaster_spec.rb +78 -69
  53. data/spec/lib/listen/tcp/listener_spec.rb +28 -71
  54. data/spec/lib/listen/tcp/message_spec.rb +48 -14
  55. data/spec/lib/listen_spec.rb +3 -3
  56. data/spec/spec_helper.rb +6 -3
  57. data/spec/support/acceptance_helper.rb +250 -31
  58. data/spec/support/fixtures_helper.rb +6 -4
  59. data/spec/support/platform_helper.rb +2 -2
  60. metadata +5 -5
  61. data/lib/listen/tcp/listener.rb +0 -108
@@ -6,15 +6,9 @@ module Listen
6
6
  # file IO than the other implementations.
7
7
  #
8
8
  class Polling < Base
9
- DEFAULT_POLLING_LATENCY = 1.0
10
-
11
- def self.usable?
12
- true
13
- end
9
+ OS_REGEXP = // # match any
14
10
 
15
- def start
16
- Thread.new { _poll_directories }
17
- end
11
+ DEFAULT_POLLING_LATENCY = 1.0
18
12
 
19
13
  private
20
14
 
@@ -22,26 +16,16 @@ module Listen
22
16
  listener.options[:latency] || DEFAULT_POLLING_LATENCY
23
17
  end
24
18
 
25
- def _poll_directories
26
- _napped_loop do
27
- listener.directories.each do |path|
28
- _notify_change(path, type: 'Dir', recursive: true)
29
- end
30
- end
31
- end
32
-
33
- def _napped_loop
19
+ def _run
34
20
  loop do
35
- _nap_time { yield }
21
+ start = Time.now.to_f
22
+ _directories.each do |path|
23
+ _notify_change(:dir, path, recursive: true)
24
+ nap_time = _latency - (Time.now.to_f - start)
25
+ sleep(nap_time) if nap_time > 0
26
+ end
36
27
  end
37
28
  end
38
-
39
- def _nap_time
40
- start = Time.now.to_f
41
- yield
42
- nap_time = _latency - (Time.now.to_f - start)
43
- sleep(nap_time) if nap_time > 0
44
- end
45
29
  end
46
30
  end
47
31
  end
@@ -1,33 +1,46 @@
1
1
  require 'celluloid/io'
2
2
 
3
+ require 'listen/tcp/message'
4
+
3
5
  module Listen
4
6
  module Adapter
5
7
  # Adapter to receive file system modifications over TCP
6
8
  class TCP < Base
9
+ OS_REGEXP = // # match any
10
+
7
11
  include Celluloid::IO
8
12
 
9
13
  finalizer :finalize
10
14
 
11
15
  attr_reader :buffer, :socket
12
16
 
13
- def self.usable?
14
- true
15
- end
16
-
17
17
  # Initializes and starts a Celluloid::IO-powered TCP-recipient
18
18
  def start
19
+ attempts = 3
19
20
  @socket = TCPSocket.new(listener.host, listener.port)
20
21
  @buffer = ''
21
- run
22
+ async.run
23
+ rescue Celluloid::Task::TerminatedError
24
+ _log :debug, "TCP adapter was terminated: #{$!.inspect}"
25
+ rescue Errno::ECONNREFUSED
26
+ sleep 1
27
+ attempts -= 1
28
+ _log :warn, "TCP.start: #{$!.inspect}"
29
+ retry if retries > 0
30
+ _log :error, "TCP.start: #{$!.inspect}:#{$@.join("\n")}"
31
+ raise
32
+ rescue
33
+ _log :error, "TCP.start: #{$!.inspect}:#{$@.join("\n")}"
34
+ raise
22
35
  end
23
36
 
24
37
  # Cleans up buffer and socket
25
38
  def finalize
26
39
  @buffer = nil
27
- if @socket
28
- @socket.close
29
- @socket = nil
30
- end
40
+ return unless @socket
41
+
42
+ @socket.close
43
+ @socket = nil
31
44
  end
32
45
 
33
46
  # Number of bytes to receive at a time
@@ -46,15 +59,15 @@ module Listen
46
59
  while (message = Listen::TCP::Message.from_buffer(@buffer))
47
60
  handle_message(message)
48
61
  end
62
+ rescue
63
+ _log :error, "TCP.handle_data crashed: #{$!}:#{$@.join("\n")}"
64
+ raise
49
65
  end
50
66
 
51
67
  # Handles incoming message by notifying of path changes
52
68
  def handle_message(message)
53
- message.object.each do |change, paths|
54
- paths.each do |path|
55
- _notify_change(path, change: change.to_sym)
56
- end
57
- end
69
+ type, modification, path, _ = message.object
70
+ _notify_change(type.to_sym, path, change: modification.to_sym)
58
71
  end
59
72
 
60
73
  def self.local_fs?
@@ -3,8 +3,8 @@ module Listen
3
3
  # Adapter implementation for Windows `wdm`.
4
4
  #
5
5
  class Windows < Base
6
- # The message to show when wdm gem isn't available
7
- #
6
+ OS_REGEXP = /mswin|mingw|cygwin/i
7
+
8
8
  BUNDLER_DECLARE_GEM = <<-EOS.gsub(/^ {6}/, '')
9
9
  Please add the following to your Gemfile to avoid polling for changes:
10
10
  require 'rbconfig'
@@ -14,39 +14,83 @@ module Listen
14
14
  EOS
15
15
 
16
16
  def self.usable?
17
- if RbConfig::CONFIG['target_os'] =~ /mswin|mingw|cygwin/i
18
- require 'wdm'
19
- true
20
- end
17
+ return false unless super
18
+ require 'wdm'
19
+ true
21
20
  rescue LoadError
21
+ _log :debug, "wdm - load failed: #{$!}:#{$@.join("\n")}"
22
22
  Kernel.warn BUNDLER_DECLARE_GEM
23
23
  false
24
24
  end
25
25
 
26
- def start
27
- worker = _init_worker
28
- Thread.new { worker.run! }
26
+ private
27
+
28
+ def _configure
29
+ _log :debug, 'wdm - starting...'
30
+ @worker = WDM::Monitor.new
31
+ _directories.each do |path|
32
+ @worker.watch_recursively(path.to_s, :files, &_file_callback)
33
+ @worker.watch_recursively(path.to_s, :directories, &_dir_callback)
34
+ @worker.watch_recursively(path.to_s, :attributes, :last_write,
35
+ &_attr_callback)
36
+ end
29
37
  end
30
38
 
31
- private
39
+ def _run
40
+ @worker.run!
41
+ end
32
42
 
33
- # Initializes a WDM monitor and adds a watcher for
34
- # each directory passed to the adapter.
35
- #
36
- # @return [WDM::Monitor] initialized worker
37
- #
38
- def _init_worker
39
- WDM::Monitor.new.tap do |worker|
40
- _directories_path.each do |path|
41
- worker.watch_recursively(path, &_worker_callback)
43
+ def _file_callback
44
+ lambda do |change|
45
+ begin
46
+ path = _path(change.path)
47
+ _log :debug, "wdm - FILE callback: #{change.inspect}"
48
+ options = { change: _change(change.type) }
49
+ _notify_change(:file, path, options)
50
+ rescue
51
+ _log :error, "wdm - callback failed: #{$!}:#{$@.join("\n")}"
52
+ raise
42
53
  end
43
54
  end
44
55
  end
45
56
 
46
- def _worker_callback
57
+ def _attr_callback
47
58
  lambda do |change|
48
- options = { type: 'File', change: _change(change.type) }
49
- _notify_change(_path(change.path), options)
59
+ begin
60
+ path = _path(change.path)
61
+ return if path.directory?
62
+
63
+ _log :debug, "wdm - ATTR callback: #{change.inspect}"
64
+ options = { change: _change(change.type) }
65
+ _notify_change(:file, _path(change.path), options)
66
+ rescue
67
+ _log :error, "wdm - callback failed: #{$!}:#{$@.join("\n")}"
68
+ raise
69
+ end
70
+ end
71
+ end
72
+
73
+ def _dir_callback
74
+ lambda do |change|
75
+ begin
76
+ path = _path(change.path)
77
+ _log :debug, "wdm - DIR callback: #{change.inspect}"
78
+ if change.type == :removed
79
+ _notify_change(:dir, path.dirname)
80
+ elsif change.type == :added
81
+ _notify_change(:dir, path)
82
+ else
83
+ # do nothing - changed directory means either:
84
+ # - removed subdirs (handled above)
85
+ # - added subdirs (handled above)
86
+ # - removed files (handled by _file_callback)
87
+ # - added files (handled by _file_callback)
88
+ # so what's left?
89
+ end
90
+ rescue
91
+ _log :error, "wdm - callback failed: #{$!}:#{$@.join("\n")}"
92
+ raise
93
+ end
50
94
  end
51
95
  end
52
96
 
@@ -55,7 +99,7 @@ module Listen
55
99
  end
56
100
 
57
101
  def _change(type)
58
- { modified: [:modified],
102
+ { modified: [:modified, :attrib], # TODO: is attrib really passed?
59
103
  added: [:added, :renamed_new_file],
60
104
  removed: [:removed, :renamed_old_file] }.each do |change, types|
61
105
  return change if types.include?(type)
@@ -11,41 +11,44 @@ module Listen
11
11
  @listener = listener
12
12
  end
13
13
 
14
- def change(path, options)
14
+ def change(type, path, options = {})
15
15
  change = options[:change]
16
16
  cookie = options[:cookie]
17
17
 
18
- unless cookie
19
- # TODO: remove silencing here (it's done later)
20
- return if _silencer.silenced?(path, options[:type])
18
+ if !cookie && listener.silencer.silenced?(path, type)
19
+ _log :debug, "(silenced): #{path.inspect}"
20
+ return
21
21
  end
22
22
 
23
+ log_details = options[:silence] && 'recording' || change || 'unknown'
24
+ _log :debug, "#{log_details}: #{type}:#{path} (#{options.inspect})"
25
+
23
26
  if change
24
- _notify_listener(change, path, cookie ? { cookie: cookie } : {})
27
+ listener.queue(type, change, path, cookie ? { cookie: cookie } : {})
25
28
  else
26
- send("_#{options[:type].downcase}_change", path, options)
29
+ return unless (record = listener.sync(:record))
30
+ record.async.still_building! if options[:build]
31
+
32
+ if type == :dir
33
+ return unless (change_queue = listener.async(:change_pool))
34
+ Directory.scan(change_queue, record, path, options)
35
+ else
36
+ change = File.change(record, path)
37
+ return if !change || options[:silence]
38
+ listener.queue(:file, change, path)
39
+ end
27
40
  end
41
+ rescue Celluloid::Task::TerminatedError
42
+ _log :debug, "Change#change was terminated: #{$!.inspect}"
43
+ rescue RuntimeError
44
+ _log :error, "Change#change crashed #{$!.inspect}:#{$@.join("\n")}"
45
+ raise
28
46
  end
29
47
 
30
48
  private
31
49
 
32
- def _file_change(path, options)
33
- change = File.new(listener, path).change
34
- if change && listener.listen? && !options[:silence]
35
- _notify_listener(change, path)
36
- end
37
- end
38
-
39
- def _dir_change(path, options)
40
- Directory.new(listener, path, options).scan
41
- end
42
-
43
- def _notify_listener(change, path, options = {})
44
- listener.changes << { change => path }.merge(options)
45
- end
46
-
47
- def _silencer
48
- listener.registry[:silencer]
50
+ def _log(type, message)
51
+ Celluloid.logger.send(type, message)
49
52
  end
50
53
  end
51
54
  end
File without changes
@@ -1,78 +1,67 @@
1
+ require 'set'
2
+
1
3
  module Listen
2
4
  class Directory
3
- attr_accessor :listener, :path, :options
4
-
5
- def initialize(listener, path, options = {})
6
- @listener = listener
7
- @path = path
8
- @options = options
9
- end
5
+ def self.scan(queue, sync_record, path, options = {})
6
+ return unless (record = sync_record.async)
10
7
 
11
- def scan
12
- _update_record
13
- _all_entries.each do |entry_path, data|
14
- case data[:type]
15
- when 'File'
16
- _async_change(entry_path, options.merge(type: 'File'))
17
- when 'Dir'
18
- if _recursive_scan?(entry_path)
19
- _async_change(entry_path, options.merge(type: 'Dir'))
20
- end
21
- end
22
- end
23
- end
8
+ previous = sync_record.dir_entries(path)
24
9
 
25
- private
10
+ record.set_path(:dir, path)
11
+ current = Set.new(path.children)
26
12
 
27
- def _update_record
28
- if ::Dir.exist?(path)
29
- _record.async.set_path(path, type: 'Dir')
13
+ if options[:silence]
14
+ _log :debug, "Recording: #{path}: #{options.inspect}"\
15
+ " [#{previous.inspect}] -> (#{current.inspect})"
30
16
  else
31
- _record.async.unset_path(path)
17
+ _log :debug, "Scanning: #{path}: #{options.inspect}"\
18
+ " [#{previous.inspect}] -> (#{current.inspect})"
32
19
  end
33
- end
34
-
35
- def _all_entries
36
- _record_entries.merge(_entries)
37
- end
38
-
39
- def _entries
40
- return {} unless ::Dir.exist?(path)
41
20
 
42
- entries = ::Dir.entries(path) - %w(. ..)
43
- entries = entries.map { |entry| [entry, type: _entry_type(entry)] }
44
- Hash[*entries.flatten]
45
- end
46
-
47
- def _entry_type(entry_path)
48
- entry_path = path.join(entry_path)
49
- if entry_path.file?
50
- 'File'
51
- elsif entry_path.directory?
52
- 'Dir'
21
+ current.each do |full_path|
22
+ if full_path.directory?
23
+ _change(queue, :dir, full_path, options)
24
+ else
25
+ _change(queue, :file, full_path, options)
26
+ end
53
27
  end
54
- end
55
28
 
56
- def _record_entries
57
- future = _record.future.dir_entries(path)
58
- future.value
59
- end
29
+ previous.reject! { |entry, _| current.include? path + entry }
30
+ _async_changes(path, queue, previous, options)
60
31
 
61
- def _record
62
- listener.registry[:record]
32
+ rescue Errno::ENOENT
33
+ record.unset_path(path)
34
+ _async_changes(path, queue, previous, options)
35
+
36
+ rescue Errno::ENOTDIR
37
+ # TODO: path not tested
38
+ record.unset_path(path)
39
+ _async_changes(path, queue, previous, options)
40
+ _change(queue, :file, path, options)
41
+ rescue
42
+ _log :warn, "scanning DIED: #{$!}:#{$@.join("\n")}"
43
+ raise
63
44
  end
64
45
 
65
- def _change_pool
66
- listener.registry[:change_pool]
46
+ def self._async_changes(path, queue, previous, options)
47
+ previous.each do |entry, data|
48
+ _change(queue, data[:type], path + entry, options)
49
+ end
67
50
  end
68
51
 
69
- def _recursive_scan?(path)
70
- !::Dir.exist?(path) || options[:recursive]
52
+ def self._change(queue, type, full_path, options)
53
+ return queue.change(type, full_path, options) if type == :dir
54
+ opts = options.dup
55
+ opts.delete(:recursive)
56
+ if opts.empty?
57
+ queue.change(type, full_path)
58
+ else
59
+ queue.change(type, full_path, opts)
60
+ end
71
61
  end
72
62
 
73
- def _async_change(entry_path, options)
74
- entry_path = path.join(entry_path)
75
- _change_pool.async.change(entry_path, options)
63
+ def self._log(type, message)
64
+ Celluloid.logger.send(type, message)
76
65
  end
77
66
  end
78
67
  end
@@ -1,118 +1,83 @@
1
1
  module Listen
2
2
  class File
3
- attr_accessor :listener, :path, :data, :md5
3
+ def self.change(record, path)
4
+ lstat = path.lstat
4
5
 
5
- def initialize(listener, path)
6
- @listener = listener
7
- @path = path
8
- @data = { type: 'File' }
9
- end
10
-
11
- def change
12
- if _existing_path? && _modified?
13
- _set_record_data
14
- :modified
15
- elsif _new_path?
16
- _set_record_data
17
- :added
18
- elsif _removed_path?
19
- _unset_record_data
20
- :removed
21
- end
22
- end
23
-
24
- private
25
-
26
- def _new_path?
27
- _exist? && !_record_data?
28
- end
29
-
30
- def _existing_path?
31
- _exist? && _record_data?
32
- end
33
-
34
- def _removed_path?
35
- !_exist?
36
- end
37
-
38
- def _record_data?
39
- !_record_data.empty?
40
- end
41
-
42
- def _exist?
43
- @exist ||= ::File.exist?(path)
44
- end
45
-
46
- def _modified?
47
- _mtime > _record_data[:mtime] || _mode_modified? || _content_modified?
48
- end
49
-
50
- def _mode_modified?
51
- _mode != _record_data[:mode]
52
- end
6
+ data = { mtime: lstat.mtime.to_f, mode: lstat.mode }
53
7
 
54
- # Only useful on Darwin because of the file mtime second precision.
55
- # Only check if in the same seconds (mtime == current time).
56
- # MD5 is eager loaded, so the first time it'll always return false.
57
- #
58
- def _content_modified?
59
- return false unless RbConfig::CONFIG['target_os'] =~ /darwin/i
60
- return false unless _mtime.to_i == Time.now.to_i
8
+ record_data = record.file_data(path)
61
9
 
62
- _set_md5
63
- if _record_data[:md5]
64
- md5 != _record_data[:md5]
65
- else
66
- _set_record_data
67
- false
10
+ if record_data.empty?
11
+ record.async.set_path(:file, path, data)
12
+ return :added
68
13
  end
69
- end
70
14
 
71
- def _set_record_data
72
- @data.merge!(_new_data)
73
- _record.async.set_path(path, data)
74
- end
75
-
76
- def _unset_record_data
77
- _record.async.unset_path(path)
78
- end
79
-
80
- def _new_data
81
- data = { mtime: _mtime, mode: _mode }
82
- data[:md5] = md5 if md5
83
- data
84
- end
85
-
86
- def _record_data
87
- @_record_data ||= _record.future.file_data(path).value
88
- end
15
+ if data[:mode] != record_data[:mode]
16
+ record.async.set_path(:file, path, data)
17
+ return :modified
18
+ end
89
19
 
90
- def _record
91
- listener.registry[:record]
92
- end
20
+ if data[:mtime] != record_data[:mtime]
21
+ record.async.set_path(:file, path, data)
22
+ return :modified
23
+ end
93
24
 
94
- def _mtime
95
- @mtime ||= _lstat.mtime.to_f
25
+ unless /1|true/ =~ ENV['LISTEN_GEM_DISABLE_HASHING']
26
+ if self.inaccurate_mac_time?(lstat)
27
+ # Check if change happened within 1 second (maybe it's even
28
+ # too much, e.g. 0.3-0.5 could be sufficient).
29
+ #
30
+ # With rb-fsevent, there's a (configurable) latency between
31
+ # when file was changed and when the event was triggered.
32
+ #
33
+ # If a file is saved at ???14.998, by the time the event is
34
+ # actually received by Listen, the time could already be e.g.
35
+ # ???15.7.
36
+ #
37
+ # And since Darwin adapter uses directory scanning, the file
38
+ # mtime may be the same (e.g. file was changed at ???14.001,
39
+ # then at ???14.998, but the fstat time would be ???14.0 in
40
+ # both cases).
41
+ #
42
+ # If change happend at ???14.999997, the mtime is 14.0, so for
43
+ # an mtime=???14.0 we assume it could even be almost ???15.0
44
+ #
45
+ # So if Time.now.to_f is ???15.999998 and stat reports mtime
46
+ # at ???14.0, then event was due to that file'd change when:
47
+ #
48
+ # ???15.999997 - ???14.999998 < 1.0s
49
+ #
50
+ # So the "2" is "1 + 1" (1s to cover rb-fsevent latency +
51
+ # 1s maximum difference between real mtime and that recorded
52
+ # in the file system)
53
+ #
54
+ if data[:mtime].to_i + 2 > Time.now.to_f
55
+ begin
56
+ md5 = Digest::MD5.file(path).digest
57
+ record.async.set_path(:file, path, data.merge(md5: md5))
58
+ :modified if record_data[:md5] && md5 != record_data[:md5]
59
+
60
+ rescue SystemCallError
61
+ # ignore failed md5
62
+ end
63
+ end
64
+ end
65
+ end
66
+ rescue SystemCallError
67
+ record.async.unset_path(path)
68
+ :removed
96
69
  rescue
97
- 0.0
70
+ Celluloid::Logger.debug "lstat failed for: #{path} (#{$!})"
71
+ raise
98
72
  end
99
73
 
100
- def _mode
101
- @mode ||= _lstat.mode
102
- rescue
103
- nil
104
- end
74
+ def self.inaccurate_mac_time?(stat)
75
+ # 'mac' means Modified/Accessed/Created
105
76
 
106
- def _lstat
107
- @lstat ||= ::File.lstat(path)
108
- rescue
109
- nil
110
- end
77
+ # Since precision depends on mounted FS (e.g. you can have a FAT partiion
78
+ # mounted on Linux), check for fields with a remainder to detect this
111
79
 
112
- def _set_md5
113
- @md5 = Digest::MD5.file(path).digest
114
- rescue
115
- nil
80
+ [stat.mtime, stat.ctime, stat.atime].map(&:usec).all?(&:zero?)
116
81
  end
117
82
  end
118
83
  end