bluepill 0.1.2 → 0.1.4

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 (43) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +820 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE +1 -1
  5. data/README.md +69 -30
  6. data/bin/bluepill +2 -2
  7. data/bluepill.gemspec +17 -6
  8. data/lib/bluepill/application.rb +311 -43
  9. data/lib/bluepill/condition_watch.rb +105 -10
  10. data/lib/bluepill/controller.rb +125 -14
  11. data/lib/bluepill/dsl/app_proxy.rb +103 -4
  12. data/lib/bluepill/dsl/process_factory.rb +185 -62
  13. data/lib/bluepill/dsl/process_proxy.rb +132 -14
  14. data/lib/bluepill/dsl.rb +27 -5
  15. data/lib/bluepill/group.rb +142 -9
  16. data/lib/bluepill/logger.rb +122 -7
  17. data/lib/bluepill/process.rb +582 -211
  18. data/lib/bluepill/process_commands.rb +262 -0
  19. data/lib/bluepill/process_conditions/always_true.rb +18 -4
  20. data/lib/bluepill/process_conditions/cpu_usage.rb +34 -0
  21. data/lib/bluepill/process_conditions/file_time.rb +40 -4
  22. data/lib/bluepill/process_conditions/http.rb +81 -23
  23. data/lib/bluepill/process_conditions/mem_usage.rb +64 -5
  24. data/lib/bluepill/process_conditions/process_condition.rb +45 -2
  25. data/lib/bluepill/process_conditions/running_time.rb +36 -1
  26. data/lib/bluepill/process_conditions/zombie_process.rb +26 -5
  27. data/lib/bluepill/process_conditions.rb +15 -1
  28. data/lib/bluepill/process_journal.rb +256 -104
  29. data/lib/bluepill/process_statistics.rb +33 -3
  30. data/lib/bluepill/socket.rb +63 -16
  31. data/lib/bluepill/system/commands.rb +286 -0
  32. data/lib/bluepill/system.rb +193 -229
  33. data/lib/bluepill/trigger.rb +111 -15
  34. data/lib/bluepill/triggers/flapping.rb +86 -5
  35. data/lib/bluepill/util/rotational_array.rb +25 -2
  36. data/lib/bluepill/version.rb +71 -19
  37. data/lib/bluepill.rb +25 -2
  38. data/sig/bluepill.rbs +926 -0
  39. data/sig/manifest.yaml +7 -0
  40. metadata +16 -35
  41. data/bin/sample_forking_server +0 -66
  42. data/lib/bluepill/application/client.rb +0 -6
  43. data/lib/bluepill/application/server.rb +0 -21
@@ -1,15 +1,25 @@
1
- require 'etc'
2
- require 'shellwords'
3
-
4
1
  module Bluepill
5
- # This class represents the system that bluepill is running on.. It's mainly used to memoize
6
- # results of running ps auxx etc so that every watch in the every process will not result in a fork
2
+ # Read what the system says about its processes, and run commands
3
+ #
4
+ # This represents the system that bluepill is running on. It memoizes
5
+ # the results of running ps each tick, so that every watch of every
6
+ # process doesn't fork a ps of its own.
7
+ #
8
+ # @api private
7
9
  module System
8
- APPEND_MODE = 'a'
10
+ # The mode that a daemon's stdout and stderr files are opened in
11
+ #
12
+ # @api private
13
+ APPEND_MODE = 'a'.freeze
9
14
 
10
- module_function
15
+ # Raised when ps could not be run, so there is no data for this tick
16
+ #
17
+ # @api private
18
+ class PsError < StandardError; end
11
19
 
12
20
  # The position of each field in ps output
