ruby/ruby にパッチ送ろうとしたけど失敗してる話 part2

注: part2といいつつpart1は書き上がってないのでこれが最初です

背景

るりまのサンプルコードを整備しようというプロジェクトがあるのはご存知ですか。

tbpgr.hatenablog.com

id:tbpg さんがこちらで精力的に活動されているのですが、ある日こういう話を聞きました。

Enumerator::Lazy#chunk の定義が可変長引数に見えるんだけど実際は引数渡すとエラーになるんだよね〜」

で、サンプルを書こうとしたときにちょっと詰まったと聞いたので、定義を可変長引数でなくできないだろうかと Ruby hack Challenge もくもく会#2 のネタにさせてもらいました。

connpass.com

事前調査

まずはざっと定義をみておきましょう。ruby/rubyにおいて組込みで用意されているクラスのメソッドはC言語で書かれていることが多く、その場合は rb_define_method を使ってメソッドが定義されます1。 使い方は以下のような感じです。

rb_define_method(rb_cKlass, "method_name", cfunk, argc)

rb_cKlass クラスに "method_name" メソッドを宣言しており、実体が cfunk、その時の引数の数が argc です。覚えてしまえばかんたんですね。

さて、今回は chunk メソッドを探すので "chunk" で検索すればよさそうです。

$ git grep '"chunk"'
enum.c:    rb_define_method(rb_mEnumerable, "chunk", enum_chunk, 0);
enumerator.c:    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);

2件ヒットしましたね。ruby/rubyのことは全然わかりませんが幸い少しはRubyの知識があるので rb_mEnumerableEnumerable モジュール、 rb_cLazyEnumerator::Lazy クラスだろうと推測できます。

一応それぞれの宣言を見ておきましょう。まずは Enumerator::Lazy のほうから。

/* enumerator.c */
void
InitVM_Enumerator(void)
{
...
    rb_cEnumerator = rb_define_class("Enumerator", rb_cObject);
    rb_include_module(rb_cEnumerator, rb_mEnumerable);
...
    /* Lazy */
    rb_cLazy = rb_define_class_under(rb_cEnumerator, "Lazy", rb_cEnumerator);
...
    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);
...
}

void Init...(void) という関数はRubyを実行したときに、Rubyコードを実行する前の初期化をする関数です(だと思っています)。

rb_define_class_under は以下のシグネチャをもちます。名前的に outer がネームスペース、super が親クラスでしょう。

VALUE rb_define_class_under(VALUE outer, const char *name, VALUE super)

よって rb_cLazyrb_cEnumerator のインナークラスであり、なおかつ rb_cEnumerator の子クラスであることがわかります。Rubyでかくとこんな感じでしょうか。

class Enumerator
  class Lazy < Enumerator
  end
end

関数の実体は lazy_super で argc が -1なので可変長です。lazy_super の定義も参照しましょう。

/* enumerator.c */
static VALUE
lazy_super(int argc, VALUE *argv, VALUE lazy)
{
    return enumerable_lazy(rb_call_super(argc, argv));
}

enumerable_lazy は、まあたぶん lazy 化してくれるんでしょう。 rb_call_super は、まあsuper呼んでそうな気がしますね。こういうのは深追いすると時間が溶けていくので一旦はここで放置します。

rb_call_super で呼ばれる関数を一応確認しましょう。先程確認したように rb_cLazy の親クラスは rb_cEnumerator ですのでそちらの定義を確認します。

