Skip to content

Commit 849e616

Browse files
committed
Fix installing gems with native extensions + transitive dependencies
I am seeing the following error during bundle install: ``` Gem::MissingSpecError: Could not find 'ffi' (>= 1.15.5) among 48 total gem(s) (Gem::MissingSpecError) ``` This is reproducible with: ```ruby source 'https://rubygems.org' gem 'llhttp-ffi' ``` A binary search of this repo indicates the issue may have been introduced in #9381. It seems only direct dependencies are checked when determining whether a Gem with native extensions is ready to install. I believe this can lead to a failure if a transitive dependency is not yet installed. In the example above, llhttp-ffi depends on ffi-compiler, which depends on ffi. Since ffi-compiler has no extensions, it is installed immediately without waiting for ffi. When llhttp-ffi then checks its direct dependencies, ffi-compiler is already installed, so llhttp-ffi starts building its native extension. The build requires ffi, which may not have been installed yet.
1 parent a3d2d2c commit 849e616

3 files changed

Lines changed: 104 additions & 48 deletions

File tree

bundler/lib/bundler/installer/parallel_installer.rb

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
module Bundler
77
class ParallelInstaller
88
class SpecInstallation
9-
attr_accessor :spec, :name, :full_name, :post_install_message, :state, :error
9+
attr_accessor :spec, :name, :full_name, :post_install_message, :state, :error, :dependencies
1010
def initialize(spec)
1111
@spec = spec
1212
@name = spec.name
@@ -46,25 +46,11 @@ def has_post_install_message?
4646
!post_install_message.empty?
4747
end
4848

49-
def ignorable_dependency?(dep)
50-
dep.type == :development || dep.name == @name
51-
end
52-
53-
# Checks installed dependencies against spec's dependencies to make
54-
# sure needed dependencies have been installed.
49+
# Recursively checks that all dependencies (direct and transitive) have been installed.
5550
def dependencies_installed?(installed_specs)
56-
dependencies.all? {|d| installed_specs.include? d.name }
57-
end
58-
59-
# Represents only the non-development dependencies, the ones that are
60-
# itself and are in the total list.
61-
def dependencies
62-
@dependencies ||= all_dependencies.reject {|dep| ignorable_dependency? dep }
63-
end
64-
65-
# Represents all dependencies
66-
def all_dependencies
67-
@spec.dependencies
51+
dependencies.all? do |dep|
52+
installed_specs.include?(dep.name) && dep.dependencies_installed?(installed_specs)
53+
end
6854
end
6955