21
+ #
22
+ # @api private
13
23
  IDX_MAP = {
14
24
  pid: 0,
15
25
  ppid: 1,
@@ -17,108 +27,142 @@ module Bluepill
17
27
  rss: 3,
18
28
  etime: 4,
19
29
  command: 5,
20
- }
21
-
22
- def pid_alive?(pid)
30
+ }.freeze #: { pid: Integer, ppid: Integer, pcpu: Integer, rss: Integer, etime: Integer, command: Integer }
31
+
32
+ # Return whether a process is running
33
+ #
34
+ # @param [Integer] pid the process's pid
35
+ #
36
+ # @return [Boolean] true if the process exists, even if bluepill may
37
+ # not signal it
38
+ #
39
+ # @api private
40
+ def self.pid_alive?(pid)
23
41
  ::Process.kill(0, pid)
24
42
  true
25
43
  rescue Errno::EPERM # no permission, but it is definitely alive
26
44
  true
27
- rescue Errno::ESRCH
28
- false
29
- rescue
30
- false # other falsy (no pid)
45
+ rescue StandardError
46
+ false # no such process (ESRCH), or no pid
31
47
  end
32
48
 
33
- def cpu_usage(pid, include_children)
49
+ # Return a process's CPU usage, from this tick's ps table
50
+ #
51
+ # @param [Integer, nil] pid the process's pid
52
+ # @param [Boolean] include_children whether to add its descendants'
53
+ #
54
+ # @return [Float, nil] the percent of a CPU it uses, or nil if ps doesn't
55
+ # list it
56
+ #
57
+ # @raise [PsError] if ps could not be run this tick
58
+ #
59
+ # @api private
60
+ def self.cpu_usage(pid, include_children)
34
61
  ps = ps_axu
35
- return unless ps[pid]
36
- cpu_used = ps[pid][IDX_MAP[:pcpu]].to_f
37
- get_children(pid).each do |child_pid|
38
- cpu_used += ps[child_pid][IDX_MAP[:pcpu]].to_f if ps[child_pid]
39
- end if include_children
62
+ return unless ps.key?(pid)
63
+
64
+ cpu_used = ps.fetch(pid).fetch(IDX_MAP.fetch(:pcpu)).to_f
65
+ if include_children
66
+ get_children(pid).each do |child_pid|
67
+ cpu_used += ps.fetch(child_pid).fetch(IDX_MAP.fetch(:pcpu)).to_f if ps.key?(child_pid)
68
+ end
69
+ end
40
70
  cpu_used
41
71
  end
42
72
 
43
- def memory_usage(pid, include_children)
73
+ # Return a process's resident memory, from this tick's ps table
74
+ #
75
+ # @param [Integer, nil] pid the process's pid
76
+ # @param [Boolean] include_children whether to add its descendants'
77
+ #
78
+ # @return [Float, nil] its resident memory in kilobytes, or nil if ps
79
+ # doesn't list it
80
+ #
81
+ # @raise [PsError] if ps could not be run this tick
82
+ #
83
+ # @api private
84
+ def self.memory_usage(pid, include_children)
44
85
  ps = ps_axu
45
- return unless ps[pid]
46
- mem_used = ps[pid][IDX_MAP[:rss]].to_f
47
- get_children(pid).each do |child_pid|
48
- mem_used += ps[child_pid][IDX_MAP[:rss]].to_f if ps[child_pid]
49
- end if include_children
86
+ return unless ps.key?(pid)
87
+
88
+ mem_used = ps.fetch(pid).fetch(IDX_MAP.fetch(:rss)).to_f
89
+ if include_children
90
+ get_children(pid).each do |child_pid|
91
+ mem_used += ps.fetch(child_pid).fetch(IDX_MAP.fetch(:rss)).to_f if ps.key?(child_pid)
92
+ end
93
+ end
50
94
  mem_used
51
95
  end
52
96
 
53
- def running_time(pid)
97
+ # Return how long a process has been running, from this tick's ps table
98
+ #
99
+ # @param [Integer, nil] pid the process's pid
100
+ #
101
+ # @return [Integer, nil] its running time in seconds, or nil if ps
102
+ # doesn't list it
103
+ #
104
+ # @raise [PsError] if ps could not be run this tick
105
+ #
106
+ # @api private
107
+ def self.running_time(pid)
54
108
  ps = ps_axu
