How I debug Python code with a Time Travel Debugger

How I debug Python code with a Time Travel Debugger

Time travel is the biggest thing to happen to debuggers in decades. Traditionally debuggers have let you see what a program is doing; the new breed of time travel debuggers let you see what the program has done: you can wind back to see any line of code that executed and any variable’s value at any point in history. No more guesswork, no more trial and error. Users report improving debug capability by 10x or even 100x. It’s not just a question of being more productive; now bugs that previously just would not have been fixed are being fixed. It’s also much easier to work on a codebase with which you are unfamiliar. We now have industrial-strength time travel debugging for most commonly-used languages: C/C++, Java, Javascript, Go, C#, Rust – even FORTRAN and COBOL! But as yet, nothing natively for Python. There are several Python debuggers available out there, but none of them have time travel capability like UDB. This article presents how I use time travel to debug Python code, but first we’ll have a look at why the standard Python debugger is incompatible.

The standard Python debugger

Python ships with a debugger called pdb that lets you interact with your program while it is running – i.e. so that you can see in detail what it’s doing. Using pdb is as simple as calling the builtin breakpoint() function which drops you into a debugger prompt, and from there you have access to commands for evaluating expressions, navigating the call stack, stepping through the code and setting up conditional breakpoints.

Debuggers like pdb work by running inside of the same Python interpreter as the program that is being executed. This makes it easy to inspect and manipulate objects, as the debugger can directly evaluate expressions and access Python’s traceback and introspection modules. However, this also rules out certain debugging workflows.

Time travel debuggers like UDB and rr work on the principle of first recording a program while it is running normally, and then replaying the execution while allowing the user to navigate and inspect state at different points in time. They work at the process level — rewinding and replaying the state of the entire Linux (or Windows) process. While the program is being replayed it must follow the exact same path of execution as it took when it was recorded, and this prevents a debugger like pdb from being able to run at replay time Python functions that weren’t already executed at record time.

Technically, UDB does allow new code paths to be executed at replay-time, but these executions are isolated and all side-effects are discarded, so even if we were able to run code from the pdb module we wouldn’t be able to use features such as setting breakpoints.

Crash dumps like core files are also not compatible with pdb, because there is no live process in which to execute the pdb Python code.

What can we use instead?

A running Python program is ultimately just a process, so you can attach GDB (or UDB) to it. When debugging a Python process with GDB (or UDB) directly we are effectively debugging the cpython interpreter.

The debugger isn’t aware of any of the Python functions or variables inside our program. Luckily, the cpython project maintains a library of GDB extensions that give GDB the ability to understand Python code. The libpython.py library knows how to inspect the internal structures of cpython in order to present the state of the Python program to the user. The library is executed by a cpython interpreter inside the debugger process, meaning that no Python code needs to be executed inside the context of the program itself. 

As the interpreter is implemented in the C programming language we must have DWARF debug information available for the Python executable for this to work, i.e the python executable must have been built with the -g compiler flag. Many modern Linux distributions come configured with a debuginfod service, in which case the debug information should be downloaded automatically. Otherwise, you may have to download a separate package with debug information.

Pretty printers

The libpython.py library defines a number of GDB pretty-printers that let GDB present cpython objects as Python values instead of C language values. For example, a Python object is stored as the generic PyObject type which is usually presented as a normal struct with fields.

> p filename
$1 = (PyObject *) 0x7f7f154aa610
> p *filename
$2 = {ob_refcnt = 10, ob_type = 0x7f7f16eb2f40 }

After sourcing libpython.py into GDB, this unicode string object is instead presented as a quoted string, just like we’re used to from the Python REPL. Note that we don’t even have to dereference the pointer.

> source ./libpython.py
> p filename
$3 = '/home/mhov/python-debugging/./race.py'

Already this is much more debuggable! Pretty-printers are applied automatically — there’s no need to specify that the cpython variable that you are trying to print represents a Python variable. These pretty printers also make it easier to read a backtrace of cpython as the Python frame objects are nicely formatted.

