listen 0.5.3 → 3.7.1

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1 -186
  3. data/CONTRIBUTING.md +45 -0
  4. data/{LICENSE → LICENSE.txt} +3 -1
  5. data/README.md +332 -181
  6. data/bin/listen +11 -0
  7. data/lib/listen/adapter/base.rb +129 -0
  8. data/lib/listen/adapter/bsd.rb +107 -0
  9. data/lib/listen/adapter/config.rb +25 -0
  10. data/lib/listen/adapter/darwin.rb +77 -0
  11. data/lib/listen/adapter/linux.rb +108 -0
  12. data/lib/listen/adapter/polling.rb +40 -0
  13. data/lib/listen/adapter/windows.rb +96 -0
  14. data/lib/listen/adapter.rb +32 -201
  15. data/lib/listen/backend.rb +40 -0
  16. data/lib/listen/change.rb +69 -0
  17. data/lib/listen/cli.rb +65 -0
  18. data/lib/listen/directory.rb +93 -0
  19. data/lib/listen/error.rb +11 -0
  20. data/lib/listen/event/config.rb +40 -0
  21. data/lib/listen/event/loop.rb +94 -0
  22. data/lib/listen/event/processor.rb +126 -0
  23. data/lib/listen/event/queue.rb +54 -0
  24. data/lib/listen/file.rb +95 -0
  25. data/lib/listen/fsm.rb +133 -0
  26. data/lib/listen/listener/config.rb +41 -0
  27. data/lib/listen/listener.rb +93 -160
  28. data/lib/listen/logger.rb +36 -0
  29. data/lib/listen/monotonic_time.rb +27 -0
  30. data/lib/listen/options.rb +26 -0
  31. data/lib/listen/queue_optimizer.rb +129 -0
  32. data/lib/listen/record/entry.rb +66 -0
  33. data/lib/listen/record/symlink_detector.rb +41 -0
  34. data/lib/listen/record.rb +123 -0
  35. data/lib/listen/silencer/controller.rb +50 -0
  36. data/lib/listen/silencer.rb +106 -0
  37. data/lib/listen/thread.rb +54 -0
  38. data/lib/listen/version.rb +3 -1
  39. data/lib/listen.rb +40 -32
  40. metadata +87 -38
  41. data/lib/listen/adapters/darwin.rb +0 -85
  42. data/lib/listen/adapters/linux.rb +0 -113
  43. data/lib/listen/adapters/polling.rb +0 -67
  44. data/lib/listen/adapters/windows.rb +0 -87
  45. data/lib/listen/dependency_manager.rb +0 -126
  46. data/lib/listen/directory_record.rb +0 -344
  47. data/lib/listen/multi_listener.rb +0 -121
  48. data/lib/listen/turnstile.rb +0 -28
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen/monotonic_time'
4
+
5
+ module Listen
6
+ module Event
7
+ class Processor
8
+ def initialize(config, reasons)
9
+ @config = config
10
+ @listener = config.listener
11
+ @reasons = reasons
12
+ _reset_no_unprocessed_events
13
+ end
14
+
15
+ # TODO: implement this properly instead of checking the state at arbitrary
16
+ # points in time
17
+ def loop_for(latency)
18
+ @latency = latency
19
+
20
+ loop do
21
+ event = _wait_until_events
22
+ _check_stopped
23
+ _wait_until_events_calm_down
24
+ _wait_until_no_longer_paused
25
+ _process_changes(event)
26
+ end
27
+ rescue Stopped
28
+ Listen.logger.debug('Processing stopped')
29
+ end
30
+
31
+ private
32
+
33
+ class Stopped < RuntimeError
34
+ end
35
+
36
+ def _wait_until_events_calm_down
37
+ loop do
38
+ now = MonotonicTime.now
39
+
40
+ # Assure there's at least latency between callbacks to allow
41
+ # for accumulating changes
42
+ diff = _deadline - now
43
+ break if diff <= 0
44
+
45
+ # give events a bit of time to accumulate so they can be
46
+ # compressed/optimized
47
+ _sleep(diff)
48
+ end
49
+ end
50
+
51
+ def _wait_until_no_longer_paused
52
+ @listener.wait_for_state(*(Listener.states.keys - [:paused]))
53
+ end
54
+
55
+ def _check_stopped
56
+ if @listener.stopped?
57
+ _flush_wakeup_reasons
58
+ raise Stopped
59
+ end
60
+ end
61
+
62
+ def _sleep(seconds)
63
+ _check_stopped
64
+ config.sleep(seconds)
65
+ _check_stopped
66
+
67
+ _flush_wakeup_reasons do |reason|
68
+ if reason == :event && !@listener.paused?
69
+ _remember_time_of_first_unprocessed_event
70
+ end
71
+ end
72
+ end
73
+
74
+ def _remember_time_of_first_unprocessed_event
75
+ @_remember_time_of_first_unprocessed_event ||= MonotonicTime.now
76
+ end
77
+
78
+ def _reset_no_unprocessed_events
79
+ @_remember_time_of_first_unprocessed_event = nil
80
+ end
81
+
82
+ def _deadline
83
+ @_remember_time_of_first_unprocessed_event + @latency
84
+ end
85
+
86
+ # blocks until event is popped
87
+ # returns the event or `nil` when the event_queue is closed
88
+ def _wait_until_events
89
+ config.event_queue.pop.tap do |_event|
90
+ @_remember_time_of_first_unprocessed_event ||= MonotonicTime.now
91
+ end
92
+ end
93
+
94
+ def _flush_wakeup_reasons
95
+ until @reasons.empty?
96
+ reason = @reasons.pop
97
+ yield reason if block_given?
98
+ end
99
+ end
100
+
101
+ # for easier testing without sleep loop
102
+ def _process_changes(event)
103
+ _reset_no_unprocessed_events
104
+
105
+ changes = [event]
106
+ changes << config.event_queue.pop until config.event_queue.empty?
107
+
108
+ return unless config.callable?
109
+
110
+ hash = config.optimize_changes(changes)
111
+ result = [hash[:modified], hash[:added], hash[:removed]]
112
+ return if result.all?(&:empty?)
113
+
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"
121
+ end
122
+
123
+ attr_reader :config
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ require 'forwardable'
6
+
7
+ module Listen
8
+ module Event
9
+ class Queue
10
+ extend Forwardable
11
+
12
+ class Config
13
+ def initialize(relative)
14
+ @relative = relative
15
+ end
16
+
17
+ def relative?
18
+ @relative
19
+ end
20
+ end
21
+
22
+ def initialize(config)
23
+ @event_queue = ::Queue.new
24
+ @config = config
25
+ end
26
+
27
+ def <<(args)
28
+ type, change, dir, path, options = *args
29
+ fail "Invalid type: #{type.inspect}" unless [:dir, :file].include? type
30
+ fail "Invalid change: #{change.inspect}" unless change.is_a?(Symbol)
31
+ fail "Invalid path: #{path.inspect}" unless path.is_a?(String)
32
+
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]
39
+ end
40
+
41
+ delegate empty?: :@event_queue
42
+ delegate pop: :@event_queue
43
+ delegate close: :@event_queue
44
+
45
+ private
46
+
47
+ def _safe_relative_from_cwd(dir)
48
+ dir.relative_path_from(Pathname.pwd)
49
+ rescue ArgumentError
50
+ dir
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Listen
6
+ class File
7
+ # rubocop:disable Metrics/MethodLength
8
+ # rubocop:disable Metrics/CyclomaticComplexity
9
+ # rubocop:disable Metrics/PerceivedComplexity
10
+ def self.change(record, rel_path)
11
+ path = Pathname.new(record.root) + rel_path
12
+ lstat = path.lstat
13
+
14
+ data = { mtime: lstat.mtime.to_f, mode: lstat.mode, size: lstat.size }
15
+
16
+ record_data = record.file_data(rel_path)
17
+
18
+ if record_data.empty?
19
+ record.update_file(rel_path, data)
20
+ return :added
21
+ end
22
+
23
+ if data[:mode] != record_data[:mode]
24
+ record.update_file(rel_path, data)
25
+ return :modified
26
+ end
27
+
28
+ if data[:mtime] != record_data[:mtime]
29
+ record.update_file(rel_path, data)
30
+ return :modified
31
+ end
32
+
33
+ if data[:size] != record_data[:size]
34
+ record.update_file(rel_path, data)
35
+ return :modified
36
+ end
37
+
38
+ return if /1|true/ =~ ENV['LISTEN_GEM_DISABLE_HASHING']
39
+ return unless inaccurate_mac_time?(lstat)
40
+
41
+ # Check if change happened within 1 second (maybe it's even
42
+ # too much, e.g. 0.3-0.5 could be sufficient).
43
+ #
44
+ # With rb-fsevent, there's a (configurable) latency between
45
+ # when file was changed and when the event was triggered.
46
+ #
47
+ # If a file is saved at ???14.998, by the time the event is
48
+ # actually received by Listen, the time could already be e.g.
49
+ # ???15.7.
50
+ #
51
+ # And since Darwin adapter uses directory scanning, the file
52
+ # mtime may be the same (e.g. file was changed at ???14.001,
53
+ # then at ???14.998, but the fstat time would be ???14.0 in
54
+ # both cases).
55
+ #
56
+ # If change happened at ???14.999997, the mtime is 14.0, so for
57
+ # an mtime=???14.0 we assume it could even be almost ???15.0
58
+ #
59
+ # So if Time.now.to_f is ???15.999998 and stat reports mtime
60
+ # at ???14.0, then event was due to that file'd change when:
61
+ #
62
+ # ???15.999997 - ???14.999998 < 1.0s
63
+ #
64
+ # So the "2" is "1 + 1" (1s to cover rb-fsevent latency +
65
+ # 1s maximum difference between real mtime and that recorded
66
+ # in the file system)
67
+ #
68
+ return if data[:mtime].to_i + 2 <= Time.now.to_f
69
+
70
+ sha = Digest::SHA256.file(path).digest
71
+ record.update_file(rel_path, data.merge(sha: sha))
72
+ if record_data[:sha] && sha != record_data[:sha]
73
+ :modified
74
+ end
75
+ rescue SystemCallError
76
+ record.unset_path(rel_path)
77
+ :removed
78
+ rescue
79
+ Listen.logger.debug "lstat failed for: #{rel_path} (#{$ERROR_INFO})"
80
+ raise
81
+ end
82
+ # rubocop:enable Metrics/MethodLength
83
+ # rubocop:enable Metrics/CyclomaticComplexity
84
+ # rubocop:enable Metrics/PerceivedComplexity
85
+
86
+ def self.inaccurate_mac_time?(stat)
87
+ # 'mac' means Modified/Accessed/Created
88
+
89
+ # Since precision depends on mounted FS (e.g. you can have a FAT partiion
90
+ # mounted on Linux), check for fields with a remainder to detect this
91
+
92
+ [stat.mtime, stat.ctime, stat.atime].map(&:usec).all?(&:zero?)
93
+ end
94
+ end
95
+ end
data/lib/listen/fsm.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code copied from https://github.com/celluloid/celluloid-fsm
4
+
5
+ require 'thread'
6
+
7
+ module Listen
8
+ module FSM
9
+ # Included hook to extend class methods
10
+ def self.included(klass)
11
+ klass.send :extend, ClassMethods
12
+ end
13
+
14
+ module ClassMethods
15
+ # Obtain or set the start state
16
+ # Passing a state name sets the start state
17
+ def start_state(new_start_state = nil)
18
+ if new_start_state
19
+ new_start_state.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_start_state.inspect})"
20
+ @start_state = new_start_state
21
+ else
22
+ defined?(@start_state) or raise ArgumentError, "`start_state :<state>` must be declared before `new`"
23
+ @start_state
24
+ end
25
+ end
26
+
27
+ # The valid states for this FSM, as a hash with state name symbols as keys and State objects as values.
28
+ def states
29
+ @states ||= {}
30
+ end
31
+
32
+ # Declare an FSM state and optionally provide a callback block to fire on state entry
33
+ # Options:
34
+ # * to: a state or array of states this state can transition to
35
+ def state(state_name, to: nil, &block)
36
+ state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{state_name.inspect})"
37
+ states[state_name] = State.new(state_name, to, &block)
38
+ end
39
+ end
40
+
41
+ # Note: including classes must call initialize_fsm from their initialize method.
42
+ def initialize_fsm
43
+ @fsm_initialized = true
44
+ @state = self.class.start_state
45
+ @mutex = ::Mutex.new
46
+ @state_changed = ::ConditionVariable.new
47
+ end
48
+
49
+ # Current state of the FSM, stored as a symbol
50
+ attr_reader :state
51
+
52
+ # checks for one of the given states to wait for
53
+ # if not already, waits for a state change (up to timeout seconds--`nil` means infinite)
54
+ # returns truthy iff the transition to one of the desired state has occurred
55
+ def wait_for_state(*wait_for_states, timeout: nil)
56
+ wait_for_states.each do |state|
57
+ state.is_a?(Symbol) or raise ArgumentError, "states must be symbols (got #{state.inspect})"
58
+ end
59
+ @mutex.synchronize do
60
+ if !wait_for_states.include?(@state)
61
+ @state_changed.wait(@mutex, timeout)
62
+ end
63
+ wait_for_states.include?(@state)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def transition(new_state_name)
70
+ new_state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_state_name.inspect})"
71
+ if (new_state = validate_and_sanitize_new_state(new_state_name))
72
+ transition_with_callbacks!(new_state)
73
+ end
74
+ end
75
+
76
+ # Low-level, immediate state transition with no checks or callbacks.
77
+ def transition!(new_state_name)
78
+ new_state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_state_name.inspect})"
79
+ @fsm_initialized or raise ArgumentError, "FSM not initialized. You must call initialize_fsm from initialize!"
80
+ @mutex.synchronize do
81
+ yield if block_given?
82
+ @state = new_state_name
83
+ @state_changed.broadcast
84
+ end
85
+ end
86
+
87
+ def validate_and_sanitize_new_state(new_state_name)
88
+ return nil if @state == new_state_name
89
+
90
+ if current_state && !current_state.valid_transition?(new_state_name)
91
+ valid = current_state.transitions.map(&:to_s).join(', ')
92
+ msg = "#{self.class} can't change state from '#{@state}' to '#{new_state_name}', only to: #{valid}"
93
+ raise ArgumentError, msg
94
+ end
95
+
96
+ unless (new_state = self.class.states[new_state_name])
97
+ new_state_name == self.class.start_state or raise ArgumentError, "invalid state for #{self.class}: #{new_state_name}"
98
+ end
99
+
100
+ new_state
101
+ end
102
+
103
+ def transition_with_callbacks!(new_state)
104
+ transition! new_state.name
105
+ new_state.call(self)
106
+ end
107
+
108
+ def current_state
109
+ self.class.states[@state]
110
+ end
111
+
112
+ class State
113
+ attr_reader :name, :transitions
114
+
115
+ def initialize(name, transitions, &block)
116
+ @name = name
117
+ @block = block
118
+ @transitions = if transitions
119
+ Array(transitions).map(&:to_sym)
120
+ end
121
+ end
122
+
123
+ def call(obj)
124
+ obj.instance_eval(&@block) if @block
125
+ end
126
+
127
+ def valid_transition?(new_state)
128
+ # All transitions are allowed if none are expressly declared
129
+ !@transitions || @transitions.include?(new_state.to_sym)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listen
4
+ class Listener
5
+ class Config
6
+ DEFAULTS = {
7
+ # Listener options
8
+ debug: false, # TODO: is this broken?
9
+ wait_for_delay: nil, # NOTE: should be provided by adapter if possible
10
+ relative: false,
11
+
12
+ # Backend selecting options
13
+ force_polling: false,
14
+ polling_fallback_message: nil
15
+ }.freeze
16
+
17
+ def initialize(opts)
18
+ @options = DEFAULTS.merge(opts)
19
+ @relative = @options[:relative]
20
+ @min_delay_between_events = @options[:wait_for_delay]
21
+ @silencer_rules = @options # silencer will extract what it needs
22
+ end
23
+
24
+ def relative?
25
+ @relative
26
+ end
27
+
28
+ attr_reader :min_delay_between_events, :silencer_rules
29
+
30
+ def adapter_instance_options(klass)
31
+ valid_keys = klass.const_get('DEFAULTS').keys
32
+ Hash[@options.select { |key, _| valid_keys.include?(key) }]
33
+ end
34
+
35
+ def adapter_select_options
36
+ valid_keys = %w[force_polling polling_fallback_message].map(&:to_sym)
37
+ Hash[@options.select { |key, _| valid_keys.include?(key) }]
38
+ end
39
+ end
40
+ end
41
+ end