Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6ed49f9
autoresearch: baseline - ~600s test time
paracycle Mar 10, 2026
61965cd
exp3: use default minitest reporter instead of SpecReporter
paracycle Mar 10, 2026
255d744
exp4: disable debug prelude to reduce test startup overhead
paracycle Mar 10, 2026
3b667b5
exp6: reduce Rails logging overhead in tests
paracycle Mar 10, 2026
c1be355
exp7: use minitest/hooks instead of minitest/hooks/default
paracycle Mar 10, 2026
01a2f83
exp9: set RAILS_ENV=test for potential test-specific optimizations
paracycle Mar 10, 2026
9af27f0
exp10: silence deprecation warnings to reduce output overhead
paracycle Mar 10, 2026
f9ba794
exp15: cache Gem.install('bundler') across test runs
paracycle Mar 10, 2026
554900c
exp16: add --jobs=4 --prefer-local to bundle install in tests
paracycle Mar 10, 2026
4df283f
exp19: cache Gemfile.lock by content hash to avoid redundant bundle i…
paracycle Mar 10, 2026
5b5b8cf
exp21: add --quiet to bundle install to reduce I/O overhead
paracycle Mar 10, 2026
c91806a
exp24: add --retry=0 to bundle install to avoid retries in tests
paracycle Mar 10, 2026
eeb452c
fix: remove RAILS_ENV=test setting that broke addon tests
paracycle Mar 10, 2026
48ca13f
fix: include gemspec content in lockfile cache key to handle version …
paracycle Mar 10, 2026
83a9953
exp25: replace sorbet subprocess syntax check with in-process Prism.p…
paracycle Mar 11, 2026
75b171c
exp26: disable runtime type checking in tapioca subprocesses during t…
paracycle Mar 11, 2026
1191d8a
exp27: use ruby -rbundler/setup instead of bundle exec for tapioca co…
paracycle Mar 11, 2026
607c944
exp28: file-lock Gem.install to enable safe parallel test execution
paracycle Mar 11, 2026
2aeff2e
exp29: add bin/parallel_test for 4-worker parallel test execution (~2…
paracycle Mar 11, 2026
b3b6776
exp30: add --disable=did_you_mean to tapioca subprocesses for faster …
paracycle Mar 11, 2026
2ecd26d
exp31: skip sorbet namer validation in tapioca subprocesses for tests…
paracycle Mar 11, 2026
cebf824
exp32: replace tapioca('configure') with in-process configure! to avo…
paracycle Mar 11, 2026
4650d61
update parallel_test runtime estimates to match current measurements
paracycle Mar 11, 2026
9b7a709
use bin/parallel_test in CI and only exclude run_gem_rbi_check on Rub…
paracycle Mar 11, 2026
c6459df
increase addon_spec wait_until_exists timeout from 4s to 30s for CI p…
paracycle Mar 11, 2026
c49556c
revert to bundle exec for tapioca subprocesses to fix gem isolation o…
paracycle Mar 11, 2026
39f3877
fix tapioca() to use bundle_exec with bundler version pinning for pro…
paracycle Mar 11, 2026
ba30894
remove RUBYOPT override that clobbered bundler's -rbundler/setup in s…
paracycle Mar 12, 2026
78c79b1
fix lockfile cache: still run bundle install to ensure gems are insta…
paracycle Mar 12, 2026
c476f5d
fix rubocop style offenses
paracycle Mar 12, 2026
d9b0ea5
remove accidental test.rb scratch file
paracycle Mar 12, 2026
d7f8d36
revert CI to bin/test instead of bin/parallel_test to measure serial …
paracycle Mar 12, 2026
b38be1b
Revert "revert CI to bin/test instead of bin/parallel_test to measure…
paracycle Mar 12, 2026
3c18d9e
fix parallel_test output: capture per-worker output to temp files and…
paracycle Mar 12, 2026
b6ff842
use GitHub Actions collapsible groups for parallel test output
paracycle Mar 12, 2026
111364d
fix parallel test race conditions: serialize bundle install with glob…
paracycle Mar 12, 2026
bacf6a6
fix ETXTBSY: use read-write lock so bundle exec never races with bund…
paracycle Mar 12, 2026
56a9d21
add live progress monitoring to parallel test runner
paracycle Mar 12, 2026
c039ac8
fix rubocop offenses in parallel_test: extract methods to reduce nesting
paracycle Mar 12, 2026
58eb8fa
restore bin/test to match main — optimizations live in bin/parallel_test
paracycle Mar 12, 2026
a85e195
replace bin/parallel_test with Minitest parallelize_me! module
paracycle Mar 13, 2026
033c3f4
fix Sorbet typecheck: use T::Module[top] for generic Module parameter
paracycle Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/tapioca/helpers/rbi_files_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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... ")
Expand Down
17 changes: 8 additions & 9 deletions lib/tapioca/helpers/test/dsl_compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions lib/tapioca/helpers/test/parallel.rb
Original file line number Diff line number Diff line change
@@ -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
#: (T::Module[top] base) -> void
def included(base)
T.cast(base, T.class_of(Minitest::Test)).parallelize_me!
end
end
end
end
end
end
1 change: 1 addition & 0 deletions spec/dsl_spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

class DslSpec < Minitest::Spec
include Tapioca::Helpers::Test::DslCompiler
include Tapioca::Helpers::Test::Parallel

class << self
#: -> singleton(DslSpec)
Expand Down
2 changes: 2 additions & 0 deletions spec/executor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
194 changes: 165 additions & 29 deletions spec/helpers/mock_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# frozen_string_literal: true

require "open3"
require "digest"
require "helpers/mock_gem"

module Tapioca
Expand All @@ -10,6 +11,9 @@ class MockProject < Spoom::Context
# Path to Tapioca's source files
TAPIOCA_PATH = (Pathname.new(__FILE__) / ".." / ".." / "..").to_s #: String

# Directory for caching Gemfile.lock files and cross-process lock/marker files
LOCKFILE_CACHE_DIR = "/tmp/tapioca/tests/lockfile_cache" #: String

# 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)
Expand Down Expand Up @@ -60,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)
Expand All @@ -68,59 +79,184 @@ 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?
::Gem.install("bundler")
"bundle install"
# 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
::Gem.install("bundler", bundler_version)
"bundle _#{bundler_version}_ install"
"bundle _#{bundler_version}_ install --jobs=4 --quiet --retry=0"
end

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

