Ruby 3.1 の debug.gem を自慢したい

技術部の笹田です。今日保育園に娘を送りにいったら、娘が先生に「サンタさんにプレゼントもらったよ! お母さんもプレゼントもらってたけどお父さんはもらってなかった!」と報告しており、私だけが悪い子と保育園に伝わってしまいました。

2021年は、笹田は Ruby 3.1 に導入された debug.gem (ruby/debug: Debugging functionality for Ruby)に結構長い時間をかけました(かけてしまいました)。だいたい半年で終わるだろうと思ってたんですが、終わらず。Ractor をもっとやる予定だったんだけどなぁ。ソフトウェア開発の見積もりは難しいですね。

本記事では、debug.gem について、導入の背景、簡単な使い方、それからちょっと面白い機能までご紹介します。

youtu.be

(本稿では動画をいくつか載せていますが、動画作成時と記事執筆時が違うので、それぞれ違うコードで掲載しています。ご了承ください。なお、動画は RubyConf 2021 の発表のために使ったものを引用しています)

なお、Ruby 3.1については先日公開した記事(プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログ )をご覧ください。

debugger 再実装の背景

すでに Ruby にはいくつかのデバッガが存在していました。

Ruby 3.0 までは、lib/debug.rb というのが大昔からバンドルされており、ruby -r debug app.rb とすると、デバッガコンソールが立ち上がり、デバッグコマンドで何やか操作する、というものでした。

ただ、lib/debug.rb は多分誰も真面目にメンテナンスしていなくて、使ってる人もほとんど居なかったんじゃないかと思います。作り直すときに色々実験したんですが、機能によっては動かないものもありました(が、多くの機能はそのまま動いていたので Ruby の互換性は意外と凄い)。

おそらく、もっとも多くの人が利用しているのは byebug(deivid-rodriguez/byebug: Debugging in Ruby 2)ではないでしょうか。もともと Ruby 2 に導入された新 API を用いたデバッガだったんだと思いますが、コンソールから利用するデバッガとしては、一番利用されていたかと思います。pry-debug(deivid-rodriguez/pry-byebug: Step-by-step debugging and stack navigation in Pry )も、byebug を利用していました。

また、Rubymine や VSCode の ruby extension では debase(ruby-debug/debase)を用いていました。こちらも、すでに実績のあるデバッガです。

ただ、これらの既存のデバッガには、次のような不満点がありました。

  • リモートデバッグがしづらい
    • 多分、やればできるんですが、なかなか難しい
    • そもそも、そういう前提で作ってない感じに見える
  • ブロックしている処理を中断できない
    • プログラムが「刺さっている」状況を確認したい、ということありますよね(私は gdb -p [PID] でよく Ruby インタプリタの状況を確認してます)。これが、従来のデバッガだとできないんですよね。
    • byebug 上でプログラムを実行しながら Ctrl-C を入力すると、うちの環境だと落ちちゃうんだけど、うちだけ?(デバッグコンソールに入ってほしい)
  • ブレイクポイントを設定すると遅い
  • Ractor 対応していない

ブレイクポイントについて。既存のデバッガでは、ブレイクポイントを設定すると、いろいろな理由から、そのブレイクポイントにたどり着か居ない場合でも遅くなる可能性がありました。

f:id:koichi-sasada:20211227172833p:plain
デバッガの性能評価

この欠点は、実はソースコード中に byebugdebuggerメソッドで byebug のデバッグコンソールを起動する、みたいにすれば問題ないんですが、プログラムが走っているときに、さらにブレイクポイントを追加しようってときに問題になります。

Ruby 2.6 で導入した TracePoint#enable(target_line:) を使うと指定した行のみで TracePoint が有効にすることができ、この辺を使えば速くなる(性能劣化が起こらない)ということはわかっていました。機能を入れたので、誰かやってくれないかな、と思ってたんですが、あまり使われる気配がないので自分で活用することにしました。

なお、lib/debug.rbはブレイクポイントを設定しなくても2桁くらい遅くなってました(昔はそう作るしかなかった)。lib/debug.rb って名前もいいところ使っているのに放置されているのは勿体ないなあ、というのが理由の一つでした。

そして、一番大きな不満というか、どうしようもない点として、Ractor 対応していないというものです。

Ractor は Ruby 3.0 で導入された並行プログラミングのための仕組みですが、そもそも並行プログラミングはデバッグが困難なので、サポートするための仕組みをいれたかったのですが、まずはデバッガの対応が要るだろうということで、2021年2月ごろから着手しました。

