v0.8: step-over (next/trace), symbol demangling, disasm len/is_call, ergonomics

Driven by the friction the v0.7 smarm session hit: single-stepping the
context-switch shim dove into every helper call, and every step needed
`nm | grep` for a mangled name whose hash changes each rebuild. This round
makes tracing the shim pleasant.

Rust (kept minimal — only what JS genuinely can't do):
- symbols.rs: demangle Rust symbols. addr("name") now resolves by exact
  mangled/case-insensitive (unchanged), then by full demangled path
  (smarm::context::switch_to_scheduler), then by *unique* final path
  component (switch_to_scheduler). Ambiguous short names resolve to None and
  bp.set reports "matches N symbols" rather than guessing. sym()/name_for_addr
  return demangled names so traces are readable. Adds rustc-demangle dep.
- disasm.rs: Decoded gains `len` (instruction byte length) and `is_call`
  (iced FlowControl::Call, incl. indirect). js.rs exposes both on disasm
  records so step-over finds the return address without re-parsing text.

Prelude (everything else, like until/finish):
- next(n): step OVER calls — one-shot bp at pc+len, cont, cleanup; else
  single-step. A stepped-over call fires the `step` listener once. Detects
  calls that don't return to pc+len (stack-switching shims, longjmp): on a
  non-matching stop it dispatches the real event and stops instead of hanging.
- trace(from, to, {over, range, capture, print, max}): run-to-A then
  step(-over)-to-B with per-step capture; `range:[lo,hi]` stops when pc leaves
  a range. Drives the native steppers directly (no listener coupling).
- watch(specs): a `step` listener printing named expressions each step.
- fmt(): BigInt-safe stringify. state.insn: disasm record at pc.
  stackContains/stackDistance/region: stack-region legibility for cross-stack
  handoffs. step(n, {summary}): first/last/count for large N.

Docs: README status row + prelude reference + sections on the next() caveat,
the conditional-breakpoint listener idiom, and BigInt ergonomics.
examples/smarm_ctx_switch_next.js traces a full switch_to_scheduler in 16
readable lines using trace({over:true}).

No-listener fast paths and the v0.7 example are unaffected (verified).
This commit is contained in:
llmdbg-dev
2026-05-28 06:20:43 +00:00
parent ce96a98829
commit f0698573cd
8 changed files with 569 additions and 18 deletions
+85 -7
View File
@@ -1,9 +1,10 @@
# llmdbg
A scriptable, stateful debugger REPL with JavaScript as the interface language.
Built from the spec in [`SPEC.md`](./SPEC.md). This repo holds the v0.1v0.6
Built from the spec in [`SPEC.md`](./SPEC.md). This repo holds the v0.1v0.8
implementation: enough to actually drive a debugged process from an agent
harness with breakpoints, disassembly, and symbol-aware addresses.
harness with breakpoints, disassembly, symbol-aware (demangled) addresses,
event listeners, and step-over.
The premise: existing debuggers optimise for humans at a terminal. An LLM
agent is a different user — it wants terse output it can parse, lazy state
@@ -21,6 +22,7 @@ each step" is the natural interface. The full motivation is in `SPEC.md`.
| v0.5 | x86-64 disassembly via `iced-x86`; `read_mem_pristine()` overlays original bytes through active INT3s; branch targets get symbolised |
| v0.6 | `state.stack`; `until(loc)`; `finish()`; `show.{disasm,bp,regs}` prelude helpers |
| v0.7 | Event-listener system: `addEventListener('step'\|'breakpoint'\|'signal'\|'exit', ...)`, `removeEventListener`, `clearListeners`, `listeners()`; `step`/`cont` fire listeners per stop with `{state, prev, stop_reason, ...}` events; native steppers moved to `__step_native`/`__cont_native` |
| v0.8 | Step-over: `next(n)` and `trace(from, to, {over})`. Rust: symbol **demangling** (`addr("switch_to_scheduler")` resolves by demangled path or unique last component; `sym()` returns demangled names), disasm records gain `.len` and `.is_call`. Prelude: `state.insn`, `stackContains`/`stackDistance`/`region`, `watch(specs)`, `fmt()` (BigInt-safe), `step(n, {summary})` |
Each `vN` corresponds to one git commit. See `git log --oneline`.
@@ -34,13 +36,16 @@ The spec calls for these; they're deliberately deferred:
- Source-level debugging via DWARF (`gimli`).
- Multi-threaded targets; shared-library symbol resolution.
- Browser visualisation / DOM API.
- Conditional breakpoints. (Now trivial to express as a guard inside a
`breakpoint` listener, but native `--condition` support is still deferred.)
- The `--summary` mode for batch stepping.
- Conditional breakpoints. (Expressible as a guard inside a `breakpoint`
listener — see the idiom below; native `--condition` support is still
deferred.)
- Reverse execution.
The event-listener system (`addEventListener('step', ...)` etc.) landed in
v0.7 — see the status table.
A basic `step(n, {summary})` batch-step mode landed in v0.8 (first/last/count
instead of N listener fires); the SPEC's fuller "interesting events" summary is
still open. Symbol **demangling** also landed in v0.8: `addr("name")` resolves
by demangled path or unique last component, and `sym()` returns demangled
names. The event-listener system landed in v0.7 — see the status table.
The complete "what not yet" list is in `SPEC.md`.
@@ -175,10 +180,27 @@ here(label?) // -> print addrstr(state.pc), optionally labelled
until(loc) // one-shot bp at addr-or-symbol, cont, cleanup
finish() // until([rbp+8]) — assumes frame pointer
next(n=1) // step OVER calls (gdb `next`): bp at ret addr, cont
trace(from,to,opts) // run to `from`, step (opts.over → over calls) to `to`,
// capturing per-step records. opts: {over, max, capture,
// print, range:[lo,hi]}. -> {steps, reason, trace:[...]}
watch(specs) // register a 'step' listener printing named exprs each
// step; specs = {label: e => value}. -> handler
fmt(v) // BigInt-safe stringify (never throws on nested BigInt)
state.insn // disasm record at pc: {addr,bytes,len,text,is_call,...}
stackContains(addr) // is addr within [usable_base, top)?
stackDistance(addr) // top - addr (how deep into the stack)
region(addr) // "stack" | "text:<sym>" | "0x.." — which mapping
show.disasm(n=8, [at])
show.bp()
show.regs([keys])
step(n, {summary:true}) // step n, print only first/last/count (no per-step
// listeners) — for large N where you just want
// the landing site
// Event listeners (v0.7). Handlers fire on each stop during step()/cont().
addEventListener(type, handler) // type: 'step'|'breakpoint'|'signal'|'exit'
removeEventListener(type, handler)
@@ -206,6 +228,62 @@ are caught and printed, so one bad listener won't abort a long step loop.
A worked example against smarm's context switch is in
`examples/smarm_ctx_switch.js`.
### Step-over (`next`) and `trace` (v0.8)
`next(n)` steps *over* calls: if the instruction at pc is a `call`, it sets a
one-shot breakpoint at the return address (`pc + insn.len`), `cont()`s to it,
and cleans up; otherwise it single-steps. A stepped-over call fires the `step`
listener exactly once, so it reads as one logical step.
**Caveat — calls that don't return to `pc+len`.** A `call` that swaps `rsp`
and `ret`s into a *different* stack (the context-switch shims themselves, or a
longjmp) never returns to the address right after the call. `next` does not
hang on this: if the `cont()` lands on anything other than its own return
breakpoint (a different bp, a signal, exit, or simply not coming back), it
cleans up, dispatches the real event, and stops, returning that reason. The
rule of thumb: use plain `step()` to walk *through* a switch shim instruction
by instruction; use `next()` to skip the ordinary helper calls (TLS accessors
etc.) that `step()` would dive into. `examples/smarm_ctx_switch_next.js` shows
both, tracing a full `switch_to_scheduler` in 16 readable lines.
`trace(from, to, {over, range, capture, print, max})` collapses the common
"run to A, step (over calls) to B, log each step" pattern into one call. With
`range: [lo, hi]` it stops when pc leaves a half-open address range — handy for
"trace until we leave this shim" without knowing the exact exit address. It
drives execution with the native steppers directly, so it neither fires nor
depends on user listeners.
### Conditional breakpoints (idiom, no new API)
A `breakpoint` listener is the guard. Inspect `e.state` and decide whether to
act; to auto-resume until a condition holds, loop `cont()`:
```js
const myBp = bp.set("worker_loop");
function runUntilRdi(want) {
while (true) {
const r = cont();
if (!r.startsWith("breakpoint:")) return r; // signal/exit — stop
if (state.registers.rdi === want) return r; // condition met
// else: fall through and cont() again
}
}
runUntilRdi(0x10n);
```
### BigInt ergonomics
All 64-bit values (`state.pc`, `addr(...)`, anything address-sized) are
**BigInt**. Two things bite:
- `JSON.stringify` throws on a raw BigInt. Use `fmt()` / `repr()` / `dump()`,
which route BigInt through a `"0x.."` replacer, instead of `JSON.stringify`
on any object that might hold addresses.
- Mixing BigInt with Number throws (`pc + 8` is an error if `pc` is BigInt).
Suffix literals with `n` (`pc + 8n`) or wrap with `BigInt(...)`. The helpers
that take addresses (`stackContains`, `stackDistance`, `region`, `trace`'s
`range`) coerce via `BigInt(...)` for you, so a Number offset is fine there.
## Architecture
```