thread_order 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,15 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: b2d68b153c8a232b3fdcb3efc2ec6799abf9e11a
4
- data.tar.gz: 5f340011a9259a523b6fa3dca0494ad8f17887aa
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ ZmFjZjVkN2Y3Y2Y0M2UwMzNiODEwMzM5YWMwZjA2NjBkMzhkZGU2Zg==
5
+ data.tar.gz: !binary |-
6
+ ZThiYjJhMDE4YzFkMzZkNWM2YWIxNzMyOTExNDgyZjJlM2MzYmRjYw==
5
7
  SHA512:
6
- metadata.gz: 6247a9d7f485371a1725e7098a2dde38b4376a822ecfcb6ea7a69c36d8a1abc7d60bded9f12c3c5297bd635c05e4c87c271bc73054f19724068e2f2a62ed979c
7
- data.tar.gz: 988632f09cc15d15fa705e87a561d86b26c8f5f079df692c8d288865cd578674f1db67371e8363e08e66adc59ce778b6bdc3a91ea9e74a9cc7de2f126c828591
8
+ metadata.gz: !binary |-
9
+ NDhhN2IyYzA0Y2UyZmZkZWI5NDU1MDdmYWE0Y2E3ZDE3YTFkZjRlZjgwODIy
10
+ OTIxMjliZDc1NmZkMTU4NjA5OTYzYjliYmI1N2QxNjdhNGVjMTZlZDc5OGFj
11
+ N2UwY2U3ZjQ5ZDdlY2RkMWE4OThjYzNlMjU0MTEwZTc4MjM0NGQ=
12
+ data.tar.gz: !binary |-
13
+ MTI1ZDZkMGE1NGM0MzIzZTdiMGRjMDAyNjJhMTlmNWE4NWY5MWFjOTQyOTg5
14
+ NDJiMTI5YzJhNzE3MTIxNzYzNzRkOGEzMTcwNzQ4ZTBjMTM3YTZiZWVlODk3
15
+ YWRlMjM5YTg3YmY0ZTdmYTVlYjYxYWQ1ZmMwZmUwMDkwM2E0OTQ=
data/.gitignore CHANGED
@@ -1,3 +1,4 @@
1
1
  *.gem
2
2
  Gemfile.lock
3
3
  tmp/
4
+ .rbx
data/Readme.md CHANGED
@@ -9,3 +9,54 @@ Its purpose is to enable reasoning about thread order.
9
9
  * Tested on 1.8.7 - 2.2, JRuby, Rbx
10
10
  * It has no external dependencies
11
11
  * It does not depend on the stdlib.
12
+
13
+ Example
14
+ -------
15
+
16
+ ```ruby
17
+ # A somewhat contrived class we're going to test.
18
+ class MyQueue
19
+ attr_reader :array
20
+
21
+ def initialize
22
+ @array, @mutex = [], Mutex.new
23
+ end
24
+
25
+ def enqueue
26
+ @mutex.synchronize { @array << yield }
27
+ end
28
+ end
29
+
30
+
31
+
32
+ require 'rspec/autorun'
33
+ require 'thread_order'
34
+
35
+ RSpec.describe MyQueue do
36
+ let(:queue) { described_class.new }
37
+ let(:order) { ThreadOrder.new }
38
+ after { order.apocalypse! } # ensure everything gets cleaned up (technically redundant for our one example, but it's a good practice)
39
+
40
+ it 'is threadsafe on enqueue' do
41
+ # will execute in a thread, can be invoked by name
42
+ order.declare :concurrent_enqueue do
43
+ queue.enqueue { :concurrent }
44
+ end
45
+
46
+ # this enqueue will block until the mutex puts the other one to sleep
47
+ queue.enqueue do
48
+ order.pass_to :concurrent_enqueue, resume_on: :sleep
49
+ :main
50
+ end
51
+
52
+ order.join_all # concurrent_enqueue may still be asleep
53
+ expect(queue.array).to eq [:main, :concurrent]
54
+ end
55
+ end
56
+
57
+ # >> MyQueue
58
+ # >> is threadsafe on enqueue
59
+ # >>
60
+ # >> Finished in 0.00131 seconds (files took 0.08687 seconds to load)
61
+ # >> 1 example, 0 failures
62
+ ```
@@ -1,73 +1,102 @@
1
1
  require 'thread_order/mutex'
2
2
 
3
3
  class ThreadOrder
4
+ Error = Class.new RuntimeError
5
+ CannotResume = Class.new Error
6
+
7
+ # Note that this must tbe initialized in a threadsafe environment
8
+ # Otherwise, syncing may occur before the mutex is set
4
9
  def initialize
