mruby-specの成果報告

自分の頭の整理のためのエントリーです。 よって超長い。

mruby-spec

以前、mrubyでruby/specを走らせるやつを書いたのですが、 その後どうなったのかまとめてみます。

ksss9.hatenablog.com

成果

  • mruby本体のSEGVを3つなおした。

ruby/specで、まず手を付けていたのはlanguage specでした。 言語の基礎が壊れてたらイカンだろうということで根っこから見ています。

正直言ってまだ走り切ることも困難です。というのもSEGVするから。 SEGVするとそこから先のspecを走らせることができなくなるため、地道に直していました。

https://github.com/mruby/mruby/pull/3204

大体はmac上でデバッグしているのでlldbを使いました。 lldbでlanguage specを走らせてみて、どこで止まったか追ってみます。 mrubyのcommit hashは d58c2585d4d63f13beeb890046ce6c7b514fab91 でした

$ pwd
ksss/mruby-spec

$ lldb mruby/bin/mruby
(lldb) run mspec/bin/mspec-run spec/language
... いっぱいspecが落ちる表示が出る ...
[/ | ========          20%                    | 00:00:00]     19F     51EProcess 3230 stopped
* thread #1: tid = 0x16e74f, 0x000000010006eb9e mruby`get_closure_irep(mrb=0x00000001003002d0, level=0) + 254 at eval.c:31, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x4800000001)
    frame #0: 0x000000010006eb9e mruby`get_closure_irep(mrb=0x00000001003002d0, level=0) + 254 at eval.c:31
   28     if (!e) return NULL;
   29     proc = c->cibase[e->cioff].proc;
   30
-> 31      if (!proc || MRB_PROC_CFUNC_P(proc)) {
   32       return NULL;
   33     }
   34     return proc->body.irep;

うんぜんぜんわからん。ちなみに止まる部分は実行する毎に変わったりします。

Cレベルでのバックトレースを取ってみます。

(lldb) bt
* thread #1: tid = 0x16fbc3, 0x000000010006eb9e mruby`get_closure_irep(mrb=0x0000000100400390, level=0) + 254 at eval.c:31, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x3500000001)
  * frame #0: 0x000000010006eb9e mruby`get_closure_irep(mrb=0x0000000100400390, level=0) + 254 at eval.c:31
    frame #1: 0x000000010006e985 mruby`search_variable(mrb=0x0000000100400390, vsym=326, bnest=0) + 37 at eval.c:44
    frame #2: 0x000000010006e903 mruby`patch_irep(mrb=0x0000000100400390, irep=0x0000000101865dd0, bnest=0) + 723 at eval.c:122
    frame #3: 0x000000010006e595 mruby`create_proc_from_string(mrb=0x0000000100400390, s="x=1", len=3, binding=mrb_value @ 0x00007fff5fbfd1c0, file="(eval)", line=1) + 1205 at eval.c:194
    frame #4: 0x000000010006ddef mruby`f_eval(mrb=0x0000000100400390, self=mrb_value @ 0x00007fff5fbfd398) + 191 at eval.c:215
    frame #5: 0x000000010003ee15 mruby`mrb_vm_exec(mrb=0x0000000100400390, proc=0x000000010202cca0, pc=0x000000010183ab90) + 6613 at vm.c:1150
    frame #6: 0x000000010003d427 mruby`mrb_vm_run(mrb=0x0000000100400390, proc=0x000000010107e670, self=mrb_value @ 0x00007fff5fbfe080, stack_keep=0) + 135 at vm.c:759
    frame #7: 0x00000001000458fb mruby`mrb_top_run(mrb=0x0000000100400390, proc=0x000000010107e670, self=mrb_value @ 0x00007fff5fbfe0f0, stack_keep=0) + 187 at vm.c:2440
    frame #8: 0x000000010006302f mruby`load_exec(mrb=0x0000000100400390, p=0x000000010201d420, c=0x0000000101851ee0) + 1183 at parse.y:5694
    frame #9: 0x0000000100062b73 mruby`mrb_load_file_cxt(mrb=0x0000000100400390, f=0x00007fff79291218, c=0x0000000101851ee0) + 67 at parse.y:5703
    frame #10: 0x0000000100094217 mruby`load_rb_file(mrb=0x0000000100400390, filepath=mrb_value @ 0x00007fff5fbfe410) + 247 at mrb_require.c:425
    frame #11: 0x000000010009305e mruby`load_file(mrb=0x0000000100400390, filepath=mrb_value @ 0x00007fff5fbfe450) + 142 at mrb_require.c:437
    frame #12: 0x0000000100092a13 mruby`mrb_load(mrb=0x0000000100400390, filename=mrb_value @ 0x00007fff5fbfe490) + 51 at mrb_require.c:455
    frame #13: 0x00000001000931d5 mruby`mrb_f_load(mrb=0x0000000100400390, self=mrb_value @ 0x00007fff5fbfe4f0) + 133 at mrb_require.c:470
    frame #14: 0x000000010003ee15 mruby`mrb_vm_exec(mrb=0x0000000100400390, proc=0x000000010107ec40, pc=0x00000001004a0e98) + 6613 at vm.c:1150
    frame #15: 0x000000010003d427 mruby`mrb_vm_run(mrb=0x0000000100400390, proc=0x0000000101047950, self=mrb_value @ 0x00007fff5fbff1d0, stack_keep=0) + 135 at vm.c:759
    frame #16: 0x00000001000458a4 mruby`mrb_top_run(mrb=0x0000000100400390, proc=0x0000000101047950, self=mrb_value @ 0x00007fff5fbff240, stack_keep=0) + 100 at vm.c:2434
    frame #17: 0x000000010006302f mruby`load_exec(mrb=0x0000000100400390, p=0x0000000101019820, c=0x0000000100432020) + 1183 at parse.y:5694
    frame #18: 0x0000000100062b73 mruby`mrb_load_file_cxt(mrb=0x0000000100400390, f=0x00007fff79291050, c=0x0000000100432020) + 67 at parse.y:5703
    frame #19: 0x0000000100001509 mruby`main(argc=3, argv=0x00007fff5fbff668) + 905 at mruby.c:226
    frame #20: 0x00007fff9b92e5ad libdyld.dylib`start + 1
    frame #21: 0x00007fff9b92e5ad libdyld.dylib`start + 1

