enkidu 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6cc70fb3863d61c5a67e5b6089de27b48a1cc1be
4
- data.tar.gz: 7b35191f85577c0fa859ab15912b60eb9979f09f
3
+ metadata.gz: 1703c3004e749c132e16c7dca1eceec5fb30f875
4
+ data.tar.gz: ea4c9081b396051029e64e5429a1a4dbb57b9306
5
5
  SHA512:
6
- metadata.gz: 1cfb5b0739aa65b78667a53f020cc4539ef78cf01f6eaae0f25c9898d164a3a37b6ef0d90cee24305057ff4aab6b3a8fa6b5615ecf23aa6ada11decb4b879b3b
7
- data.tar.gz: 69b02f4e3fb66053fd80b4ec47612d65f79c0009399b1a764db096ac8e9256ebb0626eb01a7dbc472bdbc9f50f8da34b237d98f05c2a9d8810bd7a111b531e55
6
+ metadata.gz: 2829b255bdca0dfe35fc315992493929c29c7d6a8c3c6307d4dc011de8dfef8ba99dd96d1fbea4902ec2f326852582abafd761c0fa96fbdbbf57723b3db1a3f4
7
+ data.tar.gz: 738636e61003210c68cb3cd43eb317ce8938561ab1bdef0f5b9e768144766f453fe0e7eea9045d651300afb45b1b9f5a7301fdf6cda2f8e3a08c5217338305c5
@@ -26,46 +26,113 @@ module Enkidu
26
26
  # d.run #Blocks
27
27
  class Dispatcher
28
28
 
29
- STOP = Object.new
30
- k=self;STOP.define_singleton_method(:inspect){ "<#{k.name}::STOP>" }
29
+ class StateError < StandardError; end
31
30
 
32
- def initialize
31
+ RUNNING = :running
32
+ STOPPED = :stopped
33
+
34
+ class STOP
35
+ attr_reader :callback, :cleanup
36
+ alias cleanup? cleanup
37
+ def initialize(callback:nil, cleanup:true)
38
+ @callback = callback
39
+ @cleanup = cleanup
40
+ end
41
+ def callable?
42
+ !!callback
43
+ end
44
+ def call(*a)
45
+ @callback && @callback.call(*a)
46
+ end
47
+ end
48
+
49
+ attr_reader :state
50
+
51
+
52
+ def initialize(&b)
33
53
  @lock = Mutex.new
34
54
  @queue = []
35
55
  @handlers = []
56
+ @plugins = []
57
+ @handler_serial = -1
36
58
  @r, @w = IO.pipe
37
- yield self if block_given?
59
+ @state = STOPPED
60
+ if b
61
+ if b.arity.nonzero?
62
+ yield self
63
+ else
64
+ instance_eval(&b)
65
+ end
66
+ end
67
+ end
68
+
69
+
70
+ def running?
71
+ state == RUNNING
72
+ end
73
+
74
+ def stopped?
75
+ state == STOPPED
38
76
  end
39
77
 
40
78
 
41
79
  # Run the loop. This will block the current thread until the loop is stopped.
42
80
  def run
81
+ raise StateError, "Dispatcher is already running" if running?
82
+ @state = RUNNING
83
+ value = nil
84
+ plugins.each do |plugin|
85
+ plugin.run if plugin.respond_to?(:run)
86
+ end
43
87
  loop do
44
88
  IO.select [@r]
45
- if vals = sync{ queue.shift }
46
- sync{ @r.read(1) }
89
+ if vals = sync{ @r.read(1); queue.shift }
47
90
  callable, args = *vals
48
- if callable == STOP
91
+ if callable.is_a?(STOP)
92
+ if callable.cleanup?
93
+ plugins.each do |plugin|
94
+ plugin.stop if plugin.respond_to?(:stop)
95
+ end
96
+ end
97
+ if callable.callable?
98
+ value = callable.call(*args)
99
+ end
100
+ @state = STOPPED
49
101
  break
50
102
  else
51
- callable.call(*args)
103
+ value = callable.call(*args)
52
104
  end
53
105
  end
54
106
  end
107
+
108
+ value
109
+ end
110
+
111
+ # Runs the loop in the same manner as `run`, but will only execute any already
112
+ # scheduled (as in scheduled before the call to `run_once`) callables and then stop.
113
+ #
114
+ # d = Dispatcher.new
115
+ # d.schedule{ puts "hello" }
116
+ # d.run_once #Runs the 1 scheduled callable above and returns
117
+ def run_once(*args, **kwargs, &b)
118
+ schedule_stop(*args, callable: b, **kwargs)
119
+ run
55
120
  end
