thread_weaver 0.1.0

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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +19 -0
  3. data/.gitignore +59 -0
  4. data/.rspec +3 -0
  5. data/.vscode/settings.json +14 -0
  6. data/CHANGELOG.md +7 -0
  7. data/Gemfile +17 -0
  8. data/LICENSE +21 -0
  9. data/README.md +37 -0
  10. data/Rakefile +13 -0
  11. data/bin/console +15 -0
  12. data/bin/gem_smoke_test +10 -0
  13. data/bin/setup +8 -0
  14. data/examples/always_deadlocks.rb +15 -0
  15. data/examples/takes_a_while.rb +7 -0
  16. data/examples/thread_safe_nonblocking_run_at_most_once.rb +23 -0
  17. data/examples/thread_safe_run_at_most_once.rb +19 -0
  18. data/examples/thread_unsafe_run_at_most_once.rb +17 -0
  19. data/lib/thread_weaver.rb +15 -0
  20. data/lib/thread_weaver/controllable_thread.rb +177 -0
  21. data/lib/thread_weaver/iterative_race_detector.rb +178 -0
  22. data/lib/thread_weaver/thread_instruction.rb +68 -0
  23. data/lib/thread_weaver/version.rb +6 -0
  24. data/sorbet/config +2 -0
  25. data/sorbet/rbi/gems/ast.rbi +48 -0
  26. data/sorbet/rbi/gems/coderay.rbi +285 -0
  27. data/sorbet/rbi/gems/method_source.rbi +64 -0
  28. data/sorbet/rbi/gems/parallel.rbi +83 -0
  29. data/sorbet/rbi/gems/parser.rbi +1510 -0
  30. data/sorbet/rbi/gems/pry-nav.rbi +29 -0
  31. data/sorbet/rbi/gems/pry.rbi +1965 -0
  32. data/sorbet/rbi/gems/rainbow.rbi +118 -0
  33. data/sorbet/rbi/gems/rake.rbi +645 -0
  34. data/sorbet/rbi/gems/regexp_parser.rbi +920 -0
  35. data/sorbet/rbi/gems/rexml.rbi +589 -0
  36. data/sorbet/rbi/gems/rspec-core.rbi +1893 -0
  37. data/sorbet/rbi/gems/rspec-expectations.rbi +1148 -0
  38. data/sorbet/rbi/gems/rspec-mocks.rbi +1091 -0
  39. data/sorbet/rbi/gems/rspec-support.rbi +280 -0
  40. data/sorbet/rbi/gems/rspec.rbi +15 -0
  41. data/sorbet/rbi/gems/rubocop-ast.rbi +1351 -0
  42. data/sorbet/rbi/gems/rubocop-performance.rbi +471 -0
  43. data/sorbet/rbi/gems/rubocop.rbi +7510 -0
  44. data/sorbet/rbi/gems/ruby-progressbar.rbi +305 -0
  45. data/sorbet/rbi/gems/standard.rbi +141 -0
  46. data/sorbet/rbi/gems/unicode-display_width.rbi +17 -0
  47. data/sorbet/rbi/hidden-definitions/errors.txt +4309 -0
  48. data/sorbet/rbi/hidden-definitions/hidden.rbi +8622 -0
  49. data/sorbet/rbi/sorbet-typed/lib/rainbow/all/rainbow.rbi +276 -0
  50. data/sorbet/rbi/sorbet-typed/lib/rubocop-performance/~>1.6/rubocop-performance.rbi +149 -0
  51. data/sorbet/rbi/todo.rbi +6 -0
  52. data/thread_weaver.gemspec +34 -0
  53. metadata +111 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dd5d72da4e20257fc871fd0871efc9ebd7504cf9275bb9b176428c315c79a072