一見意味不明ですが、よくみるとeval.cでcreate_proc_from_string関数に"x=1"という文字列を渡していることに気が付きます。

$ cd spec && pwd
ksss/mruby-spec/spec

$ git grep x=1 language
language/and_spec.rb:    # evaluates (x=1) and (y=2)
language/ensure_spec.rb:      eval('x=1') # to prevent opts from triggering

どうやらlanguage/ensure_spec.rbがクサいっぽいです。

  it "in no-exception scenarios, is the value of the last statement of the protected body" do
    begin
      v = 1
      eval('x=1') # to prevent opts from triggering
      v
    ensure
      v = 2
    end.should == 1
  end

ありました。eval('x=1')の部分が実に怪しいです。

しかしながら単純にmruby -e 'eval("x=1")'としてもSEGVしません。

再現コードを書こうにも、mspecのコードを読んでできるだけ余計なコードを削除して展開して……。 とやっていると、「ある時点でメソッドを展開するとSEGVが起こらなくなる」という現象に遭遇しました。 最初はサッパリでしたが、途中で「もしやメソッドの呼び出しスタックが深くなるとSEGVが起こりやすくなるのでは?」とひらめき、

1.times{1.times{1.times{1.times{1.times{1.times{1.times{1.times{
1.times{1.times{1.times{1.times{1.times{1.times{1.times{1.times{
  begin
    eval('raise')
  rescue => e
    print e.backtrace
  end
}}}}}}}}
}}}}}}}}

とすることでSEGVを再現できることをたまたま発見できました。

ところがどっこい。 この再現コードはこれまでと全く違う部分でSEGVしているようです。

(lldb) run t.rb
Process 4744 launched: '/Users/yuki/src/github.com/ksss/mruby-spec/mruby/bin/mruby' (x86_64)
Process 4744 stopped
* thread #1: tid = 0x173171, 0x00007fff95b04132 libsystem_c.dylib`strlen + 18, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
    frame #0: 0x00007fff95b04132 libsystem_c.dylib`strlen + 18
libsystem_c.dylib`strlen:
->  0x7fff95b04132 <+18>: pcmpeqb (%rdi), %xmm0
    0x7fff95b04136 <+22>: pmovmskb %xmm0, %esi
    0x7fff95b0413a <+26>: andq   $0xf, %rcx
    0x7fff95b0413e <+30>: orq    $-0x1, %rax
(lldb) up
frame #1: 0x0000000100030d8b mruby`mrb_str_cat_cstr(mrb=0x0000000100600000, str=mrb_value @ 0x00007fff5fbfe380, ptr=0x0000000000000000) + 43 at string.c:2585
   2582 MRB_API mrb_value
   2583 mrb_str_cat_cstr(mrb_state *mrb, mrb_value str, const char *ptr)
   2584 {
-> 2585    return mrb_str_cat(mrb, str, ptr, strlen(ptr));
   2586 }
   2587
   2588 MRB_API mrb_value
(lldb) up
frame #2: 0x000000010000645d mruby`mrb_restore_backtrace(mrb=0x0000000100600000) + 541 at backtrace.c:415
   412          mrb_str_cat(mrb, mrb_entry, &entry->sep, 1);
   413        }
   414
-> 415         mrb_str_cat_cstr(mrb, mrb_entry, mrb_sym2name(mrb, entry->method_id));
   416      }
   417
   418      mrb_ary_push(mrb, backtrace, mrb_entry);
(lldb) up
frame #3: 0x0000000100014eec mruby`exc_get_backtrace(mrb=0x0000000100600000, exc=mrb_value @ 0x00007fff5fbfe510) + 156 at error.c:190
   187    backtrace = mrb_iv_get(mrb, exc, attr_name);
   188    if (mrb_nil_p(backtrace)) {
   189      if (mrb_obj_ptr(exc) == mrb->backtrace.exc && mrb->backtrace.n > 0) {
-> 190         backtrace = mrb_restore_backtrace(mrb);
   191        mrb->backtrace.n = 0;
   192        mrb->backtrace.exc = 0;
   193      }
(lldb) up
frame #4: 0x000000010003ee15 mruby`mrb_vm_exec(mrb=0x0000000100600000, proc=0x000000010081f670, pc=0x000000010064ba58) + 6613 at vm.c:1150
   1147           ci->argc = n;
   1148           ci->nregs = n + 2;
   1149         }
-> 1150          result = m->body.func(mrb, recv);
   1151         mrb_gc_arena_restore(mrb, ai);
   1152         if (mrb->exc) goto L_RAISE;
   1153         /* pop stackpos */

