Module: SimpleCov::ParallelResultMerger
- Extended by:
- ParallelResultMerger
- Included in:
- ParallelResultMerger
- Defined in:
- lib/simplecov/parallel_result_merger.rb,
lib/simplecov/parallel_result_merger/worker_payload.rb
Overview
ResultMerger.absorb_results fanned out across forked workers, driving
SimpleCov.collate(..., processes: N). Each worker folds a contiguous
slice of the file list and ships the pair back over a pipe.
The slices are contiguous and merged back in order, so the fold visits the
resultsets in the order the serial fold visits them and the merged result
is identical to SimpleCov.collate's, not merely equivalent.
Defined Under Namespace
Modules: WorkerPayload
Instance Method Summary collapse
- #abandon(workers) ⇒ Object
-
#absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new, context_maps: ContextMap::Union.new) ⇒ Object
Answers nil when the work could not be fanned out and the caller should merge in this process instead.
- #chunk(file_paths, processes) ⇒ Object
-
#collect_payloads(workers) ⇒ Object
Answers nil if any worker failed, so the caller redoes the fold serially rather than report a subset of the resultsets as the whole.
-
#drain(workers) ⇒ Object
A thread per worker, so every pipe is drained while the workers are still writing.
-
#fan_out(chunks, ignore_timeout:, tracked_files:, context_maps:) ⇒ Object
A
forkthat fails here raises, and is left to: the runtimes that never fork are already excluded, so what remains is the OS refusing a process we expected to get, which says something is wrong with the machine rather than with the merge. - #merge_and_store(*file_paths, processes:, ignore_timeout: false) ⇒ Object
- #merge_results(*file_paths, processes:, ignore_timeout: false) ⇒ Object
- #read_payload(reader) ⇒ Object
-
#run_in_child(reader, writer, chunk, ignore_timeout) ⇒ Object
exit!rather thanexitbecause the child must never fall through to the collating process's inheritedat_exithandlers, SimpleCov's own report generation included. -
#run_worker(chunk, writer, ignore_timeout:) ⇒ Object
Kept free of the exit itself so it can be exercised in-process.
-
#spawn_worker(chunk, ignore_timeout:) ⇒ Object
The caller cleans up the workers it knows about, but this pipe is ours.
- #spawn_workers(chunks, ignore_timeout:) ⇒ Object
- #succeeded?(pid) ⇒ Boolean
- #warn_about_failed_workers(failed, total) ⇒ Object
Instance Method Details
#abandon(workers) ⇒ Object
86 87 88 89 90 91 |
# File 'lib/simplecov/parallel_result_merger.rb', line 86 def abandon(workers) workers.each do |worker| worker.fetch(:reader).close succeeded?(worker.fetch(:pid)) end end |
#absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new, context_maps: ContextMap::Union.new) ⇒ Object
Answers nil when the work could not be fanned out and the caller should merge in this process instead. The tracked paths and context-map union a worker's slice carried come back with its payload rather than through a collector block, since that block would be mutating state in the wrong process.
42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/simplecov/parallel_result_merger.rb', line 42 def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new, context_maps: ContextMap::Union.new) return nil if processes < 2 || file_paths.size < 2 # JRuby cannot fork on the JVM and deliberately answers false here, but # still defines `Kernel#fork` and raises `NotImplementedError` from it, # so probing that instead would send it down the fan-out. return nil unless Process.respond_to?(:fork) fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files, context_maps: context_maps) end |
#chunk(file_paths, processes) ⇒ Object
54 55 56 57 58 59 60 |
# File 'lib/simplecov/parallel_result_merger.rb', line 54 def chunk(file_paths, processes) groups = [processes, file_paths.size].min base, remainder = file_paths.size.divmod(groups) remaining = file_paths.dup Array.new(groups) { |index| remaining.shift(base + ((index < remainder) ? 1 : 0)) } end |
#collect_payloads(workers) ⇒ Object
Answers nil if any worker failed, so the caller redoes the fold serially rather than report a subset of the resultsets as the whole.
128 129 130 131 132 133 134 135 136 |
# File 'lib/simplecov/parallel_result_merger.rb', line 128 def collect_payloads(workers) payloads = drain(workers) failed = workers.count { |worker| !succeeded?(worker.fetch(:pid)) } return warn_about_failed_workers(failed, workers.size) unless failed.zero? && payloads.all? payloads ensure workers.each { |worker| worker.fetch(:reader).close } end |
#drain(workers) ⇒ Object
A thread per worker, so every pipe is drained while the workers are still writing. A payload larger than the pipe buffer would otherwise block its worker mid-write, and the parent would block reaping a worker that can never finish.
142 143 144 |
# File 'lib/simplecov/parallel_result_merger.rb', line 142 def drain(workers) workers.map { |worker| Thread.new { read_payload(worker.fetch(:reader)) } }.map(&:value) end |
#fan_out(chunks, ignore_timeout:, tracked_files:, context_maps:) ⇒ Object
A fork that fails here raises, and is left to: the runtimes that never
fork are already excluded, so what remains is the OS refusing a process
we expected to get, which says something is wrong with the machine rather
than with the merge.
66 67 68 69 70 71 72 73 |
# File 'lib/simplecov/parallel_result_merger.rb', line 66 def fan_out(chunks, ignore_timeout:, tracked_files:, context_maps:) workers = spawn_workers(chunks, ignore_timeout: ignore_timeout) payloads = collect_payloads(workers) return nil unless payloads payloads.each { |payload| WorkerPayload.absorb(payload, tracked_files, context_maps) } ResultMerger.merge_coverage(*payloads.map { |payload| WorkerPayload.pair(payload) }) end |
#merge_and_store(*file_paths, processes:, ignore_timeout: false) ⇒ Object
18 19 20 21 22 23 24 |
# File 'lib/simplecov/parallel_result_merger.rb', line 18 def merge_and_store(*file_paths, processes:, ignore_timeout: false) return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2 result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout) ResultMerger.store_result(result) if result result end |
#merge_results(*file_paths, processes:, ignore_timeout: false) ⇒ Object
26 27 28 29 30 31 32 33 34 35 |
# File 'lib/simplecov/parallel_result_merger.rb', line 26 def merge_results(*file_paths, processes:, ignore_timeout: false) tracked_files = Set.new context_maps = ContextMap::Union.new pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout, tracked_files: tracked_files, context_maps: context_maps) return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair command_names, coverage = pair ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map) end |
#read_payload(reader) ⇒ Object
146 147 148 149 150 151 152 153 |
# File 'lib/simplecov/parallel_result_merger.rb', line 146 def read_payload(reader) # The writer is a fork of this very process and the pipe never leaves it, # so this is our own data coming back through our own kernel buffer, not # input. Marshal.load(reader) # rubocop:disable Security/MarshalLoad rescue nil end |
#run_in_child(reader, writer, chunk, ignore_timeout) ⇒ Object
exit! rather than exit because the child must never fall through to
the collating process's inherited at_exit handlers, SimpleCov's own
report generation included.
111 112 113 114 |
# File 'lib/simplecov/parallel_result_merger.rb', line 111 def run_in_child(reader, writer, chunk, ignore_timeout) reader.close exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout)) end |
#run_worker(chunk, writer, ignore_timeout:) ⇒ Object
Kept free of the exit itself so it can be exercised in-process.
117 118 119 120 121 122 123 124 |
# File 'lib/simplecov/parallel_result_merger.rb', line 117 def run_worker(chunk, writer, ignore_timeout:) Marshal.dump(WorkerPayload.build(chunk, ignore_timeout: ignore_timeout), writer) writer.close 0 rescue => e warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e}" if SimpleCov.print_errors 1 end |
#spawn_worker(chunk, ignore_timeout:) ⇒ Object
The caller cleans up the workers it knows about, but this pipe is ours.
The safe navigation keeps the cleanup well-defined when IO.pipe itself
raised, and Steep cannot type body locals inside an ensure.
96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/simplecov/parallel_result_merger.rb', line 96 def spawn_worker(chunk, ignore_timeout:) reader, writer = IO.pipe pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) } {pid: pid, reader: reader} ensure # @type var writer: IO? # @type var reader: IO? # @type var pid: Integer? writer&.close reader&.close unless pid end |
#spawn_workers(chunks, ignore_timeout:) ⇒ Object
75 76 77 78 79 80 81 82 83 84 |
# File 'lib/simplecov/parallel_result_merger.rb', line 75 def spawn_workers(chunks, ignore_timeout:) workers = [] #: Array[Hash[Symbol, untyped]] chunks.each do |chunk| workers << spawn_worker(chunk, ignore_timeout: ignore_timeout) rescue abandon(workers) raise end workers end |
#succeeded?(pid) ⇒ Boolean
155 156 157 158 159 160 161 162 |
# File 'lib/simplecov/parallel_result_merger.rb', line 155 def succeeded?(pid) _pid, status = Process.wait2(pid) status.success? rescue SystemCallError # Errno::ECHILD: nothing left to reap, so there is no status to judge # this worker's slice by and we have to assume it did not finish. false end |
#warn_about_failed_workers(failed, total) ⇒ Object
164 165 166 167 168 169 |
# File 'lib/simplecov/parallel_result_merger.rb', line 164 def warn_about_failed_workers(failed, total) return unless SimpleCov.print_errors warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \ "merging the resultsets in this process instead." end |