How this is builtGroundworkHow this is built
14 / 26 written chapters

I8 · Part 2 · How it runs

Stepping through a run

Record every statement once, then scrub through the recording as if it were live.

7 min read1 diagram8 sections
08On this page
  1. A recording, not a live session
  2. Instrumenting JavaScript and TypeScript
  3. Why getters, not values
  4. Capturing in the worker
  5. Tracing Python
  6. Turning values into pictures
  7. The panel
  8. Mapping a step back to the editor

A recording, not a live session

A real debugger pauses a running program. A web page cannot pause code inside a worker, so the Debug button does something simpler that looks the same from the outside. It runs the program once from start to finish, records the line and the variables at every statement, and hands the whole recording to a panel that can move forwards and backwards through it. Stepping back costs nothing, because nothing is being re-run.

Debug is offered for JavaScript, TypeScript and Python, on the playground and on problem pages, but not for React component exercises or inside the mock interview. On a problem page the reader's code usually only defines a function, and a trace of a definition is empty. So the workspace appends one call using the first recorded test case from /problems/[slug]/cases, which is why the button's tooltip says "Step through on the first test".

Editor code JS, TS or Python Tools worker instrument() TS then transpiled JavaScript worker __t() per statement 8 s limit Python Pyodide worker sys.settrace on the reader's code, 20 s limit Trace at most 2,000 steps DebugPanel slider, step buttons, arrow keys Editor line highlight setDebugLine, scrolled to centre
Two ways of capturing, one format for the result. The panel and the editor highlight do not know which language produced the trace.

Instrumenting JavaScript and TypeScript

The tools worker receives an instrument request and passes the code to lib/debug/instrument.ts. That file uses the TypeScript compiler's own transformer API, so it works on a real syntax tree rather than on text. Before every statement it inserts a call to __t with two arguments: the statement's line number in the original source, and an arrow function that returns a getter for every name in scope. Nine kinds of statement are left alone because they do nothing when they run: function, class, interface, type alias, enum, module, import and export declarations, and empty statements.

Every function, method and arrow function is also wrapped in __enter(name) and a try/finally that calls __exit(), which keeps a call stack for the trace. The name comes from the function itself, or from the variable or property it is assigned to. An arrow with an expression body becomes a block with a return statement, and a single-statement if or loop body becomes a block, so there is always somewhere to put the trace call. This is the real output for a small function, with one line shortened:

function sum(list) {
    __enter("sum");
    try {
        __t(2, () => ({ "sum": () => sum, "list": () => list, "total": () => total }));
        let total = 0;
        __t(3, () => ({ "sum": () => sum, "list": () => list, "total": () => total }));
        for (const n of list) {
            __t(3, () => ({ ..., "n": () => n }));
            total += n;
        }
        __t(4, () => ({ "sum": () => sum, "list": () => list, "total": () => total }));
        return total;
    }
    finally {
        __exit();
    }
}

The scope is tracked while walking the tree: each block adds the names it declares (including destructured ones, functions and classes), each function adds its parameters, and each for adds its loop variables. TypeScript is instrumented first and transpiled after, so the types come off but the line numbers, which are plain number literals by then, stay.

Why getters, not values

The trace call runs before let total = 0. Reading total at that moment throws, because the variable is in its temporal dead zone. If the rewrite built an object of values, the reader's program would crash on the first line. Instead each name is behind a getter, and the worker calls each getter inside its own try. A throw is recorded as tdz, and the panel leaves those out. Functions are dropped as well, so the panel shows data, not every helper in scope.

Capturing in the worker

When the runner is asked to trace, it turns tracing on in the JavaScript worker before the reader's code starts. Each __t call converts every variable with toView from lib/debug/view.ts and pushes a step: the line, the function on top of the call stack (or "(top level)"), the stack depth and the variables. After 2,000 steps it stops recording and sets a truncated flag, but lets the program finish. While tracing, every console line is tagged with the number of steps recorded so far instead of a line number, which lets the panel show exactly what had been printed by any step. A debug run has an eight-second limit.

Tracing Python

Python needs no rewriting, because it has a tracing hook built in. At load, the Pyodide worker runs a prelude that defines _gw_trace_run. It compiles the reader's code with the file name <exec> and installs a function with sys.settrace. The tracer ignores any frame from another file, so Python's own library code is never recorded. On each line event it records the frame's line number, the function name, the depth (counting only the reader's frames), and the frame's locals, leaving out names that start with an underscore and anything callable. A Python version of toView converts each value into the same shapes as the JavaScript one. When the run ends, even with an error, the steps are dumped to JSON and read back by the worker. The same 2,000-step cap applies, and the run keeps Python's 20-second limit.

Turning values into pictures

toView turns a value into one of 15 shapes, and DebugPanel.tsx has a renderer for each. Detection is by structure, not by class name, so a reader's own ListNode class, a plain object literal and a Python class with the same fields all get the same picture.

The value looks likeIt is drawn asLimit
An object with a next fieldA linked list: node values joined by arrows, ending in null, "…" or "back to the start" for a cycle30 nodes
An object with left or rightA binary tree of nested nodes, each shared node drawn once7 levels
An array of arrays of primitivesA grid with numbered rows40 per row
Any other array or tupleBoxes with their indices underneath, then "+n" for the rest40 items
A SetA tagged row of boxes without indices40 items
A Map or a dictA two-column table40 entries
Any other objectIts class name and a table of its fields20 fields
A string, number, boolean, null, undefined or bigintThe value, coloured by type60 characters

A node's value is read from the first of val, value, data or key that exists. Nesting stops after three levels with "…", which keeps a deep structure from producing an enormous step.

The panel

The panel shows a counter such as "step 7 / 42", with a plus sign when the trace was truncated, and under it the current line, the function it is in and, past the first level, how many calls deep it is. It has first, previous, next and last buttons, a range slider over every step, and the left and right arrow keys. Each variable is compared with the same variable in the previous step, and one that changed is marked, so the eye goes straight to what the last statement did. Under the variables, "Printed so far" lists only the output tagged at or before the current step.

Mapping a step back to the editor

Every step already carries the reader's own line number. In JavaScript it was written into the code as a literal before anything ran, and in Python the tracer reads it from the frame of the reader's own file. No source map is needed. That is simpler than an ordinary run, where the worker has to work out a line by subtracting stack positions.

When the step changes, the panel calls onLine. The workspace passes it to the editor's showDebugLine, which dispatches a setDebugLine effect. A state field in lib/editor/inline.ts turns it into a line decoration with the class cm-debug-line, and the same transaction scrolls that line to the middle of the view. Typing anything clears the highlight, because a highlighted line in edited code would point at the wrong statement. Leaving the Debug tab clears it too. The console lines of a debug run have their line numbers removed, so inline results do not compete with the highlight.

Tested without a browser

tests/debug-trace.test.ts has six cases that run instrumented code in Node with a stub __t. The first checks that a three-pass loop records lines 1, 2, 3, 3, 3 and 5 and still prints 6, which is the promise that matters most: tracing must never change what the program does.