どうやらException#backtraceを呼び出した時にmrb_sym2name(mrb, entry->method_id)NULLを返してしまい、strlen(NULL)のような形になってしまっているようです。entry->method_id23459のような数字でした。これはシンボルのIDのはずなのですが、2万個もシンボルを作っていないはずなので多すぎます。この値が異常っぽいです。それゆえmrb_sym2nameはシンボルIDから文字列に変換するメソッドなのですが、該当するシンボルがないのでNULLを返しているようです。

そもそもmrubyのbacktraceはどうやって取っているのでしょうか? それすら全くわからなかったので、ひたすらコードを読みました。

すると、次のようなことが見えてきました。

  • mrubyのbacktrace情報はmrb->c->ciから取ってきている
  • mrb->cstruct mrb_contextのことで、struct mrb_contextにはcibase, ci, ciendというメンバーがある。
  • cibaseは単に配列で、ciは配列の現在位置、ciendはメモリを確保している最後の印。
  • 最初にcibaseに長さ32個分のメモリを確保、ci = cibaseとして初期化される。
  • mruby側でメソッドを呼び出すたびにciポインタは一つ進み、これまでどういうスタックで呼ばれてきたのかを記録している。
  • ciciendまでくると、mrb_reallocで確保するメモリが2倍に伸長される。

f:id:ksss9:20160909223450p:plain

backtraceはciの位置から一つづつcibaseの位置にもどりつつスタックの情報をbacktrace用に作っていて、肝心要のentry->method_idci->midからコピーしている。

https://github.com/mruby/mruby/blob/501e1ef2605541b98b6f0d2ba2fec69ff068f1cf/src/backtrace.c#L147

当然ci->midは今のスタックのメソッド名(シンボル)でおかしな値はここからコピーされていることがわかった。

さて、ci->midはどこから来るのか。この値はシンボルなので加減算はされないだろう。 さらにあたりをつけると、こんな異常で毎回大きく異る値をとり得るのは、未初期化な場合だろう。

ではciスタックは、どこで作られるんだろう。

実は2箇所しかない。 初期化時と伸長時だ。

https://github.com/mruby/mruby/blob/501e1ef2605541b98b6f0d2ba2fec69ff068f1cf/src/vm.c#L93 https://github.com/mruby/mruby/blob/501e1ef2605541b98b6f0d2ba2fec69ff068f1cf/src/vm.c#L226

バイこのまま細かく書いているといつまでたっても終わらない。どんどん巻いていくと、mrb_callocはじつはmemsetで0初期化する処理が含まれているのでここで異常な値が入るとは考えにくい。 そしてmrb_reallocはその名の通りreallocするので0初期化しない。

さらに、このmrb_realloccibaseを取り直しているcipushはmrubyのevalメソッドからmrb_top_runを通じて呼ばれている。 そしてなんと、このパスではci->midはなにも操作されない。reallocで確保されたメモリではあるが、野ざらしで雑草がぼうぼうに生えた空き地があるだけだ。その雑草が23459。ようやく犯人(草)を掴んだ。

ここで雑草を抜いて綺麗にしておけばなんとかなる。 mrubyの場合、mid=0がCRubyでの<main>を意味するようだ。mrubyではこの場合、単にメソッド名を表示していない。

$ ruby -e 'eval("raise")'
-e:1:in `eval': (eval):1:in `<main>' (RuntimeError)
    from -e:1:in `eval'
    from -e:1:in `<main>'
$ mruby -e 'eval("raise")'
trace:
    [0] (eval):1
    [1] -e:1
(eval):1: RuntimeError

これでようやく一つ倒した。ここまでで一週間以上かかっている。

https://github.com/mruby/mruby/pull/3206

さて、ここまできても最初に見つけたSEGVはなおっていない。つらい。 気を取り直して調べていこう。

lldbで見つけた部分を思い出そう。

   28      if (!e) return NULL;
   29     proc = c->cibase[e->cioff].proc;
   30
-> 31      if (!proc || MRB_PROC_CFUNC_P(proc)) {
   32       return NULL;
   33     }
   34     return proc->body.irep;

ここでいろいろ調べてみると、e->cioff-1であることがわかった。

(lldb) p e->cioff
(ptrdiff_t) $0 = -1

前回、cibaseはスタックの最初であることが分かっていた。cibase[-1]ではあきらかにおかしい。つまりこの時点でe->cioff-1なのがおかしいことになる。

e->cioffとはなにか。そもそもe(REnv*)とはなにか。

REnvで検索していくと、mrb_callinfoRProcという構造体にメンバーとして含まれている。

https://github.com/mruby/mruby/blob/501e1ef2605541b98b6f0d2ba2fec69ff068f1cf/include/mruby/proc.h#L32-L40 https://github.com/mruby/mruby/blob/08a1dd2ac89333bd928a3300f712d1a7fb57e8ff/include/mruby.h#L72-L85

また、REnv型としてメモリ確保されているのはproc.cのenv_new、さらにenv_newclosure_setupが呼んでいる。

https://github.com/mruby/mruby/blob/08a1dd2ac89333bd928a3300f712d1a7fb57e8ff/src/proc.c#L37-L64

closure...クロージャーと言えば、なぜか外側の変数をメソッド内で保持している不思議な奴だった気がするやつだ。 ここで、「struct REnvは外側の環境を保存しておくためのものではないか?」という仮説が浮かび上がる。 ほかにも「環境変数を表すやつか?」とかいろんな仮説が浮かんでは死んでいった。今回もそうだろうか?

proc.hには気になるマクロがある。

https://github.com/mruby/mruby/blob/08a1dd2ac89333bd928a3300f712d1a7fb57e8ff/include/mruby/proc.h#L27-L28