7056
def to_s
@@ -85,6 +71,12 @@ def initialize(installer, all_specs, size, standalone, force, local: false, skip
8571
@force = force
8672
@local = local
8773
@specs = all_specs.map {|s| SpecInstallation.new(s) }
74+
specs_by_name = @specs.to_h {|s| [s.name, s] }
75+
@specs.each do |spec_install|
76+
spec_install.dependencies = spec_install.spec.dependencies.filter_map do |dep|
77+
specs_by_name[dep.name] unless dep.type == :development || dep.name == spec_install.name
78+
end
79+
end
8880
@specs.each do |spec_install|
8981
spec_install.state = :installed if skip.include?(spec_install.name)
9082
end if skip

spec/bundler/installer/parallel_installer_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,49 @@
66
require "bundler"
77

88
RSpec.describe Bundler::ParallelInstaller do
9+
def build_spec(name, extensions: [], dependencies: [])
10+
spec = Object.new
11+
spec.define_singleton_method(:name) { name }
12+
spec.define_singleton_method(:full_name) { "#{name}-1.0" }
13+
spec.define_singleton_method(:extensions) { extensions }
14+
spec.define_singleton_method(:dependencies) { dependencies }
15+
spec
16+
end
17+
18+
def build_dep(name, type: :runtime)
19+
dep = Object.new
20+
dep.define_singleton_method(:name) { name }
21+
dep.define_singleton_method(:type) { type }
22+
dep
23+
end
24+
25+
describe "install ordering" do
26+
it "blocks gems with extensions until all dependencies are installed" do
27+
alpha = build_spec("alpha")
28+
beta = build_spec("beta", dependencies: [build_dep("alpha")])
29+
gamma = build_spec("gamma", extensions: ["ext/Rakefile"], dependencies: [build_dep("beta")])
30+
31+
parallel_installer = described_class.new(double("installer"), [alpha, beta, gamma], 1, false, false)
32+
gamma_install = parallel_installer.instance_variable_get(:@specs).find {|s| s.name == "gamma" }
33+
gamma_install.state = :downloaded
34+
35+
expect(gamma_install.ready_to_install?({})).to be_falsey
36+
expect(gamma_install.ready_to_install?({ "beta" => true })).to be_falsey
37+
expect(gamma_install.ready_to_install?({ "alpha" => true, "beta" => true })).to be_truthy
38+
end
39+
40+
it "does not block gems without extensions regardless of dependencies" do
41+
alpha = build_spec("alpha")
42+
beta = build_spec("beta", dependencies: [build_dep("alpha")])
43+
44+
parallel_installer = described_class.new(double("installer"), [alpha, beta], 1, false, false)
45+
beta_install = parallel_installer.instance_variable_get(:@specs).find {|s| s.name == "beta" }
46+
beta_install.state = :downloaded
47+
48+
expect(beta_install.ready_to_install?({})).to be_truthy
49+
end
50+
end
51+
952
describe "priority queue" do
1053
before do
1154
require "support/artifice/compact_index"

spec/bundler/installer/spec_installation_spec.rb

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
require "bundler/installer/parallel_installer"
44

55
RSpec.describe Bundler::ParallelInstaller::SpecInstallation do
6-
let!(:dep) do
7-
a_spec = Object.new
8-
def a_spec.name
9-
"I like tests"
10-
end
11-
12-
def a_spec.full_name
13-
"I really like tests"
14-
end
15-
a_spec
6+
def build_spec(name, extensions: [])
7+
spec = Object.new
8+
spec.define_singleton_method(:name) { name }
9+
spec.define_singleton_method(:full_name) { "#{name}-1.0" }
10+
spec.define_singleton_method(:extensions) { extensions }
11+
spec.define_singleton_method(:dependencies) { [] }
12+
spec
1613
end
1714

15+
let!(:dep) { build_spec("I like tests") }
16+
1817
describe "#ready_to_enqueue?" do
1918
context "when in enqueued state" do
2019
it "is falsey" do
@@ -39,29 +38,51 @@ def a_spec.full_name
3938
end
4039

4140
describe "#dependencies_installed?" do
42-
context "when all dependencies are installed" do
43-
it "returns true" do
44-
dependencies = []
45-
dependencies << instance_double("SpecInstallation", spec: "alpha", name: "alpha", installed?: true, all_dependencies: [], type: :production)
46-
dependencies << instance_double("SpecInstallation", spec: "beta", name: "beta", installed?: true, all_dependencies: [], type: :production)
47-
all_specs = dependencies + [instance_double("SpecInstallation", spec: "gamma", name: "gamma", installed?: false, all_dependencies: [], type: :production)]
41+
it "returns true when all dependencies are installed" do
42+
alpha = described_class.new(build_spec("alpha"))
43+
alpha.dependencies = []
44+
45+
beta = described_class.new(build_spec("beta"))
46+
beta.dependencies = [alpha]
47+
48+
gamma = described_class.new(build_spec("gamma"))
49+
gamma.dependencies = [beta]
50+
51+
expect(gamma.dependencies_installed?({})).to be_falsey
52+
expect(gamma.dependencies_installed?({ "beta" => true })).to be_falsey
53+
expect(gamma.dependencies_installed?({ "alpha" => true, "beta" => true })).to be_truthy
54+
end
55+
end
56+
57+
describe "#ready_to_install?" do
58+
context "when spec has no extensions" do
59+
it "returns true regardless of dependencies" do
60+
beta = described_class.new(build_spec("beta"))
61+
beta.dependencies = []
62+
4863
spec = described_class.new(dep)
49-
allow(spec).to receive(:all_dependencies).and_return(dependencies)
50-
installed_specs = all_specs.select(&:installed?).map {|s| [s.name, true] }.to_h
51-
expect(spec.dependencies_installed?(installed_specs)).to be_truthy
64+
spec.state = :downloaded
65+
spec.dependencies = [beta]
66+
67+
expect(spec.ready_to_install?({})).to be_truthy
5268
end
5369
end
5470

55-
context "when all dependencies are not installed" do
56-
it "returns false" do
57-
dependencies = []
58-
dependencies << instance_double("SpecInstallation", spec: "alpha", name: "alpha", installed?: false, all_dependencies: [], type: :production)
59-
dependencies << instance_double("SpecInstallation", spec: "beta", name: "beta", installed?: true, all_dependencies: [], type: :production)
60-
all_specs = dependencies + [instance_double("SpecInstallation", spec: "gamma", name: "gamma", installed?: false, all_dependencies: [], type: :production)]
61-
spec = described_class.new(dep)
62-
allow(spec).to receive(:all_dependencies).and_return(dependencies)
63-
installed_specs = all_specs.select(&:installed?).map {|s| [s.name, true] }.to_h
64-
expect(spec.dependencies_installed?(installed_specs)).to be_falsey
71+
context "when spec has extensions" do
72+
it "returns true when all dependencies are installed" do
73+
alpha = described_class.new(build_spec("alpha"))
74+
alpha.dependencies = []
75+
76+
beta = described_class.new(build_spec("beta"))
77+
beta.dependencies = [alpha]
78+
79+
gamma = described_class.new(build_spec("gamma", extensions: ["ext/Rakefile"]))
80+
gamma.state = :downloaded
81+
gamma.dependencies = [beta]
82+
83+
expect(gamma.ready_to_install?({})).to be_falsey
84+
expect(gamma.ready_to_install?({ "beta" => true })).to be_falsey
85+
expect(gamma.ready_to_install?({ "alpha" => true, "beta" => true })).to be_truthy
6586
end
6687
end
6788
end

0 commit comments

Comments
 (0)