56
121
 
57
122
 
58
123
  # Schedule a callable to be run. This will push the callable to the back of
59
124
  # the queue, so anything scheduled before it will run first.
125
+ # TODO: Allow kwargs to be passed to callable
60
126
  #
61
127
  # schedule{ puts "I have been called" }
62
128
  # callable = ->(arg){ p arg }
63
129
  # schedule('an argument', callable: callable)
64
- def schedule(*args, callable: nil, &b)
130
+ def schedule(*args, callable: nil, **kwargs, &b)
131
+ args << kwargs
65
132
  callable = callable(callable, b)
66
133
  sync do
67
134
  queue.push [callable, args]
68
- @w.write '.'
135
+ @w.write '.' #TODO Figure out what to do when this blocks (pipe is full); lock will not be released
69
136
  end
70
137
  end
71
138
  alias push schedule
@@ -74,7 +141,8 @@ module Enkidu
74
141
  #
75
142
  # schedule{ puts "Hey, that's not nice :(" }
76
143
  # unshift{ puts "Cutting in line" }
77
- def unshift(*args, callable: nil, &b)
144
+ def unshift(*args, callable: nil, **kwargs, &b)
145
+ args << kwargs
78
146
  callable = callable(callable, b)
79
147
  sync do
80
148
  queue.unshift [callable, args]
@@ -82,18 +150,83 @@ module Enkidu
82
150
  end
83
151
  end
84
152
 
153
+ # Schedule multiple callables at once
154
+ #
155
+ # Takes an array of [callable, args, position] arrays. args defaults to [], position to :back
156
+ # All callables are guaranteed to be added to the scheduler atomically; that is, neither of
157
+ # the passed callables nor any already scheduled callables will run until they have all been
158
+ # scheduled.
159
+ #
160
+ # The array bundles are processed in the order they appear in the array, so each callable
161
+ # will be added either at the back (default) or the front in that order.
162
+ #
163
+ # # Note: the :back here is unnecessary:
164
+ # schedule_multiple([[->(msg){ puts msg }, ['hello'], :back], [->{ puts "I will run first" }, [], :front], [->{ puts "I will run last" }, []]])
165
+ def schedule_multiple(bundles)
166
+ sync do
167
+ bundles.each do |callable, args=[], position=:back|
168
+ if position == :front
169
+ queue.unshift [callable, args]
170
+ else
171
+ queue.push [callable, args]
172
+ end
173
+ @w.write '.'
174
+ end
175
+ end
176
+ end
177
+
178
+
179
+ def schedule_stop(*args, callable: nil, **kwargs, &b)
180
+ schedule(*args, callable: STOP.new(callback: callable || b), **kwargs)
181
+ end
182
+
183
+ def unshift_stop(*args, callable: nil, cleanup: true, **kwargs, &b)
184
+ unshift(*args, callable: STOP.new(callback: callable || b, cleanup: cleanup), **kwargs)
185
+ end
186
+
85
187
 
86
188
  # Stop the dispatcher. This schedules a special STOP signal that will stop the
87
189
  # dispatcher when encountered. This means that all other items that were scheduled
88
190
  # before it will run first.
89
- def stop
90
- schedule(callable: STOP)
191
+ #
192
+ # This action is idempotent; it returns true if the dispatcher is currently running
193
+ # and will be stopped, false if it's already stopped.
194
+ #
195
+ # A callable can be provided either in the form of the callable: kwarg or a block,
196
+ # which will be called after the dispatcher has stopped (also if dispatcher is already stopped).
197
+ #
198
+ # All attached plugins will have their `stop` method called before shutdown.
199
+ def stop(*args, callable: nil, **kwargs, &b)
200
+ callable ||= b
201
+ if stopped?
202
+ callable && callable.call(*args, **kwargs)
203
+ false
204
+ else
205
+ schedule_stop(*args, callable: callable, **kwargs)
206
+ true
207
+ end
91
208
  end
92
209
 