抜粋した中に rb_include_module(rb_cEnumerator, rb_mEnumerable) という箇所があり rb_mEnumerable が include されていることが確認できさらに grep 結果から実体が enum.cenum_chunk であることそしてargcが0であり引数をとらないことがわかります(早口

enum.c:    rb_define_method(rb_mEnumerable, "chunk", enum_chunk, 0);

enum_chunk まで追う必要はなさそうですがシグネチャくらいは置いておきます。

static VALUE
enum_chunk(VALUE enumerable)
{
...
}

ここまでで、以下のことが確認できました。

  • Enumurator::Lazy#chunk が可変長引数であること
  • 実体は Enumerator#chunk といってよさそうなこと
  • Enumerator#chunk は引数をとらないこと

hackしてみる

さて、では引数の数を変えてみましょう。さきほど見たように rb_define_method の argc を -1 から 0 に変えればよいでしょう。かんたんですね。こんなんで「hackしてみる」というような見出しをつけていいものでしょうか。心が痛みます(フラグ

diff --git a/enumerator.c b/enumerator.c
index d61d79e897..da109c8116 100644
--- a/enumerator.c
+++ b/enumerator.c
@@ -2379,7 +2379,7 @@ InitVM_Enumerator(void)
     rb_define_method(rb_cLazy, "drop", lazy_drop, 1);
     rb_define_method(rb_cLazy, "drop_while", lazy_drop_while, 0);
     rb_define_method(rb_cLazy, "lazy", lazy_lazy, 0);
-    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);
+    rb_define_method(rb_cLazy, "chunk", lazy_super, 0);
     rb_define_method(rb_cLazy, "slice_before", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_after", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_when", lazy_super, -1);

さっそくビルドして検証しましょう。こういうgemが絡まないような検証のときは miniruby でやるものだと聞いた気がするのでそうします。検証コードも、どうせ動くし雑でいいんじゃないでしょうか(フラグ2

# tmp/lazy.rb
arr = %w(a a b)

p Enumerator.instance_method(:chunk).arity
p Enumerator::Lazy.instance_method(:chunk).arity
p arr.chunk do |e|
  p e
end
p arr.lazy.chunk do |e|
  p e
end

さて、minirubyを作って実行しましょう。

$ make miniruby
$ ruby -v
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-darwin16]
$ ruby tmp/lazy.rb
0
-1
#<Enumerator: ["a", "a", "b"]:chunk>
#<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: ["a", "a", "b"]>:chunk>>
$ ./miniruby tmp/lazy.rb
0
0
#<Enumerator: ["a", "a", "b"]:chunk>
Traceback (most recent call last):
        2: from tmp/lazy.rb:8:in `<main>'
        1: from tmp/lazy.rb:8:in `chunk'
tmp/lazy.rb:8:in `chunk': wrong number of arguments (given 1309040872, expected 0) (ArgumentError) 

ん……?

given 1309040872

( д) ゚ ゚

引数の数が合わず、 expected が 0 なのに 1309040872 もの引数が渡されたことになっています。んなわけあるかーい。

なんどか実行してみると given の値が変化していることがわかります。また、この大きさからだいたいメモリアドレスを指しているんじゃないかなあという気持ちになります。

関数の中身をモックにしたりして原因を切り分けてみてはどうだろう、ともくもく会参加者の yui-knk さんにアドバイスをもらったのでやってみましょう。

diff --git a/enumerator.c b/enumerator.c
index d61d79e897..381f039c8a 100644
--- a/enumerator.c
+++ b/enumerator.c
@@ -2270,6 +2270,7 @@ lazy_uniq(VALUE obj)
 static VALUE
 lazy_super(int argc, VALUE *argv, VALUE lazy)
 {
+    return INT2FIX(1);
     return enumerable_lazy(rb_call_super(argc, argv));
 }

@@ -2379,7 +2380,7 @@ InitVM_Enumerator(void)
     rb_define_method(rb_cLazy, "drop", lazy_drop, 1);
     rb_define_method(rb_cLazy, "drop_while", lazy_drop_while, 0);
     rb_define_method(rb_cLazy, "lazy", lazy_lazy, 0);
-    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);
+    rb_define_method(rb_cLazy, "chunk", lazy_super, 0);
     rb_define_method(rb_cLazy, "slice_before", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_after", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_when", lazy_super, -1);

INT2FIXC言語での int を Ruby の世界の Fixnum に変換するマクロです。必ず1を返すようにしてみました。

$ make miniruby
$ ./miniruby tmp/lazy.rb
0
0
#<Enumerator: ["a", "a", "b"]:chunk>
1

これでは普通に通りますね。lazy_super の中身は、 enumerable_lazyrb_call_super にわけられるのでとりあえず後者だけを呼ぶようにしてみます。

diff --git a/enumerator.c b/enumerator.c
index d61d79e897..d022c09ebd 100644
--- a/enumerator.c
+++ b/enumerator.c
@@ -2270,6 +2270,7 @@ lazy_uniq(VALUE obj)
 static VALUE
 lazy_super(int argc, VALUE *argv, VALUE lazy)
 {
+    return rb_call_super(argc, argv);
     return enumerable_lazy(rb_call_super(argc, argv));
 }

@@ -2379,7 +2380,7 @@ InitVM_Enumerator(void)
     rb_define_method(rb_cLazy, "drop", lazy_drop, 1);
     rb_define_method(rb_cLazy, "drop_while", lazy_drop_while, 0);
     rb_define_method(rb_cLazy, "lazy", lazy_lazy, 0);
-    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);
+    rb_define_method(rb_cLazy, "chunk", lazy_super, 0);
     rb_define_method(rb_cLazy, "slice_before", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_after", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_when", lazy_super, -1);

実行してみると……

$ ./miniruby tmp/lazy.rb
0
0
#<Enumerator: ["a", "a", "b"]:chunk>
Traceback (most recent call last):
        2: from tmp/lazy.rb:8:in `<main>'
        1: from tmp/lazy.rb:8:in `chunk'
tmp/lazy.rb:8:in `chunk': wrong number of arguments (given -477732640, expected 0) (ArgumentError)

同じ事象が出ました。どうもsuper側で何かが起きているようですね。

super側の定義をタグジャンプして追ってみたのですがよくわかりません。仕方がないのでlldb(デバッガ)で無理やり止めてみるとあっさりわかりました2ブレークポイントを挟むのですが、 vm_call_super に挟むといろんなものでとまってめんどくさそうです。mid かなんかでとめるとよさそうな気がしました。rb_intern で文字列から ID3 を取り出せるのでif文つくっておけばいいでしょう。

diff --git a/enumerator.c b/enumerator.c
index d61d79e897..da109c8116 100644
--- a/enumerator.c
+++ b/enumerator.c
@@ -2379,7 +2379,7 @@ InitVM_Enumerator(void)
     rb_define_method(rb_cLazy, "drop", lazy_drop, 1);
     rb_define_method(rb_cLazy, "drop_while", lazy_drop_while, 0);
     rb_define_method(rb_cLazy, "lazy", lazy_lazy, 0);
-    rb_define_method(rb_cLazy, "chunk", lazy_super, -1);
+    rb_define_method(rb_cLazy, "chunk", lazy_super, 0);
     rb_define_method(rb_cLazy, "slice_before", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_after", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_when", lazy_super, -1);
diff --git a/vm_eval.c b/vm_eval.c
index f0f336a233..a2efe31736 100644
--- a/vm_eval.c
+++ b/vm_eval.c
@@ -229,6 +229,9 @@ vm_call_super(rb_execution_context_t *ec, int argc, const VALUE *argv)
        return method_missing(recv, id, argc, argv, MISSING_SUPER);
     }
     else {
+ if (id == rb_intern("chunk")) {
+     printf("break\n");
+ }
        return vm_call0(ec, recv, id, argc, argv, me);
     }
 }

