Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 16年前の古いgemハックを2024年に踏んでヤケドした話(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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は不要でした。

関連記事

brew upgrade でのエラー対処からCommand Line Toolsについてまとめてみる


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。