(ちなみに、着手してみたのはいいのですが、結局 Ractor に対応するためには Ruby 自体に機能がいくつか足りないことがわかって、その仕様を検討していたら Ruby 3.1 には間に合いませんでした。来年がんばります。)

そんなこんなで、新しく作ることにしました。新しく作るのなら、いろいろモダンにしようと思って、ここ数年の irb をカッコよくしている立役者の reline を利用したり、コンソールをカラフルにする仕組みを使ったりして、pretty な REPL を実現していたりします。また、いろいろ便利機能を思いつく限り盛り込んでいます。

debug.gem の利用方法

簡単に debug.gem の利用方法をご紹介します。いろいろ端折ってご紹介しているので、詳細はドキュメント(ruby/debug: Debugging functionality for Ruby )を参照してください。

起動の方法

まずは、debug.gem つきで Ruby を起動しなければなりません。debug.gem の使い方はいろいろあるので、用途に合わせて使ってみてください。

起動方法 その1:binding.pry とか binding.irb みたいに使う

先に、require 'debug' とプログラムの先頭に置いておき、プログラム中で debuggerbinding.break などと記述すると、その行でデバッグコンソールが開きます。

$ cat app.rb
def fib n
  if n <= 1
    debugger
    1
  else
    fib(n-2) + fib(n-1)
  end
end

p fib(10)

この状態で、ruby -r debug app と起動してみます。なお、ここでは、require 'debug' と書く代わりに、-r debug オプションを ruby 起動時に加えています。実はこの方法だと、Ruby 3.0 以前では、lib/debug.rb が優先されてしまうかもしれません。その場合は、Gemfile をおいて gem 'debug' などと指定してみてください。

$ ruby -r debug app.rb
[1, 10] in app.rb
     1| def fib n
     2|   if n <= 1
=>   3|     debugger
     4|     1
     5|   else
     6|     fib(n-2) + fib(n-1)
     7|   end
     8| end
     9|
    10| p fib(10)
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:6
  # and 5 frames (use `bt' command for all frames)
(rdbg)

この通り、3行目で止まって、デバッグコンソールが開いているのがわかります((rdbg) というプロンプトが出て、デバッグコマンドの入力を待ち受けています)。

なお、このブログですと、文字にすると色情報が抜けちゃってるんですが、エスケープシーケンスで次のように綺麗に色付けした状態になっています。

f:id:koichi-sasada:20211227172639p:plain
色付き REPL の例

ここに、デバッグコマンドを入力できるのですが、イッパイあります(Debug command on the debug console)。

例えば、とまっている状態のバックトレースを確認するのは backtrace コマンドです。よく使うので bt というエイリアスが用意されています。

(rdbg) bt    # backtrace command
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:6
  #2    Object#fib(n=4) at app.rb:6
  #3    Object#fib(n=6) at app.rb:6
  #4    Object#fib(n=8) at app.rb:6
  #5    Object#fib(n=10) at app.rb:6
  #6    <main> at app.rb:10
(rdbg)

バックトレースが表示されました。

ここで、ポイントが2つあります。

  1. # backtrace command と、bt が何の略か書いてある
  2. n=0 のように、各メソッドのレシーバ(のクラス)や引数の情報が書いてある

1番目は、このブログでの解説のためにいれたコメントではなく、デバッガで入力中に右のほうに出てきます。ちょっと気の利いた感じがしませんか。あとで、この小粋な機能がなぜ必要か、もう少し説明します。

2番目は、引数の情報が出ることです。callerメソッドなどでとれる情報はファイル名と行番号だけですが、debug.gem ではこんな感じで情報が色々出てきます(しかも、可能ならカラフルに)。

起動方法の紹介のはずだったのに、ちょっと脇道にそれました。

起動方法 その2: rdbgコマンドを使う

debug.gem をインストールすると(つまり、Ruby 3.1.0 をインストールすると)、rdbg コマンドがインストールされます。rdbg コマンドを用いて、いろいろな起動オプションとともに debug.gem を有効にして起動できます。

実例をお見せします。

$ rdbg app.rb
[1, 9] in app.rb
=>   1| def fib n
     2|   if n <= 1
     3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    <main> at app.rb:1
(rdbg)

このように、app.rb の先頭で止まり、デバッグコマンドを受け付ける状態になりました。この状態でデバッグコマンドを使ってブレイクポイントを指定し(例:b 3 でブレイクポイントを設定できます)、実行を再開(continueコマンド、もしくはc)すると3行目に到達した時点で止まります。

