Docs

Debugger — Delivery Notes

Companion to ADR-0025. What was measured, what was built, and what is left.

Why not adopt something

The Pulsar registry has 22 packages matching “debugger”. None is viable:

PackageState
atom-ide-debugger (the UI shell consuming debugger.provider)v0.0.3, 9 stars, 26 open issues, last push Oct 2023, self-described WIP
atom-ide-debugger-node / -python / -native-gdb / -react-nativeArchived facebook-atom repos; depend on nuclide-rpc-services, a runtime that no longer exists
node-debugger (highest-starred, 267)Archived Sept 2020; speaks Node’s pre-inspector protocol, which Deno cannot serve
The restGDB / Python / Swift / Android wrappers

VS Code was checked as a reference implementation rather than a source of code: it does not vendor vscode-js-debug (product.json downloads it as a prebuilt artifact) and ships nothing Deno-related. Its debug model was worth copying, and was.

The measurement that shaped the design

Deno’s transpile re-prints from the AST rather than stripping types in place. Decoding every inline source map in ~/.tranquil/deno-cache/gen/file/:

21.7% of 8155 mapping segments land on the same line.  Worst drift: 127 lines
  tabs.ts.js  1% aligned / 127   cdp.ts.js  2% / 60   stage.ts.js  6% / 25   mod.ts.js  9% / 26

A deleted blank line is enough to break 1:1 mapping. This is why a VLQ source-map decoder is mandatory rather than a refinement, and why the breakpoint UI has to be able to move a dot.

Spike: the protocol path, proven before it was built

A throwaway harness drove a real deno run --inspect-brk child through a bootstrap mirroring the runner’s (static mod.ts import, dynamic import(TRANQUIL_ENTRY), explicit exit):

QuestionResult
scriptParsed.url for the dynamically imported moduleExactly file:///…/user.ts — no blob, no deno:, no .js rewrite; sourceMapURL present
Discovery/json/list works. --quiet suppresses the “Debugger listening” banner entirely — stderr scraping would have failed outright
Where --inspect-brk pausesIn mod.ts, the deepest static dependency (module evaluation is bottom-up) — not the bootstrap
Instrumentation breakpointbeforeScriptWithSourceMapExecution fires for the dynamically imported module
Line mappingTS 15 → generated 8; TS 24 → generated 16. Naive 1:1 wrong for both
TeardownThe child does not exit while the inspector is attached, even after its own Deno.exit(0); it exits 0 the instant the socket closes

The last one changed the design: every session must close its socket on completion, not just cancelled ones, or each debug run strands a suspended orphan.

The spike also demonstrated the intent-vs-resolved model by accident — the target line was a blank line, the DevTools heuristic slid the breakpoint to the next executable statement, and the round-trip came back one line lower. Exactly the case where the marker must visibly move.

As built

New package tranquil-debug (own repo, symlinked into ~/.tranquil/dev/packages/):

FileWhat it does
breakpoints.jsStore, persistence to ~/.tranquil/debug/breakpoints.json, and the gutter column. Implements the intent-vs-resolved model
source-map.jsVLQ decoder; originalToGenerated (DevTools’ “lowest generated position at or after the requested line” heuristic) and generatedToOriginal
cdp-client.jsRenderer CDP client over the global WebSocket; /json/list discovery over Node’s http
debug-session.jsAttach, instrumentation dance, breakpoint binding, stepping, evaluation, teardown, attach watchdog
variables-tree.jsLazy expansion and VS Code’s chunking (buckets of 100, multiplied until the collection fits)
debug-panel.jsLeft-dock pane: call stack, variables, watch, console
debug-controls.jsDebug button, plus a second pane-controls registration for the transport row while a session is live
current-line.jsThe execution pointer, deliberately separate from the breakpoint decoration

Changes in tranquil-automations: run() takes debug: true (free port, --inspect-brk, no wall-clock timeout, mode: "debug", did-start-inspector); a paused run state; cancel() gains a non-graceful path; pane-controls exports refresh; a new automation-runner service.

Gutter UX: a dedicated column left of the line numbers. VS Code toggles on a single click of its glyph margin and has no double-click gesture at all; here both single and double click toggle (a double click arrives as two mousedowns, so the second is swallowed — otherwise it would undo the first). shift-click enables/disables, F9 from the keyboard. Editor text double-click is untouched, since that is word-select.

Dots are drawn in CSS, not codicons — a font glyph would mean hard-coding codicon private-use codepoints, which break silently when the icon font updates.

Gutter landmines

