From dd2afc14143d74f1c54e88defdf9e8d34e43b44e Mon Sep 17 00:00:00 2001 From: Chad Wilson <29788154+chadlwilson@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:58:54 +0800 Subject: [PATCH] fix: improve isolation and bundler version/CLI boot issues with more opinionated boot process - Ensure bundler tries to run with the system (embedded jruby) version by default; ignoring the locked version - Ensure bundler runs frozen by default, to avoid unnecessary resolution attempts and fail fast on gem combinations that won't work. - When booting rails, try and preboot bundler via `Bundler.setup` to workaround the default Rails `config/boot.rb`'s use of the CLI `bundler/setup`. This should make jruby-rack more reliable and easier to debug for modern bundler, requiring less rigorous alignment of bundler versions at build time to runtime, and avoiding lost error messages due to Bundler doing setup in 'CLI' mode, which requires tty checks. # Conflicts: # CHANGELOG.md # pom.xml # src/main/ruby/jruby/rack/booter.rb # src/main/ruby/jruby/rack/rails/environment.rb # src/main/ruby/jruby/rack/version.rb # src/spec/ruby/jruby/rack/rails_booter_spec.rb --- CHANGELOG.md | 3 +- src/main/ruby/jruby/rack/booter.rb | 21 ++++++ src/main/ruby/jruby/rack/rails_booter.rb | 24 ++++++- src/spec/ruby/jruby/rack/booter_spec.rb | 65 +++++++++++++++++++ src/spec/ruby/jruby/rack/rails_booter_spec.rb | 64 ++++++++++++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46d42923..3e94eadf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,9 @@ Breaking configuration capability changes - Drop deprecated `jruby.rack.ignore.env` property, replaced long ago by `jruby.runtime.env` and optional `jruby.runtime.env.rubyopt` - Drop deprecated `jruby.rack.filter.*` properties, replaced long ago by init parameters `addsHtmlToPathInfo` and `verifiesHtmlResource` -## 1.2.8 (UNRELEASED) +## 1.2.8 +- Improve isolation and bundler version/CLI boot issues with more opinionated boot process (#461) - Fix possible infinite loop in Response#isClientAbortException (#449, #450) - Fix startup logging of captured errors when config properties cannot be dumped - Update (bundled) rack to 2.2.24 diff --git a/src/main/ruby/jruby/rack/booter.rb b/src/main/ruby/jruby/rack/booter.rb index 20bff3e6a..9b2aececa 100644 --- a/src/main/ruby/jruby/rack/booter.rb +++ b/src/main/ruby/jruby/rack/booter.rb @@ -90,6 +90,7 @@ def boot! change_working_directory export_global_settings load_settings_from_init_rb + prepare_bundler_env set_relative_url_root run_boot_hooks self @@ -178,6 +179,26 @@ def load_settings_from_init_rb end end + # Default Bundler settings for bundled applications (Rails or plain Rack), guarded on a Gemfile being present. These + # are *defaults* only: values already present in the environment (passed through from the hosting process) or set by + # the application's *init.rb* (evaluated just before this) always take precedence, as does a `.bundle/config` + # shipped with the application (e.g. as generated by Warbler). + def prepare_bundler_env + if ! ENV['BUNDLE_GEMFILE'] && app_path + gemfile = expand_path('Gemfile') + ENV['BUNDLE_GEMFILE'] = gemfile if gemfile && File.exist?(gemfile) + end + return unless ENV['BUNDLE_GEMFILE'] # not a bundled application + + # never "auto-switch" to Gemfile.lock's BUNDLED WITH bundler version - Bundler restarts the process with + # `Kernel.exec` to do so, which cannot work embedded in a JVM. + ENV['BUNDLE_VERSION'] ||= 'system' + # fail fast with a descriptive error on Gemfile vs Gemfile.lock drift instead of attempting a runtime + # re-resolution that reports a misleading GemNotFound. (Deliberately not BUNDLE_DEPLOYMENT, which forces the + # vendor/bundle path.) + ENV['BUNDLE_FROZEN'] ||= 'true' if File.exist?("#{ENV['BUNDLE_GEMFILE']}.lock") + end + def relative_url_root(init_param = 'rack.relative_url_append') relative_url_root = @rack_context.getContextPath || '' if relative_url_append = @rack_context.getInitParameter(init_param) diff --git a/src/main/ruby/jruby/rack/rails_booter.rb b/src/main/ruby/jruby/rack/rails_booter.rb index e9d05cfd8..4e1c66e2d 100644 --- a/src/main/ruby/jruby/rack/rails_booter.rb +++ b/src/main/ruby/jruby/rack/rails_booter.rb @@ -38,7 +38,9 @@ def to_app # Loads the Rails environment (*config/environment.rb*). def load_environment - require expand_path('config/boot.rb') + user_boot = expand_path('config/boot.rb') + prepare_bundler(user_boot) + require user_boot require 'jruby/rack/rails/railtie' require expand_path('config/environment.rb') require 'jruby/rack/rails/extensions' @@ -65,6 +67,26 @@ def run_boot_hooks private + # For a default (unmodified) Rails *config/boot.rb*, runs `Bundler.setup` up-front. `bundler/setup` can swallow setup + # errors and end up calling `exit` when it believes stdout is a tty (which is frequently mis-detected under a servlet + # container); pre-booting makes failures raise instead, so the container logs the actual error. + def prepare_bundler(boot_rb_path) + return unless ENV['BUNDLE_GEMFILE'] # not a bundled application + + if rails_has_default_bundler_boot?(boot_rb_path) + # pre-boot bundler with groups respecting BUNDLE_WITHOUT from the environment + require 'bundler' + Bundler.ui.silence { Bundler.setup } + end + end + + def rails_has_default_bundler_boot?(boot_rb_path) + boot_rb_content = File.read(boot_rb_path) if File.readable?(boot_rb_path) + return false unless boot_rb_content + # Assume default if there is a `require 'bundler/setup'` and no `BUNDLE_WITHOUT` in the boot.rb file. + %r{^\s*require\s+["']bundler/setup["']} =~ boot_rb_content && %r{BUNDLE_WITHOUT} !~ boot_rb_content + end + class << self # @see #RailsRackApplicationFactory diff --git a/src/spec/ruby/jruby/rack/booter_spec.rb b/src/spec/ruby/jruby/rack/booter_spec.rb index 14e99e80e..9cebdb910 100644 --- a/src/spec/ruby/jruby/rack/booter_spec.rb +++ b/src/spec/ruby/jruby/rack/booter_spec.rb @@ -358,4 +358,69 @@ end + describe "#prepare_bundler_env" do + require 'tmpdir'; require 'fileutils' + + before :each do + @original_pwd = Dir.pwd + @original_bundle_env = {} + %w(BUNDLE_GEMFILE BUNDLE_VERSION BUNDLE_FROZEN).each { |k| @original_bundle_env[k] = ENV.delete(k) } + end + + after :each do + Dir.chdir(@original_pwd) + @original_bundle_env.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + FileUtils.rm_rf @app_dir if @app_dir + end + + def boot_app(gemfile = "source 'https://rubygems.org'\n", lockfile = "GEM\n") + @app_dir = File.realpath(Dir.mktmpdir('rack-app')) # macOS: /var -> /private/var + File.write(File.join(@app_dir, 'Gemfile'), gemfile) if gemfile + File.write(File.join(@app_dir, 'Gemfile.lock'), lockfile) if gemfile && lockfile + booter.layout_class = JRuby::Rack::FileSystemLayout + booter.app_path = @app_dir + booter.boot! + end + + it "defaults BUNDLE_GEMFILE, BUNDLE_VERSION and BUNDLE_FROZEN for a bundled (plain Rack) application" do + boot_app + expect( ENV['BUNDLE_GEMFILE'] ).to eq File.join(@app_dir, 'Gemfile') + expect( ENV['BUNDLE_VERSION'] ).to eq 'system' + expect( ENV['BUNDLE_FROZEN'] ).to eq 'true' + end + + it "does not default BUNDLE_FROZEN without a Gemfile.lock" do + boot_app "source 'https://rubygems.org'\n", nil + expect( ENV['BUNDLE_GEMFILE'] ).to eq File.join(@app_dir, 'Gemfile') + expect( ENV['BUNDLE_VERSION'] ).to eq 'system' + expect( ENV['BUNDLE_FROZEN'] ).to be nil + end + + + it "does not touch the environment for a non-bundled application (no Gemfile)" do + boot_app nil + expect( ENV['BUNDLE_GEMFILE'] ).to be nil + expect( ENV['BUNDLE_VERSION'] ).to be nil + expect( ENV['BUNDLE_FROZEN'] ).to be nil + end + + it "tolerates (custom) layouts without a real app path" do + booter.layout = double('layout', :app_path => nil) + expect { booter.send :prepare_bundler_env }.to_not raise_error + expect( ENV['BUNDLE_GEMFILE'] ).to be nil + expect( ENV['BUNDLE_VERSION'] ).to be nil + end + + it "respects values from the environment (or set by init.rb, evaluated before)" do + ENV['BUNDLE_GEMFILE'] = gemfile = File.join(Dir.pwd, 'Gemfile') + ENV['BUNDLE_VERSION'] = 'lockfile' + ENV['BUNDLE_FROZEN'] = 'false' + boot_app + expect( ENV['BUNDLE_GEMFILE'] ).to eq gemfile + expect( ENV['BUNDLE_VERSION'] ).to eq 'lockfile' + expect( ENV['BUNDLE_FROZEN'] ).to eq 'false' + end + + end + end diff --git a/src/spec/ruby/jruby/rack/rails_booter_spec.rb b/src/spec/ruby/jruby/rack/rails_booter_spec.rb index ea394c46d..97a4c0798 100644 --- a/src/spec/ruby/jruby/rack/rails_booter_spec.rb +++ b/src/spec/ruby/jruby/rack/rails_booter_spec.rb @@ -99,6 +99,70 @@ expect(booter.public_path).to eq "." end + describe "#prepare_bundler (default rails boot.rb pre-boot)" do + require 'tmpdir'; require 'fileutils' + + DEFAULT_RAILS_BOOT_RB = <<-BOOT # Rails 5.x - 8.x default config/boot.rb + ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + + require "bundler/setup" # Set up gems listed in the Gemfile. + BOOT + + before :each do + @original_pwd = Dir.pwd + @original_bundle_env = {} + %w(BUNDLE_GEMFILE BUNDLE_VERSION BUNDLE_FROZEN).each { |k| @original_bundle_env[k] = ENV.delete(k) } + require 'bundler' + end + + after :each do + Dir.chdir(@original_pwd) + @original_bundle_env.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + FileUtils.rm_rf @app_dir if @app_dir + end + + def booted_with_boot_rb(boot_rb, gemfile = "source 'https://rubygems.org'\n") + @app_dir = File.realpath(Dir.mktmpdir('rails-app')) # macOS: /var -> /private/var + FileUtils.mkdir_p File.join(@app_dir, 'config') + File.write File.join(@app_dir, 'config', 'boot.rb'), boot_rb + File.write File.join(@app_dir, 'Gemfile'), gemfile if gemfile + booter.layout_class = JRuby::Rack::FileSystemLayout + booter.app_path = @app_dir + booter.boot! + booter + end + + def prepare_bundler!(booted) + booted.send :prepare_bundler, File.join(@app_dir, 'config', 'boot.rb') + end + + it "pre-boots bundler for a default rails boot.rb" do + booted = booted_with_boot_rb DEFAULT_RAILS_BOOT_RB + allow(Bundler.ui).to receive(:silence).and_yield + expect(Bundler).to receive(:setup) + prepare_bundler! booted + end + + it "does not pre-boot when boot.rb manages BUNDLE_WITHOUT itself" do + booted = booted_with_boot_rb %Q{ENV['BUNDLE_WITHOUT'] = 'test'\nrequire "bundler/setup"\n} + expect(Bundler).to_not receive(:setup) + prepare_bundler! booted + end + + it "does not pre-boot when the require is commented out" do + booted = booted_with_boot_rb %Q{# require "bundler/setup"\n} + expect(Bundler).to_not receive(:setup) + prepare_bundler! booted + end + + it "does not pre-boot a non-bundled application (no Gemfile)" do + booted = booted_with_boot_rb DEFAULT_RAILS_BOOT_RB, nil + expect(Bundler).to_not receive(:setup) + prepare_bundler! booted + end + + end + RAILS_ROOT_DIR = File.expand_path("../../../rails", __FILE__) describe "Rails (stubbed)", :lib => :stub do