Reversible Debugging

Your program has crashed. The stack trace tells you where it died — but the corpse is rarely the crime scene. The variable that is wrong at the crash was corrupted a thousand, or a billion, instructions earlier, by a line of code that ran perfectly happily and moved on. You know this dance from debugging logic errors: the symptom is in front of you; the cause is somewhere in the past.

Every conventional debugger can only drive forwards. So the standard workflow is a guessing game: guess where the bug might be, set a breakpoint before it, rerun the whole program, discover you guessed too late (the damage is already done) or too early (nothing has happened yet), adjust, rerun, adjust, rerun… Now imagine the debugger had a reverse gear. Run the program once, to the crash. Then simply walk backwards from the corpse, along the actual execution, until you watch the corruption happen — in reverse. This is reversible debugging (also sold as time-travel debugging), and it is the killer app of everything this module has built.

The workflow, inverted

The difference is not one of convenience but of direction of reasoning:

Conventional debuggingReversible debugging
Starting pointa guess about the causethe failure itself
Directionforwards, hoping to arrive just before the bugbackwards, along the causal chain
Runs neededmany (one per guess)one
Works on flaky bugs?poorly — the bug may not reappear on rerunyes — you interrogate the run that actually failed

The reverse gear comes as a small family of commands, mirroring the forward ones:

Build one in thirty lines

The essence of a time-travel debugger fits in a code box. Take a tiny buggy program, journal every write during one forward run, and then answer the "who touched this?" question by scanning the journal backwards:

type Mem = { [name: string]: number }; type Line = { no: number; src: string; run: (m: Mem) => void }; // A little program with a logic bug on line 5 (the fee is charged twice). const program: Line[] = [ { no: 1, src: "balance = 100", run: (m) => { m.balance = 100; } }, { no: 2, src: "bonus = 20", run: (m) => { m.bonus = 20; } }, { no: 3, src: "balance += bonus", run: (m) => { m.balance += m.bonus; } }, { no: 4, src: "fee = 15", run: (m) => { m.fee = 15; } }, { no: 5, src: "balance -= fee * 2", run: (m) => { m.balance -= m.fee * 2; } }, // BUG { no: 6, src: "audit(balance)", run: (m) => { /* expects 105, sees less */ } }, ]; type Write = { step: number; line: number; name: string; before: number; after: number }; const journal: Write[] = []; // RECORD: one forward run, journalling every changed variable. const mem: Mem = {}; program.forEach(function (line, step) { const before: Mem = { ...mem }; line.run(mem); for (const name in mem) { if (mem[name] !== before[name]) { journal.push({ step, line: line.no, name, before: before[name] === undefined ? 0 : before[name], after: mem[name] }); } } }); console.log("crash state: " + JSON.stringify(mem) + " (audit expected balance = 105)"); // watch balance; reverse-continue == scan the journal backwards for the last write function whoLastWrote(name: string): Write { for (let i = journal.length - 1; i >= 0; i--) { if (journal[i].name === name) return journal[i]; } throw new Error(name + " was never written"); } const culprit = whoLastWrote("balance"); console.log("watch balance; reverse-continue"); console.log(" -> stopped at line " + culprit.line + ": \"" + program[culprit.step].src + "\" (balance " + culprit.before + " -> " + culprit.after + ")"); // reverse-step twice from the end: undo writes one at a time. console.log("reverse-step x2:"); const back: Mem = { ...mem }; for (const w of journal.slice(-2).reverse()) { back[w.name] = w.before; console.log(" undo line " + w.line + " state: " + JSON.stringify(back)); }

Line 5 is fingered immediately — no guessing, no rerunning. Notice how the toy works: it keeps a journal of destroyed values (each write's before), which is exactly the "log the forgetting" strategy — memory spent to fake reversibility on an irreversible machine.

Where does the reverse gear come from?

There are only two places it can come from, and this module has met them both:

And here is the pleasing theoretical punchline: option (b) is not a hack that betrays the theory — it is the theory. A debugger that journals destroyed information is precisely Bennett's history construction from the reversible-Turing-machine proof, running in production: augment an irreversible computation with a history tape, and the augmented computation becomes reversible. Every time-travel debugger is a history-keeping reversible simulation of an irreversible program — the compute–record–rewind idea, wearing a toolbelt.

This sounds like a fortune cookie, but it is a precise statement about causality. A failure is an observation; its cause must lie in the observation's past light-cone — the chain of reads and writes that actually fed into the bad value. Forward debugging tries to predict where that chain begins, which is why it needs luck. Backward debugging follows the chain: from the bad value, to the instruction that wrote it, to the bad operands of that instruction, to the instructions that wrote those — a finite regress that provably terminates at the root cause, because the run itself is finite. Each watchpoint-plus-reverse-continue hop is one link of the chain. Debugging stops being detective fiction ("who might have done it?") and becomes archaeology ("dig down one layer; the evidence is all there").

A tempting mental model: "the debugger executes the program's statements in reverse order". Wrong — and dangerously wrong for a working engineer. Statements in reverse order would be a different program (and mostly nonsense). What reverse-step restores is the machine's previous state along the original forward execution — same path, same branches, same loop iterations, traversed backwards. A reversible debugger never explores executions that didn't happen; it revisits the one that did. That is its superpower against flaky, once-in-a-blue-moon bugs: the recorded run is the failing run, so the bug cannot fail to be there when you go looking.