· 6 min read ·

WebAssembly's Determinism Is the Feature Time Travel Debugging Has Been Waiting For

Source: lobsters

Record-and-replay debugging has existed at the native level for years. Mozilla’s rr has been letting Linux developers step backward through C and C++ programs since 2014. Microsoft’s Time Travel Debugging landed in WinDbg Preview in 2017. Undo’s UDB has been a commercial offering for embedded and server workloads for even longer. The hard part of all of these tools is the same: native execution is not deterministic. System calls, signals, thread scheduling, RDTSC, hardware performance counters — each of these can produce different values on every run, so a replay must intercept and record each one to reproduce execution faithfully.

WebAssembly starts from a different position entirely, and that changes the engineering problem in meaningful ways. The gabagool debug adapter is a time travel debugger built on top of a custom Wasm interpreter, and it reaches for record-and-replay because the runtime’s specification practically hands it to you.

Why Wasm is naturally suited to this

The WebAssembly specification defines execution in terms of a purely functional reduction relation over a configuration consisting of the store, the stack, and the current instruction. There are no unspecified behaviors, no undefined behaviors, no data races in the core spec, and no exposure to wall-clock time or OS entropy unless the host explicitly imports them. A conforming Wasm module given the same initial store and the same imported function results will produce the same sequence of instructions on every run.

This is a stronger guarantee than anything a native debugger gets to assume. rr handles it by recording every system call result and every piece of non-deterministic hardware state, then replaying those recordings in a deterministic virtual machine. The overhead is real: rr typically adds 1.2x to 2x execution time during recording, and the replay infrastructure is complex enough that it only runs on Linux x86-64. For WebAssembly, the situation is simpler. If your module does not import any non-deterministic host functions, the execution trace is fully reproducible from just the initial memory snapshot and the sequence of instructions executed. For modules that do import host functions like wasi_snapshot_preview1::clock_time_get or custom JavaScript imports, you need to record those return values, but the scope is narrow and well-defined.

The practical consequence is that building a time travel debugger on top of an interpreter is significantly more tractable for Wasm than for native code. You do not need kernel-level record/replay infrastructure. You do not need to interpose on syscalls. You need an interpreter that snapshots state periodically and can restore to any checkpoint, then step forward from there.

The Debug Adapter Protocol as the right abstraction layer

The debug adapter component of gabagool implements Microsoft’s Debug Adapter Protocol, which is the interface VS Code and many other editors use to communicate with debugger backends. DAP is a JSON-RPC protocol over stdin/stdout or a socket; it defines requests like launch, setBreakpoints, stackTrace, variables, next, stepIn, and stepBack. The last one is the important one here.

DAP has included a stepBack request and a reverseContinue request since very early in its design, but most debug adapters simply return false for the supportsStepBack capability and leave it there. Implementing those capabilities requires the underlying debugger to actually support reverse execution, which native adapters rarely do without rr or similar infrastructure underneath them. A Wasm interpreter with snapshotting can support stepBack natively because it controls the entire execution environment.

This matters for editor integration. By exposing supportsStepBack: true in the capabilities response, a gabagool debug adapter session lets VS Code (or any DAP-compliant client) enable the reverse step buttons in its debug toolbar. The user gets the same UI they already know, and the adapter handles the checkpoint-and-restore logic behind the scenes. This is a clean example of the right way to build developer tooling: implement a standard protocol so the investment in the frontend already made by the IDE ecosystem applies immediately.

How checkpoint-based replay typically works

The standard approach for interpreter-level time travel is to take full snapshots of the VM state at regular intervals, then on a reverse step, restore the most recent snapshot before the target instruction and replay forward to one step before where you want to be. The tradeoff is snapshot frequency against reverse-step latency. Snapshots every 1000 instructions means a reverse step might replay up to 999 instructions to land correctly; snapshots every 100 instructions cuts that to 99 but costs 10x the snapshot storage.

For Wasm, a full VM state snapshot consists of the linear memory (up to 4 GiB in the current spec, typically much less in practice), the value stack, the call stack, the table contents, and the global variables. Linear memory dominates. A real-world Wasm module compiled from C or Rust might use tens of megabytes of heap, which makes storing a snapshot every hundred instructions impractical. Production implementations typically use copy-on-write page tracking to record only the pages that changed since the last checkpoint, which brings snapshot size down from the total memory footprint to just the working set of each execution window.

This is conceptually similar to how Mozilla’s Pernosco works on top of rr recordings: it keeps a sparse set of checkpoints and uses the rr trace to replay between them efficiently. The difference is that rr operates at the syscall boundary on real hardware, while an interpreter-based Wasm debugger operates at the instruction boundary in software, which gives much finer granularity at the cost of slower baseline execution.

DWARF and the source-level debugging problem

Time travel is only half the problem. The other half is mapping Wasm instructions back to source lines. The WebAssembly binary format supports a DWARF debug info section, and modern compilers emit it: Clang/LLVM has supported --target=wasm32-unknown-unknown with full DWARF output for several years, and wasm-pack propagates debug symbols from Rust through wasm-bindgen. The LLDB project added Wasm DWARF support, and Chrome DevTools uses DWARF via WASM DWARF debugging extension when available.

For a standalone debug adapter, consuming DWARF means either linking against LLVM’s debug info libraries or implementing a reader independently. The gimli crate in Rust is the standard choice for the latter: it is a zero-copy DWARF reader that handles the full DWARF 4 and 5 specs and is used by both the addr2line crate and the Rust standard library’s own panic backtraces. A Wasm debugger written in Rust has a clean path to source-level variable inspection through gimli plus whatever Wasm binary parsing library it uses.

Source maps are a separate consideration for modules compiled from languages that do not emit DWARF, particularly AssemblyScript and tools targeting browsers. The DWARF-to-source-map conversion that Chrome DevTools performs is a one-way transformation for display purposes; a standalone DAP adapter working with raw Wasm binaries is better served by requiring DWARF directly.

Positioning against the existing ecosystem

The existing Wasm debugging landscape is mostly oriented toward browsers. Chrome DevTools and Firefox’s developer tools both have Wasm debugging support, and they work well for modules running in a browser context. wasmtime has a GDB/LLDB interface via its debug info emission, and WABT includes wasm-interp with basic stepping. None of these have built-in time travel.

For server-side and edge Wasm, where modules run in runtimes like Wasmtime, Wasmer, or WasmEdge, the debugging story is weaker. GDB remote protocol support exists for some runtimes but requires platform-specific setup. A self-contained debug adapter that bundles its own interpreter, handles DWARF, implements DAP including reverse execution, and works without a browser or a specific Wasm runtime is filling a real gap.

Gabagool’s approach of building the interpreter and the debug adapter together, rather than wrapping an existing runtime, gives it the freedom to instrument execution at whatever granularity the time travel implementation needs. That architectural choice has a cost in compatibility — you are not debugging code running in the same Wasmtime or V8 instance that your production system uses — but for the use case of understanding a subtle algorithmic bug or a memory corruption in a Wasm module, the ability to reverse-step through execution from the exact instruction where things went wrong is worth the tradeoff.

The deterministic execution model that the Wasm spec enforces is not incidental to this. It is what makes the whole approach clean. WebAssembly was designed to be a portable compilation target with verifiable safety properties, and it turns out those same constraints that make it safe to run untrusted code also make it tractable to debug in ways that native code never quite allows.

Was this interesting?