diff --git a/script/ci_parts/run_specs_file_by_file b/script/ci_parts/run_specs_file_by_file index 9a1f6e8cf..c1fc69d78 100755 --- a/script/ci_parts/run_specs_file_by_file +++ b/script/ci_parts/run_specs_file_by_file @@ -6,22 +6,15 @@ source "script/ci_parts/setup_env" "test" $1 $2 # of entire gems when just one spec file is run. unset COVERAGE -# Setup standalone binstubs for RSpec that we use below. -bundle install --standalone && bundle binstubs rspec-core --standalone - function run_gem_specs_file_by_file() { gem=$1 pushd $gem - for file in `find spec -iname '*_spec.rb'`; do - echo "Running $file" - - # Note: here we avoid using `bundle exec`, opting for a binstub instead. - # Our `bundle install` command (with `--standalone` and `binstubs` options) creates the - # rspec binstub in a way where it won't actually load bundler at runtime. This is - # *slightly* faster. Not enough to usually matter, but it adds up when we boot rspec - # once for each spec file as we do here! - ../bin/rspec $file -b --format progress --no-profile - done + # Note: here we avoid booting `rspec` (and paying for `bundle exec`) once per spec + # file. The runner script boots bundler and rspec-core a single time and then runs + # each spec file in a `fork`, so every file still executes in its own isolated + # process. That saves 1-2 seconds per spec file, which adds up over the hundreds of + # spec files this build part covers. + ../script/rspec_file_by_file `find spec -iname '*_spec.rb'` popd } diff --git a/script/rspec_file_by_file b/script/rspec_file_by_file new file mode 100755 index 000000000..77d5e865f --- /dev/null +++ b/script/rspec_file_by_file @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby + +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +# Runs each given spec file in a separate, isolated process -- equivalent to booting +# `rspec ` once per file, but far cheaper: bundler and rspec-core are loaded only +# once, in this parent process, and each spec file then runs in a `fork` of it. +# +# The parent never loads any ElasticGraph code or spec code, so each child starts from +# the same clean slate as a freshly booted `rspec` process. That preserves what +# file-by-file runs are meant to verify: that every spec file requires everything it +# needs and can run in isolation. +# +# Unlike our previous approach (`bundle install --standalone` with binstubs, which skips +# RubyGems gem activation entirely), children inherit a normally-activated bundle, so +# gems that consult `Gem.loaded_specs` behave the same as they do in production. See +# https://github.com/elastic/elastic-transport-ruby/pull/128 for an example of a gem +# that breaks when loaded without activation. + +invocation_env = ENV.to_h + +ENV["BUNDLE_GEMFILE"] ||= ::File.expand_path("../Gemfile", __dir__) +require "bundler/setup" +require "rspec/core" + +# Booting bundler above mutated ENV (BUNDLE_GEMFILE, RUBYOPT, etc.) and captured that +# mutated state in `Bundler::ORIGINAL_ENV`, which children inherit. A freshly booted +# `rspec` process wouldn't have any of that: it would load bundler lazily (if at all) +# with the unmodified environment, so e.g. `Bundler.with_original_env` in a spec would +# restore the clean invocation environment for shelled-out commands. Restore both here +# so forked children observe the same environment a fresh process would. +ENV.replace(invocation_env) +::Bundler::ORIGINAL_ENV.replace(invocation_env) + +ARGV.each do |spec_file| + puts "Running #{spec_file}" + + pid = fork do + exit ::RSpec::Core::Runner.run([spec_file, "-b", "--format", "progress", "--no-profile"]) + end + + _, status = ::Process.wait2(pid) + exit(status.exitstatus || 1) unless status.success? +end