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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1 -186
- data/CONTRIBUTING.md +45 -0
- data/{LICENSE → LICENSE.txt} +3 -1
- data/README.md +332 -181
- data/bin/listen +11 -0
- data/lib/listen/adapter/base.rb +129 -0
- data/lib/listen/adapter/bsd.rb +107 -0
- data/lib/listen/adapter/config.rb +25 -0
- data/lib/listen/adapter/darwin.rb +77 -0
- data/lib/listen/adapter/linux.rb +108 -0
- data/lib/listen/adapter/polling.rb +40 -0
- data/lib/listen/adapter/windows.rb +96 -0
- data/lib/listen/adapter.rb +32 -201
- data/lib/listen/backend.rb +40 -0
- data/lib/listen/change.rb +69 -0
- data/lib/listen/cli.rb +65 -0
- data/lib/listen/directory.rb +93 -0
- data/lib/listen/error.rb +11 -0
- data/lib/listen/event/config.rb +40 -0
- data/lib/listen/event/loop.rb +94 -0
- data/lib/listen/event/processor.rb +126 -0
- data/lib/listen/event/queue.rb +54 -0
- data/lib/listen/file.rb +95 -0
- data/lib/listen/fsm.rb +133 -0
- data/lib/listen/listener/config.rb +41 -0
- data/lib/listen/listener.rb +93 -160
- data/lib/listen/logger.rb +36 -0
- data/lib/listen/monotonic_time.rb +27 -0
- data/lib/listen/options.rb +26 -0
- data/lib/listen/queue_optimizer.rb +129 -0
- data/lib/listen/record/entry.rb +66 -0
- data/lib/listen/record/symlink_detector.rb +41 -0
- data/lib/listen/record.rb +123 -0
- data/lib/listen/silencer/controller.rb +50 -0
- data/lib/listen/silencer.rb +106 -0
- data/lib/listen/thread.rb +54 -0
- data/lib/listen/version.rb +3 -1
- data/lib/listen.rb +40 -32
- metadata +87 -38
- data/lib/listen/adapters/darwin.rb +0 -85
- data/lib/listen/adapters/linux.rb +0 -113
- data/lib/listen/adapters/polling.rb +0 -67
- data/lib/listen/adapters/windows.rb +0 -87
- data/lib/listen/dependency_manager.rb +0 -126
- data/lib/listen/directory_record.rb +0 -344
- data/lib/listen/multi_listener.rb +0 -121
- 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
|
data/lib/listen/file.rb
ADDED
@@ -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
|