93
- # Stop the dispatcher immediately by scheduling it at the front of the queue. This
94
- # means that any other already scheduled items will be ignored.
95
- def stop!
96
- unshift(callable: STOP)
210
+ # Stop the dispatcher immediately by scheduling the stop action at the front of the
211
+ # queue. This means that any other already scheduled items will be ignored.
212
+ #
213
+ # This action is idempotent; it returns true if the dispatcher is currently running
214
+ # and will be stopped, false if it's already stopped.
215
+ #
216
+ # A callable can be provided either in the form of the `callable` option or a block,
217
+ # which will be called after the dispatcher has stopped (also if dispatcher is already stopped).
218
+ #
219
+ # If the `cleanup` option is true, all attached plugins will have their `stop` method
220
+ # called before shutdown.
221
+ def stop!(*args, callable: nil, cleanup: false, **kwargs, &b)
222
+ callable ||= b
223
+ if stopped?
224
+ callable && callable.call(*args, **kwargs)
225
+ false
226
+ else
227
+ unshift_stop(*args, callable: callable, cleanup: cleanup, **kwargs)
228
+ true
229
+ end
97
230
  end
98
231
 
99
232
 
@@ -107,14 +240,19 @@ module Enkidu
107
240
  # signal ['foo', 'bar', 'baz'], arg1, arg2 #Same as above
108
241
  def signal(type, *args)
109
242
  type = type.join('.') if Array === type
243
+ scheduled_handlers = []
244
+ #puts "SIGNAL: #{type}, #{handlers.size} handlers"
110
245
  0.upto(handlers.size - 1).each do |index|
111
246
  if vals = sync{ handlers[index] }
112
- regex, handler = *vals
247
+ id, regex, handler = *vals
248
+ #puts "HANDLER: #{regex.inspect} =~ #{type} => #{!!(regex =~ type)} -> #{handler.inspect}"
113
249
  if regex =~ type
114
- schedule(*args, callable: handler)
250
+ schedule(type, *args, callable: handler)
251
+ scheduled_handlers << id
115
252
  end
116
253
  end#if vals
117
254
  end#each
255
+ scheduled_handlers
118
256
  end
119
257
 
120
258
 
@@ -135,24 +273,72 @@ module Enkidu
135
273
  #
136
274
  # * An Array: The elements of the array are joined with a '.', and the
137
275
  # resulting string is used as above.
276
+ #
277
+ # Returns a unique ID which can be used to deregister the handler from the
278
+ # dispatcher with `remove_handler`.
138
279
  def add_handler(type, callable=nil, &b)
139
280
  callable = callable(callable, b)
140
281
  regex = regex_for(type)
141
282
  sync do
142
- handlers << [regex, callable]
283
+ id = @handler_serial+=1
284
+ handlers << [id, regex, callable]
285
+ id
143
286
  end
144
287
  end
288
+ alias subscribe add_handler
145
289
  alias on add_handler
146
290
 
147
291
 
148
- def add(source, name=nil)
149
- source = source.new(self) if Class === source
292
+ def remove_handler(id)
293
+ index = handlers.index{|i,*| i == id }
294
+ sync{ handlers.delete_at(index) }
295
+ end
296
+ alias unsubscribe remove_handler
297
+
298
+
299
+ # Add a plugin to this dispatcher. Plugins are objects that attach themselves
300
+ # to the dispatcher during its lifecycle and listen for or send events. Examples
301
+ # of plugins are SignalSource, LogSource and LogSink, that use the dispatcher to
302
+ # listen for and dispatch interrupt signals and log messages.
303
+ #
304
+ # `plugin` can be any object. It can also be a class, in which case its `new` method
305
+ # will be called with the dispatcher as the argument. It is not required to add objects
306
+ # that interact with the scheduler using this method, but doing so has some advantages:
307
+ #
308
+ # * If a second argument is provided, an accessor to the object will be available
309
+ # on the dispatcher with the name provided:
310
+ #
311
+ # dispatcher.use(SignalSource, :signals)
312
+ # dispatcher.signals.on_int{ puts "Got INT"; dispatcher.stop }
313
+ #
314
+ # * Any object registered in this way will have its `run` and `stop` methods called
315
+ # when the dispatcher starts and stops to do initialization/cleanup. The `run`
316
+ # method will also be called when a plugin is added if the dispatcher is already
317
+ # running.
318
+ #
319
+ # class PingPong
320
+ # def initialize(d)
321
+ # @dispatcher = d
322
+ # end
323
+ # def run
324
+ # @handler_id = @dispatcher.on('ping'){ @dispatcher.signal('pong') }
325
+ # end
326
+ # def stop
327
+ # @dispatcher.remove_handler(@handler_id)
328
+ # end
329
+ # end
330
+ #TODO rename -> `use`
331
+ def add(plugin, name=nil)
332
+ plugin = plugin.new(self) if plugin.is_a?(Class)
150
333
  sync do
