listen 2.7.5 → 2.7.6

Sign up to get free protection for your applications and to get access to all the features.
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