Skip to content

Commit a1f5751

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' ``` 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 2fd3496 commit a1f5751

File tree

3 files changed

+103
-48
lines changed

3 files changed

+103
-48
lines changed

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/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

spec/commands/install_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,48 @@ def run
13791379
end
13801380
end
13811381

1382+
describe "when a native extension requires a transitive dependency at build time" do
1383+
before do
1384+
build_repo4 do
1385+
build_gem "alpha", "1.0.0" do |s|
1386+
extension = "ext/alpha/extconf.rb"
1387+
s.extensions = extension
1388+
s.write(extension, <<~CODE)
1389+
require "mkmf"
1390+
sleep 1
1391+
create_makefile("alpha")
1392+
CODE
1393+
s.write "lib/alpha.rb", "ALPHA = '1.0.0'"
1394+
end
1395+
1396+
build_gem "beta", "1.0.0" do |s|
1397+
s.add_dependency "alpha"
1398+
s.write "lib/beta.rb", "require 'alpha'\nBETA = '1.0.0'"
1399+
end
1400+
1401+
build_gem "gamma", "1.0.0" do |s|
1402+
s.add_dependency "beta"
1403+
extension = "ext/gamma/extconf.rb"
1404+
s.extensions = extension
1405+
s.write(extension, <<~EXTCONF)
1406+
require "beta"
1407+
require "mkmf"
1408+
create_makefile("gamma")
1409+
EXTCONF
1410+
end
1411+
end
1412+
end
1413+
1414+
it "installs successfully" do
1415+
install_gemfile <<~G
1416+
source "https://gem.repo4"
1417+
gem "gamma"
1418+
G
1419+
1420+
expect(the_bundle).to include_gems "alpha 1.0.0", "beta 1.0.0", "gamma 1.0.0"
1421+
end
1422+
end
1423+
13821424
describe "when configured path is UTF-8 and a file inside a gem package too" do
13831425
let(:app_path) do
13841426
path = tmp("♥")

0 commit comments

Comments
 (0)