lldb でブレークポイントを貼って実行します。bブレークポイントを貼って run ARGS で実行。とまったあとは p で中身見て bt でトレースが見れます。n がnext, s が step, c がcontinueなのまで覚えておけばだいたい大丈夫でしょう。

$ make miniruby
$ lldb ./miniruby
(lldb) target create "./miniruby"
Current executable set to './miniruby' (x86_64).
(lldb) b vm_eval.c:233
Breakpoint 1: where = miniruby`vm_call_super + 251 at vm_eval.c:233, address = 0x0000000100272a0b
(lldb) run tmp/lazy.rb
Process 35984 launched: './miniruby' (x86_64)
0
0
#<Enumerator: ["a", "a", "b"]:chunk>
Process 35984 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x0000000100272a0b miniruby`vm_call_super(ec=0x0000000100606e78, argc=25583824, argv=0x00000001018660d0) at vm_eval.c:233
   230      }
   231      else {
   232          if (id == rb_intern("chunk")) {
-> 233              printf("break\n");
   234          }
   235          return vm_call0(ec, recv, id, argc, argv, me);
   236      }
Target 0: (miniruby) stopped.
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x0000000100272a0b miniruby`vm_call_super(ec=0x0000000100606e78, argc=25583824, argv=0x00000001018660d0) at vm_eval.c:233
    frame #1: 0x00000001002728a1 miniruby`rb_call_super(argc=25583824, argv=0x00000001018660d0) at vm_eval.c:244
    frame #2: 0x000000010008c60f miniruby`lazy_super(argc=25583824, argv=0x00000001018660d0, lazy=0) at enumerator.c:2273
    frame #3: 0x00000001002844d3 miniruby`call_cfunc_0(func=(miniruby`lazy_super at enumerator.c:2272), recv=4320551120, argc=0, argv=0x0000000102000048) at vm_insnhelper.c:1735
    frame #4: 0x000000010028329d miniruby`vm_call_cfunc_with_frame(ec=0x0000000100606e78, reg_cfp=0x00000001020fffa0, calling=0x00007ffeefbfe428, ci=0x000000010051c910, cc=0x000000010051ca98) at vm_insnhelper.c:1924
    frame #5: 0x000000010027e9bd miniruby`vm_call_cfunc(ec=0x0000000100606e78, reg_cfp=0x00000001020fffa0, calling=0x00007ffeefbfe428, ci=0x000000010051c910, cc=0x000000010051ca98) at vm_insnhelper.c:1940
    frame #6: 0x000000010027de9e miniruby`vm_call_method_each_type(ec=0x0000000100606e78, cfp=0x00000001020fffa0, calling=0x00007ffeefbfe428, ci=0x000000010051c910, cc=0x000000010051ca98) at vm_insnhelper.c:2238
    frame #7: 0x000000010027dc30 miniruby`vm_call_method(ec=0x0000000100606e78, cfp=0x00000001020fffa0, calling=0x00007ffeefbfe428, ci=0x000000010051c910, cc=0x000000010051ca98) at vm_insnhelper.c:2359
    frame #8: 0x000000010027db85 miniruby`vm_call_general(ec=0x0000000100606e78, reg_cfp=0x00000001020fffa0, calling=0x00007ffeefbfe428, ci=0x000000010051c910, cc=0x000000010051ca98) at vm_insnhelper.c:2402
    frame #9: 0x000000010026a5d8 miniruby`vm_exec_core(ec=0x0000000100606e78, initial=0) at insns.def:933
    frame #10: 0x0000000100279586 miniruby`vm_exec(ec=0x0000000100606e78) at vm.c:1797
    frame #11: 0x000000010027a2fb miniruby`rb_iseq_eval_main(iseq=0x0000000101866dc8) at vm.c:2045
    frame #12: 0x000000010009b928 miniruby`ruby_exec_internal(n=0x0000000101866dc8) at eval.c:246
    frame #13: 0x000000010009b831 miniruby`ruby_exec_node(n=0x0000000101866dc8) at eval.c:310
    frame #14: 0x000000010009b7f0 miniruby`ruby_run_node(n=0x0000000101866dc8) at eval.c:302
    frame #15: 0x0000000100001881 miniruby`main(argc=2, argv=0x00007ffeefbfed10) at main.c:42
    frame #16: 0x00007fff5bd3b145 libdyld.dylib`start + 1

