RubyがSEGVしたら

$Id: segv.html,v 1.9 2002/10/24 18:37:50 aamine Exp $

この文書は 2002-10-24 の日記に書いていたのを単独ページに 移したものです。とある SEGV をデバッグする過程を記録しました。 デバッグ方法の参考にでもしてください。

きっかけ

TMail の質問メールが来た。 マルチパートメールを作るにはどうしたらいいのかという質問だ。 このへんのユーティリティメソッドは欲しいと思ってたので、 答えを書くついでにライブラリにも追加することにする。 書き終わって、それじゃあテストしようと思ったら、

~/r/tmail % t
.......................................................
..............F........................................
..../home/aamine/lib/ruby/tmail/port.rb:161: [BUG] Segmentation fault
ruby 1.7.3 (2002-09-13) [i686-linux]
zsh: 1758 abort (core dumped)  exectest

……というわけでデバッグを始めることにした。

デバッグ開始 (あたりをつける)

tmail/port.rb:161 というと、ここだ。

    def procinfostr( str, tag, true_p )
      a = str.upcase.split(//)

この形はなんとなく見覚えがある。 以前、puts で落ちたときもこの形だった ([ruby-dev:18194])。 struct RString の ptr は 1.6 までは NULL にはならなかったのだけど、 1.7 の途中から NULL になるようになった。そのために最近いろいろな ところで SEGVっているのだ。

スタックトレースを眺める

まずは何を置いてもスタックトレースを取る。 念のため最新版をコンパイルしてコアを作り直し、 gdb -c core ruby で bt。再現性があるバグだとこのへんが楽だ。

#0  0x40035431 in kill () from /lib/libc.so.6
#1  0x4003507e in raise () from /lib/libc.so.6
#2  0x4003672f in abort () from /lib/libc.so.6
#3  0x080dabb0 in rb_bug (fmt=0x80f8816 "Segmentation fault") at error.c:179
#4  0x080b766f in sigsegv (sig=11) at signal.c:412
#5  0x40035338 in sigaction () from /lib/libc.so.6
#6  0x0805d4d4 in call_cfunc (func=0x80c063c <rb_str_split_m>, 
    recv=1076716592, len=-1, argc=1, argv=0xbfff61a0) at eval.c:4393
#7  0x0805de73 in rb_call0 (klass=1074997592, recv=1076716592, id=4321, 
    oid=4321, argc=1, argv=0xbfff61a0, body=0x40132200, nosuper=1)
    at eval.c:4530
#8  0x0805e8f8 in rb_call (klass=1074997592, recv=1076716592, mid=4321, 
    argc=1, argv=0xbfff61a0, scope=0) at eval.c:4746
#9  0x080583c3 in rb_eval (self=1076718772, n=0x4011c040) at eval.c:2739
#10 0x08059363 in rb_eval (self=1076718772, n=0x4011c02c) at eval.c:2912
#11 0x08056693 in rb_eval (self=1076718772, n=0x4011be88) at eval.c:2225
#12 0x0805e427 in rb_call0 (klass=1074819072, recv=1076718772, id=15073, 
    oid=15073, argc=0, argv=0xbfff70dc, body=0x4011be88, nosuper=0)
    at eval.c:4656
                             :
                             :
                             :

ダーっと出るが、普通は SEGV をくらった sigaction の直前に 問題があることがほとんどである。従って後半は無駄なので捨ててよい。

今回は原因がかなりはっきり予測できてしまっているので簡単だ。 まず f (frame) で sigaction 直前の call_cfunc に移動し、

(gdb) f 6
#6  0x0805d4d4 in call_cfunc (func=0x80c063c <rb_str_split_m>, 
    recv=1076716592, len=-1, argc=1, argv=0xbfff61a0) at eval.c:4393
4393    in eval.c

