[ruby-dev:52235] [Ruby Bug#22123] Ruby::Box + `BUNDLER_SETUP` can evaluate gemspecs before main-box RubyGems initialization
Issue #22123 has been reported by se4weed.dev@gmail.com (Yuto NORINAGA). ---------------------------------------- Bug #22123: Ruby::Box + `BUNDLER_SETUP` can evaluate gemspecs before main-box RubyGems initialization https://bugs.ruby-lang.org/issues/22123 * Author: se4weed.dev@gmail.com (Yuto NORINAGA) * Status: Open * ruby -v: ruby 4.1.0dev (2026-06-13T03:25:52Z master 0e3b8918b3) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- # Ruby::Box + `BUNDLER_SETUP` can evaluate gemspecs before main-box RubyGems initialization ## Subject Ruby::Box + BUNDLER_SETUP can evaluate gemspecs before main-box RubyGems initialization ## Description When Ruby is started with `RUBY_BOX=1` and Bundler's `BUNDLER_SETUP` environment variable is present, RubyGems can require `bundler/setup` while loading `gem_prelude` for the root box. That can make Bundler run code through `TOPLEVEL_BINDING` in the main box before the main box has finished loading RubyGems. In one common case, Bundler evaluates a path gem's `.gemspec`, and a normal gemspec using `Gem::Specification.new` then fails with: ```text uninitialized constant Gem::Specification ``` This happens during Ruby startup, before application code starts. ## Environment Observed with: ```text ruby 4.1.0dev (2026-06-13T03:25:52Z master 0e3b8918b3) +PRISM [arm64-darwin25] macOS 26.4.1 bundler 4.1.0.dev Ruby::Box enabled with RUBY_BOX=1 ``` ## Minimal Reproduction Create a tiny path gem: ```sh rm -rf /tmp/box-bundler-repro mkdir -p /tmp/box-bundler-repro/box_repro cd /tmp/box-bundler-repro ``` `Gemfile`: ```ruby source "https://rubygems.org" gem "box_repro", path: "./box_repro" ``` `box_repro/box_repro.gemspec`: ```ruby Gem::Specification.new do |spec| spec.name = "box_repro" spec.version = "0.1.0" spec.summary = "Ruby::Box BUNDLER_SETUP repro" spec.authors = ["repro"] spec.files = [] end ``` Choose the Ruby executable being tested: ```sh RUBY_UNDER_TEST="${RUBY_UNDER_TEST:-ruby}" ``` Generate the lockfile with the same Ruby/Bundler environment: ```sh "$RUBY_UNDER_TEST" -S bundle lock ``` Then use the same executable to find the bundled `bundler/setup` path: ```sh BUNDLER_SETUP_PATH="$("$RUBY_UNDER_TEST" -rrbconfig -e 'puts File.join(RbConfig::CONFIG["rubylibdir"], "bundler/setup")')" ``` Then run Ruby with `RUBY_BOX=1` and `BUNDLER_SETUP`: ```sh RUBY_BOX=1 \ BUNDLE_GEMFILE="$PWD/Gemfile" \ BUNDLER_SETUP="$BUNDLER_SETUP_PATH" \ "$RUBY_UNDER_TEST" -e 'puts :ok' ``` ## Actual Result ```text [!] There was an error while loading `box_repro.gemspec`: uninitialized constant Gem::Specification. Bundler cannot continue. # from /tmp/box-bundler-repro/box_repro/box_repro.gemspec:1 # -------------------------------------------
Gem::Specification.new do |spec| # spec.name = "box_repro" # -------------------------------------------
## Expected Result
```text
ok
## Control Cases This succeeds without Ruby::Box: ```sh BUNDLE_GEMFILE="$PWD/Gemfile" \ BUNDLER_SETUP="$BUNDLER_SETUP_PATH" \ "$RUBY_UNDER_TEST" -e 'puts :ok' ``` This also succeeds with Ruby::Box if Bundler is loaded later through `-rbundler/setup` instead of RubyGems' `BUNDLER_SETUP` hook: ```sh RUBY_BOX=1 \ BUNDLE_GEMFILE="$PWD/Gemfile" \ "$RUBY_UNDER_TEST" -rbundler/setup -e 'puts :ok' ``` So this does not appear to be application-specific. The failure is triggered by the Bundler startup environment, especially `BUNDLER_SETUP`. ## Analysis Ruby currently loads `gem_prelude` for both the root and main boxes: ```c // builtin.c rb_load_gem_prelude((VALUE)rb_root_box()); rb_load_gem_prelude((VALUE)rb_main_box()); ``` `gem_prelude.rb` requires RubyGems: ```ruby require "rubygems" ``` At the end of RubyGems, Bundler may be loaded from `BUNDLER_SETUP`: ```ruby # lib/rubygems.rb require ENV["BUNDLER_SETUP"] if ENV["BUNDLER_SETUP"] && !defined?(Bundler) ``` This means the root box's RubyGems load can trigger `bundler/setup` before the main box's RubyGems state is ready. Bundler evaluates path gemspecs with `TOPLEVEL_BINDING`: ```ruby # lib/bundler.rb eval(contents, TOPLEVEL_BINDING.dup, path.expand_path.to_s) ``` To confirm which box evaluated the gemspec and which RubyGems constants were available, I temporarily added the following diagnostics at the beginning of the path gem's `.gemspec`, before `Gem::Specification.new`: ```ruby if defined?(Ruby::Box) warn "box=#{Ruby::Box.current.inspect}" warn "root=#{Ruby::Box.root.inspect}" warn "main=#{Ruby::Box.main.inspect}" else warn "box=nil" warn "root=nil" warn "main=nil" end warn "gem=#{!!defined?(Gem)}" warn "gem_spec=#{!!defined?(Gem::Specification)}" warn "gem_version=#{!!defined?(Gem::VERSION)}" ``` During the failure, that diagnostic output showed: ```text box=#<Ruby::Box:3,user,main> root=#<Ruby::Box:2,root> main=#<Ruby::Box:3,user,main> gem=true gem_spec=false gem_version=false ``` So the gemspec is evaluated in the main box, but before the main box's RubyGems state is ready. The diagnostics suggest that `Gem` is present, but RubyGems constants such as `Gem::Specification` and `Gem::VERSION` are not fully initialized in the main box at that point. ## Why this should not be fixed in gemspecs Normal gemspecs conventionally use: ```ruby Gem::Specification.new do |spec| # ... end ``` Adding `require "rubygems/specification"` to the gemspec does not seem like the right fix. In local testing it only moved the failure further into RubyGems initialization, with other missing pieces such as: ```text Gem::Deprecate Gem::Requirement Gem::VERSION Gem.platforms ``` The problem is that Bundler is evaluating the gemspec before RubyGems is ready in the current box. ## Possible Fix Direction The core issue seems to be that `BUNDLER_SETUP` is consumed from the root box's RubyGems prelude before the main box's RubyGems state is ready. One possible direction is to fix this in Ruby startup / Ruby::Box prelude sequencing. For example, `builtin.c` could prevent the root box `gem_prelude` from consuming `BUNDLER_SETUP`, while still allowing the main box `gem_prelude` to consume it normally. Another possible direction is to fix this on the Bundler/RubyGems side, so that the `BUNDLER_SETUP` hook does not run user/top-level code in the main box before the main box RubyGems state is ready. The invariant I think we want is: `BUNDLER_SETUP` should not be able to trigger main-box code execution before the main box's RubyGems initialization is complete. With a local prototype that temporarily hides `BUNDLER_SETUP` while loading the root box prelude in `builtin.c`, then restores it before loading the main box prelude, the minimal reproduction succeeds. ## Related PR A proposed fix with a regression test is available at: https://github.com/ruby/ruby/pull/17323 -- https://bugs.ruby-lang.org/
participants (1)
-
se4weed.dev@gmail.com (Yuto NORINAGA)