55
- return unless ps[pid]
56
- parse_elapsed_time(ps[pid][IDX_MAP[:etime]])
109
+ return unless ps.key?(pid)
110
+
111
+ parse_elapsed_time(ps.fetch(pid).fetch(IDX_MAP.fetch(:etime)))
57
112
  end
58
113
 
59
- def command(pid)
114
+ # Return a process's command line, from this tick's ps table
115
+ #
116
+ # @param [Integer, nil] pid the process's pid
117
+ #
118
+ # @return [String, nil] its command and arguments, or nil if ps doesn't
119
+ # list it
120
+ #
121
+ # @raise [PsError] if ps could not be run this tick
122
+ #
123
+ # @api private
124
+ def self.command(pid)
60
125
  ps = ps_axu
61
- return unless ps[pid]
62
- ps[pid][IDX_MAP[:command]]
126
+ return unless ps.key?(pid)
127
+
128
+ ps.fetch(pid).fetch(IDX_MAP.fetch(:command))
63
129
  end
64
130
 
65
- def get_children(parent_pid)
66
- child_pids = []
131
+ # Return the pids of a process's descendants, from this tick's ps table
132
+ #
133
+ # @param [Integer, nil] parent_pid the process's pid
134
+ #
135
+ # @return [Array<Integer>] its children's pids, followed by theirs, or
136
+ # none if ps could not be run
137
+ #
138
+ # @api private
139
+ def self.get_children(parent_pid)
140
+ # A process that is still starting has no pid yet, and nil.to_i is 0.
141
+ # The ps header row parses as pid 0 with ppid 0, so it would be its own
142
+ # child and the recursion would never end.
143
+ return [] unless parent_pid.to_i > 0
144
+
145
+ child_pids = [] #: Array[Integer]
67
146
  ps_axu.each_pair do |_pid, chunks|
68
- child_pids << chunks[IDX_MAP[:pid]].to_i if chunks[IDX_MAP[:ppid]].to_i == parent_pid.to_i
147
+ child_pids << Integer(chunks.fetch(IDX_MAP.fetch(:pid))) if chunks.fetch(IDX_MAP.fetch(:ppid)).to_i.eql?(parent_pid)
69
148
  end
70
149
  grand_children = child_pids.collect { |pid| get_children(pid) }.flatten
71
150
  child_pids.concat grand_children
151
+ rescue PsError
152
+ []
72
153
  end
73
154
 
74
- # Returns the pid of the child that executes the cmd
75
- def daemonize(cmd, options = {})
76
- rd, wr = IO.pipe
77
-
78
- child = Daemonize.safefork
79
- if child
80
- # we don't want to create zombies, so detach ourselves from the child exit status
81
- ::Process.detach(child)
82
-
83
- # parent
84
- wr.close
85
-
86
- daemon_id = rd.read.to_i
87
- rd.close
88
-
89
- return daemon_id if daemon_id > 0
90
- else
91
- # child
92
- rd.close
93
-
94
- drop_privileges(options[:uid], options[:gid], options[:supplementary_groups])
95
-
96
- # if we cannot write the pid file as the provided user, err out
97
- exit unless can_write_pid_file(options[:pid_file], options[:logger])
98
-
99
- to_daemonize = lambda do
100
- # Setting end PWD env emulates bash behavior when dealing with symlinks
101
- Dir.chdir(ENV['PWD'] = options[:working_dir].to_s) if options[:working_dir]
102
- options[:environment].each { |key, value| ENV[key.to_s] = value.to_s } if options[:environment]
103
-
104
- redirect_io(*options.values_at(:stdin, :stdout, :stderr))
105
-
106
- ::Kernel.exec(*Shellwords.shellwords(cmd))
107
- exit
108
- end
109
-
110
- daemon_id = Daemonize.call_as_daemon(to_daemonize, nil, cmd)
111
-
112
- File.open(options[:pid_file], 'w') { |f| f.write(daemon_id) }
113
-
114
- wr.write daemon_id
115
- wr.close
116
-
117
- ::Process.exit!(true)
118
- end
119
- end
120
-
121
- def delete_if_exists(filename)
155
+ # Delete a file if it exists
156
+ #
157
+ # It tries three times when permission is denied, and then prints a
158
+ # warning instead of raising.
159
+ #
160
+ # @param [String, nil] filename the file's path
161
+ #
162
+ # @return [void]
163
+ #
164
+ # @api private
165
+ def self.delete_if_exists(filename)
122
166
  tries = 0