> bt 2
#0 _PyEval_EvalFrameDefault (tstate=, f=, throwflag=) at ./Python-3.10.12/Python/ceval.c:1848
#1 0x00007f7f16a6d226 in _PyEval_EvalFrame (throwflag=0, f=Frame 0x7f7f15567a40, for file /home/mhov/python-debugging/./race.py, line 54, in do_some_prints (x=3), tstate=0x114cc60) at ./Python-3.10.12/Include/internal/pycore_ceval.h:46

Backtraces

There’s a number of new commands for manually inspecting Python state. The py-bt command displays a Python backtrace in the standard Python traceback format.

> py-bt
Traceback (most recent call first):
  File "/home/mhov/python-debugging/./race.py", line 54, in do_some_prints
    y = 4
  File "/home/mhov/python-debugging/./race.py", line 60, in main
    do_some_prints()
  File "/home/mhov/python-debugging/./race.py", line 85, in 
    main()

There’s also a py-bt-full variant which shows a few more pieces of information, including frame numbers corresponding to the cpython frames, the addresses of each Python frame object, and arguments for the Python functions.

Source code

The py-list command shows the current source code and the current position as indicated with the > character next to the line numbers.

> py-list
  49        	print(name)
  50
  51
  52	def do_some_prints():
  53    	x = 3
 >54    	y = 4
  55    	print(f"Hello from a function call: {x+y=}")
  56
  57
  58	def main():
  59    	print('Issuing a "print" statement')

The py-list command will present the source code and current position in your currently selected frame, meaning you can use the py-up and py-down commands in combination with py-list to navigate the source code along the whole call chain.

Variables

We previously saw how the print command has the ability to display Python values, but to do so we had to specify a Python object in terms of a cpython expression. Instead, what we really want to do is to specify the name of the Python variable, and the new py-print command lets us do this. Instead of considering the symbols from cpython it looks up the name of the variable inside of the currently active Python scopes (local, global and builtin). 

> py-print x
local 'x' = 3

> py-print lock
global 'lock' = <_thread.lock at remote 0x7f7f15396240>

The py-locals command will show you all the variables that are available in the currently selected local scope and you can select different scopes with the py-up and py-down commands.

> py-locals
x = 3
y = 4

By using a combination of these commands you should be able to understand what the current state of your Python program is — even across threads! However, the main benefit of using a Time Travel debugger comes from being able to step or run your program forwards or backwards to see how the state of your program develops over time.

Navigation

Within UDB we have access to the regular navigation commands such as next, reverse-step and finish, but these operate on the cpython interpreter and not the Python source code. If we want to move around in the Python code we’ll have to implement some new commands ourselves.

Function calls

In the rest of this guide we’ll be extending libpython.py with some new functionality. Check out the repository at https://github.com/undoio/python-debugging/tree/blog-post-2024-01-18 for the full code.

One way to navigate Python code is to move between different function calls, and in order to know when cpython starts executing a Python function we need to hook into the appropriate cpython routines.

There’s a number of different routines available to invoke callables in Python:

  • Builtin Python functions are typically implemented as C functions. We can hook into those by intercepting the cfunction_enter_call C function.
  • Normal Python functions are compiled into Python bytecode which is then evaluated as separate frames. Unless you’re doing something funky these end up going through the _PyEval_EvalFrameDefault C function.

After setting breakpoints on these two cpython functions we can run our program forwards and backwards with continue and reverse-continue. However, what we really want is to be able to specify which Python function we would like to stop on. In order to achieve that we need to borrow and extend some of the logic in libpython.py to make our breakpoints conditional.

The following code implements two commands for advancing forwards and backwards to a specific Python function:

Now we can easily get to the start of our program by issuing the command py-advance-function main or the command py-reverse-advance-function main, depending on where we are in history. 