334
+ plugins << plugin
151
335
  define_singleton_method name do
152
- source
336
+ plugin
153
337
  end if name
154
338
  end
339
+ plugin.run if running? && plugin.respond_to?(:run)
155
340
  end
341
+ alias use add
156
342
 
157
343
 
158
344
  def self.run(*a, &b)
@@ -170,7 +356,7 @@ module Enkidu
170
356
  # ['foo', '*', 'baz'] => /\Afoo\.[^\.]+\.baz\Z/ #foo.*.baz
171
357
  # ['foo', '#', 'baz'] => /\Afoo\..*?\.baz\Z/ #foo.#.baz
172
358
  #
173
- # A string will be split('.') first, and a RegExp returned as-is.
359
+ # A string will be split('.') first, and a Regexp returned as-is.
174
360
  def regex_for(pattern)
175
361
  return pattern if Regexp === pattern
176
362
  pattern = pattern.split('.') if String === pattern
@@ -178,10 +364,19 @@ module Enkidu
178
364
  source = ''
179
365
  pattern.each_with_index do |part, index|
180
366
  if part == '*'
181
- source << '\\.' unless index == 0
182
- source << '[^\.]+'
367
+ if index == 0
368
+ source << '[^\.]+'
369
+ else
370
+ source << '\\.[^\.]+'
371
+ end
183
372
  elsif part == '#'
184
- source << '.*?' # .*? ?
373
+ if index == 0
374
+ source << '.*?'
375
+ elsif index == pattern.size-1
376
+ source << "\\Z|\\A#{source}\\..*"
377
+ else
378
+ source << '\\..*?'
379
+ end
185
380
  else
186
381
  source << '\\.' unless index == 0
187
382
  source << part
@@ -206,6 +401,10 @@ module Enkidu
206
401
  @queue
207
402
  end
208
403
 
404
+ def plugins
405
+ @plugins
406
+ end
407
+
209
408
  def callable(*cs)
210
409
  cs.each do |c|
211
410
  return c if c
@@ -213,6 +412,19 @@ module Enkidu
213
412
  raise ArgumentError, "No callable detected"
214
413
  end
215
414
 
415
+ # Called from inside the loop to execute a callable. This method is extracted only to
416
+ # avoid repetition; it's not to be used for other purposes.
417
+ def run_callable(callable, args)
418
+ if callable.is_a?(STOP)
419
+ @state = STOPPED
420
+ callable.call(*args)
421
+ true #Loop should be stopped
422
+ else
423
+ callable.call(*args)
424
+ false #Loop should continue
425
+ end
426
+ end
427
+
216
428
  end
217
429
 
218
430
 
@@ -220,18 +432,35 @@ module Enkidu
220
432
 
221
433
  class ThreadedDispatcher < Dispatcher
222
434
 
435
+ attr_reader :thread
436
+
437
+
223
438
  def run
439
+ running = false
440
+ unshift{ running = true }
224
441
  @thread = Thread.new do
442
+ Thread.current.abort_on_exception = true
225
443
  super
226
444
  end
227
- @thread.abort_on_exception = true
445
+ sleep 0.01 until running #Block current thread until scheduler thread is up and running
228
446
  @thread
229
447
  end
230
448
 
231
- def join
232
- @thread.join
449
+
450
+ def join(*a)
451
+ @thread.join(*a)
233
452
  end
234
453
 
454
+ def value
455
+ @thread.value
456
+ end
457
+
458
+ def wait(*a)
459
+ stop
460
+ join(*a)
461
+ end
462
+
463
+
235
464
  end#class ThreadedDispatcher
236
465
 
237
466
 
@@ -1,5 +1,7 @@
1
1
  require 'enkidu/dispatcher'
2
2
  require 'enkidu/tools'
3
+ require 'securerandom'
4
+ require 'time'
3
5
 
4
6
  module Enkidu
5
7
 
@@ -17,28 +19,34 @@ module Enkidu
17
19
  end
18
20
 
19
21
 
