mirror of
https://github.com/ruby/ruby.git
synced 2025-08-24 13:34:17 +02:00

I did a bad thing (script that edits the Gemfile.lock directly) and
ended up with a Gemfile.lock that was completely missing some indirect
dependencies. While this is my fault and an error is reasonable, I
noticed that the error got progressively less friendly in recent
versions of bundler.
Something similar came up in https://github.com/rubygems/rubygems/issues/6210,
and this commit would have helped with that case as well
(although we've already handled this a different way with #6219).
Details:
---
Back on Bundler 2.2.23, a corrupt lockfile like this would cause a helpful error:
```
Unable to find a spec satisfying minitest (>= 5.1) in the set. Perhaps the lockfile is corrupted?
```
Bundler 2.3.26 gave a helpful warning:
```
Warning:
Your lockfile was created by an old Bundler that left some things out.
Because of the missing DEPENDENCIES, we can only install gems one at a time,
instead of installing 16 at a time.
You can fix this by adding the missing gems to your Gemfile, running bundle
install, and then removing the gems from your Gemfile.
The missing gems are:
* minitest depended upon by activesupport
```
But then continued on and crashed while trying to report the unmet
dependency:
```
--- ERROR REPORT TEMPLATE -------------------------------------------------------
NoMethodError: undefined method `full_name' for nil:NilClass
lib/bundler/installer/parallel_installer.rb:127:in `block (2 levels) in check_for_unmet_dependencies'
...
```
Bundler 2.4.0 and up crash as above when jobs=1, but crash
even harder when run in parallel:
```
--- ERROR REPORT TEMPLATE -------------------------------------------------------
fatal: No live threads left. Deadlock?
3 threads, 3 sleeps current:0x00007fa6b6704660 main thread:0x00007fa6b6704660
* #<Thread:0x000000010833b130 sleep_forever>
rb_thread_t:0x00007fa6b6704660 native:0x0000000108985600 int:0
* #<Thread:0x0000000108dea630@Parallel Installer Worker #0 tmp/1/gems/system/gems/bundler-2.5.0.dev/lib/bundler/worker.rb:90 sleep_forever>
rb_thread_t:0x00007fa6b67f67c0 native:0x0000700009a62000 int:0
* #<Thread:0x0000000108dea4a0@Parallel Installer Worker #1 tmp/1/gems/system/gems/bundler-2.5.0.dev/lib/bundler/worker.rb:90 sleep_forever>
rb_thread_t:0x00007fa6b67f63c0 native:0x0000700009c65000 int:0
<internal:thread_sync>:18:in `pop'
tmp/1/gems/system/gems/bundler-2.5.0.dev/lib/bundler/worker.rb:42:in `deq'
...
```
Changes
---
This commit fixes the confusing thread deadlock crash by detecting if
dependencies are missing such that we'll never be able to enqueue. When
that happens we treat it as a failure so the install can finish.
That gets us back to the `NoMethodError`, which this commit fixes by
using a different warning in the case where no spec is found.
d73001a21d
233 lines
6.6 KiB
Ruby
233 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "../worker"
|
|
require_relative "gem_installer"
|
|
|
|
module Bundler
|
|
class ParallelInstaller
|
|
class SpecInstallation
|
|
attr_accessor :spec, :name, :full_name, :post_install_message, :state, :error
|
|
def initialize(spec)
|
|
@spec = spec
|
|
@name = spec.name
|
|
@full_name = spec.full_name
|
|
@state = :none
|
|
@post_install_message = ""
|
|
@error = nil
|
|
end
|
|
|
|
def installed?
|
|
state == :installed
|
|
end
|
|
|
|
def enqueued?
|
|
state == :enqueued
|
|
end
|
|
|
|
def failed?
|
|
state == :failed
|
|
end
|
|
|
|
def ready_to_enqueue?
|
|
state == :none
|
|
end
|
|
|
|
def has_post_install_message?
|
|
!post_install_message.empty?
|
|
end
|
|
|
|
def ignorable_dependency?(dep)
|
|
dep.type == :development || dep.name == @name
|
|
end
|
|
|
|
# Checks installed dependencies against spec's dependencies to make
|
|
# sure needed dependencies have been installed.
|
|
def dependencies_installed?(all_specs)
|
|
installed_specs = all_specs.select(&:installed?).map(&:name)
|
|
dependencies.all? {|d| installed_specs.include? d.name }
|
|
end
|
|
|
|
# Check whether spec's dependencies are missing, which can indicate a
|
|
# corrupted lockfile
|
|
def dependencies_missing?(all_specs)
|
|
spec_names = all_specs.map(&:name)
|
|
dependencies.any? {|d| !spec_names.include? d.name }
|
|
end
|
|
|
|
# Represents only the non-development dependencies, the ones that are
|
|
# itself and are in the total list.
|
|
def dependencies
|
|
@dependencies ||= all_dependencies.reject {|dep| ignorable_dependency? dep }
|
|
end
|
|
|
|
# Represents all dependencies
|
|
def all_dependencies
|
|
@spec.dependencies
|
|
end
|
|
|
|
def to_s
|
|
"#<#{self.class} #{full_name} (#{state})>"
|
|
end
|
|
end
|
|
|
|
def self.call(*args)
|
|
new(*args).call
|
|
end
|
|
|
|
attr_reader :size
|
|
|
|
def initialize(installer, all_specs, size, standalone, force)
|
|
@installer = installer
|
|
@size = size
|
|
@standalone = standalone
|
|
@force = force
|
|
@specs = all_specs.map {|s| SpecInstallation.new(s) }
|
|
@spec_set = all_specs
|
|
@rake = @specs.find {|s| s.name == "rake" }
|
|
end
|
|
|
|
def call
|
|
if @rake
|
|
do_install(@rake, 0)
|
|
Gem::Specification.reset
|
|
end
|
|
|
|
if @size > 1
|
|
install_with_worker
|
|
else
|
|
install_serially
|
|
end
|
|
|
|
check_for_unmet_dependencies
|
|
|
|
handle_error if failed_specs.any?
|
|
@specs
|
|
ensure
|
|
worker_pool&.stop
|
|
end
|
|
|
|
def check_for_unmet_dependencies
|
|
unmet_dependencies = @specs.map do |s|
|
|
[
|
|
s,
|
|
s.dependencies.reject {|dep| @specs.any? {|spec| dep.matches_spec?(spec.spec) } },
|
|
]
|
|
end.reject {|a| a.last.empty? }
|
|
return if unmet_dependencies.empty?
|
|
|
|
warning = []
|
|
warning << "Your lockfile doesn't include a valid resolution."
|
|
warning << "You can fix this by regenerating your lockfile or manually editing the bad locked gems to a version that satisfies all dependencies."
|
|
warning << "The unmet dependencies are:"
|
|
|
|
unmet_dependencies.each do |spec, unmet_spec_dependencies|
|
|
unmet_spec_dependencies.each do |unmet_spec_dependency|
|
|
found = @specs.find {|s| s.name == unmet_spec_dependency.name && !unmet_spec_dependency.matches_spec?(s.spec) }
|
|
if found
|
|
warning << "* #{unmet_spec_dependency}, dependency of #{spec.full_name}, unsatisfied by #{found.full_name}"
|
|
else
|
|
warning << "* #{unmet_spec_dependency}, dependency of #{spec.full_name} but missing from lockfile"
|
|
end
|
|
end
|
|
end
|
|
|
|
Bundler.ui.warn(warning.join("\n"))
|
|
end
|
|
|
|
private
|
|
|
|
def failed_specs
|
|
@specs.select(&:failed?)
|
|
end
|
|
|
|
def install_with_worker
|
|
enqueue_specs
|
|
process_specs until finished_installing?
|
|
end
|
|
|
|
def install_serially
|
|
until finished_installing?
|
|
raise "failed to find a spec to enqueue while installing serially" unless spec_install = @specs.find(&:ready_to_enqueue?)
|
|
spec_install.state = :enqueued
|
|
do_install(spec_install, 0)
|
|
end
|
|
end
|
|
|
|
def worker_pool
|
|
@worker_pool ||= Bundler::Worker.new @size, "Parallel Installer", lambda {|spec_install, worker_num|
|
|
do_install(spec_install, worker_num)
|
|
}
|
|
end
|
|
|
|
def do_install(spec_install, worker_num)
|
|
Plugin.hook(Plugin::Events::GEM_BEFORE_INSTALL, spec_install)
|
|
gem_installer = Bundler::GemInstaller.new(
|
|
spec_install.spec, @installer, @standalone, worker_num, @force
|
|
)
|
|
success, message = gem_installer.install_from_spec
|
|
if success
|
|
spec_install.state = :installed
|
|
spec_install.post_install_message = message unless message.nil?
|
|
else
|
|
spec_install.error = "#{message}\n\n#{require_tree_for_spec(spec_install.spec)}"
|
|
spec_install.state = :failed
|
|
end
|
|
Plugin.hook(Plugin::Events::GEM_AFTER_INSTALL, spec_install)
|
|
spec_install
|
|
end
|
|
|
|
# Dequeue a spec and save its post-install message and then enqueue the
|
|
# remaining specs.
|
|
# Some specs might've had to wait til this spec was installed to be
|
|
# processed so the call to `enqueue_specs` is important after every
|
|
# dequeue.
|
|
def process_specs
|
|
worker_pool.deq
|
|
enqueue_specs
|
|
end
|
|
|
|
def finished_installing?
|
|
@specs.all? do |spec|
|
|
return true if spec.failed?
|
|
spec.installed?
|
|
end
|
|
end
|
|
|
|
def handle_error
|
|
errors = failed_specs.map(&:error)
|
|
if exception = errors.find {|e| e.is_a?(Bundler::BundlerError) }
|
|
raise exception
|
|
end
|
|
raise Bundler::InstallError, errors.join("\n\n")
|
|
end
|
|
|
|
def require_tree_for_spec(spec)
|
|
tree = @spec_set.what_required(spec)
|
|
t = String.new("In #{File.basename(SharedHelpers.default_gemfile)}:\n")
|
|
tree.each_with_index do |s, depth|
|
|
t << " " * depth.succ << s.name
|
|
unless tree.last == s
|
|
t << %( was resolved to #{s.version}, which depends on)
|
|
end
|
|
t << %(\n)
|
|
end
|
|
t
|
|
end
|
|
|
|
# Keys in the remains hash represent uninstalled gems specs.
|
|
# We enqueue all gem specs that do not have any dependencies.
|
|
# Later we call this lambda again to install specs that depended on
|
|
# previously installed specifications. We continue until all specs
|
|
# are installed.
|
|
def enqueue_specs
|
|
@specs.select(&:ready_to_enqueue?).each do |spec|
|
|
if spec.dependencies_installed? @specs
|
|
spec.state = :enqueued
|
|
worker_pool.enq spec
|
|
elsif spec.dependencies_missing? @specs
|
|
spec.state = :failed
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|