長いのが出てきてウゲーとなりますが、よくみるとargcという文字列も見えますね。そこだけ追うと以下の箇所がめちゃくちゃあやしいです。

    frame #2: 0x000000010008c60f miniruby`lazy_super(argc=25583824, argv=0x00000001018660d0, lazy=0) at enumerator.c:2273
    frame #3: 0x00000001002844d3 miniruby`call_cfunc_0(func=(miniruby`lazy_super at enumerator.c:2272), recv=4320551120, argc=0, argv=0x0000000102000048) at vm_insnhelper.c:1735

call_cfunc_0 では 0 だった argc が lazy_super には 25583824 で渡されています。こいつっぽい。

vm_insnhelper.c:1735 らしいので call_cfunc_0 を見てみましょう。

static VALUE
call_cfunc_0(VALUE (*func)(ANYARGS), VALUE recv, int argc, const VALUE *argv)
{
    return (*func)(recv);
}

な、なんもわからん……。もう1つ前のフレームを見ましょう。vm_call_cfunc_with_framevm_insnhelper.c:1924 だそうです。いらなそうなところを消していくとこんな感じです。

static VALUE
vm_call_cfunc_with_frame(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp, struct rb_calling_info *calling, const struct rb_call_info *ci, struct rb_call_cache *cc)
{
    VALUE val;
    const rb_callable_method_entry_t *me = cc->me;
    const rb_method_cfunc_t *cfunc = vm_method_cfunc_entry(me);
    int len = cfunc->argc;

    VALUE recv = calling->recv;
    VALUE block_handler = calling->block_handler;
    int argc = calling->argc;
...
    val = (*cfunc->invoker)(cfunc->func, recv, argc, reg_cfp->sp + 1);
...
    return val;
}