4
+ data.tar.gz: 6b4b129c96cc1e7f594c73c13a2147e576e404f80abd1c51d0615e84215912f1
5
+ SHA512:
6
+ metadata.gz: 6b1659da15d1c1ae01f231c02926fce500d61ce2401507b18b262e5a3fc5ddecbc327d1f9866567378c40b0e92ca83606dae67f538899c9d9f38add06bbc6042
7
+ data.tar.gz: 07fc3d2be16a4e36b2b83fef5160c777b4b74ee891755087d722b00a294c5bcd1c072c07a9fe6357fb2e323f50d038386b53cbb1961c153a4a055ee78dc88d01
@@ -0,0 +1,19 @@
1
+ name: Ruby
2
+
3
+ on: [push,pull_request]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v2
10
+ - name: Set up Ruby
11
+ uses: ruby/setup-ruby@v1
12
+ with:
13
+ ruby-version: 2.7.1
14
+ - name: Run the default task
15
+ run: |
16
+ gem install bundler -v 2.2.3
17
+ bundle install
18
+ bundle exec rake
19
+ bin/gem_smoke_test
@@ -0,0 +1,59 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ Gemfile.lock
49
+ .ruby-version
50
+ .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
57
+
58
+ # rspec failure tracking
59
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,14 @@
1
+ {
2
+ "cSpell.words": [
3
+ "bindir",
4
+ "nilable"
5
+ ],
6
+ "ruby.format": "standard",
7
+ "ruby.lint": {
8
+ "standard": {
9
+ "useBundler": true,
10
+ }
11
+ },
12
+ "ruby.useLanguageServer": true,
13
+ "ruby.useBundler": true
14
+ }
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in thread_weaver.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
11
+
12
+ gem "standard"
13
+
14
+ gem "sorbet", group: :development
15
+ gem "sorbet-runtime"
16
+
17
+ gem "byebug"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Andrew Hamon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,37 @@
1
+ # ThreadWeaver
2
+
3
+ ThreadWeaver is a testing tool which can help uncover thread safety issues in code intended concurrently.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'thread_weaver'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install thread_weaver
20
+
21
+ ## Usage
22
+
23
+ TODO: Write usage instructions here
24
+
25
+ ## Development
26
+
27
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
28
+
29
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
30
+
31
+ ## Contributing
32
+
33
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/thread_weaver.
34
+
35
+ ## License
36
+
37
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "standard/rake"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :sorbet do
10
+ sh "bundle exec srb tc"
11
+ end
12
+
13
+ task default: %i[sorbet spec standard]
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "thread_weaver"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ # See test/gem_smoke_test/README.md for more information as to the purpose of this file.
7
+
8
+ cd test/gem_smoke_test
9
+ bundle
10
+ bundle exec ruby ./test.rb
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ # This is a bit of a contrived example, but this class always deadlocks if the secondary is run
2
+ # without eventually running the primary.
3
+ class AlwaysDeadlocks
4
+ def initialize
5
+ @primary_has_run = false
6
+ end
7
+
8
+ def call(is_primary:)
9
+ if is_primary
10
+ @primary_has_run = true
11
+ else
12
+ Thread.pass until @primary_has_run
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # This is perfectly thread safe and "nonblocking", just kinda slow. It is used to simulate expensive
2
+ # computations.
3
+ class TakesAWhile
4
+ def call(duration_ms:)
5
+ sleep(duration_ms / 1000.0)
6
+ end
7
+ end
@@ -0,0 +1,23 @@
1
+ # This is a non-blocking version of ThreadSafeRunAtMostOnce. It is still thread-safe, but instead of
2
+ # blocking while waiting for the lock, it simply does nothing. If the lock can not be immediately
3
+ # acquired, then another thread must have acquired it which means the block already has or will soon
4
+ # be run.
5
+ class ThreadSafeNonblockingRunAtMostOnce
6
+ def initialize(&blk)
7
+ @blk = blk
8
+ @ran = false
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def call
13
+ lock_acquired = @mutex.try_lock
14
+ if lock_acquired
15
+ unless @ran
16
+ @ran = true
17
+ @blk.call
18
+ end
19
+ end
20
+ ensure
21
+ @mutex.unlock if lock_acquired
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # This is a thread-safe version of ThreadUnsafeRunAtMostOnce. It wraps the call with a mutex which
2
+ # guarantees that the critical section is only ever run in one thread at a time, thereby making this
3
+ # safe to use and call from multiple threads.
4
+ class ThreadSafeRunAtMostOnce
5
+ def initialize(&blk)
6
+ @blk = blk
7
+ @ran = false
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def call
12
+ @mutex.synchronize do
13
+ unless @ran
14
+ @ran = true
15
+ @blk.call
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # This is a naively written routine whose intended purpose is to run the provided block at most
2
+ # once. In a single-threaded application this should work just fine, but if an instance of this
3
+ # class is shared among several threads who all try to invoke call on it, it will eventually fail at
4
+ # its intended purpose and execute the provided block more than once.
5
+ class ThreadUnsafeRunAtMostOnce
6
+ def initialize(&blk)
7
+ @blk = blk
8
+ @ran = false
9
+ end
10
+
11
+ def call
12
+ unless @ran
13
+ @ran = true
14
+ @blk.call
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "timeout"
6
+
7
+ module ThreadWeaver
8
+ class Error < StandardError; end
9
+ # Your code goes here...
10
+ end
11
+
12
+ require_relative "thread_weaver/controllable_thread"
13
+ require_relative "thread_weaver/iterative_race_detector"
14
+ require_relative "thread_weaver/thread_instruction"
15
+ require_relative "thread_weaver/version"
@@ -0,0 +1,177 @@
1
+ # typed: strict
2
+
3
+ module ThreadWeaver
4
+ class ThreadCompletedEarlyError < Error; end
5
+
6
+ class ControllableThread < Thread
7
+ extend T::Sig
8
+
9
+ sig { returns(String) }
10
+ attr_reader :last_trace_point_summary
11
+
12
+ sig do
13
+ params(context: T.untyped, name: String, blk: T.proc.params(arg0: T.untyped).void).void
14
+ end
15
+ def initialize(context, name:, &blk)
16
+ @waiting = T.let(false, T::Boolean)
17
+ @execution_counter = T.let(-1, Integer)
18
+ @last_trace_point_summary = T.let("<no traces detected>", String)
19
+ @line_counts_by_class = T.let({}, T::Hash[Module, Integer])
20
+ @current_instruction = T.let(PauseAtThreadStart.new, ThreadInstruction)
21
+
22
+ self.name = name
23
+ self.report_on_exception = false
24
+
25
+ super do
26
+ tracer = TracePoint.new(:line, :call, :return, :b_call, :b_return, :thread_begin, :thread_end, :c_call, :c_return) { |tp|
27
+ current_thread = Thread.current
28
+ if current_thread == self
29
+ current_thread.handle_trace_point(tp)
30
+ end
31
+ }
32
+ handle_thread_start
33
+ tracer.enable
34
+ blk.call(context)
35
+ handle_thread_end
36
+ ensure
37
+ tracer&.disable
38
+ end
39
+
40
+ wait_until_next_instruction_complete
41
+ end
42
+
43
+ sig { void }
44
+ def wait_until_next_instruction_complete
45
+ assert_self_is_not_current_thread
46
+
47
+ do_nothing while alive? && !@waiting
48
+ end
49
+
50
+ sig { void }
51
+ def release
52
+ assert_self_is_not_current_thread
53
+
54
+ @waiting = false
55
+ end
56
+
57
+ sig { void }
58
+ def next
59
+ assert_self_is_not_current_thread
60
+
61
+ case @current_instruction
62
+ when PauseWhenLineCount, PauseAtSourceLine
63
+ set_next_instruction(
64
+ @current_instruction.next
65
+ )
66
+ else
67
+ raise "Next is only supported when paused on a #{PauseWhenLineCount.name} or a #{PauseAtSourceLine} instruction "
68
+ end
69
+ end
70
+
71
+ sig { params(instruction: ThreadInstruction).void }
72
+ def set_next_instruction(instruction)
73
+ assert_self_is_not_current_thread
74
+ @current_instruction = instruction
75
+ release
76
+ end
77
+
78
+ sig { params(instruction: ThreadInstruction).void }
79
+ def set_and_wait_for_next_instruction(instruction)
80
+ set_next_instruction(instruction)
81
+ wait_until_next_instruction_complete
82
+ end
83
+
84
+ sig { params(tp: TracePoint).void }
85
+ def handle_trace_point(tp)
86
+ event = T.let(tp.event, Symbol)
87
+ klass = T.let(tp.defined_class, T.nilable(Module))
88
+ path = T.let(tp.path, T.nilable(String))
89
+ line = T.let(tp.lineno, T.nilable(Integer))
90
+ method_name = T.let(tp.method_id, T.nilable(Symbol))
91
+
92
+ @last_trace_point_summary = "#{event} #{klass}##{method_name} #{path}#L#{line}"
93
+
94
+ if klass
95
+ current_count = @line_counts_by_class.fetch(klass, 0)
96
+ @line_counts_by_class[klass] = (current_count + 1)
97
+ end
98
+
99
+ case @current_instruction
100
+ when PauseAtThreadStart
101
+ if event == :thread_begin
102
+ wait_until_released
103
+ end
104
+ when ContinueToThreadEnd
105
+ # do nothing
106
+ when PauseWhenLineCount
107
+ current_count = @current_instruction.target_classes.map { |klass| @line_counts_by_class.fetch(klass, 0) }.sum
108
+ required_count = @current_instruction.count
109
+ if required_count == current_count
110
+ wait_until_released
111
+ end
112
+ when PauseAtMethodCall
113
+ if @current_instruction.klass == klass && @current_instruction.method_name == method_name
114
+ wait_until_released
115
+ end
116
+ when PauseAtMethodReturn
117
+ if @current_instruction.klass == klass && @current_instruction.method_name == method_name
118
+ wait_until_released
119
+ end
120
+ when PauseAtSourceLine
121
+ if path&.end_with?(@current_instruction.path_suffix) && @current_instruction.line == line
122
+ wait_until_released
123
+ end
124
+ else
125
+ T.absurd(@current_instruction)
126
+ end
127
+ end
128
+
129
+ sig { override.returns(ControllableThread) }
130
+ def join
131
+ while alive?
132
+ release
133
+ do_nothing
134
+ end
135
+ super()
136
+ end
137
+
138
+ private
139
+
140
+ sig { void }
141
+ def handle_thread_start
142
+ assert_self_is_current_thread
143
+ if @current_instruction.is_a?(PauseAtThreadStart)
144
+ wait_until_released
145
+ end
146
+ end
147
+
148
+ sig { void }
149
+ def handle_thread_end
150
+ assert_self_is_current_thread
151
+ unless @current_instruction.is_a?(ContinueToThreadEnd)
152
+ raise ThreadCompletedEarlyError.new("Thread #{name} completed while attempting to match instruction #{@current_instruction}")
153
+ end
154
+ end
155
+
156
+ sig { void }
157
+ def wait_until_released
158
+ @waiting = true
159
+ do_nothing while @waiting
160
+ end
161
+
162
+ sig { void }
163
+ def do_nothing
164
+ Thread.pass
165
+ end
166
+
167
+ sig { void }
168
+ def assert_self_is_current_thread
169
+ raise "illegal call from thread other than self" unless Thread.current == self
170
+ end
171
+
172
+ sig { void }
173
+ def assert_self_is_not_current_thread
174
+ raise "illegal call from self" unless Thread.current != self
175
+ end
176
+ end
177
+ end