2018-03-24

byebugがdebase(ruby-debug-ide)と同時に使えるようになった

byebug 10.0.1でdebaseと同時に使えるようになったのでその紹介記事です。

10.0.0まではbyebugとdebaseを同時にrequireした状態で byebugメソッドを呼び出すと以下のエラーが発生していました。

/root/vendor/bundle/ruby/2.4.0/gems/byebug-10.0.0/lib/byebug/attacher.rb:20:in `attach': 
undefined method `step_out' for #<Byebug::Context:0x000055d8a26b0f78> 
(NoMethodError)
	from /root/vendor/bundle/ruby/2.4.0/gems/byebug-10.0.0/lib/byebug/attacher.rb:38:in `byebug'

Byebug::Contextにはstep_outメソッドがC拡張で定義されており単体で利用したときには何も問題ないのですが、同時に使った場合のみbyebugが正常に動かない状況でした。1年前の私も以下のようなツイートをしており完全にお手上げ状態でした。

https://twitter.com/tzm_freedom/status/810002699185979392

が、最近byebugのコードを読み、動作原理は理解したので今だったら修正できるんじゃね?ということで修正してみたら無事動いたのでPRを出し、先日マージされました。

修正内容としては関数名の競合だったのですが、PRの内容の裏側は結構面白かったので以下、解決へのアプローチと原因を紹介します。

ちなみにC言語は初心者レベルですので間違っている可能性が高いことをご了承くださいm(_ _)m

解決へのアプローチと原因

なにはともあれC拡張なのでgdbでデバッグです。gdbが使えるDockerfileを作ったのでDockerコンテナ立ち上げてその中でgdbを実行しました。

こんな感じなスクリプトを

#!/usr/bin/env ruby

require 'byebug'
require 'ruby-debug-ide'
require 'debase'

byebug

gdbでデバッグします(bundle噛ますと引数多くなるのでDockerコンテナ内でグローバルインストールしても良いです)

gdb --args ruby `which bundle` exec ruby ./hoge.rb

その後、初期化してるっぽい関数にブレークポイント( b {関数名})を貼ってrunしたところ、Init_context()がなぜか二回呼ばれていることに気づきました。そこで各々のbacktraceを取ってみたところ…

1回目はdebaseから呼ばれているのですが

>>> bt
#0  Init_context (mDebase=93825000314760) at context.c:393
#1  0x00007ffff5c47c8c in Init_debase_internals () at debase_internals.c:702

2回目はなぜかbyebugのInit_byebug()からdebaseのInit_context()を呼んじゃっています。

>>> bt
#0  Init_context (mDebase=93825006662680) at context.c:393
#1  0x00007ffff58389f6 in Init_byebug () at byebug.c:894
#2  0x00005555556eecac in dln_load (file=0x5555564c5110 "/root/vendor/bundle/ruby/2.4.0/gems/byebug-10.0.0/lib/byebug/byebug.so") at dln.c:1357

byebug側の実際のコードはこんな感じで、まずKernel.byebugを読んだタイミングでC拡張のbyebug/byebugが呼ばれます。C拡張はrequireされるとInit_ライブラリ名の関数が呼ばれるためInit_byebug()が呼ばれ、その中でcontext.cに定義されているInit_context()を呼びだすようなコードになっています。

void
Init_byebug()
{
  mByebug = rb_define_module("Byebug");

# ...

  Init_context(mByebug);

ということで原因はこれで、byebugのInit_context()で初期化しようとしたがdebaseのInit_context()を呼んだ結果Debase::Contextの定義の内容になってしまったことになります。運が悪いことにどちらの関数もモジュールを引数にして、そのモジュールに対してメソッドやクラスを定義していたんですね。なので初期化以降のタイミングでエラーが出てしまっていたのです。

debaseのInit_context↓

VALUE
Init_context(VALUE mDebase)
{
  cContext = rb_define_class_under(mDebase, "Context", rb_cObject);
  rb_define_method(cContext, "stack_size", Context_stack_size, 0);
  rb_define_method(cContext, "thread", Context_thread, 0);
  rb_define_method(cContext, "dead?", Context_dead, 0);

byebugのInit_context↓

Init_context(VALUE mByebug)
{
  cContext = rb_define_class_under(mByebug, "Context", rb_cObject);

  rb_define_method(cContext, "backtrace", Context_backtrace, 0);
  rb_define_method(cContext, "dead?", Context_dead, 0);
  rb_define_method(cContext, "frame_binding", Context_frame_binding, -1);

ちなみにRubyのC拡張の読み込みはLinuxの場合はdlopenで行われています。

ruby/dln.c at 96db72ce38b27799dd8e80ca00696e41234db6ba · ruby/ruby

dlopenのフラグにRTLD_GLOBALが指定されているので、ロードされるライブラリのシンボル解決に使われてしまい競合してしまった、という感じです。

解決方法としては関数名の変更で対応しました。extern から staticの変更でも良かったかもしれませんが、そうするとdebase側も何かしら変更しないといけないし、今後同じようなInit_context()を持つgemが出てくることを考えるとキリがないなーと思ったからです。手軽で安全。

Init_context()もですがcontext_create()もアウツっぽかったのでこちらもbyebugのprefixを付けて対応しました。contextってライブラリにおいてはよく使われる単語な感じがするので名前をそのまま使うのは危ない感じがしますね…。

ということでbyebug愛好家のみなさんも、debase(ruby-debug-ide)愛好家のみなさんもお互い気にすること無くGemfileに記述できる世界になりました!

(と思ったんだけど今度はInit_breakpoint()が競合していてbreakpointが使えてないとさ)

このエントリーをはてなブックマークに追加