Skip to content

Instantly share code, notes, and snippets.

@tagomoris
Created July 26, 2026 04:12
Show Gist options
  • Select an option

  • Save tagomoris/c8856aee83b7442c8af6e4c326517c02 to your computer and use it in GitHub Desktop.

Select an option

Save tagomoris/c8856aee83b7442c8af6e4c326517c02 to your computer and use it in GitHub Desktop.

bundle exec ruby に限定した詳細を詰めます。まず Box の API と、失敗時の gemspec 評価コンテキストを計測します。

Gem の状態が明確に違います(メソッド数 106 vs 238、object_id も別)。namespace を直接特定します。

namespace が確定しました。最後に root 側の状態を確認して、話を閉じます。

計測が揃いました。bundle exec ruby(rake なし)に限定した詳細です。

起きていること

RUBY_BOX=1 のとき Ruby は少なくとも2つの namespace を持ちます — root(#<Ruby::Box:2,root>)と main(#<Ruby::Box:3,user,main>)。あなたのコードは main で動きます。

bundle exec は子プロセスに BUNDLER_SETUP を渡します。これを拾うのが rubygems.rb の**最終行(1474行目)**です:

require ENV["BUNDLER_SETUP"] if ENV["BUNDLER_SETUP"] && !defined?(Bundler)

つまり Bundler の起動が、ユーザーコードが動き出す前・RubyGems 自身のブートストラップの途中に前倒しされます。この時点での両 namespace の状態を gemspec の中から実測すると:

[where] gemspec evaluated in : #<Ruby::Box:3,user,main>
[main]  Gem.methods.size=106  Gem::Specification?=nil        Bundler?=nil
[root]  Gem.methods.size=238  Gem::Specification?="constant" Bundler?="constant"

ここにねじれがあります:

  • Bundler は root にロードされている(main からは Bundler すら見えない)
  • 一方 gemspec は main で評価される。Bundler は Bundler.eval_gemspeceval(contents, TOPLEVEL_BINDING.dup, path) を使い、TOPLEVEL_BINDING の self は main の main オブジェクトだからです
  • そしてその瞬間の main の RubyGems はまだ組み立て途中です。Gem は存在するがメソッドは 106 個しかなく(root は 238)、Gem::Specification は未定義

結果、gemspec の Gem::Specification.newuninitialized constant Gem::Specification で落ちます。これが最初に遭遇されたエラーです。

require "rubygems" を足すと何が変わるか

その行は main 側の RubyGems を最後まで読み込ませるので、Gem::Specification は確かに見えるようになります。しかし別物です:

current(main) Gem::Specification.object_id = 664
root's        Gem::Specification.object_id = 672
same object? false

gemspec が作る spec は main の Gem::Specification のインスタンスです。ところが要求元の Bundler は root にいて、root の Gem::Specification を前提に照合します。クラスが別オブジェクトなので Definition#materialize が「path source から test-unit-3.7.9 が得られなかった」と判断し:

Could not find test-unit-3.7.9 in gemspec at `.`

になります。require "rubygems" は症状を A から B に付け替えただけ、というのはこの意味です。

なお計測中に、この経路では gemspec が 2回評価されるのも観測できました。1回目の require "rubygems" が main 側で rubygems.rb を読み切り、その最終行 1474 が(main では Bundler 未定義なので)もう一度 bundler/setup を呼び、Bundler.setup → path source → gemspec 評価、と再入するためです。

なぜ BUNDLER_SETUP を外すと直るのか

外すと 1474 行目のフックが空振りし、Bundler の起動は bundle exec がもう一つ用意している RUBYOPT=-r<path>/bundler/setup に委ねられます。こちらは gem_prelude の後、ブート完了後に走るため、その時点で main の RubyGems は完成済み(Gem.methods.size=238Gem::Specification 定義済み)です。同じ namespace の中で完結するのでクラスのねじれも起きません:

[gemspec] defined?(Gem::Specification)="constant" defined?(Bundler)="constant"
[gemspec] Gem.methods.size=238
ok

要するに タイミングの問題です。BUNDLER_SETUP 経路は「RubyGems が main を組み立て終わる前」に Bundler を root で起動してしまい、RUBYOPT 経路は「組み上がった後」に main で起動します。RUBY_BOX なしでは両者に差が出ない(namespace が1つで、どちらの経路でも同じ Gem::Specification になる)ため表面化しません。

対処

RUBY_BOX=1 bundle exec env -u BUNDLER_SETUP ruby -e '...'

env -u が効くのは、まさに 1474 行目の条件 ENV["BUNDLER_SETUP"] && を偽にするからです。RUBYOPT は残るので Bundler のセットアップ自体は行われ、bundle exec の gem 解決もそのまま効きます。

前回述べたとおり、これは子プロセスを生成しない ruby 直実行だから成立します。rake を挟むと、rake プロセスが bundler/setup を読んだ時点で Bundler が BUNDLER_SETUP を ENV に再設定するので、孫プロセスがまた同じ罠を踏みます。

test-unit 側に非はなく、test-unit.gemspecrequire "rubygems" は削除して構いません(この経路を避ければ不要であることは確認済みです)。根本的には Ruby::Box と RubyGems ブートストラップの相互作用の問題なので、ruby-core に報告するのが筋だと思います。報告する場合、rubygems.rb:1474TOPLEVEL_BINDING(main)から見て未完成な RubyGems の上で Bundler を root に起動してしまう、という形で書けば再現手順込みで伝わるはずです。

リポジトリには変更を加えていません。計測は作業ツリーのコピー(scratchpad/fullcopy)で行いました。

BUNDLER_SETUP の正体を、値・設定箇所・目的の順に確認します。

BUNDLER_SETUP の設定箇所と中身が取れました。値と、なぜ RUBYOPT と二重に存在するのかを確認します。

BUNDLER_SETUP の正体が分かりました。

何者か

bundler/setup.rb への絶対パスです。それ以上でも以下でもありません。

BUNDLER_SETUP=/Users/s-tagomori/.rbenv/versions/4.1-dev/lib/ruby/4.1.0/bundler/setup
RUBYOPT      =-r/Users/s-tagomori/.rbenv/versions/4.1-dev/lib/ruby/4.1.0/bundler/setup

見てのとおり RUBYOPT-r が指す先とまったく同じファイルです。つまり bundle exec は「子プロセスで bundler/setup.rb を読ませる」ための経路を2つ渡しています。

どこで設定されるか

bundler/shared_helpers.rb の set_bundle_variables で、他の BUNDLE_* 変数とまとめてセットされます:

def set_bundle_variables
  Bundler::SharedHelpers.set_env "BUNDLE_BIN_PATH", bundle_bin_path
  Bundler::SharedHelpers.set_env "BUNDLE_GEMFILE",  find_gemfile.to_s
  Bundler::SharedHelpers.set_env "BUNDLE_LOCKFILE", default_lockfile.to_s
  Bundler::SharedHelpers.set_env "BUNDLER_VERSION", Bundler::VERSION
  Bundler::SharedHelpers.set_env "BUNDLER_SETUP",   File.expand_path("setup", __dir__)
end

__dir__.../lib/ruby/4.1.0/bundler なので、値は同ディレクトリの setup(拡張子なし = setup.rb)になります。

これは Bundler がロードされるたびに呼ばれます。前回 rake testenv -u が効かなかったのは、rake プロセスが bundler/setup を読んだ時点でここが再実行され、BUNDLER_SETUP が ENV に復活して孫プロセスへ渡るからです。

EnvironmentPreserver::BUNDLER_KEYS にも登録されていて、BUNDLER_ORIG_BUNDLER_SETUP として元の値が退避されます(今回は元々未設定なので BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL)。Bundler.unbundle_env は逆に env.delete("BUNDLER_SETUP")RUBYOPT からの -r...bundler/setup 削除をセットで行っており、両者が同じ役割の対であることがコード上も明示されています。

消費側と、2経路の違い

読む側は rubygems.rb の最終行だけです:

require ENV["BUNDLER_SETUP"] if ENV["BUNDLER_SETUP"] && !defined?(Bundler)

同じファイルを読ませるのに、発火タイミングが違うのが今回の肝でした:

経路 発火点 実行される namespace
BUNDLER_SETUP RubyGems 自身のロード中(rubygems.rb の最終行) Bundler は root に載る。main の RubyGems はまだ未完成
RUBYOPT -r RubyGems のロード完了後、スクリプト実行前 main で完結

先に走った方が !defined?(Bundler)$LOADED_FEATURES で他方を無効化するので、通常は「先着した BUNDLER_SETUP 経路が勝つ」ことになります。RUBY_BOX=1 がないときは namespace が1つなので、どちらが勝っても同じ結果になり差は出ません。

env -u BUNDLER_SETUP が回避策として機能するのは、この条件式を偽にして後着の RUBYOPT 経路に処理を譲らせるからで、Bundler のセットアップ自体を止めているわけではありません。

なお、なぜ RubyGems 側にこのフックを増やしたのかという設計意図までは、手元の 4.1-dev には CHANGELOG が同梱されておらず(gem ディレクトリには exe しかありません)確認できませんでした。RUBYOPT は子プロセスが上書き・消去してしまうことがあるので、それに依存しない冗長経路として追加されたのだろう、というのが素直な読みですが、これは私の推測です。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment