debug gem には、例外時にデバッグ出来る機能 (postmortem デバッグ) があります。
プロセスが例外で死んでしまったとき、その例外発生時にさかのぼって状況を調査したいというニーズがあり、これを ポストモーテム(postmortem/検死)デバッグというそうです。
config postmortem = true として設定しておくと、このモードがオンになります。continueなどはできなくなりますが(もう死んでいるので)、backtrace や p var として変数の中身を調べる、などといったことができます。
本来は例外によってプロセスが終了する場合に debugger を立ち上げる機能ですが、うまいことやると他のタイミングで立ち上げることが可能です。(※ internal な API を使っているため少し強引にはなります)
これを活用して、「ブロック内の例外を postmortem デバッグする」「RSpec テストが落ちたときに postmortem デバッグする」ということを行えるようにしてみました。
サンプルコードは gist にあります。
サンプルでは以下のように、テスト内で例外が発生した場合、または、テストが失敗した場合に、以下のように postmortem デバッグが自動で立ち上がるようになります。
デバッガを利用する、または IRB を起動することで、例外が発生した原因をその場で詳細に調査することが出来て非常に便利です。
$ 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 デバッグ出来るようにする」をやってみます。
以下のコードのようなイメージです。これが出来ると、活用できるユースケースが広がります。
debug_on_error do
# この中で発生した例外を postmortem デバッグする
raise RuntimeError, "error" # ここで debugger が立ち上がるようにする
end
debug gem の postmortem モードはどのように動いているか
postmortem モードを最大限活用していくために、ざっくり postmortem モードがどのように動いているかを把握してみましょう。
以下は debug gem の postmortem モードを有効にするためのコードです。
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
このコードから、 postmortem モードは、
- 例外発生時に、例外オブジェクトに、コンテキスト情報を埋め込むフック (TracePoint)
- プロセス終了時 (at_exit) に、例外で終了している場合は、 postmortem デバッグ用の debugger を立ち上げる (
enter_postmortem_session
) ようにするフック
の2つから成り立っていることが推測できます。 しかも postmortem デバッグ用の debugger を立ち上げるためのメソッド (enter_postmortem_session(exception)
) が用意されているので、
- postmortem モードを有効にする
- enter_postmortem_session(exception) メソッドを呼び出して、 postmortem デバッグ用の debugger を立ち上げる
という工程を踏めば、任意のタイミングで postmortem デバッグ用の debugger を立ち上げることができそうです。
ちなみに、 enter_postmortem_session
メソッドも念のため見てみると、
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
基本的には ThreadClient.current.suspend
に投げているだけなので詳細はそちらを見ないとわからないですが、先のフックで例外オブジェクトに埋め込まれたコンテキスト情報を取り出し、渡していることがわかります。
postmortem デバッグを呼び出すメソッドを実装する
調査した内容を元に、debug gem のこれらのメソッドを呼び出す実装を作ってみます。
先程の debug gem のメソッドは DEBUGGER__::Session
クラスのインスタンスメソッドで、 DEBUGGER__::SESSION
という定数からオブジェクトにアクセスできます。
実装が以下になります。
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 gem の RSpec 用の実装を参考にします。
RSpec の hook を実装する
RSpec でのテストの失敗は例外で表現されるので、RSpec のテスト実行を 1 で作ったブロック内で実行するのと、発生した例外があるかをチェックすることで、実現できます。
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 デバッグが自動で出来るようになりました。
全体のコードは ↓ です。
おわりに
ということで、RSpec のテストが落ちたときに postmortem デバッグが自動で出来るようになりました。
テストが落ちた原因をその場で調査できるというのは、非常に便利で、コンパクトな実装で実現できることから、個人的にはめちゃくちゃ重宝しています。(近いうちに gem にするかもです。)
元々は、Pry には同様のことを行ってくれる pry-rescue gem があり、めちゃくちゃ重宝していたので、 debug gem でも同様のことが出来ないか調査し、 postmortem 機能を活用することでなんとか実現できた形です。
単純にこういうことが出来て便利、ということと debug gem を掘ると便利ユースケースが見つかるかもと調べてみるモチベーションになれば幸いです。