Rails: 16年前の古いgemハックを2024年に踏んでヤケドした話(翻訳)
私がコンサルティングしているプロジェクトのプログラマーは、もっぱらクラウド環境上で開発しています。このセットアップのおかげで多くの変動要素をシンプルにでき、コードを実行するために同じコンテナを開発者全員に提供できるというメリットが得られます。私のボックスで実行できるものは、全員のボックスで実行できるというわけです。ここで言うボックスはLinuxベースなのですが、開発者のたくましいローカルマシン(Apple Silicon上で動作するMacBook Pro)よりもレイテンシが大きく、リソースの制限も大きいという短所があります。
最近、この開発環境をRuby 3.2.2からRuby 3.3.0にアップグレードしました。クラウド環境の作業はスムーズで予測も効きました。私のボックスで実行できるものは、もちろん全員のボックスでも実行できましたが、ローカルマシンについては必ずしもそうとは限りません。ローカルMacのRubyを早いうちにアップグレードしていた開発者については特に問題はなかったのですが、ローカルMacのRubyアップグレードを少しばかり先延ばししていた開発者はというと...
先延ばし開発者は、AppleのCommand Line Toolsの新しいリリースによってハマりました。ご興味のある方向けにCommand Line Toolsのバージョン番号を以下に示しておきます。
$ pkgutil --pkg-info=com.apple.pkg.CLTools_Executables
package-id: com.apple.pkg.CLTools_Executables
version: 15.3.0.0.1.1708646388
volume: /
location: /
install-time: 1710339117
Command Line Toolsというシステムツールが新しくリリースされたぐらいで、新しいRuby VM上でRubyアプリケーションを動かすのに困ることがあるのでしょうか?実はgem、具体的にはC拡張を利用しているgemで問題が発生したのです!そうしたgemは、ソースをコンパイルしてバイナリを得るためにAppleのシステムツールであるCommand Line Toolsに依存しています。
Gemfile
の中に、クラウド開発ボックス内でリモートデバッグを行うためのgemが2つ見つかりました。
group :development do
gem 'ruby-debug-ide', '0.7.3'
gem 'debase', '0.2.5.beta2'
end
容疑者は、このdebase gemでした。新しいCommand Line Toolsではビルドできなかったのです。
▶エラーログ(クリックで展開)
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
current directory: /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/ruby-debug-ide-b671a1cbb6d8/ext
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/bin/ruby mkrf_conf.rb
Installing base gem
Building native extensions. This could take a while...
Building native extensions. This could take a while...
ERROR: Failed to build gem native extension.
current directory: /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/debase-0.2.5.beta2/ext
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/bin/ruby extconf.rb
checking for vm_core.h... yes
checking for iseq.h... yes
checking for version.h... yes
creating Makefile
current directory: /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/debase-0.2.5.beta2/ext
make DESTDIR\= sitearchdir\=./.gem.20240311-43199-d198st sitelibdir\=./.gem.20240311-43199-d198st clean
current directory: /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/debase-0.2.5.beta2/ext
make DESTDIR\= sitearchdir\=./.gem.20240311-43199-d198st sitelibdir\=./.gem.20240311-43199-d198st
compiling breakpoint.c
compiling context.c
compiling debase_internals.c
debase_internals.c:319:25: warning: initializing 'rb_control_frame_t *' (aka 'struct rb_control_frame_struct *') with an expression of type 'const
rb_control_frame_t *' (aka 'const struct rb_control_frame_struct *') discards qualifiers [-Wincompatible-pointer-types-discards-qualifiers]
rb_control_frame_t *start_cfp = RUBY_VM_END_CONTROL_FRAME(TH_INFO(thread));
^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
debase_internals.c:770:3: error: incompatible function pointer types passing 'void (VALUE, VALUE)' (aka 'void (unsigned long, unsigned long)') to parameter
of type 'VALUE (*)(VALUE, VALUE)' (aka 'unsigned long (*)(unsigned long, unsigned long)') [-Wincompatible-function-pointer-types]
rb_define_module_function(mDebase, "set_trace_flag_to_iseq", Debase_set_trace_flag_to_iseq, 1);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:338:142: note: expanded from macro 'rb_define_module_function'
#define rb_define_module_function(mod, mid, func, arity) RBIMPL_ANYARGS_DISPATCH_rb_define_module_function((arity), (func))((mod), (mid), (func),
(arity))
^~~~~~
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:274:1: note: passing argument to parameter here
RBIMPL_ANYARGS_DECL(rb_define_module_function, VALUE, const char *)
^
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:256:72: note: expanded from macro 'RBIMPL_ANYARGS_DECL'
RBIMPL_ANYARGS_ATTRSET(sym) static void sym ## _01(__VA_ARGS__, VALUE(*)(VALUE, VALUE), int);\
^
debase_internals.c:773:3: error: incompatible function pointer types passing 'void (VALUE, VALUE)' (aka 'void (unsigned long, unsigned long)') to parameter
of type 'VALUE (*)(VALUE, VALUE)' (aka 'unsigned long (*)(unsigned long, unsigned long)') [-Wincompatible-function-pointer-types]
rb_define_module_function(mDebase, "unset_iseq_flags", Debase_unset_trace_flags, 1);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:338:142: note: expanded from macro 'rb_define_module_function'
#define rb_define_module_function(mod, mid, func, arity) RBIMPL_ANYARGS_DISPATCH_rb_define_module_function((arity), (func))((mod), (mid), (func),
(arity))
^~~~~~
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:274:1: note: passing argument to parameter here
RBIMPL_ANYARGS_DECL(rb_define_module_function, VALUE, const char *)
^
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/include/ruby-3.3.0/ruby/internal/anyargs.h:256:72: note: expanded from macro 'RBIMPL_ANYARGS_DECL'
RBIMPL_ANYARGS_ATTRSET(sym) static void sym ## _01(__VA_ARGS__, VALUE(*)(VALUE, VALUE), int);\
^
1 warning and 2 errors generated.
make: *** [debase_internals.o] Error 1
make failed, exit code 2
Gem files will remain installed in /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/debase-0.2.5.beta2 for inspection.
Results logged to /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/extensions/x86_64-darwin-23/3.3.0/debase-0.2.5.beta2/gem_make.out
...
/Users/kakadudu/.rvm/rubies/ruby-3.3.0/lib/ruby/3.3.0/rubygems/dependency_installer.rb:250:in `install'
mkrf_conf.rb:31:in `rescue in <main>'
mkrf_conf.rb:24:in `<main>'
rake failed, exit code 1
Gem files will remain installed in /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/ruby-debug-ide-b671a1cbb6d8 for inspection.
Results logged to /Users/kakadudu/.rvm/gems/ruby-3.3.0/bundler/gems/extensions/x86_64-darwin-23/3.3.0/ruby-debug-ide-b671a1cbb6d8/gem_make.out
...
An error occurred while installing ruby-debug-ide (0.7.3), and Bundler cannot continue.
In Gemfile:
ruby-debug-ide
ビルドのエラーメッセージでは回避方法が示されていました。以下のように、エラーが発生する条件をオフにしてみるとgemのビルドは成功しました。
gem install debase -v '0.2.5.beta2' -- --with-cflags=-Wno-error=incompatible-function-pointer-types
この設定をbundlerコンフィグに移し替えてbundle install
で指定すれば、素直に修正できそうに思えました。
bundle config build.debase --with-cflags=-Wno-error=incompatible-function-pointer-types
しかし修正できなかったのです!いったいどういうわけでしょうか?
🔗 16年前のハックのしわざ
エラーメッセージを改めて見返してみると、あることに気づきました。コンパイラに指示する適切なフラグがbundlerに渡されているにもかかわらず、コンパイラはdebase
gemをビルドできませんでした。その原因は、ruby-debug-ide
の方にあったのです。
このruby-debug-ide
gemのgemspecファイルには、これといった依存関係はありませんでした。
$ gem dependency -r ruby-debug-ide -v '0.7.3'
Gem ruby-debug-ide-0.7.3
rake (>= 0.8.1)
にもかかわらず、debase
gemのビルドは開始されていました。ruby-debug-ide
のソースコードをざっと調べたところ、このgemにはC拡張が付属していることが判明しました。
Gem::Specification.new do |spec|
spec.name = "ruby-debug-ide"
...
spec.extensions << "ext/mkrf_conf.rb" unless ENV['NO_EXT']
end
しかも、このC拡張はニセモノでした。
install_dir = File.expand_path("../../../..", __FILE__)
if !defined?(RUBY_ENGINE) || RUBY_ENGINE == 'ruby'
require 'rubygems'
require 'rubygems/command.rb'
require 'rubygems/dependency.rb'
require 'rubygems/dependency_installer.rb'
begin
Gem::Command.build_args = ARGV
rescue NoMethodError
end
if RUBY_VERSION < "1.9"
dep = Gem::Dependency.new("ruby-debug-base", '>=0.10.4')
elsif RUBY_VERSION < '2.0'
dep = Gem::Dependency.new("ruby-debug-base19x", '>=0.11.30.pre15')
else
dep = Gem::Dependency.new("debase", '> 0')
end
begin
puts "Installing base gem"
inst = Gem::DependencyInstaller.new(:prerelease => dep.prerelease?, :install_dir => install_dir)
inst.install dep
rescue
begin
inst = Gem::DependencyInstaller.new(:prerelease => true, :install_dir => install_dir)
inst.install dep
rescue Exception => e
puts e
puts e.backtrace.join "\n "
exit(1)
end
end unless dep.nil? || dep.matching_specs.any?
end
# ダミーのRakefileを作成して成功を通知する
f = File.open(File.join(File.dirname(__FILE__), "Rakefile"), "w")
f.write("task :default\n")
f.close
私は、たまたまRubyのGem::DependencyInstaller
クラスについて知っていました。このクラスは、bundlerのコンフィグやgemのビルドフラグを尊重せず、無視します。
「ダミーのRakefileを作成して成功を通知する」というコメントをきっかけに、さらに詳しく調べたところ、GitHubリポジトリで同じようなgemspecsファイルが180個ほど見つかりました。
最終的に、以下のWikibooksに記述されていたパターンを発見しました。
参考: How to install different versions of gems depending on which version of ruby the installee is using
このパターンが存在する理由は一体何でしょうか?条件部分を取り出してよく見てみましょう。
if RUBY_VERSION < "1.9"
dep = Gem::Dependency.new("ruby-debug-base", '>=0.10.4')
elsif RUBY_VERSION < '2.0'
dep = Gem::Dependency.new("ruby-debug-base19x", '>=0.11.30.pre15')
else
dep = Gem::Dependency.new("debase", '> 0')
end
Gem::DependencyInstaller.new(:prerelease => dep.prerelease?, :install_dir => install_dir).install(dep)
このパターンでは、このgemをインストールするRuby VMのバージョンに応じて依存関係を動的に追加するようになっています。どうやら、当時はこれしか解決方法がなかったのでしょう。
しかし現代では、Gemfile
で以下のようにBundler platformsを利用すれば同じことができます。
gem "weakling", platforms: :jruby
gem "ruby-debug", platforms: :mri_31
gem "nokogiri", platforms: [:windows_31, :jruby]
一方、gemを配布しているライブラリ開発者ならplatform specification機能が使えます。これを利用すれば、ランタイムのプラットフォームの種類に応じたgemをビルドできるようになります。
よい利用例のひとつはgoogle-protobuf
gemです。このgemはライブラリのリリースごとに10種類もの異なるパッケージを配布しています。
4.26.0 - March 12, 2024 (255 KB)
4.26.0 - March 12, 2024 x86_64-darwin (916 KB)
4.26.0 - March 12, 2024 aarch64-linux (879 KB)
4.26.0 - March 12, 2024 x64-mingw-ucrt (698 KB)
4.26.0 - March 12, 2024 x64-mingw32 (532 KB)
4.26.0 - March 12, 2024 x86_64-linux (887 KB)
4.26.0 - March 12, 2024 x86-linux (915 KB)
4.26.0 - March 12, 2024 java (4.92 MB)
4.26.0 - March 12, 2024 arm64-darwin (876 KB)
4.26.0 - March 12, 2024 x86-mingw32 (901 KB)
gemspecのRuby風の仕様は、通常はより静的な仕様に変換されるので、gemspec内に記述した条件文は機能しません。私たちは以下のようにplatform
を用いて、欲しいランタイムごとに複数の仕様を提供するよう指示しています。
if RUBY_PLATFORM == "java"
s.platform = "java"
s.files += ["lib/google/protobuf_java.jar"] +
Dir.glob('ext/**/*').reject do |file|
File.basename(file) =~ /^((convert|defs|map|repeated_field)\.[ch]|
BUILD\.bazel|extconf\.rb|wrap_memcpy\.c)$/x
end
s.extensions = ["ext/google/protobuf_c/Rakefile"]
s.add_dependency "ffi", "~>1"
s.add_dependency "ffi-compiler", "~>1"
else
s.files += Dir.glob('ext/**/*').reject do |file|
File.basename(file) =~ /^(BUILD\.bazel)$/
end
s.extensions = %w[
ext/google/protobuf_c/extconf.rb
ext/google/protobuf_c/Rakefile
]
s.add_development_dependency "rake-compiler-dock", "= 1.2.1"
end
さて、私たちのプロジェクトではどうやって対応したと思いますか?私たちはRuby IDEを構築して飯を食っているわけではないので、Ruby 3.3.0より古いRubyをサポートする必要はありません。
そういうわけで以下のオチとなりました。
Gemfile
に明示的に記載されている依存関係は変更しない- 条件付き依存関係をオプトアウトした:
具体的には、ruby-debug-ide
をforkして、spec.extensions=
行とext/mkrf_conf.rb
ファイルを削除した
技術スタックの複雑な部分によって開発者たちが気を散らされずに済んだのは幸いでした。このパターンをサポートするのにbundlerは不要でした。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。