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:
| Package | State |
|---|---|
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-native | Archived 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 rest | GDB / 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):
| Question | Result |
|---|---|
scriptParsed.url for the dynamically imported module | Exactly 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 pauses | In mod.ts, the deepest static dependency (module evaluation is bottom-up) — not the bootstrap |
| Instrumentation breakpoint | beforeScriptWithSourceMapExecution fires for the dynamically imported module |
| Line mapping | TS 15 → generated 8; TS 24 → generated 16. Naive 1:1 wrong for both |
| Teardown | The 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/):
| File | What it does |
|---|---|
breakpoints.js | Store, persistence to ~/.tranquil/debug/breakpoints.json, and the gutter column. Implements the intent-vs-resolved model |
source-map.js | VLQ decoder; originalToGenerated (DevTools’ “lowest generated position at or after the requested line” heuristic) and generatedToOriginal |
cdp-client.js | Renderer CDP client over the global WebSocket; /json/list discovery over Node’s http |
debug-session.js | Attach, instrumentation dance, breakpoint binding, stepping, evaluation, teardown, attach watchdog |
variables-tree.js | Lazy expansion and VS Code’s chunking (buckets of 100, multiplied until the collection fits) |
debug-panel.js | Left-dock pane: call stack, variables, watch, console |
debug-controls.js | Debug button, plus a second pane-controls registration for the transport row while a session is live |
current-line.js | The 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.
- A custom
type: 'line-number'gutter never reaches the DOM in this build. It appears ineditor.getGutters()and in the component’sguttersToRender, andaddGuttereven callsscheduleComponentUpdate(), yet no node is produced. Verified against a live editor by adding it, togglinghide()/show(), destroying and re-adding, and forcingcomponent.updateSync(). Use a decorated gutter (the default type). The temptation is real — core wiresonMouseDown/onMouseMoveonly for line-number gutters — but free mouse events are worthless if the gutter never renders. - 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. Decoration.setProperties()replaces, it does not merge. Passing only{class}drops thetypeandgutterNamethatgutter.decorateMarker()set up, and the decoration quietly stops belonging to the gutter. Always repeattypeandgutterName. (Core’s own doc example passestypeback in — easy to read past.)- A
type: 'line-number'gutter withlabelFn: () => ""yields zero-height rows. Core doesif (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 asetTimeoutor 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.setBreakpointalready takes a condition; the UI does not expose one yet. - Debugging inside
tab.evaluate(). Setting a breakpoint there is refused with a pointer todebugger;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-sidedebuggerpauses 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.