そういえば問題はe->cioff = -1のときなんだった。忘れてた。 これはMRB_ENV_UNSHARE_STACKされた時と見るのが可能性が高そうだ。 多分この数字によってREnvのモードが変わる的な数字だと思う。

cioffの名の通り、おそらくciのoffsetなんだろう。

https://github.com/ksss/mruby/blob/501e1ef2605541b98b6f0d2ba2fec69ff068f1cf/src/proc.c#L45

をみると、cibaseからciがどれぐらい動いているのかを保存している。 おそらくREnvがciスタックにアクセスしたいときに、どのスタックを呼び出せばいいかを保存しているものと見た。

cioff=-1ということはciスタックを参照することはないんだけど、REnvが使われるかもしれないから残しておきたいフラグなのかなあと推測。 MRB_ENV_UNSHARE_STACKが使われるのはcipopされた時だ。自分でも何を言っているのかわからなくなってきた。

vm.cを読むと、cipopcipushという関数がある。cipopというからにはciスタックを元に戻る操作なんだろう。逆にcipushはci スタックを積み上げる操作。

f:id:ksss9:20160910181003p:plain

さらにヒントになったのが、blameしていてみつけたこのPR

github.com

この再現コードでいわゆる"unshare"なREnvを使うことができる。

いろいろいじってみると、たしかに問題のeval.cのあの部分で問題を再現できるのだけど、どうもSEGVにならない。

   28      if (!e) return NULL;
   29     proc = c->cibase[e->cioff].proc;
   30
-> 31      if (!proc || MRB_PROC_CFUNC_P(proc)) {
   32       return NULL;
   33     }
   34     return proc->body.irep;

ここでprocはNULLを指しているからだ。

ここで前回の問題が浮かんだ。"ciスタックが深いと再現するのでは?" そうしてできたのがPRの再現コード。

def m
  Proc.new { eval('raise') }
end

1.times{1.times{1.times{1.times{1.times{1.times{1.times{1.times{
1.times{1.times{1.times{1.times{1.times{1.times{1.times{1.times{
  m.call
}}}}}}}}
}}}}}}}}

ついに来た。これで勝つるというやつだ。

eval.cで問題になっているのは、evalメソッドでRubyコード文字列を引数にして実行した場合に変数も使えるようにしている部分だった。 ここではcioffからciスタックをたどって、その時点の実行コードを読み込もうとしていたが、mメソッド内のciスタックはすでにpopしているのでeはunsharedになっている。これをevalメソッドが引いてきた時にアウトとそういうことっぽい。

つまりはevalメソッド実行時にunshredなREnvが存在すること自体はおかしくないので、もしunshredなら無視するように修正した。

はーこれに二週間かっかったけど勉強になった。

スタックが深くなると起こるバグはほかにもある気がするので、いつかまたハマったら思い出してみよう。

https://github.com/mruby/mruby/pull/3207

これもruby/spec/languageを走らせて遭遇した。

$ lldb mruby/bin/mruby
(lldb) run mspec/bin/mspec-run spec/language/variables_spec.rb
...
* thread #1: tid = 0x1acaab, 0x00000001000023ff mruby`mrb_ary_concat(mrb=0x0000000100202ad0, self=mrb_value @ 0x00007fff5fbfd3a0, other=mrb_value @ 0x00007fff5fbfd390) + 47 at array.c:273, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x28)
    frame #0: 0x00000001000023ff mruby`mrb_ary_concat(mrb=0x0000000100202ad0, self=mrb_value @ 0x00007fff5fbfd3a0, other=mrb_value @ 0x00007fff5fbfd390) + 47 at array.c:273
   270  {
   271    struct RArray *a2 = mrb_ary_ptr(other);
   272
-> 273     ary_concat(mrb, mrb_ary_ptr(self), a2->ptr, a2->len);
   274  }
   275
   276  static mrb_value
(lldb) up
frame #1: 0x00000001000437fa mruby`mrb_vm_exec(mrb=0x0000000100300390, proc=0x00000001008460f0, pc=0x000000010070ccf0) + 25562 at vm.c:2122
   2119
   2120     CASE(OP_ARYCAT) {
   2121       /* A B            mrb_ary_concat(R(A),R(B)) */
-> 2122        mrb_ary_concat(mrb, regs[GETARG_A(i)],
   2123                      mrb_ary_splat(mrb, regs[GETARG_B(i)]));
   2124       ARENA_RESTORE(mrb, ai);
   2125       NEXT;
(lldb) up
frame #2: 0x000000010003d407 mruby`mrb_vm_run(mrb=0x0000000100300390, proc=0x00000001008321d0, self=mrb_value @ 0x00007fff5fbfe070, stack_keep=0) + 135 at vm.c:759
   756    }
   757    stack_extend(mrb, irep->nregs, stack_keep);
   758    mrb->c->stack[0] = self;
-> 759     return mrb_vm_exec(mrb, proc, irep->iseq);
   760  }
   761
   762  MRB_API mrb_value

mrb_ary_concatでの引数othernilになっているので、nilをarrayとして扱おうとして落ちている。再現率100%だ。やった。 OP_ARYCATというvm命令でおきていて、mrb_ary_splatという関数がnilを返しているようだ。

Rubysplatと言えば、*での展開するヤツのことだ。 a = *nil #=> [] a = *'a' #=> ["a"] a = *1 #=> [1] a, b, c = *[1,2,3]; a #=> 1

さて、次の場合はどうなるか。

o = Object.new
a = *o

答えはStringやIntegerと同じく、自身を[]で囲む。