10
+ @mutex = Mutex.new
5
11
  @bodies = {}
6
12
  @threads = []
7
- @queue = [] # we may not have thread stdlib required, so may not have Queue class
8
- @mutex = Mutex.new
9
- @worker = Thread.new { loop { work } }
10
- @worker.abort_on_exception = true
13
+ @queue = [] # Queue is in stdlib, but half the purpose of this lib is to avoid such deps, so using an array in a Mutex
14
+ @worker = Thread.new do
15
+ Thread.current.abort_on_exception = true
16
+ Thread.current[:thread_order_name] = :internal_worker
17
+ loop { break if :shutdown == work() }
18
+ end
11
19
  end
12
20
 
13
21
  def declare(name, &block)
14
- @bodies[name] = block
22
+ sync { @bodies[name] = block }
15
23
  end
16
24
 
17
25
  def current
18
26
  Thread.current[:thread_order_name]
19
27
  end
20
28
 
21
- def pass_to(name, options)
22
- parent = Thread.current
29
+ def pass_to(name, options={})
23
30
  child = nil
24
- resume_event = extract_resume_event! options
25
- resume_if = lambda do |event|
26
- return unless event == sync { resume_event }
27
- parent.wakeup
28
- end
29
-
31
+ parent = Thread.current
32
+ resume_event = extract_resume_event!(options)
30
33
  enqueue do
31
- child = Thread.new do
32
- enqueue { @threads << child }
33
- sync { resume_event } == :sleep &&
34
- enqueue { watch_for_sleep(child) { resume_if.call :sleep } }
35
- begin
36
- enqueue { resume_if.call :run }
37
- Thread.current[:thread_order_name] = name
38
- @bodies.fetch(name).call
39
- rescue Exception => error
40
- enqueue { parent.raise error }
41
- raise
42
- ensure
43
- enqueue { resume_if.call :exit }
44
- end
34
+ sync do
35
+ @threads << Thread.new {
36
+ child = Thread.current
37
+ child[:thread_order_name] = name
38
+ body = sync { @bodies.fetch(name) }
39
+ wait_until { parent.stop? }
40
+ :run == resume_event && parent.wakeup
41
+ wake_on_sleep = lambda do
42
+ child.status == 'sleep' ? parent.wakeup :
43
+ child.status == nil ? :noop :
44
+ child.status == false ? parent.raise(CannotResume.new "#{name} exited instead of sleeping") :
45
+ enqueue(&wake_on_sleep)
46
+ end
47
+ :sleep == resume_event && enqueue(&wake_on_sleep)
48
+ begin
49
+ body.call parent
50
+ rescue Exception => e
51
+ enqueue { parent.raise e }
52
+ raise
53
+ ensure
54
+ :exit == resume_event && enqueue { parent.wakeup }
55
+ end
56
+ }
45
57
  end
46
58
  end
47
-
48
59
  sleep
49
60
  child
50
61
  end
51
62
 
63
+ def join_all
64
+ sync { @threads }.each { |th| th.join }
65
+ end
66
+
52
67
  def apocalypse!(thread_method=:kill)
53
68
  enqueue do
54
69
  @threads.each(&thread_method)
55
70
  @queue.clear
56
- @worker.kill
71
+ :shutdown
57
72
  end
58
73
  @worker.join
59
74
  end
60
75
 
76
+ def enqueue(&block)
77
+ sync { @queue << block if @worker.alive? }
78
+ end
79
+
80
+ def wait_until(&condition)
81
+ return if condition.call
82
+ thread = Thread.current
83
+ wake_when_true = lambda do
84
+ if thread.stop? && condition.call
85
+ thread.wakeup
86
+ else
87
+ enqueue(&wake_when_true)
88
+ end
89
+ end
90
+ enqueue(&wake_when_true)
91
+ sleep
92
+ end
93
+
61
94
  private
62
95
 
63
96
  def sync(&block)
64
97
  @mutex.synchronize(&block)
65
98
  end
66
99
 
67
- def enqueue(&block)
68
- sync { @queue << block }
69
- end
70
-
71
100
  def work
72
101
  task = sync { @queue.shift }
73
102
  task ||= lambda { Thread.pass }
@@ -78,18 +107,8 @@ class ThreadOrder
78
107
  resume_on = options.delete :resume_on
79
108
  options.any? &&
80
109
  raise(ArgumentError, "Unknown options: #{options.inspect}")