Stepping bytecodes

Being able to navigate between function calls is great, but what if we want to go somewhere else, like stepping through each line of source code? By the time the interpreter executes our Python code it doesn’t really operate on source lines anymore, instead we have individual instructions of Python bytecode being executed by the bytecode interpreter loop (_PyEval_EvalFrameDefault).

By placing a breakpoint inside the interpreter loop we can step one bytecode instruction at a time. This isn’t as convenient as stepping source lines, but it is a lot easier to implement with the extensions that are already available to us.

The following code implements two commands, py-step and py-reverse-step, for stepping individual bytecode instructions forwards and backwards:

If we are going to step bytecode instructions then we will also need a way to see which bytecode instruction we are currently executing. With a little bit of plumbing we are able to fetch the bytes out of the interpreter state and disassemble it with Python’s disassembler module.

The following code implements a py-dis command which shows the bytecode of the currently selected Python frame:

The command indicates the current bytecode instruction, and includes the names of constants, arguments and variables that are referenced by the instructions.

 

 > py-dis
      	0 LOAD_CONST           	1 (3)
      	2 STORE_FAST           	0 (x)
      	4 LOAD_CONST           	2 (4)
      	6 STORE_FAST           	1 (y)
      	8 LOAD_GLOBAL          	0 (print)
-->  	10 LOAD_CONST           3 ('Hello from a function call: x+y=')
     	12 LOAD_FAST            0 (x)
     	14 LOAD_FAST            1 (y)
     	16 BINARY_ADD
     	18 FORMAT_VALUE         2 (repr)
     	20 BUILD_STRING         2
     	22 CALL_FUNCTION        1
     	24 POP_TOP
     	26 LOAD_CONST           0 (None)
     	28 RETURN_VALUE

Tracking value changes

One of the most useful features of UDB is the ability to search backwards for changes to data by using watchpoints or the last command. That is, if when debugging you see some piece of state that looks wrong (or you’re just generally curious about when that state got changed) you can wind back immediately to the line of code that most recently updated that value. It is an incredibly powerful capability.

In order to achieve the same thing for Python our extensions need to understand how Python objects are manipulated under the hood. Support for tracking changes to object attributes can be achieved by intercepting the PyObject_SetAttr C function.

The following code implements a py-last-attr command which searches backwards (or forwards) in time for the last time a given attribute name was assigned on a given Python object:

User Interface

Now that we have some means of getting around our Python program we could start exploring some actual code, but first let’s see if we can improve the user interface.

These days it’s fairly straightforward to extend GDB’s Text-User-Interface (TUI) mode with new types of windows. After a bit of plumbing, all we need to do is define a get_lines() method that can provide the content for our TUI window.

class MyWindow(Window):
    title = "My Window"

    def get_lines(self):
       return "This is the content in my new window"

Let’s create a new layout that can show us the output of these Python commands automatically. The following file implements a new TUI layout that shows the output of py-bt, py-locals, py-dis and py-list.

The layout can be enabled like any other TUI layout — with the layout command.

> layout python

The following screen recording demonstrates what some of these commands and the user interface components look like when used on an example program (don’t forget to view in full screen):

(https://asciinema.org/a/4sxAEWhqFI8rJF4atfsXQskYu)

Getting started

If you want to try this yourself, you can get started by heading over to https://github.com/undoio/python-debugging/tree/blog-post-2024-01-18 and reading the README.md file. Check out https://undo.io/udb-free-trial for a free trial version of UDB if you don’t already have access to UDB. The included race.py example Python program contains some simple function calls as well as a concurrency issue involving multiple threads.

These extensions are built for Python 3.10, so you might find that they don’t work as well for other Python versions.

If your Python source code doesn’t show up automatically, you might have to use the py-substitute-path command to let the debugger know how to find your local checkout of the Python source code.

 

Author: Magne Hov,  Software Engineer at Undo

Stay informed. Get the latest in your inbox.