20
- def log(atts)
21
- atts = Enkidu.deep_merge(defaults, atts)
22
- path = atts[:type] || atts['type'] || 'log'
23
- dispatcher.signal(path, atts)
22
+ def log(*args)
23
+ signal attify(args)
24
24
  end
25
25
 
26
- def info(atts)
27
- log(Enkidu.deep_merge({tags: ['INFO']}, atts))
26
+ def info(*args)
27
+ atts = attify(args)
28
+ atts[:tags].unshift('INFO')
29
+ signal atts
28
30
  end
29
31
 
30
- def error(atts)
31
- log(Enkidu.deep_merge({tags: ['ERROR']}, atts))
32
+ def error(*args)
33
+ atts = attify(args)
34
+ atts[:tags].unshift('ERROR')
35
+ signal atts
32
36
  end
33
37
 
34
- def exception(e)
35
- atts = {tags: ['ERROR', 'EXCEPTION'], message: "#{e.class}: #{e.message}"}
36
- atts[:exception] = {type: e.class.name, message: e.message, stacktrace: e.backtrace}
38
+ def exception(e, *args)
39
+ atts = attify(args)
40
+ atts[:tags].unshift('EXCEPTION')
41
+ atts[:tags].unshift('ERROR')
42
+
43
+ atts[:message] ||= "#{e.class}: #{e.message}"
44
+ atts[:data][:exception] = {type: e.class.name, message: e.message, stacktrace: e.backtrace}
37
45
  if e.respond_to?(:cause) && e.cause
38
- atts[:exception][:cause] = {type: e.cause.class.name, message: e.cause.message, stacktrace: e.cause.backtrace}
46
+ atts[:data][:exception][:cause] = {type: e.cause.class.name, message: e.cause.message, stacktrace: e.cause.backtrace}
39
47
  end
40
48
 
41
- log atts
49
+ signal atts
42
50
  end
43
51
 
44
52
  def tail(pattern='#', &b)
@@ -48,6 +56,29 @@ module Enkidu
48
56
 
49
57
  private
50
58
 
59
+ def signal(atts)
60
+ dispatcher.signal(atts[:type], atts)
61
+ end
62
+
63
+ def attify(args)
64
+ aa = if args[0].is_a?(String)
65
+ args[1] ? {message: args[0]}.merge(args[1]) : {message: args[0]}
66
+ else
67
+ args[0] ? args[0].dup : {}
68
+ end
69
+ if aa[:atts]
70
+ if aa[:attributes]
71
+ aa[:attributes].update(aa.delete(:atts))
72
+ else
73
+ aa[:attributes] = aa.delete(:atts)
74
+ end
75
+ end
76
+ Enkidu.deep_merge(
77
+ Enkidu.deep_merge(defaults, {id: SecureRandom.uuid, time: Time.now.utc, type: 'log', tags: [], attributes: {}, data: {}}),
78
+ aa
79
+ )
80
+ end
81
+
51
82
  def dispatcher
52
83
  @dispatcher
53
84
  end
@@ -60,24 +91,152 @@ module Enkidu
60
91
 
61
92
  class LogSink
62
93
 