Getting a breakpoint dot on screen took four attempts. Each fix left the symptom identical — nothing visible — so they are worth recording exactly.

  1. A custom type: 'line-number' gutter never reaches the DOM in this build. It appears in editor.getGutters() and in the component’s guttersToRender, and addGutter even calls scheduleComponentUpdate(), yet no node is produced. Verified against a live editor by adding it, toggling hide()/show(), destroying and re-adding, and forcing component.updateSync(). Use a decorated gutter (the default type). The temptation is real — core wires onMouseDown/onMouseMove only for line-number gutters — but free mouse events are worthless if the gutter never renders.
  2. A decorated gutter ignores the class: option. Core renders it as a bare <div class="gutter" gutter-name="…">. Style it via the attribute selector .gutter[gutter-name="…"]; a class selector matches nothing, silently.
  3. Decoration.setProperties() replaces, it does not merge. Passing only {class} drops the type and gutterName that gutter.decorateMarker() set up, and the decoration quietly stops belonging to the gutter. Always repeat type and gutterName. (Core’s own doc example passes type back in — easy to read past.)
  4. A type: 'line-number' gutter with labelFn: () => "" yields zero-height rows. Core does if (number) appendChild(textNode(number)), so an empty string appends nothing and an empty block div collapses regardless of line-height. Moot once you use a decorated gutter, but it is what makes attempt 1 look almost right.

Mouse handling on a decorated gutter is the package’s own: listeners on the gutter element, with clientY mapped through screenPositionForPixelPosition so folds, soft wrap and block decorations stay correct. Note core’s line-number handlers hand you parseInt(event.target.dataset.bufferRow), which is NaN whenever the pointer is on the gutter but not on a row — and every comparison against NaN is false, so it must be rejected by type.

Guarded by

debugger-breakpoints.ts in the smoke suite, against fixtures/debug/breakpoint-drift.ts — a file built from the exact constructs that move lines (blank lines, a multi-line object literal, a fluent chain). It asserts the pause maps back to the requested TypeScript row, that a breakpoint with no source-map mapping anywhere near it stays unbound, that stopping a paused session leaves no suspended child, and that session data is cleared on teardown. The fixture declares no permissions, so consent short-circuits and no dialog can block the run.

The “stays unbound” case is narrower than it first looks — a breakpoint inside a real function that is simply never called verifies successfully (V8 compiles it when the script loads regardless), and so does a type-only line or the file’s own trailing comment, since both the source-map decoder and V8 itself snap forward to the nearest mapping rather than refusing an inexact request. Only a request with no mapping anywhere near it stays genuinely unbound — the smoke suite’s assertion was corrected to test exactly that after shipping wrong the first time; see Markdown Preview Toggle & Tree-View Controls — Delivery Notes for how that surfaced.

Landmines beyond the gutter

Found the hard way while driving real debug runs; every one produced a symptom that pointed somewhere else.

A Deno child will not exit while an inspector is attached. This is the root of two separate bugs. It strands the process after the script finishes, so RunManager never sees an exit, the run sits at “running” forever and its debug controls never disappear — and there is no CDP event for “the user’s top level finished”. The bootstrap now ends with a debugger sentinel (see the ADR). The same fact means every session must close its socket on completion, not just cancelled ones.

V8 ignores debugger statements inside blackboxed scripts. Blackboxing the runtime directory to keep step-into out of mod.ts silently disabled the completion sentinel in main.ts. The pattern needs a negative lookahead: /tranquil-automations/deno/(?!main\.ts).

Host-side timers keep running while the debuggee is frozen. The RPC bridge refuses a socket with code 4001 if AUTH does not arrive within 3 s of connect — so pausing on a breakpoint shortly after the bridge opened killed the bridge for a script that was merely stopped. Same class of problem for the 60 s token TTL and the guest’s 30 s CDP call timeout. All three widen for debug runs.

tranquil-rpc is bundled. The app loads the committed dist/host.js; editing lib/ alone changes nothing, and the symptom is “the fix did not work”. yarn build there needs Node 20 — on Node 16 corepack fails with URL.canParse is not a function.

A paused run state has to be handled everywhere running is. Adding it without teaching RunManager’s restore path about it left a reloaded window showing a paused run whose Cancel button could never do anything, because its child had died with the window.

Adding a state to the run record is not enough on its own. cancel() silently returned false when there was no live child, so any stuck row had a dead button. It now resolves the record directly.

Follow-ups

  • Post-top-level breakpoints. The bootstrap calls Deno.exit(0) as soon as the dynamic import resolves, so a breakpoint in a setTimeout or event callback is unreachable. Pre-existing runner behaviour, but a debugger makes it look like a debugger bug — worth detecting “armed but never hit” and saying so explicitly.
  • Conditional breakpoints and logpoints. Debugger.setBreakpoint already takes a condition; the UI does not expose one yet.
  • Debugging inside tab.evaluate(). Setting a breakpoint there is refused with a pointer to debugger; plus the page’s DevTools. Doing it properly means attaching a second CDP session to the page target; the hard part is that the callback arrives in the page as an anonymous serialized function with no source map back to the .ts, so line mapping is a real problem. A tractable first slice would be surfacing page-side debugger pauses in the debug pane.
  • Watch expressions re-evaluate on every stop but are not persisted across sessions.
  • Multiple sessions. Deliberately limited to one at a time; the transport controls assume it.