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:
@@ -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.1–v0.6
|
||||
Built from the spec in [`SPEC.md`](./SPEC.md). This repo holds the v0.1–v0.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
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user