123
167
 
124
168
  begin
@@ -130,166 +174,86 @@ module Bluepill
130
174
  end
131
175
  end
132
176
 
133
- # Returns the stdout, stderr and exit code of the cmd
134
- def execute_blocking(cmd, options = {})
135
- rd, wr = IO.pipe
136
-
137
- child = Daemonize.safefork
138
- if child
139
- # parent
140
- wr.close
141
-
142
- cmd_status = rd.read
143
- rd.close
144
-
145
- ::Process.waitpid(child)
146
-
147
- cmd_status.strip != '' ? Marshal.load(cmd_status) : {exit_code: 0, stdout: '', stderr: ''}
148
- else
149
- # child
150
- rd.close
151
-
152
- # create a child in which we can override the stdin, stdout and stderr
153
- cmd_out_read, cmd_out_write = IO.pipe
154
- cmd_err_read, cmd_err_write = IO.pipe
155
-
156
- pid = fork do
157
- begin
158
- # grandchild
159
- drop_privileges(options[:uid], options[:gid], options[:supplementary_groups])
160
-
161
- Dir.chdir(ENV['PWD'] = options[:working_dir].to_s) if options[:working_dir]
162
- options[:environment].each { |key, value| ENV[key.to_s] = value.to_s } if options[:environment]
163
-
164
- # close unused fds so ancestors wont hang. This line is the only reason we are not
165
- # using something like popen3. If this fd is not closed, the .read call on the parent
166
- # will never return because "wr" would still be open in the "exec"-ed cmd
167
- wr.close
168
-
169
- # we do not care about stdin of cmd
170
- STDIN.reopen('/dev/null')
171
-
172
- # point stdout of cmd to somewhere we can read
173
- cmd_out_read.close
174
- STDOUT.reopen(cmd_out_write)
175
- cmd_out_write.close
176
-
177
- # same thing for stderr
178
- cmd_err_read.close
179
- STDERR.reopen(cmd_err_write)
180
- cmd_err_write.close
181
-
182
- # finally, replace grandchild with cmd
183
- ::Kernel.exec(*Shellwords.shellwords(cmd))
184
- rescue => e
185
- (cmd_err_write.closed? ? STDERR : cmd_err_write).puts "Exception in grandchild: #{e}."
186
- (cmd_err_write.closed? ? STDERR : cmd_err_write).puts e.backtrace
187
- exit 1
188
- end
189
- end
190
-
191
- # we do not use these ends of the pipes in the child
192
- cmd_out_write.close
193
- cmd_err_write.close
194
-
195
- # wait for the cmd to finish executing and acknowledge it's death
196
- ::Process.waitpid(pid)
197
-
198
- # collect stdout, stderr and exitcode
199
- result = {
200
- stdout: cmd_out_read.read,
201
- stderr: cmd_err_read.read,
202
- exit_code: $?.exitstatus,
203
- }
204
-
205
- # We're done with these ends of the pipes as well
206
- cmd_out_read.close
207
- cmd_err_read.close
208
-
209
- # Time to tell the parent about what went down
210
- wr.write Marshal.dump(result)
211
- wr.close
212
-
213
- ::Process.exit!
214
- end
215
- end
216
-
217
- def store
177
+ # Return what this tick has memoized
178
+ #
179
+ # @return [Hash{Symbol => Hash{Integer => Array<String>}, String}] the
180
+ # ps table, or ps's error, by name
181
+ #
182
+ # @api private
183
+ def self.store
218
184
  @store ||= {}
