diff --git a/lib/puma/launcher.rb b/lib/puma/launcher.rb index 60c45d25..63202374 100644 --- a/lib/puma/launcher.rb +++ b/lib/puma/launcher.rb @@ -6,6 +6,7 @@ require 'puma/cluster' require 'puma/single' require 'puma/const' require 'puma/binder' +require 'puma/launcher/bundle_pruner' module Puma # Puma::Launcher is the single entry point for starting a Puma server based on user @@ -80,7 +81,7 @@ module Puma Dir.chdir(@restart_dir) - prune_bundler if prune_bundler? + prune_bundler! @environment = @options[:environment] if @options[:environment] set_rack_environment @@ -300,67 +301,10 @@ module Puma @runner.reload_worker_directory if @runner.respond_to?(:reload_worker_directory) end - # @!attribute [r] files_to_require_after_prune - def files_to_require_after_prune - puma = spec_for_gem("puma") - - require_paths_for_gem(puma) + extra_runtime_deps_directories - end - - # @!attribute [r] extra_runtime_deps_directories - def extra_runtime_deps_directories - Array(@options[:extra_runtime_dependencies]).map do |d_name| - if (spec = spec_for_gem(d_name)) - require_paths_for_gem(spec) - else - log "* Could not load extra dependency: #{d_name}" - nil - end - end.flatten.compact - end - - # @!attribute [r] puma_wild_location - def puma_wild_location - puma = spec_for_gem("puma") - dirs = require_paths_for_gem(puma) - puma_lib_dir = dirs.detect { |x| File.exist? File.join(x, '../bin/puma-wild') } - File.expand_path(File.join(puma_lib_dir, "../bin/puma-wild")) - end - - def prune_bundler - return if ENV['PUMA_BUNDLER_PRUNED'] - return unless defined?(Bundler) - require_rubygems_min_version!(Gem::Version.new("2.2"), "prune_bundler") - unless puma_wild_location - log "! Unable to prune Bundler environment, continuing" - return - end - - dirs = files_to_require_after_prune - - log '* Pruning Bundler environment' - home = ENV['GEM_HOME'] - bundle_gemfile = Bundler.original_env['BUNDLE_GEMFILE'] - bundle_app_config = Bundler.original_env['BUNDLE_APP_CONFIG'] - with_unbundled_env do - ENV['GEM_HOME'] = home - ENV['BUNDLE_GEMFILE'] = bundle_gemfile - ENV['PUMA_BUNDLER_PRUNED'] = '1' - ENV["BUNDLE_APP_CONFIG"] = bundle_app_config - args = [Gem.ruby, puma_wild_location, '-I', dirs.join(':')] + @original_argv - # Ruby 2.0+ defaults to true which breaks socket activation - args += [{:close_others => false}] - Kernel.exec(*args) - end - end - - # # Puma's systemd integration allows Puma to inform systemd: # 1. when it has successfully started # 2. when it is starting shutdown # 3. periodically for a liveness check with a watchdog thread - # - def integrate_with_systemd return unless ENV["NOTIFY_SOCKET"] @@ -378,14 +322,6 @@ module Puma systemd.start_watchdog end - def spec_for_gem(gem_name) - Bundler.rubygems.loaded_specs(gem_name) - end - - def require_paths_for_gem(gem_spec) - gem_spec.full_require_paths - end - def log(str) @events.log str end @@ -424,6 +360,11 @@ module Puma @options[:prune_bundler] && clustered? && !@options[:preload_app] end + def prune_bundler! + return unless prune_bundler? + BundlePruner.new(@original_argv, @options[:extra_runtime_dependencies], @events).prune + end + def generate_restart_data if dir = @options[:directory] @restart_dir = dir @@ -534,23 +475,6 @@ module Puma end end - def require_rubygems_min_version!(min_version, feature) - return if min_version <= Gem::Version.new(Gem::VERSION) - - raise "#{feature} is not supported on your version of RubyGems. " \ - "You must have RubyGems #{min_version}+ to use this feature." - end - - # @version 5.0.0 - def with_unbundled_env - bundler_ver = Gem::Version.new(Bundler::VERSION) - if bundler_ver < Gem::Version.new('2.1.0') - Bundler.with_clean_env { yield } - else - Bundler.with_unbundled_env { yield } - end - end - def log_config log "Configuration:" diff --git a/lib/puma/launcher/bundle_pruner.rb b/lib/puma/launcher/bundle_pruner.rb new file mode 100644 index 00000000..ef8bade8 --- /dev/null +++ b/lib/puma/launcher/bundle_pruner.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Puma + class Launcher + + # This class is used to pickup Gemfile changes during + # application restarts. + class BundlePruner + + def initialize(original_argv, extra_runtime_dependencies, events) + @original_argv = Array(original_argv) + @extra_runtime_dependencies = Array(extra_runtime_dependencies) + @events = events + end + + def prune + return if ENV['PUMA_BUNDLER_PRUNED'] + return unless defined?(Bundler) + + require_rubygems_min_version! + + unless puma_wild_path + log "! Unable to prune Bundler environment, continuing" + return + end + + dirs = paths_to_require_after_prune + + log '* Pruning Bundler environment' + home = ENV['GEM_HOME'] + bundle_gemfile = Bundler.original_env['BUNDLE_GEMFILE'] + bundle_app_config = Bundler.original_env['BUNDLE_APP_CONFIG'] + + with_unbundled_env do + ENV['GEM_HOME'] = home + ENV['BUNDLE_GEMFILE'] = bundle_gemfile + ENV['PUMA_BUNDLER_PRUNED'] = '1' + ENV["BUNDLE_APP_CONFIG"] = bundle_app_config + args = [Gem.ruby, puma_wild_path, '-I', dirs.join(':')] + @original_argv + # Ruby 2.0+ defaults to true which breaks socket activation + args += [{:close_others => false}] + Kernel.exec(*args) + end + end + + private + + def require_rubygems_min_version! + min_version = Gem::Version.new('2.2') + + return if min_version <= Gem::Version.new(Gem::VERSION) + + raise "prune_bundler is not supported on your version of RubyGems. " \ + "You must have RubyGems #{min_version}+ to use this feature." + end + + def puma_wild_path + puma_lib_dir = puma_require_paths.detect { |x| File.exist? File.join(x, '../bin/puma-wild') } + File.expand_path(File.join(puma_lib_dir, '../bin/puma-wild')) + end + + def with_unbundled_env + bundler_ver = Gem::Version.new(Bundler::VERSION) + if bundler_ver < Gem::Version.new('2.1.0') + Bundler.with_clean_env { yield } + else + Bundler.with_unbundled_env { yield } + end + end + + def paths_to_require_after_prune + puma_require_paths + extra_runtime_deps_paths + end + + def extra_runtime_deps_paths + @extra_runtime_dependencies.map do |dep_name| + if (spec = spec_for_gem(dep_name)) + require_paths_for_gem(spec) + else + log "* Could not load extra dependency: #{dep_name}" + nil + end + end.flatten.compact + end + + def puma_require_paths + require_paths_for_gem(spec_for_gem('puma')) + end + + def spec_for_gem(gem_name) + Bundler.rubygems.loaded_specs(gem_name) + end + + def require_paths_for_gem(gem_spec) + gem_spec.full_require_paths + end + + def log(str) + @events.log(str) + end + end + end +end diff --git a/test/test_bundle_pruner.rb b/test/test_bundle_pruner.rb new file mode 100644 index 00000000..19d7e651 --- /dev/null +++ b/test/test_bundle_pruner.rb @@ -0,0 +1,57 @@ +require_relative 'helper' + +require 'puma/events' +require 'puma/launcher/bundle_pruner' + +class TestBundlePruner < Minitest::Test + + def test_paths_to_require_after_prune_is_correctly_built_for_no_extra_deps + skip_if :no_bundler + + dirs = bundle_pruner.send(:paths_to_require_after_prune) + + assert_equal(2, dirs.length) + assert_match(%r{puma/lib$}, dirs[0]) # lib dir + assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir + refute_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) + end + + def test_paths_to_require_after_prune_is_correctly_built_with_extra_deps + skip_if :no_bundler + + dirs = bundle_pruner([], ['rdoc']).send(:paths_to_require_after_prune) + + assert_equal(3, dirs.length) + assert_match(%r{puma/lib$}, dirs[0]) # lib dir + assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir + assert_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) # rdoc dir + end + + def test_extra_runtime_deps_paths_is_empty_for_no_config + assert_equal([], bundle_pruner.send(:extra_runtime_deps_paths)) + end + + def test_extra_runtime_deps_paths_is_correctly_built + skip_if :no_bundler + + dep_dirs = bundle_pruner([], ['rdoc']).send(:extra_runtime_deps_paths) + + assert_equal(1, dep_dirs.length) + assert_match(%r{gems/rdoc-[\d.]+/lib$}, dep_dirs.first) + end + + def test_puma_wild_path_is_an_absolute_path + skip_if :no_bundler + puma_wild_path = bundle_pruner.send(:puma_wild_path) + + assert_match(%r{bin/puma-wild$}, puma_wild_path) + # assert no "/../" in path + refute_match(%r{/\.\./}, puma_wild_path) + end + + private + + def bundle_pruner(original_argv = nil, extra_runtime_dependencies = nil) + @bundle_pruner ||= Puma::Launcher::BundlePruner.new(original_argv, extra_runtime_dependencies, Puma::Events.null) + end +end diff --git a/test/test_launcher.rb b/test/test_launcher.rb index 370e0c38..30abe6b7 100644 --- a/test/test_launcher.rb +++ b/test/test_launcher.rb @@ -7,55 +7,6 @@ require 'puma/events' class TestLauncher < Minitest::Test include TmpPath - def test_files_to_require_after_prune_is_correctly_built_for_no_extra_deps - skip_if :no_bundler - - dirs = launcher.send(:files_to_require_after_prune) - - assert_equal(2, dirs.length) - assert_match(%r{puma/lib$}, dirs[0]) # lib dir - assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir - refute_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) - end - - def test_files_to_require_after_prune_is_correctly_built_with_extra_deps - skip_if :no_bundler - conf = Puma::Configuration.new do |c| - c.extra_runtime_dependencies ['rdoc'] - end - - dirs = launcher(conf).send(:files_to_require_after_prune) - - assert_equal(3, dirs.length) - assert_match(%r{puma/lib$}, dirs[0]) # lib dir - assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir - assert_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) # rdoc dir - end - - def test_extra_runtime_deps_directories_is_empty_for_no_config - assert_equal([], launcher.send(:extra_runtime_deps_directories)) - end - - def test_extra_runtime_deps_directories_is_correctly_built - skip_if :no_bundler - conf = Puma::Configuration.new do |c| - c.extra_runtime_dependencies ['rdoc'] - end - dep_dirs = launcher(conf).send(:extra_runtime_deps_directories) - - assert_equal(1, dep_dirs.length) - assert_match(%r{gems/rdoc-[\d.]+/lib$}, dep_dirs.first) - end - - def test_puma_wild_location_is_an_absolute_path - skip_if :no_bundler - puma_wild_location = launcher.send(:puma_wild_location) - - assert_match(%r{bin/puma-wild$}, puma_wild_location) - # assert no "/../" in path - refute_match(%r{/\.\./}, puma_wild_location) - end - def test_prints_thread_traces launcher.thread_status do |name, _backtrace| assert_match "Thread: TID", name