81
- resume_on && ![:run, :exit, :sleep].include?(resume_on) and
110
+ resume_on && ![:run, :exit, :sleep, nil].include?(resume_on) and
82
111
  raise(ArgumentError, "Unknown status: #{resume_on.inspect}")
83
- resume_on
84
- end
85
-
86
- def watch_for_sleep(thread, &cb)
87
- if thread.status == false || thread.status == nil
88
- # noop, dead threads dream no dreams
89
- elsif thread.status == 'sleep'
90
- cb.call
91
- else
92
- enqueue { watch_for_sleep(thread, &cb) }
93
- end
112
+ resume_on || :none
94
113
  end
95
114
  end
@@ -1,21 +1,21 @@
1
- # On > 1.9, this is in core.
2
- # On 1.8.7, it's in the stdlib.
3
- # We don't want to load the stdlib, b/c this is a test tool, and can affect the test environment,
4
- # causing tests to pass where they should fail.
5
- #
6
- # So we're transcribing it here, from.
7
- # It is based on this implementation: https://github.com/ruby/ruby/blob/v1_8_7_374/lib/thread.rb#L56
8
- # If it's not already defined. Some methods we don't need are deleted.
9
- # Anything I don't understand is left in.
10
1
  class ThreadOrder
11
2
  Mutex = if defined? ::Mutex
3
+ # On 1.9 and up, this is in core, so we just use the real one
12
4
  ::Mutex
13
5
  else
6
+
7
+ # On 1.8.7, it's in the stdlib.
8
+ # We don't want to load the stdlib, b/c this is a test tool, and can affect the test environment,
9
+ # causing tests to pass where they should fail.
10
+ #
11
+ # So we're transcribing/modifying it from https://github.com/ruby/ruby/blob/v1_8_7_374/lib/thread.rb#L56
12
+ # Some methods we don't need are deleted.
13
+ # Anything I don't understand (there's quite a bit, actually) is left in.
14
14
  Class.new do
15
15
  def initialize
16
16
  @waiting = []
17
17
  @locked = false;
18
- @waiting.taint # enable tainted comunication
18
+ @waiting.taint
19
19
  self.taint
20
20
  end
21
21
 
@@ -1,3 +1,3 @@
1
1
  class ThreadOrder
2
- VERSION = '1.0.0'
2
+ VERSION = '1.1.0'
3
3
  end
data/spec/run CHANGED
@@ -34,25 +34,25 @@ get_gem "rspec-core" "https://rubygems.org/downloads/rspec-core-3.2.1.ge
34
34
  get_gem "rspec-support" "https://rubygems.org/downloads/rspec-support-3.2.2.gem" &&
35
35
  get_gem "rspec-expectations" "https://rubygems.org/downloads/rspec-expectations-3.2.0.gem" &&
36
36
  get_gem "rspec-mocks" "https://rubygems.org/downloads/rspec-mocks-3.2.1.gem" &&
37
- get_gem "diff-lcs" "https://rubygems.org/downloads/diff-lcs-1.2.5.gem"
37
+ get_gem "diff-lcs" "https://rubygems.org/downloads/diff-lcs-1.2.5.gem" || exit 1
38
38
 
39
39
 
40
40
  # run specs
41
- cd $project_root
41
+ cd "$project_root"
42
42
 
43
43
  export PATH="$project_root/tmp/rspec-core/exe:$PATH"
44
44
 
45
- opts=""
46
- opts=" -I $project_root/tmp/diff-lcs/lib $opts"
47
- opts=" -I $project_root/tmp/rspec/lib $opts"
48
- opts=" -I $project_root/tmp/rspec-core/lib $opts"
49
- opts=" -I $project_root/tmp/rspec-expectations/lib $opts"
50
- opts=" -I $project_root/tmp/rspec-mocks/lib $opts"
51
- opts=" -I $project_root/tmp/rspec-support/lib $opts"
45
+ opts=()
46
+ opts+=(-I "$project_root/tmp/diff-lcs/lib")
47
+ opts+=(-I "$project_root/tmp/rspec/lib")
48
+ opts+=(-I "$project_root/tmp/rspec-core/lib")
49
+ opts+=(-I "$project_root/tmp/rspec-expectations/lib")
50
+ opts+=(-I "$project_root/tmp/rspec-mocks/lib")
51
+ opts+=(-I "$project_root/tmp/rspec-support/lib")
52
52
 
53
53
  if `ruby -e "exit RUBY_VERSION != '1.8.7'"`
54
54
  then
55
- opts="--disable-gems $opts"
55
+ opts+=(--disable-gems)
56
56
  fi
57
57
 
