rspec-multiprocess_runner 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.
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/tmp
10
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.2.3
5
+ before_install: gem install bundler -v 1.10.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # 0.1.0
2
+
3
+ * Initial version
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rspec-multiprocess_runner.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Collaborative Drug Discovery
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Rspec::MultiprocessRunner
2
+
3
+ This gem provides a mechanism for running a suite of RSpec tests in multiple
4
+ processes on the same machine, potentially allowing substantial performance
5
+ improvements.
6
+
7
+ It differs from `parallel-tests` in that it uses a coordinator process to manage
8
+ the workers, hand off work to them, and receive results. This means it can
9
+ dynamically balance the workload among the processors. It also means it can
10
+ provide consolidated results in the console.
11
+
12
+ It does follow parallel-tests' `TEST_ENV_NUMBER` convention so it's easy to
13
+ switch.
14
+
15
+ ## Benefits
16
+
17
+ * Running slow (IO-bound) specs in parallel can greatly reduce the wall-clock
18
+ time needed to run a suite. Even CPU-bound specs can be aided
19
+ * Provides detailed logging of each example as it completes, including the
20
+ failure message (you don't have to wait until the end to see the failure
21
+ reason).
22
+ * Detects, kills, and reports spec files that take longer than expected (five
23
+ minutes by default).
24
+ * Detects and reports spec files that crash (without interrupting the
25
+ remainder of the suite).
26
+
27
+ ## Limitations
28
+
29
+ * Only works with RSpec 2. Does not work with RSpec 3.
30
+ * Does not work on Windows or JRuby. Since it relies on `fork(2)`, it probably
31
+ never will.
32
+ * Does not support RSpec custom formatters.
33
+ * The built-in output format is very verbose — it's intended for CI servers,
34
+ where more logging is better.
35
+ * Intermediate-quality code. Happy path works, and workers are
36
+ managed/restarted, but:
37
+ * There's no test coverage of the runner itself, only auxiliaries.
38
+ * Does not handle the coordinator process dying (e.g., from `^C`).
39
+
40
+ ## Installation
41
+
42
+ Add this line to your application's Gemfile:
43
+
44
+ ```ruby
45
+ gem 'rspec-multiprocess_runner'
46
+ ```
47
+
48
+ And then execute:
49
+
50
+ $ bundle
51
+
52
+ Or install it yourself as:
53
+
54
+ $ gem install rspec-multiprocess_runner
55
+
56
+ ## Usage
57
+
58
+ ### Command line
59
+
60
+ Use `multirspec` to run a bunch of spec files:
61
+
62
+ $ multirspec spec
63
+
64
+ Runs three workers by default — use `-w` to chose another count. `--help` will
65
+ detail the other options.
66
+
67
+ You can provide options that will be passed to the separate RSpec processes by
68
+ including them after a `--`:
69
+
70
+ $ multirspec -w 5 spec -- -b -I ./lib
71
+
72
+ In this case, each RSpec process would receive the options `-b -I ./lib`. Note
73
+ that not that many RSpec options really make sense to pass this way. In
74
+ particular, file selection and output formatting options are unlikely to work
75
+ the way you expect.
76
+
77
+ ### Rake
78
+
79
+ There is a rake task wrapper for `multirspec`:
80
+
81
+ require 'rspec/multiprocess_runner/rake_task'
82
+
83
+ RSpec::MultiprocessRunner::RakeTask.new(:spec) do |t|
84
+ t.worker_count = 5
85
+ t.pattern = "spec/**/*_spec.rb"
86
+ end
87
+
88
+ See its source for the full list of options.
89
+
90
+ ### Code
91
+
92
+ Create a coordinator and tell it to run:
93
+
94
+ require 'rspec/multiprocess_runner/coordinator'
95
+
96
+ worker_count = 4
97
+ per_file_timeout = 5 * 60 # 5 minutes in seconds
98
+ rspec_args = %w(--backtrace)
99
+ files = Dir['**/*_spec.rb']
100
+
101
+ coordinator = RSpec::MultiprocessRunner::Coordinator(worker_count, per_file_timeout, rspec_args, files)
102
+ coordinator.run
103
+
104
+ ## Contributing
105
+
106
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cdd/rspec-multiprocess_runner.
107
+
108
+
109
+ ## License
110
+
111
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/TODO.md ADDED
@@ -0,0 +1,11 @@
1
+ # Tests
2
+
3
+ Tricky things which I am manually testing:
4
+
5
+ * Timing out slow files (as_spec.rb)
6
+ * Recognizing and handling workers that die (ax_spec.rb, x*_spec.rb)
7
+ * Preserving the configuration from spec_helper, etc., across runs (w*_spec.rb)
8
+
9
+ Need to write automated tests for these (in addition to tests for all the basic
10
+ behavior). Maybe use Cucumber (like RSpec does for acceptance tests) to avoid
11
+ the weirdness of testing rspec within rspec.
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "rspec/multiprocess_runner"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
data/exe/multirspec ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rspec/multiprocess_runner/command_line_options'
4
+ require 'rspec/multiprocess_runner/coordinator'
5
+
6
+ options = RSpec::MultiprocessRunner::CommandLineOptions.new.parse(ARGV.dup)
7
+ exit(2) unless options
8
+
9
+ success = RSpec::MultiprocessRunner::Coordinator.new(
10
+ options.worker_count,
11
+ options.file_timeout_seconds,
12
+ options.rspec_options,
13
+ options.files_to_run
14
+ ).run
15
+
16
+ exit(success ? 0 : 1)
@@ -0,0 +1,98 @@
1
+ require 'rspec/multiprocess_runner'
2
+ require 'optparse'
3
+ require 'pathname'
4
+
5
+ module RSpec::MultiprocessRunner
6
+ # @private
7
+ class CommandLineOptions
8
+ attr_accessor :worker_count, :file_timeout_seconds, :rspec_options,
9
+ :explicit_files_or_directories, :pattern
10
+
11
+ def initialize
12
+ self.worker_count = 3
13
+ self.file_timeout_seconds = 300
14
+ self.pattern = "**/*_spec.rb"
15
+ self.rspec_options = []
16
+ end
17
+
18
+ def parse(command_line_args, error_stream=$stderr)
19
+ args = command_line_args.dup
20
+ parser = build_parser
21
+
22
+ begin
23
+ parser.parse!(args)
24
+ rescue OptionParser::ParseError => e
25
+ error_stream.puts e.to_s
26
+ error_stream.puts parser
27
+ return nil
28
+ end
29
+
30
+ if help_requested?
31
+ error_stream.puts parser
32
+ return nil
33
+ end
34
+
35
+ extract_files_and_rspec_options(args)
36
+ self
37
+ end
38
+
39
+ def files_to_run
40
+ self.explicit_files_or_directories = %w(.) unless explicit_files_or_directories
41
+ relative_root = Pathname.new('.')
42
+ explicit_files_or_directories.map { |path| Pathname.new(path) }.flat_map do |path|
43
+ if path.file?
44
+ path.to_s
45
+ else
46
+ Dir[path.join(pattern).relative_path_from(relative_root)]
47
+ end
48
+ end.sort_by { |path| path.downcase }
49
+ end
50
+
51
+ private
52
+
53
+ def help_requested!
54
+ @help_requested = true
55
+ end
56
+
57
+ def help_requested?
58
+ @help_requested
59
+ end
60
+
61
+ def build_parser
62
+ OptionParser.new do |parser|
63
+ parser.banner = "#{File.basename $0} [options] [files or directories] [-- rspec options]"
64
+
65
+ parser.on("-w", "--worker-count COUNT", Integer, "Number of workers to run (default: #{worker_count})") do |n|
66
+ self.worker_count = n
67
+ end
68
+
69
+ parser.on("-t", "--file-timeout SECONDS", Integer, "Maximum time to allow any single file to run (default: #{file_timeout_seconds})") do |s|
70
+ self.file_timeout_seconds = s
71
+ end
72
+
73
+ parser.on("-P", "--pattern PATTERN", "A glob to use to select files to run (default: #{pattern})") do |pattern|
74
+ self.pattern = pattern
75
+ end
76
+
77
+ parser.on_tail("-h", "--help", "Prints this help") do
78
+ help_requested!
79
+ end
80
+ end
81
+ end
82
+
83
+ def extract_files_and_rspec_options(remaining_args)
84
+ files = []
85
+ rspec_options = []
86
+ target = files
87
+ remaining_args.each do |arg|
88
+ if arg[0] == '-'
89
+ target = rspec_options
90
+ end
91
+ target << arg
92
+ end
93
+
94
+ self.explicit_files_or_directories = files unless files.empty?
95
+ self.rspec_options = rspec_options unless rspec_options.empty?
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,197 @@
1
+ # encoding: utf-8
2
+ require 'rspec/multiprocess_runner'
3
+ require 'rspec/multiprocess_runner/worker'
4
+
5
+ module RSpec::MultiprocessRunner
6
+ class Coordinator
7
+ def initialize(worker_count, file_timeout, rspec_options, files)
8
+ @worker_count = worker_count
9
+ @file_timeout = file_timeout
10
+ @rspec_options = rspec_options
11
+ @spec_files = files
12
+ @workers = []
13
+ @deactivated_workers = []
14
+ end
15
+
16
+ def run
17
+ @start_time = Time.now
18
+ expected_worker_numbers.each do |n|
19
+ create_and_start_worker_if_necessary(n)
20
+ end
21
+ run_loop
22
+ quit_all_workers
23
+ print_summary
24
+
25
+ !failed?
26
+ end
27
+
28
+ def failed?
29
+ !@deactivated_workers.empty? || any_example_failed?
30
+ end
31
+
32
+ private
33
+
34
+ def worker_sockets
35
+ @workers.map(&:socket)
36
+ end
37
+
38
+ def run_loop
39
+ loop do
40
+ act_on_available_worker_messages(0.3)
41
+ reap_stalled_workers
42
+ start_missing_workers
43
+ break unless @workers.detect(&:working?)
44
+ end
45
+ end
46
+
47
+ def quit_all_workers
48
+ quit_threads = @workers.map do |worker|
49
+ Thread.new do
50
+ worker.quit
51
+ worker.wait_until_quit
52
+ end
53
+ end
54
+ quit_threads.each(&:join)
55
+ end
56
+
57
+ def work_left_to_do?
58
+ !@spec_files.empty?
59
+ end
60
+
61
+ def act_on_available_worker_messages(timeout)
62
+ while (select_result = IO.select(worker_sockets, nil, nil, timeout))
63
+ select_result.first.each do |readable_socket|
64
+ ready_worker = @workers.detect { |worker| worker.socket == readable_socket }
65
+ worker_status = ready_worker.receive_and_act_on_message_from_worker
66
+ if worker_status == :dead
67
+ reap_one_worker(ready_worker, "died")
68
+ elsif work_left_to_do? && !ready_worker.working?
69
+ ready_worker.run_file(@spec_files.shift)
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def reap_one_worker(worker, reason)
76
+ worker.reap
77
+ @deactivated_workers << worker
78
+ worker.deactivation_reason = reason
79
+ @workers.reject! { |w| w == worker }
80
+ end
81
+
82
+ def reap_stalled_workers
83
+ @workers.select(&:stalled?).each do |stalled_worker|
84
+ reap_one_worker(stalled_worker, "stalled")
85
+ end
86
+ end
87
+
88
+ def expected_worker_numbers
89
+ (1..@worker_count).to_a
90
+ end
91
+
92
+ def create_and_start_worker_if_necessary(n)
93
+ if work_left_to_do?
94
+ $stderr.puts "(Re)starting worker #{n}"
95
+ new_worker = Worker.new(n, @file_timeout, @rspec_options)
96
+ @workers << new_worker
97
+ new_worker.start
98
+ new_worker.run_file(@spec_files.shift)
99
+ end
100
+ end
101
+
102
+ def start_missing_workers
103
+ if @workers.size < @worker_count && work_left_to_do?
104
+ running_process_numbers = @workers.map(&:environment_number)
105
+ missing_process_numbers = expected_worker_numbers - running_process_numbers
106
+ missing_process_numbers.each do |n|
107
+ create_and_start_worker_if_necessary(n)
108
+ end
109
+ end
110
+ end
111
+
112
+ def print_summary
113
+ elapsed = Time.now - @start_time
114
+ by_status_and_time = combine_example_results.each_with_object({}) do |result, idx|
115
+ (idx[result.status] ||= []) << result
116
+ end
117
+ print_pending_example_details(by_status_and_time["pending"])
118
+ print_failed_example_details(by_status_and_time["failed"])
119
+ print_failed_process_details
120
+ puts
121
+ print_elapsed_time(elapsed)
122
+ puts failed? ? "FAILURE" : "SUCCESS"
123
+ print_example_counts(by_status_and_time)
124
+ end
125
+
126
+ def combine_example_results
127
+ (@workers + @deactivated_workers).flat_map(&:example_results).sort_by { |r| r.time_finished }
128
+ end
129
+
130
+ def any_example_failed?
131
+ (@workers + @deactivated_workers).detect { |w| w.example_results.detect { |r| r.status == "failed" } }
132
+ end
133
+
134
+ def print_pending_example_details(pending_example_results)
135
+ return if pending_example_results.nil?
136
+ puts
137
+ puts "Pending:"
138
+ pending_example_results.each do |pending|
139
+ puts
140
+ puts pending.details.sub(/^\s*Pending:\s*/, '')
141
+ end
142
+ end
143
+
144
+ def print_failed_example_details(failed_example_results)
145
+ return if failed_example_results.nil?
146
+ puts
147
+ puts "Failures:"
148
+ failed_example_results.each_with_index do |failure, i|
149
+ puts
150
+ puts " #{i.next}) #{failure.description}"
151
+ puts failure.details
152
+ end
153
+ end
154
+
155
+ # Copied from RSpec
156
+ def pluralize(count, string)
157
+ "#{count} #{string}#{'s' unless count.to_f == 1}"
158
+ end
159
+
160
+ def print_example_counts(by_status_and_time)
161
+ example_count = by_status_and_time.map { |status, results| results.size }.inject(0) { |sum, ct| sum + ct }
162
+ failure_count = by_status_and_time["failed"] ? by_status_and_time["failed"].size : 0
163
+ pending_count = by_status_and_time["pending"] ? by_status_and_time["pending"].size : 0
164
+ process_failure_count = @deactivated_workers.size
165
+
166
+ # Copied from RSpec
167
+ summary = pluralize(example_count, "example")
168
+ summary << ", " << pluralize(failure_count, "failure")
169
+ summary << ", #{pending_count} pending" if pending_count > 0
170
+ summary << ", " << pluralize(process_failure_count, "failed proc") if process_failure_count > 0
171
+ puts summary
172
+ end
173
+
174
+ def print_failed_process_details
175
+ return if @deactivated_workers.empty?
176
+ puts
177
+ puts "Failed processes:"
178
+ @deactivated_workers.each do |worker|
179
+ puts " - #{worker.pid} (env #{worker.environment_number}) #{worker.deactivation_reason} on #{worker.current_file}"
180
+ end
181
+ end
182
+
183
+ def print_elapsed_time(seconds_elapsed)
184
+ minutes = seconds_elapsed.to_i / 60
185
+ seconds = seconds_elapsed % 60
186
+ m =
187
+ if minutes > 0
188
+ "%d minute%s" % [minutes, minutes == 1 ? '' : 's']
189
+ end
190
+ s =
191
+ if seconds > 0
192
+ "%.2f second%s" % [seconds, seconds == 1 ? '' : 's']
193
+ end
194
+ puts "Finished in #{[m, s].compact.join(", ")}"
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,114 @@
1
+ require 'rspec/multiprocess_runner'
2
+ require 'rake'
3
+ require 'rake/tasklib'
4
+ require 'shellwords'
5
+
6
+ module RSpec::MultiprocessRunner
7
+ # Rake task to invoke `multispec`. Lots of it is copied from RSpec::Core::RakeTask.
8
+ #
9
+ # @see Rakefile
10
+ class RakeTask < ::Rake::TaskLib
11
+ include ::Rake::DSL if defined?(::Rake::DSL)
12
+
13
+ # Default path to the multirspec executable.
14
+ DEFAULT_MULTIRSPEC_PATH = File.expand_path('../../../../exe/multirspec', __FILE__)
15
+
16
+ # Name of task. Defaults to `:multispec`.
17
+ attr_accessor :name
18
+
19
+ # Files matching this pattern will be loaded.
20
+ # Defaults to `'**/*_spec.rb'`.
21
+ attr_accessor :pattern
22
+
23
+ # File search will be limited to these directories or specific files.
24
+ # Defaults to nil.
25
+ attr_accessor :files_or_directories
26
+ alias_method :files=, :files_or_directories=
27
+ alias_method :directories=, :files_or_directories=
28
+
29
+ # The number of workers to run. Defaults to 3.
30
+ attr_accessor :worker_count
31
+
32
+ # The maximum number of seconds to allow a single spec file to run
33
+ # before killing it. Defaults to 300.
34
+ attr_accessor :file_timeout_seconds
35
+
36
+ # Whether or not to fail Rake when an error occurs (typically when
37
+ # examples fail). Defaults to `true`.
38
+ attr_accessor :fail_on_error
39
+
40
+ # A message to print to stderr when there are failures.
41
+ attr_accessor :failure_message
42
+
43
+ # Use verbose output. If this is set to true, the task will print the
44
+ # executed spec command to stdout. Defaults to `true`.
45
+ attr_accessor :verbose
46
+
47
+ # Path to the multispec executable. Defaults to the absolute path to the
48
+ # rspec binary from the loaded rspec-core gem.
49
+ attr_accessor :multirspec_path
50
+
51
+ # Command line options to pass to the RSpec workers. Defaults to `nil`.
52
+ attr_accessor :rspec_opts
53
+
54
+ def initialize(*args, &task_block)
55
+ @name = args.shift || :multispec
56
+ @verbose = true
57
+ @fail_on_error = true
58
+ @multirspec_path = DEFAULT_MULTIRSPEC_PATH
59
+
60
+ define(args, &task_block)
61
+ end
62
+
63
+ # @private
64
+ def run_task(verbose)
65
+ command = spec_command
66
+ puts Shellwords.shelljoin(command) if verbose
67
+
68
+ return if system(*command)
69
+ puts failure_message if failure_message
70
+
71
+ return unless fail_on_error
72
+ $stderr.puts "#{command} failed" if verbose
73
+ exit $?.exitstatus
74
+ end
75
+
76
+ private
77
+
78
+ # @private
79
+ def define(args, &task_block)
80
+ desc "Run RSpec code examples" unless ::Rake.application.last_comment
81
+
82
+ task name, *args do |_, task_args|
83
+ RakeFileUtils.__send__(:verbose, verbose) do
84
+ task_block.call(*[self, task_args].slice(0, task_block.arity)) if task_block
85
+ run_task verbose
86
+ end
87
+ end
88
+ end
89
+
90
+ def spec_command
91
+ cmd_parts = []
92
+ cmd_parts << RUBY
93
+ cmd_parts << multirspec_path
94
+ if worker_count
95
+ cmd_parts << '--worker-count' << worker_count.to_s
96
+ end
97
+ if file_timeout_seconds
98
+ cmd_parts << '--file-timeout' << file_timeout_seconds.to_s
99
+ end
100
+ if pattern
101
+ cmd_parts << '--pattern' << pattern
102
+ end
103
+ if files_or_directories
104
+ cmd_parts.concat(files_or_directories)
105
+ end
106
+ if rspec_opts
107
+ cmd_parts << '--'
108
+ cmd_parts.concat(Shellwords.shellsplit rspec_opts)
109
+ end
110
+
111
+ cmd_parts
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,77 @@
1
+ # encoding: utf-8
2
+ require 'rspec/multiprocess_runner'
3
+ require 'rspec/core/formatters/base_text_formatter'
4
+
5
+ module RSpec::MultiprocessRunner
6
+ ##
7
+ # RSpec formatter used by workers to communicate spec execution to the
8
+ # coordinator.
9
+ #
10
+ # @private
11
+ class ReportingFormatter < RSpec::Core::Formatters::BaseTextFormatter
12
+ class << self
13
+ # The worker to which to report spec status. This has to be a class-level
14
+ # attribute because you can't access the formatter instance used by
15
+ # RSpec's runner.
16
+ attr_accessor :worker
17
+ end
18
+
19
+ def initialize(*ignored_args)
20
+ super(StringIO.new)
21
+ @current_example_groups = []
22
+ end
23
+
24
+ def example_group_started(example_group)
25
+ super(example_group)
26
+
27
+ @current_example_groups.push(example_group.description.strip)
28
+ end
29
+
30
+ def example_group_finished(example_group)
31
+ @current_example_groups.pop
32
+ end
33
+
34
+ def example_passed(example)
35
+ super(example)
36
+ report_example_result(:passed, current_example_description(example))
37
+ end
38
+
39
+ def example_pending(example)
40
+ super(example)
41
+ details = capture_output { dump_pending }
42
+ pending_examples.clear
43
+ report_example_result(:pending, current_example_description(example), details)
44
+ end
45
+
46
+ def example_failed(example)
47
+ super(example)
48
+ details = capture_output {
49
+ dump_failure_info(example)
50
+ dump_backtrace(example)
51
+ }
52
+ report_example_result(:failed, current_example_description(example), details)
53
+ end
54
+
55
+ private
56
+
57
+ def capture_output
58
+ output.string = ""
59
+ yield
60
+ captured = output.string
61
+ output.string = ""
62
+ captured
63
+ end
64
+
65
+ def worker
66
+ self.class.worker
67
+ end
68
+
69
+ def current_example_description(example)
70
+ (@current_example_groups + [example.description.strip]).join('·')
71
+ end
72
+
73
+ def report_example_result(example_status, description, details=nil)
74
+ worker.report_example_result(example_status, description, details)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ module RSpec
2
+ module MultiprocessRunner
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,262 @@
1
+ # encoding: utf-8
2
+ require "rspec/multiprocess_runner"
3
+ require "rspec/multiprocess_runner/reporting_formatter"
4
+
5
+ require "rspec/core"
6
+ require "rspec/core/runner"
7
+
8
+ require "socket"
9
+ require "json"
10
+ require "timeout"
11
+
12
+ module RSpec::MultiprocessRunner
13
+ ##
14
+ # This object has several roles:
15
+ # - It forks the worker process
16
+ # - In the coordinator process, it is used to send messages to the worker and
17
+ # track the worker's status, completed specs, and example results.
18
+ # - In the worker process, it is used to send messages to the coordinator and
19
+ # actually run specs.
20
+ #
21
+ # @private
22
+ class Worker
23
+ attr_reader :pid, :environment_number, :example_results, :current_file
24
+ attr_accessor :deactivation_reason
25
+
26
+ COMMAND_QUIT = "quit"
27
+ COMMAND_RUN_FILE = "run_file"
28
+
29
+ STATUS_EXAMPLE_COMPLETE = "example_complete"
30
+ STATUS_RUN_COMPLETE = "run_complete"
31
+
32
+ def initialize(environment_number, file_timeout, rspec_arguments=[])
33
+ @environment_number = environment_number
34
+ @worker_socket, @coordinator_socket = Socket.pair(:UNIX, :STREAM)
35
+ @rspec_arguments = rspec_arguments + ["--format", ReportingFormatter.to_s]
36
+ @file_timeout = file_timeout
37
+ @example_results = []
38
+
39
+ # Use a single configuration and world across all individual runs
40
+ # This will not be necessary to do manually in RSpec 3 — it does not
41
+ # reset the globals after each run.
42
+ @rspec_configuration = RSpec.configuration
43
+ @rspec_world = RSpec.world
44
+ end
45
+
46
+ ##
47
+ # Workers can be found in the coordinator process by their coordinator socket.
48
+ def ==(other)
49
+ case other
50
+ when Socket
51
+ other == @coordinator_socket
52
+ else
53
+ super
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Forks the worker process. In the parent, returns the PID.
59
+ def start
60
+ pid = fork
61
+ if pid
62
+ @worker_socket.close
63
+ @pid = pid
64
+ else
65
+ @coordinator_socket.close
66
+ @pid = Process.pid
67
+ ENV["TEST_ENV_NUMBER"] = environment_number.to_s
68
+ set_process_name
69
+ run_loop
70
+ end
71
+ end
72
+
73
+ def socket
74
+ if self.pid == Process.pid
75
+ @worker_socket
76
+ else
77
+ @coordinator_socket
78
+ end
79
+ end
80
+
81
+ ###### COORDINATOR METHODS
82
+ ## These are methods that the coordinator process calls on its copy of
83
+ ## the workers.
84
+
85
+ public
86
+
87
+ def quit
88
+ send_message_to_worker(command: COMMAND_QUIT)
89
+ end
90
+
91
+ def wait_until_quit
92
+ Process.wait(self.pid)
93
+ end
94
+
95
+ def run_file(filename)
96
+ send_message_to_worker(command: COMMAND_RUN_FILE, filename: filename)
97
+ @current_file = filename
98
+ @current_file_started_at = Time.now
99
+ end
100
+
101
+ def working?
102
+ @current_file
103
+ end
104
+
105
+ def stalled?
106
+ working? && (Time.now - @current_file_started_at > @file_timeout)
107
+ end
108
+
109
+ def reap
110
+ begin
111
+ Timeout.timeout(4) do
112
+ $stderr.puts "Reaping troubled process #{environment_number} (#{pid}; #{@current_file}) with QUIT"
113
+ Process.kill(:QUIT, pid)
114
+ Process.wait(pid)
115
+ end
116
+ rescue Timeout::Error
117
+ $stderr.puts "Reaping troubled process #{environment_number} (#{pid}) with KILL"
118
+ Process.kill(:KILL, pid)
119
+ Process.wait(pid)
120
+ end
121
+ end
122
+
123
+ def receive_and_act_on_message_from_worker
124
+ act_on_message_from_worker(receive_message_from_worker)
125
+ end
126
+
127
+ private
128
+
129
+ def receive_message_from_worker
130
+ receive_message(@coordinator_socket)
131
+ end
132
+
133
+ def act_on_message_from_worker(message_hash)
134
+ return :dead unless message_hash # ignore EOF
135
+ case message_hash["status"]
136
+ when STATUS_RUN_COMPLETE
137
+ @current_file = nil
138
+ @current_file_started_at = nil
139
+ when STATUS_EXAMPLE_COMPLETE
140
+ example_results << ExampleResult.new(message_hash)
141
+ suffix =
142
+ case message_hash["example_status"]
143
+ when "failed"
144
+ " - FAILED"
145
+ when "pending"
146
+ " - pending"
147
+ end
148
+ if message_hash["details"]
149
+ suffix += "\n#{message_hash["details"]}"
150
+ end
151
+ $stdout.puts "#{environment_number} (#{pid}): #{message_hash["description"]}#{suffix}"
152
+ else
153
+ $stderr.puts "Received unsupported status #{message_hash["status"].inspect} in worker #{pid}"
154
+ end
155
+ return :alive
156
+ end
157
+
158
+ def send_message_to_worker(message_hash)
159
+ send_message(@coordinator_socket, message_hash)
160
+ end
161
+
162
+ ###### WORKER METHODS
163
+ ## These are methods that the worker process calls on the copy of this
164
+ ## object that lives in the fork.
165
+
166
+ public
167
+
168
+ def report_example_result(example_status, description, details)
169
+ send_message_to_coordinator(
170
+ status: STATUS_EXAMPLE_COMPLETE,
171
+ example_status: example_status,
172
+ description: description,
173
+ details: details
174
+ )
175
+ end
176
+
177
+ private
178
+
179
+ def set_process_name
180
+ name = "RSpec::MultiprocessRunner::Worker #{environment_number}"
181
+ status = current_file ? "running #{current_file}" : "idle"
182
+ $0 = "#{name} #{status}"
183
+ end
184
+
185
+ def run_loop
186
+ loop do
187
+ select_result = IO.select([@worker_socket], nil, nil, 1)
188
+ if select_result
189
+ readables, _, _ = select_result
190
+ act_on_message_from_coordinator(
191
+ receive_message_from_coordinator(readables.first)
192
+ )
193
+ end
194
+ end
195
+ end
196
+
197
+ def receive_message_from_coordinator(socket)
198
+ receive_message(socket)
199
+ end
200
+
201
+ def send_message_to_coordinator(message_hash)
202
+ send_message(@worker_socket, message_hash)
203
+ end
204
+
205
+ def act_on_message_from_coordinator(message_hash)
206
+ return unless message_hash # ignore EOF
207
+ case message_hash["command"]
208
+ when "quit"
209
+ exit
210
+ when "run_file"
211
+ execute_spec(message_hash["filename"])
212
+ else
213
+ $stderr.puts "Received unsupported command #{message_hash["command"].inspect} in worker #{pid}"
214
+ end
215
+ set_process_name
216
+ end
217
+
218
+ def execute_spec(spec_file)
219
+ @current_file = spec_file
220
+ set_process_name
221
+
222
+ RSpec.configuration = @rspec_configuration
223
+ RSpec.world = @rspec_world
224
+ # If we don't do this, every previous spec is run every time run is called
225
+ RSpec.world.example_groups.clear
226
+
227
+ ReportingFormatter.worker = self
228
+ RSpec::Core::Runner.run(@rspec_arguments + [spec_file])
229
+ send_message_to_coordinator(status: STATUS_RUN_COMPLETE, filename: spec_file)
230
+ ensure
231
+ @current_file = nil
232
+ end
233
+
234
+ ###### UTILITY FUNCTIONS
235
+ ## Methods that used by both the coordinator and worker processes.
236
+
237
+ private
238
+
239
+ def receive_message(socket)
240
+ message_json = socket.gets
241
+ if message_json
242
+ JSON.parse(message_json)
243
+ end
244
+ end
245
+
246
+ def send_message(socket, message_hash)
247
+ socket.puts(message_hash.to_json)
248
+ end
249
+ end
250
+
251
+ # @private
252
+ class ExampleResult
253
+ attr_reader :status, :description, :details, :time_finished
254
+
255
+ def initialize(example_complete_message)
256
+ @status = example_complete_message["example_status"]
257
+ @description = example_complete_message["description"]
258
+ @details = example_complete_message["details"]
259
+ @time_finished = Time.now
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,6 @@
1
+ require "rspec/multiprocess_runner/version"
2
+
3
+ module RSpec
4
+ module MultiprocessRunner
5
+ end
6
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'rspec/multiprocess_runner/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "rspec-multiprocess_runner"
8
+ spec.version = RSpec::MultiprocessRunner::VERSION
9
+ spec.authors = ["Rhett Sutphin"]
10
+ spec.email = ["rhett@detailedbalance.net"]
11
+
12
+ spec.summary = %q{A runner for RSpec 2 that uses multiple processes to execute specs in parallel}
13
+ spec.homepage = "https://github.com/cdd/rspec-multiprocess_runner"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|manual_test_specs)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "rspec-core", "~> 2.0", "< 2.99.0"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.10"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec"
26
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-multiprocess_runner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Rhett Sutphin
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2015-12-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec-core
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ - - <
23
+ - !ruby/object:Gem::Version
24
+ version: 2.99.0
25
+ type: :runtime
26
+ prerelease: false
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ - - <
34
+ - !ruby/object:Gem::Version
35
+ version: 2.99.0
36
+ - !ruby/object:Gem::Dependency
37
+ name: bundler
38
+ requirement: !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: '1.10'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: !ruby/object:Gem::Requirement
47
+ none: false
48
+ requirements:
49
+ - - ~>
50
+ - !ruby/object:Gem::Version
51
+ version: '1.10'
52
+ - !ruby/object:Gem::Dependency
53
+ name: rake
54
+ requirement: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ~>
58
+ - !ruby/object:Gem::Version
59
+ version: '10.0'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ~>
66
+ - !ruby/object:Gem::Version
67
+ version: '10.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ none: false
72
+ requirements:
73
+ - - ! '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ description:
85
+ email:
86
+ - rhett@detailedbalance.net
87
+ executables:
88
+ - multirspec
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - .gitignore
93
+ - .rspec
94
+ - .travis.yml
95
+ - CHANGELOG.md
96
+ - Gemfile
97
+ - LICENSE.txt
98
+ - README.md
99
+ - Rakefile
100
+ - TODO.md
101
+ - bin/console
102
+ - bin/setup
103
+ - exe/multirspec
104
+ - lib/rspec/multiprocess_runner.rb
105
+ - lib/rspec/multiprocess_runner/command_line_options.rb
106
+ - lib/rspec/multiprocess_runner/coordinator.rb
107
+ - lib/rspec/multiprocess_runner/rake_task.rb
108
+ - lib/rspec/multiprocess_runner/reporting_formatter.rb
109
+ - lib/rspec/multiprocess_runner/version.rb
110
+ - lib/rspec/multiprocess_runner/worker.rb
111
+ - rspec-multiprocess_runner.gemspec
112
+ homepage: https://github.com/cdd/rspec-multiprocess_runner
113
+ licenses:
114
+ - MIT
115
+ post_install_message:
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ none: false
121
+ requirements:
122
+ - - ! '>='
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ segments:
126
+ - 0
127
+ hash: -1609184745420465239
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ segments:
135
+ - 0
136
+ hash: -1609184745420465239
137
+ requirements: []
138
+ rubyforge_project:
139
+ rubygems_version: 1.8.25
140
+ signing_key:
141
+ specification_version: 3
142
+ summary: A runner for RSpec 2 that uses multiple processes to execute specs in parallel
143
+ test_files: []