o = Object.new
a = *o
# => [#<Object:0x007fea4315bc08>]

実はこのsplatはto_aメソッドがなければ単に[]で囲んで、to_aがあればそれを呼び出すようになっている。

o = Object.new
def o.to_a
  ['hi']
end
a = *o
#=> ["hi"]

ここから更に細かいが、CRubyでは、to_aメソッドを読んで、nilならto_aメソッドがなかった時と同じく単に[]で囲む。 nilじゃなければArray#concatと同じことをする。くっつけるのは他の変数達。

a = *1, *'', *o
=> [1, "", "hi"]

コードは多分この辺

https://github.com/ruby/ruby/blob/d432af3df7ffa3fb2666674f70dab685e73de374/insns.def#L491-L513

これがmrubyでは実装されておらず、splatは単にto_aの結果を返しているだけだった。

するとto_aメソッドがarray以外を返すと、mrb_ary_concatメソッドでarrayとして処理しようとしてバグとなる。

ここではCRubyと同じ動きになるように、mrb_ary_splatを修正した。

CRubyの挙動にも詳しくなれてmrubyのバグも直って一石二鳥!

このブログ自体も時間をかけて作成されたため、途中で口調が変わっている。

おしまい。

mruby-at_eixtでmrb_state_atexitを直接呼ぶのをやめた

以前作ったmruby-at_exitに勘違いがあったので改良した。

mruby-at_exit gem リリース - スペクトラム

以前はmrb_state_atexit APIを直接呼んでいたけど、実はこれを呼ばなくてもat_exitは実現できる。

mrb_state_atexitはat_exit的なもの専用APIだと思っていたけど、*_gem_final関数を呼ぶのにも使われていた。

そもそもmrb_state_atexitが産まれた経緯は、 https://github.com/mruby/mruby/pull/1844 のような遅延読み込みがやりたかったかったがreject、その後、似たようなことを実現させようとしたのが、今のmrb_state_atexitの原型であるmrb_atexitだった。 https://github.com/mruby/mruby/pull/2211

どうやらこの時点で*_gem_final関数がatexitの仕組みと統合されていたようだ。

mrb_state_atexitを呼び出す前の設計だと余計にmalloc/reallocを呼んでしまうので、できれば呼ばないように改良した。

at_exitでは隠し変数にブロックをためておき、*_gem_final関数でためていたブロックを全て呼び出すシンプルな形になった。

mrubyでruby/specを走らせることに成功した

長いと思うので結果だけ

リポジトリはこちら。

github.com

使い方はgit cloneしてmakeするだけと大変お手軽。

make TESTS="core/nil"のように、ディレクトリ指定もファイル名指定もできる。

全国のmrubyistの皆様に於かれましては、是非お試し願いたいところです。

以下つらつらと

モチベーション

数年前に始めてからというもの、mrubyという船に乗りかかったからには「mrubyには〜がない」とか「mrubyはバグが多い」とか言われたくない。と思うぐらいには愛着というか責任感を勝手に持っている。

「mrubyはCRubyと動作が違う」というのはよくある話なのだが、これを極力減らしたい。(完全には無理だけど)

仕様が同じならCRubyの知識がそのままmrubyに使えるし、ドキュメントもCRubyのものがそのまま使える。 「CRubyのライブラリをmrubyに移植」というのも行いやすくなってくる。 前回のブログで書いた「mrubyでもCRubyでも動くgem」なんていう離れ業も可能になる。

そんなわけで「mrubyの細かい挙動をCRubyに合わせる」という活動はメリットがあると信じて結構やってきたつもりなんだけど、細かい仕様は結構CRubyのテストやruby/specを読まないとわからなかったりすることが多かった。

もし誰でもmrubyでruby/specのチェックができたなら、どんどんmrubyがよくなっていくに違いないと思い、やってみたらできた。という感じ。

ruby/spec

ruby/spec とは様々に存在するRuby処理系において、その挙動を統一するために作られたもの。 「Rubyである」をチェックするテストケース集だ(要出典)。

「mrubyでruby/specを走らせる」

このパワーワードを実行に移すため、何年か前に試した時は超えなければいけない山が多すぎて挫折した。 しかし、ここ数年でmruby界隈も盛り上がり材料が揃ってきた気もするので再度挑戦してみたらできた。

途中でぶつかった様々な障害を紹介する。

mrubyでruby/specを走らせるために必要なものたち

Rubyである」という仕様チェックをするのがruby/specなわけだけど、実はそのruby/specを走らせるためにも、ある程度Ruby」でなくては動かない。

今回は以下のある程度を解消した。

構文

  • "a" "b" #=> "ab"のような文字列リテラルの連結はできない。(mrubyがなぜそうしているかはわからない)
  • defined?が使えない。https://github.com/mruby/mruby/issues/1696 によると、実装しちゃうとmrubyが大きく複雑になってしまうしObject.const_defined?でだいたいなんとかなるという理由のようだ。

ruby/specがテストケース集とすれば、そのランナーとなるのがruby/mspecruby/specを走らせるというのは、正確にはruby/mspecを走らせると同義だ。

この構文の問題がruby/mspecにあった。

構文の問題はとりあえずruby/mspecをforkして問題の部分を書き換えることで解消させている。 問題の部分がまとまったらmruby対応PRとしてpatchを投げようと思う。

ライブラリ