(rdbg) b 3    # break command
#0  BP - Line  /home/ko1/app/app.rb:3 (line)
(rdbg) c    # continue command
[1, 9] in app.rb
     1| def fib n
     2|   if n <= 1
=>   3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:5
  # and 5 frames (use `bt' command for all frames)

Stop by #0  BP - Line  /home/ko1/app/app.rb:3 (line)
(rdbg)

「起動方法 その1」と比べると、ソースコードに何も足さなくても利用できるというのは利点です。例えば、間違ってコミットするようなことが起こりません(diff で気づくかな...)。しかし、ブレイクポイントの指定のことを考えると、ちょっと面倒かもしれません。

ちなみに、ruby コマンドじゃない場合、-c を使って別のコマンドを実行できます。

$ rdbg -c -- rails # rails コマンドを起動
$ rdbg -c -- rake  # rake コマンドを起動
$ rdbg -c -- bundle exec rspec # bundle exec rspec を起動

byebug コマンドだと、これがやりづらかったんですよねえ。

起動方法 その3:リモートデバッグを行う

別プロセスをデバッグできます。

$ rdbg -O app.rb
DEBUGGER: Debugger can attach via UNIX domain socket (/tmp/ruby-debug-sock-1000/ruby-debug-ko1-29540)
DEBUGGER: wait for debugger connection...

-O--open)付きで実行すると、UNIX Domain socket(この場合、/tmp/ruby-debug-sock-1000/ruby-debug-ko1-29540)を開いて止まります。

別のターミナルで、次のように rdbg -Ardbg --attach)してください。

$ rdbg -A
[1, 9] in app.rb
=>   1| def fib n
     2|   if n <= 1
     3|     1
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
     9| p fib(10)
=>#0    <main> at app.rb:1
(rdbg:remote)

こんな感じで、別のプロセスにつながります。プロンプトが (rdbg:remote) になっています。他にも UNIX Domain socket でデバッグポートを開いているプロセスがいると、具体的にどこにつなぐかファイルを指定する必要があります。

TCP/IP で開く場合は、-O で開くときに --port オプションなどを指定します(-Aでつなぐ側はポート指定が必要)。

youtu.be

起動方法 その4: VSCode から使う

VSCode の拡張(VSCode rdbg Ruby Debugger - Visual Studio Marketplace )をインストールしていただくと、VSCode のデバッグ機能から debug.gem を利用することができます。

f:id:koichi-sasada:20211227171454p:plain
debug.gem: VSCode からの利用

".rb" のつくファイルを開いて、F5 キーを押すと「どんなコマンドでデバッグしますか?」という意味で "Debug Command line" というダイアログが開きます(デフォルトは、ruby path/to/file.rb)。そして、OK を押すとデバッグが始まると思います。ここで、rakerspec なども指定できます。もし、うまくいかなかったら設定が必要かもしれませんので、ドキュメントを読んでみてください。

ブレイクポイントは、ソースコードの行の右端をクリックすると設定できます(他の言語と同じですね)。ブレイクコマンドを指定する方法は、これが一番楽だと思うんですよね。よかったら活用してください。

youtu.be

デバッグコマンドを使う

先ほど、次の3つのコマンドをご紹介しました。

  • backtrace(bt)
  • continue(c)
  • break(b)

他にもよく使いそうなコマンドをご紹介します。

なお、デバッグコマンドは、gdb や lldb、lib/debug.rb などを参考にしながら新たに作りました(私が gdb をよく使うので、そちらに引っ張られているところが多い)。

Ruby の式を入力する

いきなりデバッグコマンドじゃないのですが、「デバッグコマンド以外」が入力されると、Ruby の式として評価されます。

(rdbg) "Hello".upcase    # ruby
"HELLO"

ただ、デバッグコマンドと同じ式だと、そちらが優先されます。

次の例では、n というローカル変数の中身を確認しようとしたら、nextコマンドが実行されてしまったという例です。

(rdbg) n    # next command
[4, 9] in app.rb
     4|   else
     5|     fib(n-2) + fib(n-1)
     6|   end
     7| end
     8|
=>   9| p fib(10)
=>#0    <main> at app.rb:9

入力行右のほうに「# ruby」や「# next command」と表示されているのがわかると思いますが、Ruby の式と間違えてデバッグコマンドを入力してしまうのを防ぐために表示しているというわけです。

個人的には、p <expr>pp <expr>という、<expr>の中身を ppp メソッドと同じように表示するデバッグコマンドがあるので、p n のように、Ruby の式を入力して結果を確認するのがオススメです。

なお、実行されるコンテキストは、現在選択中のフレームです。フレームについてはあとでご紹介します。