out, err, status = Open3.capture3(cmd, opts)
Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
Spoom::ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
end
end
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

# 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)
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")
#: (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")
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"] = if enforce_typechecking
"1"
env = {
"ENFORCE_TYPECHECKING" => enforce_typechecking ? "1" : "0",
}
env["TAPIOCA_SKIP_VALIDATION"] = "1" if skip_validation

bundle_exec("tapioca #{args.join(" ")}", env)
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

# 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
warn("Ignoring typechecking errors in CLI test")
"0"
".bundler_installed_#{bundler_version}"
end
marker_path = File.join(LOCKFILE_CACHE_DIR, marker_name)

bundle_exec(exec_command.join(" "), env)
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

private
# 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)
Expand Down
1 change: 1 addition & 0 deletions spec/rails_spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Dummy < Rails::Application
}
}
config.logger = Logger.new('/dev/null')
Copy link
Contributor

@amomchilov amomchilov Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this would still be making syscalls. Wanna use a NullLogger like so?

class NullLogger
  include Singleton

  # : (*untyped) -> nil
  def unknown(*) = nil
  # : (*untyped) -> nil
  def fatal(*) = nil
  # : (*untyped) -> nil
  def error(*) = nil
  # : (*untyped) -> nil
  def warn(*) = nil
  # : (*untyped) -> nil
  def info(*) = nil
  # : (*untyped) -> nil
  def debug(*) = nil
  # : (untyped) -> nil

  # : () -> bool
  def fatal? = false
  # : () -> bool
  def error? = false
  # : () -> bool
  def warn? = false
  # : () -> bool
  def info? = false
  # : () -> bool
  def debug? = false

  def level=(_)
    nil
  end
end
Suggested change
config.logger = Logger.new('/dev/null')
config.logger = NullLogger.instance

I'm curious to measure how much it might help

config.log_level = :fatal
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add a comment to explain that this is a performance optimization?

end
# The defaults are loaded with the first two version numbers (e.g. "7.1")
defaults_version = Rails.gem_version.segments.take(2).join(".")
Expand Down
19 changes: 3 additions & 16 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,18 @@
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"
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"

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/})
Comment on lines -26 to -29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we ok with giving this up?


Minitest::Reporters.use!(SpecReporter.new(color: true), ENV, backtrace_filter)
# Use default minitest reporter (faster than SpecReporter)

require "minitest/mock"

Expand Down
2 changes: 1 addition & 1 deletion spec/tapioca/addon_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def shutdown_client
end

def wait_until_exists(path)
Timeout.timeout(4) do
Timeout.timeout(30) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because we're expecting more CPU contention, now that we might actually be fully saturating multiple threads?

sleep(0.2) until File.exist?(path)
end
rescue Timeout::Error
Expand Down
Loading
Loading