58
- ruby $opts -S rspec
58
+ ruby "${opts[@]}" -S rspec --colour --fail-fast --format documentation
@@ -45,35 +45,75 @@ RSpec.describe ThreadOrder do
45
45
  order.declare(:t) { Thread.exit }
46
46
  order.pass_to :t, :resume_on => :exit
47
47
  end
48
+
49
+ it 'passes the parent to the thread' do
50
+ parent = nil
51
+ order.declare(:t) { |p| parent = p }
52
+ order.pass_to :t, :resume_on => :exit
53
+ expect(parent).to eq Thread.current
54
+ end
55
+
56
+ it 'sleeps until woken if it does not provide a :resume_on key' do
57
+ order.declare(:t) { |parent|
58
+ order.enqueue {
59
+ expect(parent.status).to eq 'sleep'
60
+ parent.wakeup
61
+ }
62
+ }
63
+ order.pass_to :t
64
+ end
65
+
66
+ it 'blows up if it is waiting on another thread to sleep and that thread exits instead' do
67
+ expect {
68
+ order.declare(:t1) { :exits_instead_of_sleeping }
69
+ order.pass_to :t1, :resume_on => :sleep
70
+ }.to raise_error ThreadOrder::CannotResume, /t1 exited/
71
+ end
72
+ end
73
+
74
+ describe 'error types' do
75
+ it 'has a toplevel lib error: ThreadOrder::Error which is a RuntimeError' do
76
+ expect(ThreadOrder::Error.superclass).to eq RuntimeError
77
+ end
78
+
79
+ specify 'all behavioural errors it raises inherit from ThreadOrder::Error' do
80
+ expect(ThreadOrder::CannotResume.superclass).to eq ThreadOrder::Error
81
+ end
48
82
  end
49
83
 
50
84
  describe 'errors in children' do
51
85
  specify 'are raised in the child' do
52
- child = nil
53
- order.declare(:err) { child = Thread.current; raise 'the roof' }
54
- order.pass_to :err, :resume_on => :exit rescue nil
55
- child.join rescue nil
56
- expect(child.status).to eq nil
86
+ order.declare(:err) { sleep }
87
+ child = order.pass_to :err, :resume_on => :sleep
88
+ begin
89
+ child.raise RuntimeError.new('the roof')
90
+ sleep
91
+ rescue RuntimeError => e
92
+ expect(e.message).to eq 'the roof'
93
+ else
94
+ raise 'expected an error'
95
+ end
57
96
  end
58
97
 
59
98
  specify 'are raised in the parent' do
60
- order.declare(:err) { raise Exception, 'to the rules' }
61
99
  expect {
62
- order.pass_to :err, :resume_on => :run
63
- loop { :noop }
100
+ order.declare(:err) { raise Exception, "to the rules" }
101
+ order.pass_to :err, :resume_on => :exit
102
+ sleep
64
103
  }.to raise_error Exception, 'to the rules'
65
104
  end
66
105
 
67
106
  specify 'even if the parent is asleep' do
107
+ order.declare(:err) { sleep }
68
108
  parent = Thread.current
69
- order.declare(:err) {
70
- :noop until parent.status == 'sleep'
71
- raise 'the roof'
72
- }
109
+ child = order.pass_to :err, :resume_on => :sleep
73
110
  expect {
74
- order.pass_to :err, :resume_on => :run
111
+ order.enqueue {
112
+ expect(parent.status).to eq 'sleep'
113
+ child.raise Exception.new 'to the rules'
114
+ }
75
115
  sleep
76
- }.to raise_error RuntimeError, 'the roof'
116
+ }.to raise_error Exception, 'to the rules'
77
117
  end
78
118
  end
79
119
 
@@ -127,4 +167,87 @@ RSpec.describe ThreadOrder do
127
167
  to raise_error(ArgumentError, /bad_key/)
128
168
  end
129
169
  end
