I8 · Part 2 · How it runs
Stepping through a run
Record every statement once, then scrub through the recording as if it were live.
08On this page
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".
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 like | It is drawn as | Limit |
|---|---|---|
An object with a next field | A linked list: node values joined by arrows, ending in null, "…" or "back to the start" for a cycle | 30 nodes |
An object with left or right | A binary tree of nested nodes, each shared node drawn once | 7 levels |
| An array of arrays of primitives | A grid with numbered rows | 40 per row |
| Any other array or tuple | Boxes with their indices underneath, then "+n" for the rest | 40 items |
| A Set | A tagged row of boxes without indices | 40 items |
| A Map or a dict | A two-column table | 40 entries |
| Any other object | Its class name and a table of its fields | 20 fields |
| A string, number, boolean, null, undefined or bigint | The value, coloured by type | 60 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.
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.
Finished reading?
0 of 26 chapters read in this series