94
+
95
+
96
+
97
+
98
+ class DefaultFormatter
99
+
100
+
101
+ def initialize
102
+ end
103
+
104
+
105
+ def tags(msg)
106
+ return '[] ' unless msg[:tags]
107
+ "[#{msg[:tags].map{|t| escape_tag(t) }.join(', ')}] "
108
+ end
109
+
110
+
111
+ def attributes(msg)
112
+ return '{} ' unless msg[:attributes]
113
+ "{#{msg[:attributes].map{|k,v| "#{escape_attr k}=#{escape_attr v}" }.join(', ')}} "
114
+ end
115
+
116
+
117
+ def message(msg)
118
+ "#{msg[:message]}" +
119
+ if e = msg[:data][:exception]
120
+ "\n #{e[:type]}: #{e[:message]}\n#{e[:stacktrace].map{|l| " #{l}" }.join("\n")}"
121
+ else
122
+ ''
123
+ end
124
+ end
125
+
126
+
127
+ def timestamp(msg)
128
+ msg[:time].getutc.iso8601(3)
129
+ end
130
+
131
+
132
+ def call(msg)
133
+ id = msg[:id].split('-')[0]
134
+ "<#{id} #{timestamp msg}> " + tags(msg) + attributes(msg) + message(msg) + " </#{id}>"
135
+ end
136
+
137
+
138
+ def escape_tag(str)
139
+ "#{str}".gsub(/([\[\],])/, '\\\\\\1')
140
+ end
141
+
142
+ def escape_attr(str)
143
+ "#{str}".gsub(/([\{\}=,])/, '\\\\\\1')
144
+ end
145
+
146
+ end#class DefaultFormatter
147
+
148
+
149
+
150
+
151
+ class HumanFormatter
152
+
153
+ def tags(m)
154
+ return '' unless m[:tags] && m[:tags].any?
155
+ tags_separator(m) + m[:tags].map{|t| tag(t) }.join(', ')
156
+ end
157
+
158
+ def tag(t)
159
+ if ['ERROR', 'EXCEPTION'].include?(t)
160
+ c [1, 91], t
161
+ else
162
+ c 1, t
163
+ end
164
+ end
165
+
166
+ def tags_separator(m)
167
+ " #{c 96, '❯❯'} "
168
+ end
169
+
170
+
171
+ def atts(m)
172
+ return '' unless m[:attributes] && m[:attributes].any?
173
+ atts_separator(m) + c(37, m[:attributes].map{|k,v| "#{k}: #{v}" }.join(', '))
174
+ end
175
+
176
+ def atts_separator(m)
177
+ " #{c 96, '❯❯'} "
178
+ end
179
+
180
+
181
+ def timestamp(m)
182
+ m[:time].getlocal.strftime('%H:%M:%S')
183
+ end
184
+
185
+
186
+ def message(m)
187
+ " ❯❯ #{m[:message]}" +
188
+ if e = m[:data][:exception]
189
+ "\n #{c 1, e[:type]}: #{e[:message]}\n#{e[:stacktrace].map{|l| " #{l}" }.join("\n")}"
190
+ else
191
+ ''
192
+ end
193
+ end
194
+
195
+
196
+ def call(m)
197
+ timestamp(m) + tags(m) + atts(m) + message(m)
198
+ end
199
+
200
+
201
+ def color(n, s=nil)
202
+ c = Array(n).map{|nn| "\e[0;#{nn}m" }.join
203
+ c << "#{s}\e[0m" if s
204
+ c
205
+ end
206
+ alias c color
207
+
208
+
209
+ def stop
210
+ "\e[0m"
211
+ end
212
+ alias s stop
213
+
214
+
215
+ end#class HumanFormatter
216
+
217
+
218
+
219
+
220
+
63
221
  attr_reader :filter
64
222
 
65
- def initialize(d, io:, filter: 'log.#', formatter: nil)
223
+ def initialize(d, io:, filter: 'log.#', formatter: nil, flush: false)
66
224
  @dispatcher = d
67
225
  @io = io
68
226
  @filter = filter
69
- @formatter = formatter
70
- run
227
+ @formatter = formatter.is_a?(Class) ? formatter.new : formatter
228
+ @flush = io.respond_to?(:flush) ? flush : false
71
229
  end
72
230
 
73
231
  def run
74
- dispatcher.on filter do |msg|
232
+ dispatcher.on filter do |_,msg|
75
233
  log msg
76
234
  end
77
235
  end
78
236
 
79
237
  def log(msg)
80
238
  io.puts format(msg)
239
+ io.flush if flush?
81
240
  end
82
241
 
83
242
  def format(msg)
@@ -85,12 +244,12 @@ module Enkidu
85
244
  end
86
245
 
87
246
  def formatter