ブレイクポイントを設定する

b 3 とすると、break 3の意味であり、そのファイルの3行目をブレイクポイントとして登録します。

  • b line 現在のファイルの line 行に設定。
  • b file:line ファイル:行に設定。まだ読み込んでないファイルも、読み込まれた時点で設定される。
  • b Foo#bar Fooクラスのbarメソッドに設定。まだ定義されていないメソッドも、定義された時点で設定される。
  • b Foo.baz Fooクラスのbar暮らすメソッドに設定。
  • b ... if: <expr> ブレイクポイントにおいて、<expr> を評価して真の場合にブレイク(止まってデバッグコンソールを出す)。
  • b if: <expr> 毎行で<expr>を評価して、真の場合にブレイク。
  • watch @... ウォッチポイントを設定。指定したインスタンス変数の値が変わったらブレイク(遅いです)。
  • catch FooException FooException が raise されるタイミングにブレイクポイントを設定。

他にもあったかも。

実際にこの辺のコマンドを毎回書いてるのはだるいので、エディタとイイ感じに連携できるといいと思っています。VSCodeみたいに。~/.rdbgrc というファイルや、rdbg -x FILE で指定したファイルを起動時に自動的に読み込む機能があるので、その辺にイイ感じに設定する機能が欲しいですね。自分でも途中まで作ったんですが、ペンディング中です。

不要になったブレイクコマンドはdeletedel)で削除できます。

プログラムの中身をチェックする

先ほども pppbacktraceコマンドをご紹介しましたが、実際に止めたらプログラムの状態を確認したくなります。そんな時に便利なコマンドはこちら:

  • backtrace or bt: バックトレースを表示。
  • p <expr>: p メソッドのように <expr> の結果を表示(色付き)。
  • pp <expr>: pp メソッドのように <expr> の結果を表示(色付き)。
  • list or l: ソースコードを表示。
  • edit: EDITOR 環境変数で指定しているエディタを起動。
  • info or i: 現在のフレームからアクセスできるローカル変数などを表示。下記のように、表示するものを指定して詳細表示することも可能。
    • i locals: ローカル変数一覧
    • i ivars: インスタンス変数一覧
    • i const: 定数一覧
    • i globals: グローバル変数一覧
    • i threads: スレッド一覧
  • outline or o: pry の ls みたいなやつ。
  • irb: binding.irb を実行。

とりあえず、i を覚えておくと良い気がします。

フレーム操作

デバッガでは、バックトレースで表示される各行、これをフレームというのですが、このフレームを表示したり選択できたりします。表示は bt コマンドでご紹介しました。選択とは何かというと、p などで評価する式を、そのフレームで実行したかのように実行する、というものです。

  • frame or f <num>: <num>番のフレームを選択。
  • up: 1個上のフレームを選択。
  • down: 1個下のフレームを選択。

よくわからないと思うので、実演します。

(rdbg) p n    # command                     <- フレーム #0 の n は 0
=> 0
(rdbg) bt    # backtrace command
=>#0    Object#fib(n=0) at app.rb:3
  #1    Object#fib(n=2) at app.rb:5
  #2    Object#fib(n=4) at app.rb:5
  #3    Object#fib(n=6) at app.rb:5
  #4    Object#fib(n=8) at app.rb:5
  #5    Object#fib(n=10) at app.rb:5
  #6    <main> at app.rb:9
(rdbg) f 2    # frame command               <- フレーム #2 を選択
=>   5|     fib(n-2) + fib(n-1)
=>#2    Object#fib(n=4) at app.rb:5
(rdbg) p n    # command                     <- n は 4
=> 4
(rdbg) down    # command                    <- フレーム #3 を選択
=>   5|     fib(n-2) + fib(n-1)
=>#3    Object#fib(n=6) at app.rb:5
(rdbg) p n    # command                     <- n は 6
=> 6
(rdbg)

プログラムの実行を制御する

ブレイクポイントで止まって調査を終えたら、プログラムを再開する必要があります。

  • continue or c: 再開
  • step or s: ステップイン
  • next or n: ステップオーバー
  • finish or f: ステップアウト

c はすでにご紹介したとおり、プログラムの実行を再開するもので、難しくありません。 s/n/f は「ちょっとずつ実行する」というコマンドですが、意味が微妙に違います。

  • step or s: ステップイン:次の行で止まる。もし現在行がメソッド呼び出し(など)の場合は、そのメソッドを呼び出した先で止まる。
  • next or n: ステップオーバー:次の行で止まる。もし現在行がメソッド呼び出し(など)の場合でも、次の行で止まる。
  • finish or f: ステップアウト:現在実行中のメソッドが終了したら止まる。