170
+
171
+ describe 'join_all' do
172
+ it 'joins with all the child threads' do
173
+ parent = Thread.current
174
+ children = []
175
+
176
+ order.declare(:t1) do
177
+ order.pass_to :t2, :resume_on => :run
178
+ children << Thread.current
179
+ end
180
+
181
+ order.declare(:t2) do
182
+ children << Thread.current
183
+ end
184
+
185
+ order.pass_to :t1, :resume_on => :run
186
+ order.join_all
187
+ statuses = children.map { |th| th.status }
188
+ expect(statuses).to eq [false, false] # none are alive
189
+ end
190
+ end
191
+
192
+ describe 'synchronization' do
193
+ it 'allows any thread to enqueue work' do
194
+ seen = []
195
+
196
+ order.declare :enqueueing do |parent|
197
+ order.enqueue do
198
+ order.enqueue { seen << 2 }
199
+ order.enqueue { seen << 3 }
200
+ order.enqueue { parent.wakeup }
201
+ seen << 1
202
+ end
203
+ end
204
+
205
+ order.pass_to :enqueueing
206
+ expect(seen).to eq [1, 2, 3]
207
+ end
208
+
209
+ it 'allows a thread to put itself to sleep until some condition is met' do
210
+ i = 0
211
+ increment = lambda do
212
+ i += 1
213
+ order.enqueue(&increment)
214
+ end
215
+ increment.call
216
+ order.wait_until { i > 20_000 } # 100k is too slow on 1.8.7, but 10k is too fast on 2.2.0
217
+ expect(i).to be > 20_000
218
+ end
219
+ end
220
+
221
+ describe 'apocalypse!' do
222
+ it 'kills threads that are still alive' do
223
+ order.declare(:t) { sleep }
224
+ child = order.pass_to :t, :resume_on => :sleep
225
+ expect(child).to receive(:kill).and_call_original
226
+ expect(child).to_not receive(:join)
227
+ order.apocalypse!
228
+ end
229
+
230
+ it 'can be overridden to call a different method than kill' do
231
+ # for some reason, the mock calling original join doesn't work
232
+ order.declare(:t) { sleep }
233
+ child = order.pass_to :t, :resume_on => :run
234
+ expect(child).to_not receive(:kill)
235
+ joiner = Thread.new { order.apocalypse! :join }
236
+ Thread.pass until child.status == 'sleep' # can't use wait_until b/c that occurs within the worker, which is apocalypsizing
237
+ child.wakeup
238
+ joiner.join
239
+ end
240
+
241
+ it 'can call apocalypse! any number of times without harm' do
242
+ order.declare(:t) { sleep }
243
+ order.pass_to :t, :resume_on => :sleep
244
+ 100.times { order.apocalypse! }
245
+ end
246
+
247
+ it 'does not enqueue events after the apocalypse' do
248
+ order.apocalypse!
249
+ thread = Thread.current
250
+ order.enqueue { thread.raise "Should not happen" }
251
+ end
252
+ end
130
253
  end
@@ -1,11 +1,11 @@
1
- require_relative 'lib/thread_order/version'
1
+ require File.expand_path('../lib/thread_order/version', __FILE__)
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'thread_order'
5
5
  s.version = ThreadOrder::VERSION
6
6
  s.licenses = ['MIT']
7
7
  s.summary = "Test helper for ordering threaded code"
8
- s.description = "Test helper for ordering threaded code (does not depend on gems or stdlib, tested on 1.8.7 - 2.2)."
8
+ s.description = "Test helper for ordering threaded code (does not depend on gems or stdlib, tested on 1.8.7 - 2.2, rbx, jruby)."
9
9
  s.authors = ["Josh Cheek"]
10
10
  s.email = 'josh.cheek@gmail.com'
11
11
  s.files = `git ls-files`.split("\n")
metadata CHANGED
@@ -1,38 +1,38 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thread_order
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Cheek
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-03-08 00:00:00.000000000 Z
11
+ date: 2015-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ~>
18
18
  - !ruby/object:Gem::Version
19
19
  version: '3.0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ~>
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
27
  description: Test helper for ordering threaded code (does not depend on gems or stdlib,
28
- tested on 1.8.7 - 2.2).
28
+ tested on 1.8.7 - 2.2, rbx, jruby).
29
29
  email: josh.cheek@gmail.com
30
30
  executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
- - ".gitignore"
35
- - ".travis.yml"
34
+ - .gitignore
35
+ - .travis.yml
36
36
  - Gemfile
37
37
  - License.txt
38
38
  - Readme.md
@@ -52,17 +52,17 @@ require_paths:
52
52
  - lib
53
53
  required_ruby_version: !ruby/object:Gem::Requirement
54
54
  requirements:
55
- - - ">="
55
+ - - ! '>='
56
56
  - !ruby/object:Gem::Version
57
57
  version: '0'
58
58
  required_rubygems_version: !ruby/object:Gem::Requirement
59
59
  requirements:
60
- - - ">="
60
+ - - ! '>='
61
61
  - !ruby/object:Gem::Version
62
62
  version: '0'
63
63
  requirements: []
64
64
  rubyforge_project:
65
- rubygems_version: 2.4.5
65
+ rubygems_version: 2.4.1
66
66
  signing_key:
67
67
  specification_version: 4
68
68
  summary: Test helper for ordering threaded code