mrubyではOSの機能を使う部分はcoreに入っていない。 そのかわり、外部gemとして用意すれば、プラグイン的にmrubyを拡張できる。 ruby/mspecを走らせるためには以下の問題があり、それぞれライブラリによって解消できた。

  • ENVが使えない
  • Dir.[]が使えない
    • Dir.globを実装している gromnitsky/mruby-dir-glob を使用。
    • 後述する雑なpatchでalias [] globとすることで解消。
    • Dir.globは実装が複雑そうだったので非常に助かった。
  • FileIOが使えない
  • Signaltrapが使えない
    • ワー コンナ メンドクサソウナ ライブラリー スデニアッタワー ksss/mruby-signalを使用。
  • Threadが使えない
    • "Threadごとにmrb_stateを作って完全に分離する"という設計思想上CRubyのThreadと仕様は異なるが、こんなに知識が必要そうなものはなかなか作るのが難しそうなので mattn/mruby-thread をありがたく使用。
  • File.executable?が使えない。
    • 意外な伏兵だったが、便利そうなものがあったので ksss/mruby-file-stat を使用。
    • これを使ってメソッドを追加することでFile.executable?は実装できた。mruby-file-statでFile.executable?は実装するか悩み中。。。
  • Processが使えない。
  • at_exitが使えない。
    • 実はこの問題を解決するために ksss/mruby-at_exit は産まれたのだった。
  • Regexpというか正規表現が使えない。
    • ほとんどのmrubyアプリにはこれが入っているんじゃないだろうか。 mattn/mruby-onig-regexpを使用。
  • Object#pretty_printが使えない
    • CRubyではrequire 'pp'すれば使えるやつ。
    • mruby-ppの実装は2つあったので悩んだが、kou/mruby-ppを選択。 takahashim/mruby-ppでも動作は変わらないと思う。
  • requireが使えない
    • mattn/mruby-requireでほとんどの問題は解決するが、コーナーケースでCRubyと非互換性があったので、といあえずforkを使用中。これは問題点を整理してpatchを投げようと思う。
  • undefined method 'method'
    • mrubyでMethod, UnboundMethod classがつかえるという神がかり的に便利な ksss/mruby-method を使用。

パッチ

ここから割りと泥臭い。

まずruby/mspecが必要としている標準ライブラリはrbconfigppiconvのみで、それぞれrequire文がruby/mspecに書かれている。

mattn/mruby-requireでは、build_config.rbに書いた順番によって、mruby-require以降に書いたモジュールについては共有ライブラリを作成してrequireでモジュール名を指定すると読み込めるというユニークな機能がある。

これを利用してファイル名を変えるなどすれば、require 'rbconfig'も実行可能だし、rbconfigっぽいものも作ろうと思えば作れるだろう。

しかしながら、問題はrbconfigだけではなく、以下の追加パッチが必要となる。

  • File.executable?
  • Dir.[]
  • SystemExit
  • RUBY_PLATFORM
  • RUBY_PATCHLEVEL
  • $:の調整
  • Kernel#abort

実はrequire 'rbconfig'はかなりはじめの方に実行されるため、rbconfigモジュールを書くより、rbconfig.rbというファイルを作ってパッチ置き場にするほうが便利なのだ。

この理由により、rbconfig.rbは、じつはruby/mspecを走らせるための細かいパッチ置き場となっている。

他のppとiconvはそれぞれpp.rbiconv.rbという空のファイルを作っておいて、ロードされるパスに置くことで回避している。(iconvが必要な場面にまだ遭遇していないだけで、遭遇したらなにか考えないといけない)

それからmrubyのIntegerの範囲のデフォルトは32bit想定となっているため、様々なspecで想定以上の数字が使われると文字列生成などでArgumentError: string (10204000000) too big for integerと表示されたりするので、64bit想定となるようにmrbconf.hを調整したりしている。

メリット

mrubyのモジュールのうちCRubyと同じAPIを持つものについて(あるいは同じ動作を期待するものについて)、CRubyとの非互換性を簡単にチェックできる。

実際にmruby-signalでも、make TESTS="core/signal"とするだけで非互換性を発見できるので、 7 failuresを2 failuresに減らすことができた。

これからもバンバン修正していくつもり。 しかも、誰でも実行できるのでバンバンmruby界隈がよくなっていく。

デメリット

仕様を合わせることのデメリットも理解している。

ごくごく一部の機能のために全体のパフォーマンスやメンテナンス性を犠牲にしなければならなくなるというのは、よくある話。

その場合はメリット・デメリットのトレードオフをコツコツ各メンテナがやっていくしかないんじゃないだろうかなあ。

しかしながら議論をすること自体は有益なはずだし、気にせずやっていくといいんじゃないですかねえ。

そんなこんなで

さまざまなモジュールの助けを借りつつ、ついにruby/specをmrubyで走らせることに成功したというわけです。感謝。

実際に実行してみると、わっさわっさとspecが落ちまくる。 大抵が〜classがないとか〜methodがないとかだ。 Encodingなどのmrubyでは実装されていない機能はしょうがないとして、 $ make TESTS="language"ではランダムにSegmentation faultする。

がんばってmrubyをよくしていきたい。

というか一人では完全に無理。

一緒にがんばっていきませんか?

JSONパーサー再考

tl;dr

json-expect-parserというgemを作った。

github.com

gem install json-expect-parser

で使えます。

きもち

JSONをパースする実装はいろいろありそうで、すでに各言語に標準添付されていたり、便利なものがたくさんある。

そこであえて車輪の再発明を試みてみた記録をここに残す。

動機は「なんかおもしろそう」だっただけ。

通常、JSON parserと言えば、JSON文字列を全部読み取って各言語オブジェクトに変換するものを思い浮かべる。