レシーバ (recv) の値を確かめる。 ここのコードは str.split(//) なので文字列は recv 以外にないからだ (引数も文字列なら、そちらの ptr が NULL になっている可能性も考えられる)。 Ruby の文字列の実体は struct RString なので、それにキャストして p する。

(gdb) p *(struct RString*)recv
$1 = {basic = {flags = 8199, klass = 1074997592}, len = 0, ptr = 0x0, aux = {
    capa = 1076716492, shared = 1076716492}}

予想通り ptr が NULL (0x0) である。十中八九、これをチェックしないで dereference したのが原因だろう。とすると、落ちたのは恐らく rb_str_split_m のどこかなのだが、シグナルをくらったために そのスタックフレームが一時的に上書きされたのだろう。 あとは rb_str_split_m のコードを見てレシーバの ptr をいじっている ところを洗えばよい。

再現コードの作成

static VALUE
rb_str_split_m(argc, argv, str)
    int argc;
    VALUE *argv;
    VALUE str;

第三引数 str がレシーバだ。str を検索してハイライトさせ (less での話)、 ptr を検査なしで使っていないかざっと見る。

……と思ったのだけど、rb_str_split_m のコードは長いうえに 候補がいくつもあって嫌になった。方針を変えて、 最小SEGVコードを作ってみる。

まず ptr が NULL である文字列を用意する。 これは前回の SEGV のときに作りかたがわかっている。

" ".dup.rstrip.gsub(/a/, '')

で、ptr が NULL になる。これに対し split(//) すれば落ちるだろう。

~/s/ruby-g % cat null.rb
' '.dup.rstrip.gsub(/a/, '').split(//)
 
~/s/ruby-g % ./ruby null.rb
null.rb:1: [BUG] Segmentation fault
ruby 1.7.3 (2002-10-23) [i686-linux]
zsh: 7357 abort (core dumped)  ./ruby null.rb

目論見通り落ちる。

デバッガで追う

そうしたら今度はデバッガから動かして詳しいデータを取ろう。

~/s/ruby-g % gdb ./ruby
GNU gdb 5.2.1
Copyright 2002 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i686-pc-linux-gnu"...

rb_str_split_m にブレークポイントを設定する。

(gdb) b rb_str_split_m
Breakpoint 1 at 0x80c0644: file string.c, line 2462.

んでスクリプトを走らせる。

(gdb) run null.rb
Starting program: /home/aamine/s/ruby-g/ruby null.rb
 
Breakpoint 1, rb_str_split_m (argc=1, argv=0xbffff2b0, str=1074943792)
    at string.c:2462
2462        int awk_split = Qfalse;

ブレークポイントで止まった。 念のため str->ptr が NULL になっているのを確認。

(gdb) p *(struct RString*)str
$1 = {basic = {flags = 7, klass = 1074997592}, len = 0, ptr = 0x0, aux = {
    capa = 0, shared = 0}}

バッチリ。 あとは落ちるまでバカみたいにトレースすればいい。 どうせすぐ落ちるでしょ。

(gdb) s
2463        long beg, end, i = 0;
(gdb) 
2464        int lim = 0;
(gdb) 
2467        if (rb_scan_args(argc, argv, "02", &spat, &limit) == 2) {

rb_scan_args は長そうだし str 関係ないので next に方針転換。

(gdb) n
2474        if (NIL_P(spat)) {
(gdb) 
2483            if (TYPE(spat) == T_STRING && RSTRING(spat)->len == 1) {
(gdb) 
2492                spat = get_pat(spat, 1);
(gdb) 
2496        result = rb_ary_new();
(gdb) 
2497        beg = 0;
(gdb) 
2498        if (awk_split) {
(gdb) 
2528            long start = beg;
(gdb) 
2530            int last_null = 0;
(gdb) 
2533            while ((end = rb_reg_search(spat, str, start, 0)) >= 0) {
(gdb) 
2534                regs = RMATCH(rb_backref_get())->regs;
(gdb) 
2535                if (start == end && BEG(0) == END(0)) {

そろそろヤバ気である。step に切り替えよう。

(gdb) s
2536                    if (last_null == 1) {
(gdb) 
2541                        start += mbclen2(RSTRING(str)->ptr[start],spat);
(gdb) n
 
Program received signal SIGSEGV, Segmentation fault.
0x080c0973 in rb_str_split_m (argc=1, argv=0xbffff2b0, str=1074943792)
    at string.c:2541
2541                        start += mbclen2(RSTRING(str)->ptr[start],spat);

油断して next に変えた瞬間に落ちるのはやめてくだちい。 あ、でもこれは引数の RSTRING(str)->ptr[start] が原因なのか。 じゃあいいや。

修正

というわけで string.c:2541 が SEGV の原因だとわかった。 あとは修正すりゃいいんだけど。うーん、こっちのほうがムズいぞ。 かなり適当な修正をしてみた。

~/s/ruby-g % vi string.c
~/s/ruby-g % cvs di string.c
Index: string.c
===================================================================
RCS file: /home/aamine/cvs/ruby/ruby/string.c,v
retrieving revision 1.122
diff -u -p -r1.122 string.c
--- string.c    23 Oct 2002 10:17:29 -0000      1.122
+++ string.c    24 Oct 2002 15:44:21 -0000
@@ -2533,7 +2533,11 @@ rb_str_split_m(argc, argv, str)
        while ((end = rb_reg_search(spat, str, start, 0)) >= 0) {
            regs = RMATCH(rb_backref_get())->regs;
            if (start == end && BEG(0) == END(0)) {
-               if (last_null == 1) {
+               if (!RSTRING(str)->ptr) {
+                   rb_ary_push(result, rb_str_new("", 0));
+                   break;
+                }
+               else if (last_null == 1) {
                    rb_ary_push(result, rb_str_substr(str, beg, mbclen2(RSTRING(str)->ptr[beg],spat)));
                    beg = start;
                }
~/s/ruby-g % make ruby
gcc -g -pipe  -I. -I. -c string.c
ar rcu libruby.a array.o bignum.o class.o compar.o dir.o dln.o
enum.o error.o eval.o file.o gc.o hash.o inits.o io.o marshal.o
math.o numeric.o object.o pack.o parse.o process.o prec.o random.o
range.o re.o regex.o ruby.o signal.o sprintf.o st.o string.o
struct.o time.o util.o variable.o version.o  dmyext.o
gcc -g -pipe  -rdynamic main.o dmyext.o libruby.a -ldl -lcrypt -lm   -o miniruby
gcc -g -pipe  -rdynamic -L.   main.o  -lruby -ldl -lcrypt -lm   -o ruby
~/s/ruby-g % ./ruby null.rb
~/s/ruby-g %

とりあえず落ちなくはなったみたいだ。

修正の検証

修正はしたが、他のパターンが動かなくなっていないか、 あるいは落ちないかを確認していない点に不安が残る。 こういうときにテストケースがあれば他の挙動に影響を 与えていないか確かめながら変更できるので非常に楽だ。 そこでまずは挙動が変化していないかを確認するため rubicon にかける。

~/s/ruby-g % cvs -Q -d ~/cvs/ruby co rubicon
~/s/ruby-g % cd rubicon
~/s/ruby-g/rubicon % make   # 拡張モジュールがあるので、作っておく
# 出力は略
~/s/ruby-g/rubicon % cd builtin
~/s/ruby-g/rubicon/builtin % ../../ruby TestString.rb
     :
     :
     :
Time: 0.094514
OK (79/79 tests  1213 asserts)

大丈夫らしい。

今度は他のコードで str->ptr == NULL に対処できているかチェックする。 こんなテストはないので地道に書くしかない。リファレンスを見てとりあえず だいたいのパターンを尽くしておく。

def nullstr
  ' '.dup.rstrip.gsub(/a/, '')
end

nullstr().split(//)
nullstr().split(//, 2)
nullstr().split(//, -1)
nullstr().split(/re/)
nullstr().split(/re/, 2)
nullstr().split(/re/, -1)
nullstr().split('s')
nullstr().split('s', 2)
nullstr().split('s', -1)
nullstr().split('')
nullstr().split('', 2)
nullstr().split('', -1)
nullstr().split
nullstr().split(nullstr())
nullstr().split(nullstr(), 2)
nullstr().split(nullstr(), -1)

こんなとこでパラメタライズするのは時間の無駄。 ひたすらコピペすればよい。 でもってテストする。

~/s/ruby-g % ruby testnull.rb
testnull.rb:6: [BUG] Segmentation fault
ruby 1.7.3 (2002-09-13) [i686-linux]
zsh: 7772 abort (core dumped)  ruby testnull.rb
~/s/ruby-g % ./ruby testnull.rb
~/s/ruby-g %

当然ながら、「修正前の ruby がちゃんと落ちるか」もテストしなければならない。 でないとテストにならないからだ。コメントアウトしながら試すと、 最初と最後の三つが落ちるパターンであった。

まとめ

そんなわけで、原因はわかった。パッチもできた。動作も検証した。 あとはレポートをまとめて ruby-dev に投げれば完了である。