Stan Lo

Build a mini Ruby debugger in under 300 lines

RubyKaigi 2023

00:00:08.179 Thank you very much. Okay, so hello everyone. Today, I want to show you how to build a functioning debugger in under 200 lines. I guess the number I came up with when titling this talk is a bit misleading, as the final result is actually smaller, which is always a good thing.
00:00:19.980 Let me start by introducing myself. My name is Stan Lo, and I'm from Taiwan, though I currently live in the UK. I'm a developer on the Shopify Ruby experience team, and one of our major projects is the Ruby LSP, which just became VS Code’s default option for Ruby users last month.
00:00:38.100 If you are interested in language servers, my teammate Wei will be giving a talk about it tomorrow. Additionally, another teammate, Alex, will also discuss the comparison between RBS and RBI services. Please check out their talks if these topics interest you.
00:01:06.720 I also maintain projects such as IRB, Reline, and Centuary Ruby. Last year, I gave a talk on how to use Ruby debug to boost your productivity. Originally, I wanted to give a similar talk with more advanced debugging techniques, but after conducting a debugger workshop with my team, I found that learning by building also applies to debugging skills.
00:01:39.360 In this talk, I want to help you get familiar with debuggers by looking inside one. I know to many people, debuggers can feel like mysterious black boxes that are hard to approach, and I want to change this mindset through my talk. By learning to build a debugger, we can gain insights into both its capabilities and limitations, which will guide us in knowing when and how to use a debugger effectively. Using the tools and techniques I'll be presenting, which we typically don't encounter when building applications, you will be better equipped to create custom tools for yourself or your team.
00:02:37.800 We will build this debugger in five steps. In each step, I will explain why we need this feature followed by a demo. I will highlight the important parts of the implementation. In other words, I won’t explain everything live, but don't worry; if you are curious about the details, I will show you the code repository at the end of my talk so you can take your time to review it.
00:03:09.660 Before we delve into the first step, I want to introduce a few key concepts and tools that we will use for building this debugger. They are binding objects, the TracePoint class, and the Reline library.
00:03:29.520 Let's talk about binding first. According to its documentation, binding objects encapsulate the execution context of a specific code part and return its context for later use. This means when you get a binding object, you have access to the context it captures.
00:03:43.680 We can better understand this by looking at an example. If we hold a binding at the top level and call it, it just retains itself just like when we call 'self' directly. But let's see what happens when we get a full instance binding using the 'binding' method. When we call 'self', it now returns the full instance, and we can access its internal state. Most importantly, we can obtain the context for its 'binding' method. For instance, we can see the method argument's value. A binding object also holds the location of the context it captures, and having access to this information is crucial for building a functional debugger.
00:04:41.460 But how can we get the binding object without altering our program to return it? This is where we need the TracePoint class. TracePoint allows us to trace different types of Ruby execution events, which you can think of as Ruby-level callback events.
00:05:16.920 For example, there's a 'raise' event that triggers whenever an exception is raised, as well as 'call' and 'return' events. However, for our mini debugger, we will only use its 'line' event which provides information such as name, line number, file path, and, most importantly, the binding object of that execution context.
00:05:57.240 Here's an example call: when the 'greeting' method is executed, the TracePoint block will be triggered. Likewise, when we put inside 'greeting', it will trigger the TracePoint block again, printing the output.
00:06:02.039 If this is your first time hearing about or using TracePoint, I want to give you a heads-up. While it's very convenient to have a TracePoint running with your program, it may add overhead and affect performance significantly. Also, if utilized incorrectly, it can complicate optimizations.
00:06:12.840 The general recommendation is not to use it in production. Now, let's move on to the last key component: Reline. This is a powerful library that handles terminal input and powers IRB and Ruby debug. Besides reading input from the terminal, it offers features like auto-completion, multi-line input, and input history.
00:06:38.580 However, for today, we won’t be using any advanced features. Its usage is simple. We generally use it with a while loop, which awaits input. We handle the input, process it, and then wait for the next iteration. If we run the example, here’s what it looks like.
00:07:04.560 Now that we have covered the necessary tools, let's start building the debugger. In the first step, we will implement the breakpoint method called 'binding.dbg', a minimum REPL simulation that behaves similarly to 'binding.pry' or 'IRB'. In the second and third steps, we will implement stepping through the 'step' and 'next' commands.
00:07:22.740 In the fourth step, we will add breakpoint commands, and finally, we will create the debugger executable. This is the simple Fibonacci implementation I will use to demonstrate the debugger features. I hope it doesn't contain any bugs.
00:08:00.240 So in the first step, we want to implement a basic breakpoint and minimum REPL. The program should stop when it hits the breakpoint, similar to what happens when we hit a binding.pry or IRB.
00:08:28.440 Once the program stops, the debugger will open the REPL, which will read the input, evaluate it, and print the result. Finally, we will have commands to either continue or execute the program.
00:08:42.900 After this step, our program will look like this: when we need to debug it, we first require the debugger and then replace calls to 'binding.pry' with 'binding.dbg' at the point we want to investigate.
00:09:05.040 Let’s see a short demo of it. As you can see, we can stop at the breakpoint, examine nearby code, and access the breakpoint's context, including method arguments.
00:09:11.880 When we continue, we will hit another breakpoint, as we are inside the recursive method, and we can also exit the program.
00:09:28.560 To achieve what I just demonstrated, it only requires 60 lines of code, which is quite impressive. Now, let me break it down into smaller pieces.
00:09:43.800 Starting from the bottom of the file, we initialize the session instance and store it in a constant. This is because we only need one debugging session at a time, accessible through the constant. I'll explain the session class in detail shortly.
00:10:07.920 Next, we add a debug method to the binding class. You might wonder why debug methods often start with binding. By defining a method on the Binding class, we can easily pass the binding itself. In this case, we pass it to the debug session's method.
00:10:43.080 What does the system method do? We know it takes a binding object, and within it, there's a while loop which is similar to our Reline example. In this case, we handle the continue and exit commands. To continue the program, we simply break the loop, and to exit, we call the exit method.
00:10:59.340 At the bottom, there's an eval method that evaluates the input with the given binding. We also need to display the code around the breakpoint as shown in the picture on the right.
00:11:26.520 Although it's not the primary focus of this step, I'll go through this quickly. This method also takes a binding, which is used to get the file path and line number. Then, using this information, we calculate and display the start line, end line, and filename. Lastly, we iterate through the lines covered in the range.
00:12:05.610 With these three small steps, we now have a basic line-based debugging tool. Let’s move on to the second step, where we will add some debugger features such as step debugging.
00:12:39.810 Let's start with 'step', which allows users to step through the next program execution. To better demonstrate this feature, we will move the breakpoint outside the fifth method so we can step into the method.
00:13:00.720 Here’s a demo. Now you can see that we are at the beginning of line 12. If we type 'step', we can step into the fifth method call and access the variables just as if we had placed 'binding.dbg' inside the method directly.
00:13:10.560 To implement this feature, we first need to handle the step command in our REPL loop. We need a step method, which, once called, lets the program continue until the next execution.
00:13:43.560 As the program continues, let’s say from line 12, it will reach the next execution, which is an if condition, and the TracePoint will be triggered. However, the debugger itself won’t tell whether the execution is from the program, standard libraries, or Ruby's internal execution.
00:14:15.600 Thus, we must configure the debugger to only stop at the user's code. We can identify internal events by comparing the TracePoint event path with certain patterns, skipping the block execution when necessary.
00:14:46.740 If the path matches the pattern we want to skip, we will first disable the TracePoint to prevent it from interfering with future executions, and then we call the suspend method with the current event's binding.
00:15:13.860 Now, we have implemented the steering functionality of our debugger. However, when debugging, we typically do not need to see every single execution, so being able to skip some is beneficial. This is why we need to implement 'step over'.
00:15:38.280 In this step, we will not change our program file, but rather we will try to skip line 12 and step directly to line 13. Now we stop at line 12 but instead of typing 'step' and stepping into it, we type 'next' to move directly to line 13.
00:16:00.960 Then, we step into line 13. The implementation of the 'next' command is quite similar to that of the 'step' command. First, we handle the input, then we implement the step over function and finally, we break the loop.
00:16:20.520 Comparing 'step over' with 'step in', the main difference is that 'step over' skips executions when the last call stack is deeper than the current stack. This means in most cases we end up to the next line.
00:16:47.760 To clarify what this means, let’s add some puts to visualize it. When 'step over' is called, we will print the current stack stats and every time a line is executed, we also print the line's context.
00:17:12.000 After re-running the program, you will see when we step over from line 12 to 13. If we zoom in, we find around 50 entries, where each entry represents an execution that the 'step' command needs to skip.
00:17:37.020 In total, we are skipping 50 steps with the 'next' command, which explains why this feature is called 'step over'—because we are effectively stepping over these executions.
00:18:02.220 Now our mini debugger is adept at performing basic step debugging. Yet, even if we can step over to the next line, our ability to navigate inside the program remains limited. Ideally, we should be able to halt execution at any chosen location using breakpoints.
00:18:28.740 For instance, we should be able to set breakpoints without altering the actual code. Therefore, in this step, we will introduce the 'break' command to facilitate this.
00:19:00.060 With the feature to add breakpoints via the 'break' command, we also need the ability to list all added breakpoints and to delete them when necessary.
00:19:34.740 Using the breakpoint commands means we no longer have to place 'binding.dbg' close to the code we are analyzing. For demonstration purposes, I will now move 'binding.dbg' to the top of the program.
00:20:04.860 As you can see, now we stop at line 2. By using the 'break' command, we can add a breakpoint at line 8, and when we continue, it will stop at line 9, just as if we had put 'binding.dbg' there.
00:20:24.840 We can also have multiple breakpoints and list them using the 'break' command as demonstrated earlier.
00:20:53.160 Finally, we can delete any breakpoints that we no longer need.
00:21:15.720 To implement this functionality, firstly we need a way to track breakpoints. We initialize a storage array in the session for this purpose. Then we need a helper method to add breakpoints, which initializes the placement and adds it to the breakpoints list.
00:21:34.200 Once we add a breakpoint, we notify the user that the breakpoint has been added. In the Background, we also enable the breakpoint TracePoint.
00:22:12.180 This step also necessitates a few changes to our REPL. We now need to parse the input since our command can have arguments. We will use regular expressions to manage breakpoints through the 'break' command. Lastly, if no argument is provided, we simply list all the breakpoints.
00:22:49.080 Deleting a breakpoint is straightforward; we take the ID as an argument and use it to locate and remove the breakpoint from our list.
00:23:29.400 The next important part is to implement the breakpoint functionality itself. The implementation is quite simple: we use the TracePoint's line event to determine if the execution occurs at the breakpoint’s location. If it does, we suspend the program with the TracePoint’s binding.
00:24:00.000 However, creating such a system can cause considerable performance overhead, especially for real-world applications. The way Ruby debug implements breakpoints is more complex—it collects objects from the object space, finds the appropriate object of the breakpoint location, and strategically checks which line to stop at.
00:24:39.060 This reduces runtime overhead using a much more sophisticated breakpoint activation logic.
00:24:59.460 Now that we have a functioning debugging tool, we are already in good shape. However, there are still improvements to make. Even though we do not need to update the code to set breakpoints, we still must update it to require the debugger and to define breakpoints at the start.
00:25:37.440 In the last step, we can make these two requirements redundant. After this step, our example program will appear free of any debugger-related code.
00:26:16.080 Now, we will use this newly created 'exe' debug executable to run our program instead of merely running Ruby as usual. As you can see, it stops at the beginning of the program execution, allowing us to set breakpoints using the commands we defined.
00:26:51.840 How does it actually work? The debug executable is just another Ruby script that invokes kernel.exec to replace the current process with a new Ruby process. In this script, we instruct Ruby to find and require the debugger file, making sure the program runs with the debugger activated.
00:27:27.840 This way, we stop at the program's beginning once it’s required. In conclusion, we've successfully built a debugger that functions well with just 189 lines of code.
00:28:07.440 To recap, by utilizing Reline and bindings, we have implemented the binding debugger, breakpoints, and the working REPL. With TracePoint, we enabled basic step debugging, allowing us to navigate within close proximity of the binding.
00:29:35.880 Moreover, by including breakpoint commands and the debugger executable, we can debug a program without modifying its source code at all.
00:30:19.320 Now that we understand how a debugger works, I would like to elucidate how to choose our debugging tools.
00:30:57.540 Please bear with me as I simplify this concept into a graph of four abstraction levels. At the bottom, we have C Ruby, then standard libraries, with the highest level being our Ruby program, libraries, and applications.
00:32:03.840 Remember when we implemented stepping? We skip certain executions. This applies to many Ruby debuggers, including Ruby debug and Byebug, meaning that we cannot effectively debug these components with Ruby-based debuggers.
00:32:59.760 Debuggers can manipulate events and store additional information as your program executes. While we didn't cover it today, debuggers often halt or receive events without your knowledge.
00:34:13.560 If your project is rooted in C Ruby's standard libraries, then typical Ruby debuggers might not perform well, due to the side effects that occur.
00:35:26.040 However, C Ruby developers often use lower-level debuggers like GDB or LLDB because they focus on different levels of detail.
00:36:58.080 In IRB development, I usually rely on simple print statements to inspect variables in a live environment.
00:38:07.320 For most of you present here, your projects probably exist at the top layer. If that's the case, feature-rich Ruby debuggers should work well for you.
00:39:33.840 If you are unsure, please check out my talk from last year's RubyKaigi, where I discussed why I frequently use Ruby debug when working on Ruby LSP, for instance.
00:40:28.140 I hope this clarifies any confusion about the utility of debuggers and motivates you to pick the appropriate debugger that suits your context.
00:40:58.920 Finally, as promised, here’s a link to the entire debugger project where I have made every effort to split all steps into separate commands. If you examine it commit by commit, it should clearly illustrate how the debugger was constructed.
00:41:29.760 To wrap things up, consider implementing additional features in the debugger; here are some ideas. Firstly, enable the step and next commands to accept integer arguments so that, for example, 'step 2' would mean stepping twice.
00:41:50.880 You could implement a catch command to add a breakpoint triggered by exceptions using TracePoint’s raise event.
00:42:26.040 Finally, consider adding a finish command to complete the current frame—this is another commonly used step-debugging command. That's all for my talk! Thanks for listening.