1924行目は val = ... の行です。cfunc->invokerが関数へのポインタになっていて4、invokerポインタの指す先の関数を cfunc->func, recv, argc, reg_cfp->sp + 1 という引数で呼んでいるようです。 どうして invoker を使っているのでしょうか。これは cfunc への呼び出しをラップするためのもののようです。例えばさっき書いた call_cfunc_0 もinvokerで、第一引数にCの関数をもらって呼び出しています。

Array のメソッドを例にとると、引数が0個の rb_ary_uniqシグネチャは以下です。

static VALUE rb_ary_uniq(VALUE ary)

可変長引数の rb_ary_maxシグネチャはこんな感じです。

static VALUE rb_ary_max(int argc, VALUE *argv, VALUE ary)

呼び出し方が全然違うので invoker でラップしているんだろう、という感じです。呼び出すときに呼び出し方を考えるより、定義したときに呼び出し方を決めておいたほうが楽だって話かと思いました。

さて、chunkのbacktraceに戻りましょう。invoker である call_cfunc_0 を再掲します。

static VALUE
call_cfunc_0(VALUE (*func)(ANYARGS), VALUE recv, int argc, const VALUE *argv)
{
    return (*func)(recv);
}

このとき func は lazy_super へのポインタなので lazy_super を引数 recv で呼び出しています。

static VALUE
lazy_super(int argc, VALUE *argv, VALUE lazy)
{
    return enumerable_lazy(rb_call_super(argc, argv));
}

lazy_superの第一引数はargcですね。recvはVALUEで実体はunsigned intです5。なのでargcを期待しているところにrecvを入れてしまったのが原因のようですね。lazy_super に関しては recv 相当のものは第三引数になければなりません。 lazy_super が可変長引数のシグネチャをもち、引数0個のシグネチャと異なるのにargcだけ変えたことが原因のようです。 別の関数を切って動かしてみましょう。

diff --git a/enumerator.c b/enumerator.c
index da109c8116..31b18251b0 100644
--- a/enumerator.c
+++ b/enumerator.c
@@ -2273,6 +2273,12 @@ lazy_super(int argc, VALUE *argv, VALUE lazy)
     return enumerable_lazy(rb_call_super(argc, argv));
 }