219
185
  end
220
186
 
221
- def reset_data
222
- store.clear unless store.empty?
187
+ # Forget what the last tick memoized, so ps runs again
188
+ #
189
+ # @return [void]
190
+ #
191
+ # @api private
192
+ def self.reset_data
193
+ store.clear
223
194
  end
224
195
 
225
- def ps_axu
226
- # TODO: need a mutex here
227
- store[:ps_axu] ||= begin
228
- # BSD style ps invocation
229
- lines = `ps axo pid,ppid,pcpu,rss,etime,command`.split("\n")
230
-
231
- lines.each_with_object({}) do |line, mem|
232
- chunks = line.split(/\s+/)
233
- chunks.delete_if { |c| c.strip.empty? }
234
- pid = chunks[IDX_MAP[:pid]].strip.to_i
235
- command = chunks.slice!(IDX_MAP[:command], chunks.length).join ' '
236
- chunks[IDX_MAP[:command]] = command
237
- mem[pid] = chunks.flatten
196
+ # Watches run in their own threads, so only let one of them run ps per tick
197
+ #
198
+ # @api private
199
+ PS_MUTEX = Mutex.new
200
+
201
+ # Return this tick's ps table, running ps the first time it's asked for
202
+ #
203
+ # @return [Hash{Integer => Array<String>}] each process's ps fields, by
204
+ # pid, in the order of IDX_MAP
205
+ #
206
+ # @raise [PsError] if ps could not be run this tick
207
+ #
208
+ # @api private
209
+ def self.ps_axu
210
+ ps = PS_MUTEX.synchronize do
211
+ store[:ps_axu] ||= begin
212
+ # POSIX options, so this also works with System V ps on Solaris and SmartOS.
213
+ # Use -A because -e shows the environment on FreeBSD and OpenBSD.
214
+ lines = `ps -Ao pid,ppid,pcpu,rss,etime,args`.split("\n")
215
+
216
+ table = {} #: ps_table
217
+ lines.each do |line|
218
+ chunks = line.split
219
+ pid = chunks.fetch(IDX_MAP.fetch(:pid)).to_i
220
+ command = chunks.slice!(IDX_MAP.fetch(:command), chunks.length) #: Array[String]
221
+ chunks[IDX_MAP.fetch(:command)] = command.join ' '
222
+ table[pid] = chunks
223
+ end
224
+ table
225
+ rescue SystemCallError => e
226
+ # Forking ps can fail, for example with ENOMEM or EAGAIN under memory pressure.
227
+ # Cache the message so ps is not run again until the next tick.
228
+ "Could not run ps: #{e.class}: #{e}"
238
229
  end
239
230
  end
231
+ raise PsError, ps if ps.instance_of?(String)
232
+
233
+ ps
240
234
  end
241
235
 
242
- def parse_elapsed_time(str)
236
+ # Convert ps's elapsed time to seconds
237
+ #
238
+ # @param [String, nil] str an elapsed time, [[dd-]hh:]mm:ss
239
+ #
240
+ # @return [Integer] the seconds, or 0 if str isn't an elapsed time
241
+ #
242
+ # @api private
243
+ def self.parse_elapsed_time(str)
243
244
  # [[dd-]hh:]mm:ss
244
- if str =~ /(?:(?:(\d+)-)?(\d\d):)?(\d\d):(\d\d)/
245
- days = (Regexp.last_match[1] || 0).to_i
246
- hours = (Regexp.last_match[2] || 0).to_i
247
- mins = Regexp.last_match[3].to_i
248
- secs = Regexp.last_match[4].to_i
245
+ match = /(?:(?:(\d+)-)?(\d\d):)?(\d\d):(\d\d)/.match(str)
246
+ if match
247
+ days = match[1].to_i
248
+ hours = match[2].to_i
249
+ mins = match[3].to_i
250
+ secs = match[4].to_i
249
251
  ((days * 24 + hours) * 60 + mins) * 60 + secs