f:id:koichi-sasada:20211227182626p:plain
step in/over/out(RailsConf 2021 の発表資料より引用)

実は、ステップオーバーは、Ruby だとブロックが絡むと難しい挙動になるんですが、なるべくイイ感じになるように動作を調整してあります(こういう調整に物凄い時間がかかりました)。

発展的な機能

VSCode や Chrome を途中で開いちゃう機能

VSCode から実行するのもいいのですが、普段は VSCode 使っていない人からすると、移行するのも大変かもしれません。そこで、デバッグだけ VSCode で実行したい、という人のために、デバッガが VSCode を開く方法を用意してあります。

  • rdbg --open=vscode ... として実行
  • デバッグコマンドで open vscode として実行

後者を用いると、デバッガで止まっているタイミングで VSCode を開いてみることができます。

youtu.be

また、同じく Chrome ブラウザをデバッガフロントエンドとして利用することができます。

youtu.be

動画では Chrome ブラウザに URL を貼っていますが、最新版 1.4.0 だと(Chrome のパスが探せれば)自動的に開いて表示することができるようになっています。

ポストモーテム(検死)デバッグ

プロセスが例外で死んでしまったとき、その例外発生時にさかのぼって状況を調査したいというニーズがあり、これを ポストモーテム(postmortem/検死)デバッグというそうです。

config postmortem = true として設定しておくと、このモードがオンになります。continueなどはできなくなりますが(もう死んでいるので)、backtracep var として変数の中身を調べる、などといったことができます。

youtu.be

すべての例外発生時にコンテキスト情報を付加するようになるため、実行時性能はちょっと悪くなります。

レコード&リプレイデバッグ

コード実行を記録しておき、あとで再生することができます。再生とは、「ちょっと前のステップ(行)を確認する」といったことができます。

record on とすると有効になります。

youtu.be

(動画では VSCode で実行していますが、コマンドラインでも利用できます)

実は見栄えのするデモを作るために入れた機能なので、性能はからっきしです(多分、rails は起動することもできない)。ごく範囲を絞って使うには便利かもしれません。例えば、step 実行をしているときとか。

debug.gem の今後

debug.gem 自体は、ご紹介の通りまだ開発して1年たっていません。それにも関わらず、多くの方にすでに使っていただいており、多くのフィードバックを頂いており、ありがたい限りです。実際使っていると不満な点がいくつか出てくると思いますので、改善点などを見つかったら、よかったら教えてください。GithubのIssue や PR も歓迎ですが、Twitter や ruby-jp slack(#debugger があります)などで気軽にお声かけ頂いてもかまいません。

個人的な積み残しは、Ractor 対応もそうなのですが、もう少し「普段使い」するための気軽さをもう少しつけたいなぁと思っています。

例えば、コードリーディングで気軽に使えるようにするとか。今は、ステップ実行しても、Rubyの制御は難しいので「あれ、なんでここに移動したの?」というのがよくわからないんですよね。trace機能をつけているんですが、これをもう少し見やすいようにするとか、いろいろ工夫があり得るのではないかと思っています。

普段使いでいうと、binding.irb は Ruby 2.5 から、何もしなくても(require 'irb' をしなくても)「その行で irb 実行する」ということが実現できています。debug.gem は、そういうことができない(rdbg で起動するか require 'debug' が必要)なので、その点も今一歩ですよね。

なんとかならんかということで、こっそり rdbg --util=init というコマンドを付けています。ここで出てくる設定を .bash_profle に入れておくと、何もしなくても debugger と書いた行で止まるようになります。rbenv でRubyのバージョンを変更すると動かなくなるとか、まだ今一歩なんですが(なので、まだドキュメントに書いていないし、削除されるかもしれない)、こういう仕組みを充実させていって、定番のツールにしていければなぁと思っています。

おわりに

本稿では、Ruby 3.1 に添付された debug.gem についてご紹介しました。debug.gem 自体は、Ruby 2.6 以降であれば gem でインストールできますので、よかったら利用を検討してみてください。

今年は debug.gem を作るために Ruby をたくさん書きました。いつもは C を触っていることのほうが多いのですが、いやぁ本当に Ruby は書きやすくていいですね。来年は Ractor 改善するためにまた C の世界に戻ります。

今回は、中身の話(どうやってデバッガを作っているか)ができませんでした。何かの機会にご紹介できるといいなと思います。ちなみに、次のような既発表資料があり、若干中身の話をしております。

それでは、よいお年をお迎えください。