From 7c3a65985dd6d2938a48b5e99e72db2b891e81c5 Mon Sep 17 00:00:00 2001 From: Josh Wilson Date: Fri, 3 Jul 2026 17:51:04 -0500 Subject: [PATCH 1/2] Replace standalone bundle with fork runner in file-by-file CI part The run_specs_file_by_file CI part boots RSpec once per spec file (288 files) to verify each file can run in isolation. To avoid paying for bundler on every boot, it used `bundle install --standalone` with binstubs, which loads gems via a static $LOAD_PATH file without ever activating them through RubyGems. That leaves `Gem.loaded_specs` empty, which breaks gems that consult it -- most recently elastic-transport 8.5.2+, which crashed on every request and forced us to pin the gem (see #1290 and https://github.com/elastic/elastic-transport-ruby/pull/128). Instead, boot bundler and rspec-core once in a small runner script and run each spec file in a fork of that parent. The parent loads no ElasticGraph or spec code, so each child still starts from the same clean slate as a freshly booted rspec process, preserving the isolation guarantee -- but children inherit a normally-activated bundle, so gems that consult `Gem.loaded_specs` behave as they do in production, and we can drop the standalone workaround. --- script/ci_parts/run_specs_file_by_file | 19 ++++--------- script/rspec_file_by_file | 39 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100755 script/rspec_file_by_file 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..0fe4ec1c7 --- /dev/null +++ b/script/rspec_file_by_file @@ -0,0 +1,39 @@ +#!/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. + +ENV["BUNDLE_GEMFILE"] ||= ::File.expand_path("../Gemfile", __dir__) +require "bundler/setup" +require "rspec/core" + +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 From 2c9913994dd42757beeb24aeb47f1bfe4ee956b0 Mon Sep 17 00:00:00 2001 From: Josh Wilson Date: Fri, 3 Jul 2026 18:22:53 -0500 Subject: [PATCH 2/2] Restore the invocation environment for forked children Booting bundler in the runner parent mutates ENV (BUNDLE_GEMFILE, RUBYOPT, etc.) and captures that mutated state in `Bundler::ORIGINAL_ENV`, which forked children inherit. A freshly booted rspec process has neither: it loads bundler lazily with the unmodified environment. This difference broke a schema_definition spec that shells out to `bundle` inside `Bundler.with_original_env` -- the shelled command picked up the repo root's BUNDLE_GEMFILE instead of the temp project's Gemfile. Snapshot the environment before booting bundler and restore both ENV and `Bundler::ORIGINAL_ENV` afterwards, so children observe the same environment a fresh process would. --- script/rspec_file_by_file | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/script/rspec_file_by_file b/script/rspec_file_by_file index 0fe4ec1c7..77d5e865f 100755 --- a/script/rspec_file_by_file +++ b/script/rspec_file_by_file @@ -23,10 +23,21 @@ # 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}"