250
252
  else
251
253
  0
252
254
  end
253
255
  end
254
-
255
- # be sure to call this from a fork otherwise it will modify the attributes
256
- # of the bluepill daemon
257
- def drop_privileges(uid, gid, supplementary_groups)
258
- return unless ::Process::Sys.geteuid.zero?
259
- uid_num = Etc.getpwnam(uid).uid if uid
260
- gid_num = Etc.getgrnam(gid).gid if gid
261
- supplementary_groups ||= []
262
- group_nums = supplementary_groups.collect do |group|
263
- Etc.getgrnam(group).gid
264
- end
265
- ::Process.groups = [gid_num] if gid
266
- ::Process.groups |= group_nums unless group_nums.empty?
267
- ::Process::Sys.setgid(gid_num) if gid
268
- ::Process::Sys.setuid(uid_num) if uid
269
- ENV['HOME'] = Etc.getpwuid(uid_num).try(:dir) || ENV['HOME'] if uid
270
- end
271
-
272
- def can_write_pid_file(pid_file, logger)
273
- FileUtils.touch(pid_file)
274
- File.unlink(pid_file)
275
- true
276
- rescue => e
277
- logger.warning(format('%s - %s', e.class.name, e.message))
278
- e.backtrace.each { |l| logger.warning l }
279
- false
280
- end
281
-
282
- def redirect_io(io_in, io_out, io_err)
283
- $stdin.reopen(io_in) if io_in
284
-
285
- if !io_out.nil? && !io_err.nil? && io_out == io_err
286
- $stdout.reopen(io_out, APPEND_MODE)
287
- $stderr.reopen($stdout)
288
-
289
- else
290
- $stdout.reopen(io_out, APPEND_MODE) if io_out
291
- $stderr.reopen(io_err, APPEND_MODE) if io_err
292
- end
293
- end
294
256
  end
295
257
  end
258
+
259
+ require 'bluepill/system/commands'
@@ -1,16 +1,76 @@
1
1
  module Bluepill
2
+ # Reacts to a process's state transitions, as one of a process's checks
3
+ #
4
+ # A config names a trigger the way it names a check, by its underscored
5
+ # class name: `checks :flapping` makes a Triggers::Flapping. Subclasses
6
+ # register themselves by that name, and implement notify.
7
+ #
8
+ # @api private
2
9
  class Trigger
3
10
  @implementations = {}
11
+
12
+ # Register a subclass by its underscored name, such as :flapping
13
+ #
14
+ # @param [Class] klass the subclass
15
+ #
16
+ # @return [void]
17
+ #
18
+ # @api private
4
19
  def self.inherited(klass)
20
+ super
5
21
  @implementations[klass.name.split('::').last.underscore.to_sym] = klass
6
22
  end
7
23
 
24
+ # Return the trigger class a config names
25
+ #
26
+ # @param [Symbol] name the trigger's underscored name, such as :flapping
27
+ #
28
+ # @return [Class, nil] the Trigger subclass, or nil when no trigger has
29
+ # that name (so the name is a check's)
30
+ #
31
+ # @api private
8
32
  def self.[](name)
9
33
  @implementations[name]
10
34
  end
11
35
 
12
- attr_accessor :process, :logger, :mutex, :scheduled_events
36
+ # Return the process the trigger watches
37
+ #
38
+ # @return [Process] the process
39
+ #
40
+ # @api private
41
+ attr_accessor :process
42
+
43
+ # Return the logger the trigger writes to
44
+ #
45
+ # @return [Logger] the process's logger
46
+ #
47
+ # @api private
48
+ attr_accessor :logger
49
+
50
+ # Return the lock that guards the scheduled events
51
+ #
52
+ # @return [Thread::Mutex] the lock
53
+ #
54
+ # @api private
55
+ attr_accessor :mutex
13
56
 