他の例としては逐次読み取るStreamなタイプ。

yajlというライブラリがこれにあたるようだ。

今回試した実装も逐次読み取るStreamなタイプにあたる。

書こうと思った動機はcrystal-langのJSON実装を読んだ時だった。 crystal-langではPullParserなるものがあって、メッソド名にread_arrayread_objectread_floatなどが並ぶ。

JSON::PullParser - github.com/crystal-lang/crystal

正直これだけでは実装がよくわからないが、なんとなく、「明示的に読み込む形式を指定することで、パフォーマンスアップと異常な形だった時に検知できるものだろうか」と思った。

実際のところは「なにかわからないが読み込む」->「何になるかわからないがとにかくオブジェクト化する」->「指定のものと合ってるか確認する。間違っていればエラー」というステップになっていた。(たぶん)

僕が想像していた「明示的に読み込む形式を指定することで、パフォーマンスアップと異常な形だった時に検知できるもの」は、 「指定した形式として読み込む」->「指定した形式通りパースできれば、指定したオブジェクトとしてオブジェクト化する。パースできなければエラー」というものだった。

まあ違いはそんなにないんだけど、crystal-langの移植ではなく、自分がヒラメイた方をとにかく実装してみた。

これは、youtubeで初めて名前を見るバンドを視聴するとき、視聴する前に想像していたもののほうがカッコよくて、実際に見てみると想像よりダサくてがっかりする現象に似ている。最初の妄想のほうがカッコイイのだ。

うまく行けば

  • パースは読み込み方を限定しているから早い
  • 逐次読み込むから省メモリ
  • 形式が想定外だった時にエラーになる。ついでにパースコードがそのままドキュメント的になる。

というJSON parserが誕生する。(もちろんすでにあるのかもしれないけど、そんなことはエモの前には関係ない。)

実装

で、Rubyしかまともに書けないのでとりあえずRubyで実装した。

[
  {
    "id": 1,
    "name": "ksss",
    "admin": true
  },
  {
    "id": 2,
    "name": "foo",
    "admin": false
  },
  {
    "id": 3,
    "name": "bar",
    "admin": false
  }
]

jsonファイルがあるとすると、

require 'json/expect/parser'

File.open("t.json") do |io|
  expect = JSON::Expect::Parser.new(io)
  expect.array do
    expect.object do
      expect.key #=> "id"
      expect.integer #=> 1, 2, 3
      expect.key #=> "name"
      expect.string #=> "ksss", "foo", "bar"
      expect.key #=> "admin"
      expect.boolean #=> true, false, false
    end
  end
end

と、こんな感じでパースできる。

特徴的なのはパースの仕方をプログラマーに委ねている点。 こうすると、特定の形式のJSONしかパースできないコードになる代わりに、 期待してないJSONだった場合にエラーにするといったことが可能になる。

普通にならべて書くとkeyの順番までも固定されることになる。 順番を不定にしたければ、case文と組み合わせるとよい。

json-expect-parserは、書かれたコードの見た目からなんとなく期待するJSONの形式が予測できるというのが大きい。

expectという名前も、このコードの形が期待している感を感じ取って名付けた。グルーヴだ。

期待していない形式がエラーになるという点ではTypeStructに似ているが、json-expect-parserの場合は型定義を書かなくて済む点が異なる。*1

肝心のパフォーマンスは、Ruby標準添付のjson/ext/parserに比べて約30倍ほど遅い。ガーン。

特に遅いのがI/O部分で、そりゃ逐次処理なんだからI/O増えて遅くなるよなあ。。。

I/O部分はある程度バッファをもたせるようにして多少マシにしてみたものの、なかなか厳しそうだ。ふーむ。

せめてjson/pure/parser並にはなりたい。これは倍ほど早くなればOK。

使用メモリーの方は、目論見通りぐっと減らせていることがわかった。(get_process_mem gemを使用して計測)

今後はパフォーマンスをあげれば使いみちが見つかるのかもしれないが、見つからないかもしれない。

思いついた妄想を書きなぐれて僕は満足だ。

ついでに、mrubyにも対応

ついでにmrubyでも動作するようにしておいた。

これは、CRubyでもmrubyでも動作する世界初のgemの誕生ではないだろうか!(妄想)

ちなみに両対応のコツを書いておくと、

  • mrblibというsymlinkをlibにはる
  • testはmrubyでテスト用に固定されたディレクトリ名なので、CRuby用は別の名前にする。
  • requireを使わない。どうしても使いたい場合は$:にpathを追加する。

あとは依存を書いたmrbgem.rakeファイルを置けばOK。

君の最強のデッキ(build_config.rb)に下の一行を加えてみよう。

conf.gem :github => "ksss/json-expect-parser"

ここまで書いてみて思ったけど

これって普通のJSONパーサーをStreamにしてパース用のAPIをpublicにしただけな気もする……。

実際に、何が来てもパースするメソッドvalueも、これまでのパースAPIを組み合わせるだけで簡単に作れた。

まあ細かいことは置いておいて、JSONパーサーは量もそんなにないし書いてみると楽しいので夏休みの工作に貴方も是非。

*1:type_structについてはるびまに詳しく書いたので是非 http://magazine.rubyist.net/?0054-typestruct

mruby-at_exit gem リリース

mrubyでat_exitができるmruby-at_exitを作った。

github.com

at_exitrubyスクリプトが何らかの理由で終了するタイミングで発動するコールバックを登録できるメソッド。

