debug gem の postmortem 機能で RSpec のテストが落ちたときにデバッグ出来るようにする


[debug gem](https://github.com/ruby/debug) には、例外時にデバッグ出来る機能 (postmortem デバッグ) があります。

> プロセスが例外で死んでしまったとき、その例外発生時にさかのぼって状況を調査したいというニーズがあり、これを ポストモーテム（postmortem/検死）デバッグというそうです。
>
> config postmortem = true として設定しておくと、このモードがオンになります。continueなどはできなくなりますが（もう死んでいるので）、backtrace や p var として変数の中身を調べる、などといったことができます。
>
> https://techlife.cookpad.com/entry/2021/12/27/202133#:~:text=%E3%81%A3%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82-,%E3%83%9D%E3%82%B9%E3%83%88%E3%83%A2%E3%83%BC%E3%83%86%E3%83%A0%EF%BC%88%E6%A4%9C%E6%AD%BB%EF%BC%89%E3%83%87%E3%83%90%E3%83%83%E3%82%B0,-%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%81%8C%E4%BE%8B%E5%A4%96

本来は例外によってプロセスが終了する場合に debugger を立ち上げる機能ですが、うまいことやると他のタイミングで立ち上げることが可能です。(※ internal な API を使っているため少し強引にはなります)
これを活用して、「ブロック内の例外を postmortem デバッグする」「RSpec テストが落ちたときに postmortem デバッグする」ということを行えるようにしてみました。

サンプルコードは gist にあります。

* https://gist.github.com/tomoasleep/64d559b8be22a32b1a5d7d97f02a51ed

サンプルでは以下のように、テスト内で例外が発生した場合、または、テストが失敗した場合に、以下のように postmortem デバッグが自動で立ち上がるようになります。
デバッガを利用する、または IRB を起動することで、例外が発生した原因をその場で詳細に調査することが出来て非常に便利です。

```console
$ DEBUG_ON_ERROR=1 ruby rspec_inline.rb
Enter postmortem mode with #<NoMethodError: undefined method `undefined_method' for 1:Integer

    1.undefined_method
     ^^^^^^^^^^^^^^^^^>
        rspec_inline.rb:12:in `block (2 levels) in <main>'
        ...
        rspec_inline.rb:20:in `<main>'

[7, 16] in rspec_inline.rb
     7|
     8| require_relative 'debug_helper'
     9|
    10| RSpec.describe 'rspec-debug-on-error' do
    11|   it 'raises exception' do
=>  12|     1.undefined_method
    13|   end
    14|
    15|   it 'fails' do
    16|     expect(true).to be false
=>#0    block in <main> (2 levels) at rspec_inline.rb:12
  #1    [C] BasicObject#instance_exec at ~/.anyenv/envs/rbenv/versions/3.1.3/lib/ruby/gems/3.1.0/gems/rspec-core-3.12.1/lib/rspec/core/example.rb:263
  # and 34 frames (use `bt' command for all frames)
(rdbg:postmortem)
```

以降は、これをどのように実装しているかを説明していきます。

# 1. ブロック内の catch されなかった例外を postmortem デバッグ出来るようにする

まず最初のステップとして 「ブロック内で catch されなかった例外を postmortem デバッグ出来るようにする」をやってみます。
以下のコードのようなイメージです。これが出来ると、活用できるユースケースが広がります。

```ruby
debug_on_error do
  # この中で発生した例外を postmortem デバッグする

  raise RuntimeError, "error" # ここで debugger が立ち上がるようにする
end
```

## debug gem の postmortem モードはどのように動いているか

postmortem モードを最大限活用していくために、ざっくり postmortem モードがどのように動いているかを把握してみましょう。
以下は debug gem の postmortem モードを有効にするためのコードです。

```ruby
    def postmortem=(is_enable)
      if is_enable
        unless @postmortem_hook
          @postmortem_hook = TracePoint.new(:raise){|tp|
            exc = tp.raised_exception
            frames = DEBUGGER__.capture_frames(__dir__)
            exc.instance_variable_set(:@__debugger_postmortem_frames, frames)
          }
          at_exit{
            @postmortem_hook.disable
            if CONFIG[:postmortem] && (exc = $!) != nil
              exc = exc.cause while exc.cause


              begin
                @ui.puts "Enter postmortem mode with #{exc.inspect}"
                @ui.puts exc.backtrace.map{|e| '  ' + e}
                @ui.puts "\n"


                enter_postmortem_session exc
              rescue SystemExit
                exit!
              rescue Exception => e
                @ui = STDERR unless @ui
                @ui.puts "Error while postmortem console: #{e.inspect}"
              end
            end
          }
        end


        if !@postmortem_hook.enabled?
          @postmortem_hook.enable
        end
      else
        if @postmortem_hook && @postmortem_hook.enabled?
          @postmortem_hook.disable
        end
      end
    end
```

https://github.com/ruby/debug/blob/v1.8.0/lib/debug/session.rb#L1903-L1940

このコードから、 postmortem モードは、

* 例外発生時に、例外オブジェクトに、コンテキスト情報を埋め込むフック ([TracePoint](https://docs.ruby-lang.org/ja/latest/class/TracePoint.html))
* プロセス終了時 (at_exit) に、例外で終了している場合は、 postmortem デバッグ用の debugger を立ち上げる (`enter_postmortem_session`) ようにするフック

の2つから成り立っていることが推測できます。 しかも postmortem デバッグ用の debugger を立ち上げるためのメソッド (`enter_postmortem_session(exception)`) が用意されているので、

1. postmortem モードを有効にする
2. enter_postmortem_session(exception) メソッドを呼び出して、 postmortem デバッグ用の debugger を立ち上げる

という工程を踏めば、任意のタイミングで postmortem デバッグ用の debugger を立ち上げることができそうです。

ちなみに、 `enter_postmortem_session` メソッドも念のため見てみると、

```ruby
    def enter_postmortem_session exc
      return unless exc.instance_variable_defined? :@__debugger_postmortem_frames


      frames = exc.instance_variable_get(:@__debugger_postmortem_frames)
      @postmortem = true
      ThreadClient.current.suspend :postmortem, postmortem_frames: frames, postmortem_exc: exc
    ensure
      @postmortem = false
    end
```

https://github.com/ruby/debug/blob/v1.8.0/lib/debug/session.rb#L1863-L1871

基本的には `ThreadClient.current.suspend` に投げているだけなので詳細はそちらを見ないとわからないですが、先のフックで例外オブジェクトに埋め込まれたコンテキスト情報を取り出し、渡していることがわかります。

## postmortem デバッグを呼び出すメソッドを実装する

調査した内容を元に、debug gem のこれらのメソッドを呼び出す実装を作ってみます。
先程の debug gem のメソッドは `DEBUGGER__::Session` クラスのインスタンスメソッドで、 `DEBUGGER__::SESSION` という定数からオブジェクトにアクセスできます。

https://github.com/ruby/debug/blob/v1.8.0/lib/debug/session.rb#L2258-L2263

実装が以下になります。

```ruby
module DebugHelper
  class << self
    def debug_on_error
      yield
    rescue => e
      enter_postmortem_session(e)
      raise e
    end

    def enter_postmortem_session(error)
      error = error.cause while error.cause

      puts "Enter postmortem mode with #{error.inspect}"
      puts error.backtrace.map { |b| "\t#{b}" }
      puts "\n"

      DEBUGGER__::SESSION.enter_postmortem_session(error)
    end

    def enable
      DEBUGGER__::SESSION.postmortem = true
    end
  end
end
```

# 2. RSpec テストが落ちたときに postmortem デバッグ出来るようにする

ということでブロック内で catch されなかった例外を postmortem デバッグ出来るようになったので、次は RSpec テストが落ちたときにも出来るようにしてみます。
ここからの実装は、 [pry-rescue](https://github.com/ConradIrwin/pry-rescue) gem の RSpec 用の実装を参考にします。

https://github.com/ConradIrwin/pry-rescue/blob/v1.5.2/lib/pry-rescue/rspec.rb

## RSpec の hook を実装する

RSpec でのテストの失敗は例外で表現されるので、RSpec のテスト実行を 1 で作ったブロック内で実行するのと、発生した例外があるかをチェックすることで、実現できます。

```ruby
RSpec.configure do |config|
  DebugHelper.enable

  config.around(:example) do |example|
    DebugHelper.debug_on_error do
      example.run
    end
  end

  config.after(:example) do |example|
    DebugHelper.enter_postmortem_session(example.exception) if example.exception
  end
end
```

ちなみに、デバッガを自動で起動してほしく無いケースもあると思うので、環境変数を使って、このコードを実行するかどうか制御出来るようにすると良いでしょう。

以上で、RSpec のテストが落ちたときに postmortem デバッグが自動で出来るようになりました。
全体のコードは ↓ です。

https://gist.github.com/tomoasleep/64d559b8be22a32b1a5d7d97f02a51ed

# おわりに

ということで、RSpec のテストが落ちたときに postmortem デバッグが自動で出来るようになりました。
テストが落ちた原因をその場で調査できるというのは、非常に便利で、コンパクトな実装で実現できることから、個人的にはめちゃくちゃ重宝しています。(近いうちに gem にするかもです。)

元々は、Pry には同様のことを行ってくれる [pry-rescue](https://github.com/ConradIrwin/pry-rescue) gem があり、めちゃくちゃ重宝していたので、 debug gem でも同様のことが出来ないか調査し、 postmortem 機能を活用することでなんとか実現できた形です。
単純にこういうことが出来て便利、ということと debug gem を掘ると便利ユースケースが見つかるかもと調べてみるモチベーションになれば幸いです。