Out of frustration with a stuck test on a Rails project I recently explored the possibilities of debugging Ruby scripts on OSX. There are various built-in ways how to debug (e.g. via puts or more gems like pry-byebug), but because I didn't want to deep-dive into the project I wanted to stay with built-in tools that ship with my operating system.

The first thing I could remember about system debugging was gdb, or also known as the GNU debugger. OSX no longer ships with gdb or any other tools of the gcc toolchain, but instead uses the LLVM toolchain. The substitute there is called lldb. Getting started is actually quite easy, as you've probably installed XCode already and so you also have lldb installed.

Let's get started - first of all we need the PID of the Ruby process we want to debug, so we run:

1
2
3
> ps | grep ruby
17400 ttys000    0:00.28 ruby bin/rspec
14807 ttys002    0:00.00 grep --color=auto --exclude-dir=.git --exclude-dir=.hg ruby

The first line is our Ruby program, which has the PID 17400. Next, we start up lldb and attach to the program via attach 17400:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> lldb
(lldb) attach 17400
Process 17400 stopped
* thread #1: tid = 0x17d6329, 0x00007fff95dc9c8a libsystem_kernel.dylib`__psynch_cvwait + 10, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
    frame #0: 0x00007fff95dc9c8a libsystem_kernel.dylib`__psynch_cvwait + 10
libsystem_kernel.dylib`__psynch_cvwait:
->  0x7fff95dc9c8a <+10>: jae    0x7fff95dc9c94            ; <+20>
    0x7fff95dc9c8c <+12>: movq   %rax, %rdi
    0x7fff95dc9c8f <+15>: jmp    0x7fff95dc2d6f            ; cerror_nocancel
    0x7fff95dc9c94 <+20>: retq   

Executable module set to "/Users/kitto/.rbenv/versions/2.3.1/bin/ruby".
Architecture set to: x86_64h-apple-macosx.
(lldb)

Here is where the fun starts: You only see the machine code, because, well, that's what machines understand.

The nice thing about lldb is that you can use Python to write helper scripts. I've assembled one with two helpful commands to ease debugging Ruby programs. We'll see in a minute what they will do for us:

1
2
3
4
5
6
7
8
9
10
11
12
# ~./lldb/ruby.py
import lldb

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add -f ruby.rb_backtrace rb_backtrace')
    debugger.HandleCommand('command script add -f ruby.rb_eval rb_eval')

def rb_backtrace(debugger, command, result, internal_dict):
    debugger.HandleCommand('expr (void)rb_backtrace()')

def rb_eval(debugger, command, result, internal_dict):
    debugger.HandleCommand('expr (void *)rb_p((void *)rb_eval_string_protect(%s, (int *) 0))' % command)

We can import that script into lldb via command script import ~/.lldb/ruby.py, which will add the two commands rb_backtrace and rb_eval to our session.

Next, let's execute rb_backtrace, which will print a stacktrace of our script to its stdout:

1
2
3
4
5
6
7
8
[rspec related lines omitted]

from /Users/kitto/Projects/olymp/spec/interactors/book_interactor_spec.rb:11:in `block (3 levels) in <top (required)>'
from /Users/kitto/Projects/olymp/app/interactors/book_interactor.rb:18:in `perform'
from /Users/kitto/Projects/olymp/app/interactors/book_interactor.rb:28:in `with_lock'
from /Users/kitto/Projects/olymp/app/interactors/book_interactor.rb:28:in `map'
from /Users/kitto/Projects/olymp/app/interactors/book_interactor.rb:28:in `block in with_lock'
from /Users/kitto/Projects/olymp/app/interactors/book_interactor.rb:28:in `sleep'

That gives us a lot of information about the reason for that hanging test. In my case, it was just about an old configuration file still lying around and carrying a timeout value way too high for testing.

Let's quickly examine the second command we've installed, rb_eval. As the name implies, this lets you execute arbitrary Ruby code from within lldb.

With that you can print or even change variables, call methods, manipulate objects,… Basically everything that you would do in a normal debugging session. Say we want to see a list of all running threads in our program, we would execute rb_eval("Thread.list"), which would give us this list:

1
2
3
4
#<Thread:0x007fa63107f3d0>
#<Honeybadger::Agent::Thread:0x007fa621552570>
#<Honeybadger::Agent::Worker::Thread:0x007fa63c0a4e18>
#<Honeybadger::Agent::Worker::Thread:0x007fa6237eeca0>

I hope you enjoyed this very quick introduction to lldb in conjunction with Ruby. If you know any tricks, let me know in the comments!