mrubyではmrb_state_atexitというCの関数でmrubyが終わるときをフックする関数を登録することができる。 mrb_state_atexitではmrb_state structのatexit_stackというメンバーに関数ポインタを配列で積んでいる(変わるかもしれないけど……)。

この仕様は結構前からあってドキュメントにもちゃんと書いてある。

mruby/mrbconf.md at master · mruby/mruby · GitHub

仕組みは2014年から出来てる。

Add API `mrb_atexit()`. by take-cheeze · Pull Request #2211 · mruby/mruby · GitHub

動的にメモリを確保する方法と静的にメモリを確保する方法まである。

と、ここまでくれば誰だってKernel.at_exitを実装したくなるが、 このPRはすでに出ており、しかもrejectされている。

Implement `Kernel#at_exit` and `Kernel.at_exit`. by take-cheeze · Pull Request #2363 · mruby/mruby · GitHub

mrubyではgem毎にfialize的に呼べる仕組みがあり、それで十分なので、 Kernel.at_exitの実装に説得力があまりなかったのだろう。

それなら……と言いたいところだが、実は作ってから検索してすでにrejectされていることを知った。。。

作ってしまったものの置き場として、とりあえずgemとして作っておくことにしたのが、 今回のmruby-at_exitというわけです。ふるってご利用ください。

日報 2016-07-31

exported form https://nippo.wikihub.io/@_ksss_/20160731140352

mrubyでのforkのバグを直した。

https://github.com/iij/mruby-process/pull/7

引数に初期化されてない変数がブロック引数に渡されているので、 これをmruby側で参照すると死ぬ。

そもそもforkにブロック引数はいらない。

mrubyのC側でブロック引数を無くす方法は2つ

  • mrb_yield(mrb, block, mrb_nil_value())
  • mrb_yield_argv(mrb, block, 0, NULL)

mrb_yieldmrb_yield_argvはほぼ同じ関数だが、ブロック引数の取り方に違いがある。 mrb_yieldはブロック引数を1つだけ指定できる。 mrb_yield_argvは0〜いくらでも指定できる。 関数の中身もほぼ同じ

MRB_API mrb_value
mrb_yield_argv(mrb_state *mrb, mrb_value b, mrb_int argc, const mrb_value *argv)
{
  struct RProc *p = mrb_proc_ptr(b);

  return mrb_yield_with_class(mrb, b, argc, argv, p->env->stack[0], p->target_class);
}

MRB_API mrb_value
mrb_yield(mrb_state *mrb, mrb_value b, mrb_value arg)
{
  struct RProc *p = mrb_proc_ptr(b);

  return mrb_yield_with_class(mrb, b, 1, &arg, p->env->stack[0], p->target_class);
}

https://github.com/mruby/mruby/blob/eb7422af581edff0dd4b1f8259598677b4d32793/src/vm.c#L657-L671

要約すると、mrb_yieldではどんなにがんばっても引数を一つ与えていることになるので、意味的にはおかしい。 ブロック引数を0個にしたければ、mrb_yield_argv(mrb, block, 0, NULL)を使うほうがヨサゲ。

どうやってこのバグを発見したかというと、単にコンパイルしたらclangがwarningで教えてくれただけだ。

つまりコンパイルさえすれば発見できる状態にあるが、2年近く放置されていたことになる。(ブロック引数なんかつけなきゃ問題ないので)

そしてこんなミミッチイ事を気にするのは、世界で僕ぐらいだったようである。

おしまい。

日報 2016-07-22

exported from https://nippo.wikihub.io/@_ksss_/20160722124458

mruby本体に2つバグがあった。

一つめ

Struct.new(:a) do
  def foo
  end
end

とすると、fooメソッドはStruct.newで作られた新しいclassのメソッドになることを期待するが、 なんと、Structclassのメソッドになるというもの。ここでメソッドを定義してしまうと、全部のStruct classの小クラスで共有されちゃう。 しかもselfはちゃんとStruct classの小クラスになっていて気が付きにくく邪悪。

ほんとStructって誰も使ってないんじゃないの……。

これの修正は1行修正でよかった

https://github.com/mruby/mruby/pull/3179/files

二つめ

Struct.new(:foo) {
  def initialize(*)
    self.foo
  end
}.new

がSEGVするというもの。

ここでのinitializeは、新しく作られたStructからのインスタンスclassのinitializeで、 それを上書きしているわけだけども、上書きしないinitializeでself.fooと呼べる準備をしており、 その準備をしないままだと、ありえない参照(StructはArrayとほぼ同じだ)をしてしまって落ちるようだった。

一つめの修正前でもselfは新しいStructのインスタンスclassなので、1つめとは別の問題。

一つめの修正をした上で

https://github.com/mruby/mruby/pull/3178/files

の修正をするとなおるっちゃなおるんだけど、実装が汚い。

なぜ汚いかというと、C言語は関数のスコープを「ファイルで閉じるスコープ」か、「実装内部だけで共有できるスコープ」か、「別のアプリケーションから参照できる公開されたスコープ」を作れる。 mrubyには「実装内部だけで共有できるスコープ」の部分がない(CRubyにはある)ので、 これをわざわざ作るのか、それともpublicなAPIとするしか共有するすべがない。

この辺を考えるのがめんどくさかったので、別の「ファイルで閉じるスコープ」の関数をコピペしてきた。これが微妙だ。

まあCRubyでいうallocにそうとうするやつなので公開してもいいような……でもmrubyではallocメソッドを作ってないから公開しないほうがいいような……。

というわけで、判断はmatzにまかせることにした。 margeされなくても、セグフォなのでいずれ誰かが何とかするでしょう。