+static VALUE
+lazy_super_0(VALUE obj)
+{
+    return enumerable_lazy(rb_call_super(0, NULL));
+}
+
 static VALUE
 lazy_lazy(VALUE obj)
 {
@@ -2379,7 +2385,7 @@ InitVM_Enumerator(void)
     rb_define_method(rb_cLazy, "drop", lazy_drop, 1);
     rb_define_method(rb_cLazy, "drop_while", lazy_drop_while, 0);
     rb_define_method(rb_cLazy, "lazy", lazy_lazy, 0);
-    rb_define_method(rb_cLazy, "chunk", lazy_super, 0);
+    rb_define_method(rb_cLazy, "chunk", lazy_super_0, 0);
     rb_define_method(rb_cLazy, "slice_before", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_after", lazy_super, -1);
     rb_define_method(rb_cLazy, "slice_when", lazy_super, -1);

どうせargc, argvは使わないはずなのでsuperの呼び方は適当でいいでしょう。

$ make miniruby
$ ./miniruby tmp/lazy.rb
0
0
#<Enumerator: ["a", "a", "b"]:chunk>
#<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: ["a", "a", "b"]>:chunk>>

無事通りました。

パッチを送るべきか?

よっしゃ、通った!パッチ送ろう!と思ったんですが。そもそも元のコードってRubyにおけるsuperへの委譲のよくあるパターンなんですよね。Rubyコードだとこんな感じ。

class Calc
  def add(x, y)
    x + y
  end
end

class Calc2 < Calc
  def add(*) # 可変長引数にしてsuperに委譲。superの引数の数を考えなくてよい
    super * 2 # 全部2倍にする、みたいな処理
  end
end

これは Calc#add の引数の数がなんであろうと絶対2倍にするんだってときに役にたつんですよね。親クラスに依存しすぎないようにしてる。

一方今回いれたhackはこの Calc2#add で仮引数が x, y であると明示しているようなパターンになります。具体的には、こういうコードでの挙動が変わります。

# tmp/include.rb
arr = %w(a a b)
module Foo
  def chunk(arg)
    puts arg # override
  end
end
Enumerator::Lazy.include Foo
arr.lazy.chunk(1) { |e| e }

まあこのコードが何がしたいのかはおいといて……。include によって Enumerator::Lazy#chunk の super 呼び出しが Enumerator#chunk から Foo#chunk に変わっています。Foo#chunk は明示的に引数を1つとるメソッドです。

$ ruby tmp/include.rb
1
$ ./miniruby tmp/include.rb
Traceback (most recent call last):
        1: from tmp/include.rb:9:in `<main>'
tmp/include.rb:9:in `chunk': wrong number of arguments (given 1, expected 0) (ArgumentError)

今回のパッチがあたっている miniruby のほうでは Enumerator::Lazy#chunk が委譲先の引数の数を気にするようになったことで ArgumentError になってしまいました6。 とゆーわけでそもそものアプローチが筋悪だったのかな、というところで今回はおしまい。いつになったらそれっぽいパッチが送れるようになるのでしょうか……。


  1. Rubyの世界でいう def みたいなもん。これは覚えちゃったほうが楽なので覚えてしまいましょう

  2. ざっと書いてるけどデバッガ準備してなかったからやるのめんどくさーいって言いながらタグジャンプで1時間ほどコード眺めていた。最初からやっておけば……

  3. Rubyプロセス内で一意に識別するためのもので、メソッドやインスタンス変数はキーバリューを保持できるテーブルからIDをキーにして探索されている

  4. cfuncはcで定義されたメソッドを表す構造体だと思っておいてよさそう

  5. たぶん環境依存だけど。僕の手元だと VALUE = uintptr_t = unsigned intっぽい

  6. エラーではあるが given 1 に安心感がある