Expand description
Recording a run so that a page can scrub through it.
cove_runtime::Debugger is asked before every instruction, and
cove debug answers that question by blocking on stdin inside the
callback: the machine calls the debugger and never the other way round,
because the dispatch loop holds a std::thread::scope borrow that cannot
leave the call that made it. crates/cove-runtime/src/vm/debug.rs argues
that at length, and nothing here changes it.
§Why this records instead of stepping
A Web Worker cannot block waiting for a message from the page. There is
no synchronous receive(); onmessage is delivered by the event loop,
and an event loop that is inside a wasm call is an event loop that is not
running. So the shape cove debug uses — stop the machine, ask a person,
resume — has no browser spelling. The one construction that would give it
one is Atomics.wait on a SharedArrayBuffer, and a SharedArrayBuffer
is only constructible on a cross-origin-isolated page, which needs the
server to send Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp. GitHub Pages sends neither
and cannot be made to.
So the direction is inverted a second time. This Debugger does not
ask anything: it writes down what it saw, the run goes to completion (or
to its fuel, or to its deadline), the worker hands the whole recording to
the page in one message, and the page scrubs through it. Nothing blocks,
nothing is shared, and the timeline runs backwards as readily as forwards
— which for reading a program is better than stepping, because “what did
n hold two lines ago” is a question live stepping answers only by
starting again.
This is not a refusal of live stepping forever. It is what is possible
without COOP/COEP. An embedder that serves those two headers could keep
this file and add a second Debugger that waits on a SharedArrayBuffer,
and the machine side would not change at all.
§What a moment holds
One captured stop — a moment — is what the four panes need and nothing else:
- Source: the 1-based line and column the instruction was written at,
and the instruction’s span as a pair of UTF-16 offsets. The page marks
that span in the editor itself rather than in a second copy of the text,
so it needs an end and not only a start, and it needs both counted the
way a JavaScript string is indexed.
crates/cove-wasm/src/highlight.rsargues UTF-16 at length; the same argument holds here, and an em dash in a comment above the marked line is enough to make it matter. - Instructions: an index into a shared table of disassembled
functions, and the pc inside it. The table is keyed by whose code a
frame runs —
Call::within— because that is what a disassembly is of. - Runtime: the backtrace, innermost first — each frame’s function,
pc, line and every local in scope there with its rendered value, plus
the
bodyit is.bodyis whose body the frame is and the index above is whose code it runs in; the two differ for a small leaflower::inlineexpanded into its caller, where naming only the second would show one function twice in a backtrace and one function’s listing under the other’s name. - Memory: every heap object named by a
refword of one of those locals, rendered with its fields, and on each local the addresses of the words that named them.
Plus the bookkeeping a timeline needs: the instruction count, the task, the frame depth, and why this instruction was captured.
The disassembly is in a table beside the moments rather than inside each
one. A recording of a loop is hundreds of moments in one function, and
repeating that function’s instructions in each of them was measured to be
most of the answer. Interning is where this format’s compression comes
from; see crate::debug_json for why the answer is still one blob.
§What is captured, and what is bounded
The policy is cove debug’s line-change rule, widened by one clause.
A stop is captured when it is the first, when the frame depth differs
from the last captured moment’s — a call or a return — when the task
differs, or when the instruction was written outside the byte range of
the last captured moment’s source line. The byte range is compared rather
than the line number for the reason Session::line_mode gives: a range
check is two comparisons and a line number is a binary search, and this
runs at every instruction.
The depth clause is the widening, and it is there because
Session::misses names its absence as a defect: a callee whose body is
written on the line that calls it is stepped over rather than into,
because the line did not change. A recording that skipped a whole call
would give the Runtime pane a backtrace that jumped. Everything else in
that list still applies here, unchanged — a loop written on one line is
one moment per turn only because the depth or the callee changes, a
statement written across several lines produces several moments in
evaluation order so the line number can go backwards, and a moment is at
the first instruction carrying a new line, which is inside the expression
rather than at the statement’s start, so a name assigned on that line
still shows its old value.
Three bounds, and each loses something nameable.
MOMENTSmoments, the first N rather than the last. Past it the recorder stops capturing and the run keeps going, so the outcome, the output and the answer are still the real ones — the recording is a prefix of the timeline and says so withtruncated. First and not last because a ring would give a timeline that does not begin at the entry, and because only a prefix lets the recorder go quiet: once full it answers from anAtomicBoolwithout taking its lock, which is what lets a long run reach its own end rather than its deadline. What is lost is the end of a long run. The number of moments that were dropped is deliberately not reported, because counting them means keeping the per-instruction check alive for the whole run, which is the cost this bound exists to stop paying.BYTESof rendered recording. Each moment is rendered to JSON as it is captured, so this bound is exact rather than estimated, and it is the one that holds when the moments are few and enormous — a deep stack of frames full of long strings. What is lost is the same end of the same timeline, andtruncatedsays which bound stopped it.FRAMESframes per moment andOBJECTSobjects per moment. Without these two the first bound would not bound memory at all: a thousand moments of a recursion a thousand deep is a million frames. A moment records its truedepth, so a pane can say how many frames it is not showing. What is lost is the outer end of a deep backtrace, and the heap past the thirty-second object a frame’s locals named.
A recording that silently truncated would be worse than one that did not
exist, so every one of these reports itself: truncated names the bound,
kept counts what is there, and depth exceeds frames.length exactly
when frames were dropped.
§What it costs the run
A mutex acquisition per instruction, as cove debug pays, plus a span
and depth comparison. What it does not pay is the rendering: a backtrace
renders every local of every frame, and that happens only at a captured
moment.
Measured under node against the release wasm, on a counting loop of fourteen million instructions:
cove_run | 119 ms |
cove_debug, 1024 moments | 186 ms |
cove_debug, 16384 moments | 326 ms |
Two things are in that table. A recorded run of a program that overran its bound early costs 1.6x a plain one — that is the quiet path, the relaxed load and the branch, for the fourteen million instructions after the recording filled. And the fifteen thousand extra captured moments cost 144 ms between the second row and the third, which is about 9 µs per moment: the rendering, and the price of asking for a longer recording rather than of being watched at all.
The third row also says the two bounds are calibrated against each other
rather than one of them being decoration. MOST_MOMENTS moments of
that loop render to 3.3 MB, just under BYTES; a program with deeper
frames or longer strings reaches the byte bound first, which is what it
is for.
Structs§
Constants§
- BYTES
- The most rendered recording a run may accumulate, in bytes.
- FRAMES
- Frames captured per moment, innermost first.
- MOMENTS
- How many moments a recording keeps by default.
- MOST_
MOMENTS - The most moments a caller may ask for.
- OBJECTS
- Heap objects captured per moment.