lib/simplecov/parallel_coordination.rb



# frozen_string_literal: true

# simplecov:disable
# Methods below only fire under a parallel test runner; not reachable
# from a single-process rspec run. Cucumber's test_projects exercise
# the parallel_tests integration end-to-end in subprocesses, but
# those subprocesses don't merge their Coverage data back into the
# parent this dogfood report measures.
module SimpleCov
  class << self
    # @api private
    def final_result_process?
      adapter = SimpleCov::ParallelAdapters.current
      # No recognized parallel-test adapter. A subprocess forked while
      # coverage was running is never the final reporter — the process that
      # spawned it merges every slice and produces the report. Without this,
      # fork-based runners that don't set TEST_ENV_NUMBER (e.g. Minitest's
      # `parallelize`) have every worker produce the final report and its
      # warnings. See issue #1171.
      return !forked_subprocess? unless adapter

      adapter.first_worker?
    end

    # @api private
    def wait_for_other_processes
      adapter = SimpleCov::ParallelAdapters.current
      return unless adapter && final_result_process?

      # Native synchronization first (adapters that wrap a runner with a
      # real "wait" primitive — parallel_tests'
      # `wait_for_other_processes_to_finish` — implement this; adapters
      # without a native API no-op and rely on the polling fallback below).
      adapter.wait_for_siblings

      # The native wait can return before sibling at_exit handlers finish
      # writing resultsets, and adapters without a native wait have
      # nothing else. Either way, poll the resultset cache until all
      # expected workers have reported or a timeout is reached. Capture
      # the outcome so `ready_to_process_results?` can suppress min/max
      # threshold checks against a partial total.
      @parallel_results_complete =
        wait_for_parallel_results(adapter.expected_worker_count, native_wait: adapter.native_wait?)
    end

    # @api private — true when every sibling reported its resultset
    # before the wait deadline. Defaults to true outside a parallel
    # run (when `wait_for_other_processes` is a no-op).
    def parallel_results_complete?
      defined?(@parallel_results_complete) ? @parallel_results_complete : true
    end

    # @api private — seconds the resultset count must hold steady, after a
    # native wait, before we accept fewer than `expected` workers as final.
    PARALLEL_RESULTS_SETTLE = 0.5
    private_constant :PARALLEL_RESULTS_SETTLE

    # @api private — returns true when the reporting worker has every
    # resultset it's going to get, false on timeout. Single-process runs
    # (expected <= 1) short-circuit to true with no waiting.
    #
    # Normally we poll until `expected` (= PARALLEL_TEST_GROUPS) workers have
    # reported or `SimpleCov.parallel_wait_timeout` elapses; raise that setting
    # when a slow worker routinely finishes well after the others.
    #
    # When a native wait already confirmed every sibling PROCESS exited
    # (`native_wait`), no further resultset will appear, so a count below
    # `expected` just means some workers produced none — e.g. parallel_test
    # groups that got no spec file on a machine with more cores than files.
    # Once the count then holds steady for `PARALLEL_RESULTS_SETTLE` we accept
    # it as final rather than blocking for the whole timeout. Without a native
    # wait (GenericAdapter) we can't tell an idle worker from a slow one, so we
    # keep waiting the full timeout.
    def wait_for_parallel_results(expected, native_wait: false)
      return true unless expected > 1 # simplecov:disable branch — only false in real parallel runs

      deadline = monotonic_time + parallel_wait_timeout
      tracker = {count: 0, since: monotonic_time}
      loop do
        seen = SimpleCov::ResultMerger.read_resultset.size
        return true if seen >= expected
        return true if native_wait && resultset_count_settled?(tracker, seen)
        return false if parallel_wait_timed_out?(deadline, expected, seen)

        sleep 0.1
      end
    end

    # Track whether the resultset count has held steady (and positive) for
    # `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and
    # the time it last changed across poll iterations.
    def resultset_count_settled?(tracker, count)
      if count > tracker[:count]
        tracker[:count] = count
        tracker[:since] = monotonic_time
        return false
      end

      count.positive? && (monotonic_time - tracker[:since]) >= PARALLEL_RESULTS_SETTLE
    end

    def monotonic_time
      Process.clock_gettime(Process::CLOCK_MONOTONIC)
    end

    # @api private — true once the wait deadline has passed; warns on
    # the first timeout so the user knows the merged total is partial.
    def parallel_wait_timed_out?(deadline, expected, seen)
      return false unless monotonic_time > deadline

      warn_about_incomplete_parallel_results(expected, seen)
      true
    end

    # @api private
    def warn_about_incomplete_parallel_results(expected, seen)
      return unless print_errors

      warn SimpleCov::Color.colorize(
        "Only #{seen} of #{expected} parallel-test workers reported within " \
        "#{parallel_wait_timeout}s, so coverage totals are partial and minimum / " \
        "maximum coverage checks are skipped for this run. Increase " \
        "SimpleCov.parallel_wait_timeout if a worker routinely needs longer.",
        :yellow
      )
    end
  end
end
# simplecov:enable