88
- @formatter ||= -> msg do
89
- (msg[:tags] ? msg[:tags].map{|t| "[#{t}]" }.join+' ' : '') +
90
- (msg[:atts] ? msg[:atts].map{|k,v| "[#{k}=#{"#{v}"[0,10]}]" }.join+' ' : '') +
91
- "#{msg[:message]}" +
92
- (msg[:exception] ? "\n#{msg[:exception][:type]}: #{msg[:exception][:message]}\n#{msg[:exception][:stacktrace].map{|l| " #{l}" }.join("\n")}" : '')
93
- end
247
+ @formatter ||= DefaultFormatter.new
248
+ end
249
+
250
+
251
+ def flush?
252
+ @flush
94
253
  end
95
254
 
96
255
 
@@ -1,8 +1,138 @@
1
- require 'thread'
2
-
3
1
  module Enkidu
4
2
 
5
3
 
4
+ # SignalTrapper is a generic callback-based signal handling utility. It queues
5
+ # all signals onto a background thread and all callbacks are run, synchronously
6
+ # and serially, on this thread. This means signal handlers will be reentrant;
7
+ # any callbacks registered are guaranteed to finish before any other callback
8
+ # triggered by a signal starts running.
9
+ #
10
+ # Only one SignalTrapper should exist per process, as having multiple risks
11
+ # one overriding the other's signal handlers.
12
+ #
13
+ # st = SignalTrapper.new
14
+ # handler = -> s do
15
+ # puts "Received #{s}, shutting down..."
16
+ # app.stop #Do cleanup and shutdown
17
+ # st.stop #Tell ST to stop
18
+ # end
19
+ # st.register('INT', handler)
20
+ # st.register('TERM', handler)
21
+ # usr1 = st.register('USR1', ->(s){ puts app.stats })
22
+ # st.register('USR2', ->(s){ st.deregister(usr1) })
23
+ #
24
+ # st.join
25
+ class SignalTrapper
26
+
27
+
28
+ class Subscription
29
+ attr_reader :signal, :id
30
+ def initialize(s, i)
31
+ @signal, @id = s, i
32
+ end
33
+ def ==(other)
34
+ if other.is_a?(self.class)
35
+ id == other.id
36
+ else
37
+ id == other
38
+ end
39
+ end
40
+ end
41
+
42
+ SIGNALS = Signal.list
43
+
44
+
45
+ def initialize
46
+ @q = []
47
+ @r, @w = IO.pipe
48
+ @trapped_signals = []
49
+ @callbacks = Hash.new{|h,k| h[k] = [] }
50
+ @callback_serial = -1
51
+ @thread = Thread.new do
52
+ loop do
53
+ IO.select [@r]
54
+ sig = @q.shift
55
+ break if sig == :stop
56
+ @r.read 1
57
+ @callbacks[sig].each do |id, callable|
58
+ callable.call(sig)
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+
65
+ # Register a callable for a specific signal. The callback will be scheduled
66
+ # on the handler thread when the signal is received.
67
+ #
68
+ # Returns an ID which can be used to `deregister` the callback
69
+ def register(sig, callable)
70
+ sig = self.class.normalize_signal(sig)
71
+ trap sig
72
+ id = Subscription.new(sig, @callback_serial += 1)
73
+ @callbacks[sig] << [id, callable]
74
+ id
75
+ end
76
+
77
+ # Deregister a callback using the ID returned by `register`
78
+ def deregister(id)
79
+ @callbacks[id.signal].delete_if{|i, _c| i == id }
80
+ end
81
+
82
+
83
+ # Tell signal handling thread to stop. It will execute any already scheduled
84
+ # handlers and then exit. No more handlers will be executed after that.
85
+ def stop
86
+ @q << :stop
87
+ @w.write '.'
88
+ end
89
+
90
+ # Join the callback execution thread. This will block until the SignalTrapper
91
+ # is told to `stop`.
92
+ def join(*a, &b)
93
+ @thread.join(*a, &b)
94
+ end
95
+
96
+ # `stop` and `join`
97
+ def wait(*a, &b)
98
+ stop
99
+ join(*a, &b)
100
+ end
101
+
102
+
103
+ # Normalize signal name
104
+ #
105
+ # 2 -> INT
106
+ # SIGINT -> INT
107
+ # INT -> INT
108
+ # 123 -> ArgumentError
109
+ def self.normalize_signal(sig)
110
+ if sig.is_a?(Integer) || sig =~ /^\d+$/
111
+ SIGNALS.key(sig.to_i) || raise(ArgumentError, "Unrecognized signal #{sig}")
112
+ elsif sig =~ /^SIG(.+)$/
113
+ $1
114
+ else
115
+ sig
116
+ end
117
+ end
118
+
119
+
120
+ private
121
+
122
+ def trap(sig)
123
+ unless @trapped_signals.include?(sig)
124
+ Signal.trap sig do
125
+ @q << sig
126
+ @w.write '.'
127
+ end
128
+ end
129
+ end
130
+
131
+
132
+ end#class SignalTrapper
133
+
134
+
135
+
6
136
 
7
137
 
8
138
  # A SignalSource will trap signals and put handlers on a Dispatcher's queue instead
@@ -19,38 +149,106 @@ module Enkidu
19
149
  # d.join
20
150
  class SignalSource
21
151
 
22
- SIGNALS = Signal.list
23
152
 
24
153
 
25
- def initialize(dispatcher)
154
+ class ShutdownProcedure
155
+
156
+ attr_reader :signals, :count
157
+
158
+ def initialize(signals, callable)
159
+ @signals = signals
160
+ @handlers = []
161
+ @count = 0
162
+ callable.call(self)
163
+ end
164
+
165
+ def handle(&b)
166
+ @handlers << b
167
+ end
168
+
169
+ def force(status=false, &b)
170
+ @force = {status: status, callback: b}
171
+ end
172
+
173
+ def call(signal)
174
+ @count += 1
175
+
176
+ if handler = @handlers.shift
177
+ handler.call(signal, count)
178
+ end#if handler
179
+
180
+ if @handlers.empty? && @force
181
+ signals.each do |signal|
182
+ Signal.trap signal do
183
+ begin
184
+ @force[:callback].call(signal) if @force[:callback]
185
+ rescue => e
186
+ warn "Exception while running force handler: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
187
+ ensure
188
+ exit @force[:status]
189
+ end
190
+ end#Signal.trap
191
+ end#signals.each
192
+ end#if @handlers.empty?
193
+ end
194
+
195
+ end
196
+
197
+
198
+
199
+ SIGNALS = SignalTrapper::SIGNALS
200
+
201
+ attr_reader :trapper
202
+
203
+
204
+ def self.trapper
205
+ @trappers ||= {}
206
+ @trappers[$$] ||= SignalTrapper.new
207
+ end
208
+
209
+
210
+ def initialize(dispatcher, trapper: self.class.trapper)
26
211
  @dispatcher = dispatcher
27
- @q = Queue.new
28
- run
212
+ @trapper = trapper
213
+ @subscriptions = []
29
214
  end
30
215
 
31
216
  def run
32
- Thread.new do
33
- loop do
34
- sig = @q.pop
35
- dispatcher.signal("signal.#{sig}", sig)
36
- end
37
- end
217
+ end
218
+
219
+ def stop
38
220
  end
39
221
 
40
222
  def on(*signals, callable:nil, &b)
41
- signals = signals.map do |signal|
42
- Integer === signal ? Signal.signame(signal) : signal
43
- end
44
- signals.each do |signal|
45
- dispatcher.on("signal.#{signal}", callable || b)
223
+ subscriptions = signals.map do |signal|
224
+ dispatcher.on("signal.#{normalize signal}", callable || b)
46
225
  end
47
226
  register *signals
227
+ subscriptions
48
228
  end
49
229
  alias trap on
50
230
 
231
+ def off(*ids)
232
+ ids.each do |id|
233
+ dispatcher.unsubscribe(id)
234
+ end
235
+ end
236
+
237
+ def shutdown(*signals, callable:nil, &b)
238
+ sp = ShutdownProcedure.new(signals, callable || b)
239
+ on(*signals){|_,s| sp.call(s) }
240
+ sp
241
+ end
242
+
51
243
  def register(*signals)
52
244
  signals.each do |signal|
53
- Signal.trap(signal){ @q.push signal } #TODO not reentrant
245
+ signal = normalize(signal)
246
+ unless @subscriptions.any?{|s| s.signal == signal }
247
+ @subscriptions << trapper.register(
248
+ signal,
249
+ ->(sig){ dispatcher.signal("signal.#{signal}", sig) }
250
+ )
251
+ end
54
252
  end
55
253
  end
56
254
 
@@ -67,6 +265,10 @@ module Enkidu
67
265
  @dispatcher
68
266
  end
69
267
 
268
+ def normalize(signal)
269
+ SignalTrapper.normalize_signal(signal)
270
+ end
271
+
70
272
 
71
273
  end#class SignalSource
72
274
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: enkidu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tore Darell
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-05-06 00:00:00.000000000 Z
11
+ date: 2017-01-21 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Enkidu is a process sidekick
14
14
  email: toredarell@gmail.com
@@ -40,7 +40,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
40
40
  version: '0'
41
41
  requirements: []
42
42
  rubyforge_project:
43
- rubygems_version: 2.4.5
43
+ rubygems_version: 2.5.2
44
44
  signing_key:
45
45
  specification_version: 4
46
46
  summary: Enkidu process sidekick