57
+ # Return the events the trigger scheduled, each with its thread
58
+ #
59
+ # @return [Array<Array(Symbol, Thread)>] each event and the thread that
60
+ # dispatches it
61
+ #
62
+ # @api private
63
+ attr_accessor :scheduled_events
64
+
65
+ # Create a trigger for a process
66
+ #
67
+ # @param [Process] process the process to watch
68
+ # @param [Hash{Symbol => Object}] options the trigger's options, with
69
+ # :logger
70
+ #
71
+ # @return [void]
72
+ #
73
+ # @api private
14
74
  def initialize(process, options = {})
15
75
  self.process = process
16
76
  self.logger = options[:logger]
@@ -18,40 +78,76 @@ module Bluepill
18
78
  self.scheduled_events = []
19
79
  end
20
80
 
81
+ # Cancel the events the trigger scheduled, as when the user unmonitors
82
+ #
83
+ # @return [void]
84
+ #
85
+ # @api private
21
86
  def reset!
22
87
  cancel_all_events
23
88
  end
24
89
 
90
+ # React to a state transition, before it happens
91
+ #
92
+ # @param [StateMachine::Transition] _transition the transition
93
+ #
94
+ # @return [void]
95
+ #
96
+ # @raise [RuntimeError] unless a subclass implements it
97
+ #
98
+ # @api private
25
99
  def notify(_transition)
26
- fail 'Implement in subclass'
100
+ raise 'Implement in subclass'
27
101
  end
28
102
 
103
+ # Send an event to the process, with the trigger's name as the reason
104
+ #
105
+ # @param [Symbol] event the event, such as :start
106
+ #
107
+ # @return [Boolean] whether the process made the transition
108
+ #
109
+ # @api private
29
110
  def dispatch!(event)
30
111
  process.dispatch!(event, self.class.name.split('::').last)
31
112
  end
32
113
 
114
+ # Dispatch an event after a delay, in a thread of its own
115
+ #
116
+ # @param [Symbol] event the event, such as :start
117
+ # @param [Numeric] delay the seconds to wait first
118
+ #
119
+ # @return [Thread] the thread that waits and dispatches
120
+ #
121
+ # @api private
33
122
  def schedule_event(event, delay)
34
- # TODO: maybe wrap this in a ScheduledEvent class with methods like cancel
35
- thread = Thread.new(self) do |trigger|
36
- begin
37
- sleep delay.to_f
38
- trigger.dispatch!(event)
39
- trigger.mutex.synchronize do
40
- trigger.scheduled_events.delete_if { |_, t| t == Thread.current }
123
+ mutex.synchronize do
124
+ thread = Thread.new do
125
+ begin
126
+ sleep delay.to_f
127
+ dispatch!(event)
128
+ mutex.synchronize do
129
+ scheduled_events.delete_if { |_, t| t.equal?(Thread.current) }
130
+ end
131
+ rescue StandardError => e
132
+ logger.err(e)
133
+ logger.err(e.backtrace.to_a.join("\n"))
41
134
  end
42
- rescue StandardError => e
43
- trigger.logger.err(e)
44
- trigger.logger.err(e.backtrace.join("\n"))
45
135
  end
136
+ scheduled_events.push([event, thread])
137
+ thread
46
138
  end
47
-
48
- scheduled_events.push([event, thread])
49
139
  end
50
140
 
141
+ # Cancel every scheduled event, killing its thread
142
+ #
143
+ # @return [void]
144
+ #
145
+ # @api private
51
146
  def cancel_all_events
52
147
  logger.info 'Canceling all scheduled events'
53
148
  mutex.synchronize do
54
- scheduled_events.each { |_, thread| thread.kill }
149
+ scheduled_events.collect(&:last).each(&:kill)
150
+ scheduled_events.clear
55
151
  end
56
152
  end
57
153
  end