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).
348 lines
16 KiB
Markdown
348 lines
16 KiB
Markdown
# 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.8
|
||
implementation: enough to actually drive a debugged process from an agent
|
||
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
|
||
access, and a scripting layer rich enough that "describe what you want to see
|
||
each step" is the natural interface. The full motivation is in `SPEC.md`.
|
||
|
||
## Status
|
||
|
||
| Version | What landed |
|
||
|---|---|
|
||
| v0.1 | Spawn, step, continue, basic state (pc/sp/regs/memory), persistent `ctx`, `print()`, JS prelude loaded via `include_str!` |
|
||
| v0.2 | `prev` register snapshot before each resume; refactored register accessors |
|
||
| v0.3 | ELF symbol parsing of main exe; `sym(addr)` / `addr(name)` / `symbols()`; `addrstr` / `here` prelude helpers |
|
||
| v0.4 | INT3 software breakpoints with by-name/by-id ops; auto disarm-step-rearm; RIP rewind on hit |
|
||
| 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`.
|
||
|
||
## Not yet implemented
|
||
|
||
The spec calls for these; they're deliberately deferred:
|
||
|
||
- `attach(pid)` — only `spawn` works today.
|
||
- Hardware watchpoints. (The `watchpoint` event type exists in the listener
|
||
registry but never fires until hardware watchpoints land.)
|
||
- Source-level debugging via DWARF (`gimli`).
|
||
- Multi-threaded targets; shared-library symbol resolution.
|
||
- Browser visualisation / DOM API.
|
||
- Conditional breakpoints. (Expressible as a guard inside a `breakpoint`
|
||
listener — see the idiom below; native `--condition` support is still
|
||
deferred.)
|
||
- Reverse execution.
|
||
|
||
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`.
|
||
|
||
## Build
|
||
|
||
Requires a Linux x86-64 host with `ptrace` permitted (no yama `ptrace_scope`
|
||
restriction in the way) and a recent stable Rust toolchain (1.87+).
|
||
|
||
```bash
|
||
cargo build # debug
|
||
cargo build --release # ~3-5x faster
|
||
```
|
||
|
||
QuickJS builds via `rquickjs-sys`'s pre-generated bindings; no `libclang` or
|
||
`bindgen` needed on the supported platforms.
|
||
|
||
## Run
|
||
|
||
The binary is a daemon. It reads JS scripts from stdin and writes results to
|
||
stdout, with sentinels separating turns:
|
||
|
||
```
|
||
$ ./target/debug/llmdbg <<'EOF'
|
||
spawn("/tmp/testprog")
|
||
bp.set("main")
|
||
cont()
|
||
show.disasm(5)
|
||
---END---
|
||
EOF
|
||
```
|
||
|
||
Protocol:
|
||
|
||
- Read lines until a line containing exactly `---END---`.
|
||
- Evaluate the accumulated buffer as one JS script in the persistent context.
|
||
- Print: any `print()` output, then the final expression's value (when not
|
||
`undefined`), or `error: ...` if the eval threw.
|
||
- Print `---DONE---` and flush.
|
||
- Repeat.
|
||
- EOF on stdin exits cleanly. If a target is still alive, it gets SIGKILL'd.
|
||
|
||
## Quick tour
|
||
|
||
A representative session against a small C program:
|
||
|
||
```js
|
||
spawn("/tmp/testprog")
|
||
bp.set("main")
|
||
bp.set("add", "add_bp")
|
||
show.bp()
|
||
// #1 0x55ca2862c1e8 (main) hits=0 enabled
|
||
// #2 "add_bp" 0x55ca2862c149 (add) hits=0 enabled
|
||
---END---
|
||
|
||
cont() // -> "breakpoint:1"
|
||
here() // -> 0x55ca2862c1e8 (main)
|
||
show.disasm(5)
|
||
// 0x55ca2862c1e8 f30f1efa endbr64
|
||
// 0x55ca2862c1ec 55 push rbp
|
||
// ...
|
||
---END---
|
||
|
||
cont() // -> "breakpoint:2"
|
||
here() // -> 0x55ca2862c149 (add)
|
||
show.regs(["rip","rdi","rsi"]) // (rdi, rsi are the first two args)
|
||
// rip=0x55... rdi=0x5 rsi=0x1
|
||
---END---
|
||
|
||
step(5)
|
||
finish() // -> "breakpoint:N", back at caller
|
||
state.registers.rax & 0xffffffffn // -> 0x6 (the eax return value)
|
||
---END---
|
||
|
||
cont() // -> "exited:0"
|
||
---END---
|
||
```
|
||
|
||
## The native surface
|
||
|
||
Everything below is installed in the global scope by the Rust side. The JS
|
||
prelude (`src/prelude.js`) adds the helpers listed at the bottom.
|
||
|
||
```js
|
||
// Process control
|
||
spawn(path, ...args) // -> { pid, status: "stopped_at_entry" }
|
||
step(n = 1) // -> stop-reason string
|
||
cont() // -> stop-reason string (NOT `continue` — JS keyword)
|
||
|
||
// Stop reasons (returned as strings):
|
||
// "step", "breakpoint:N", "signal:SIGSEGV", "exited:0", "killed:SIGKILL"
|
||
|
||
// Live state (each property re-reads via ptrace on access)
|
||
state.pc // -> BigInt
|
||
state.sp // -> BigInt
|
||
state.registers // -> { rax, rbx, ..., r15, eflags } all BigInt
|
||
state.memory(addr) // -> { u8(), u32(), u64(), i64(), bytes(n) }
|
||
state.disasm(n = 8) // -> [{addr, bytes, text, target?, target_sym?}]
|
||
state.stack // -> { top, usable_base, guard_size }
|
||
|
||
// Snapshot before the most recent step/cont — same shape as state for regs.
|
||
// Throws if no step has happened yet.
|
||
prev.pc / prev.sp / prev.registers
|
||
|
||
// Symbols (main exe only; shared libs deferred)
|
||
sym(addr) // -> "name+0xN" | "name" | null
|
||
addr(name) // -> BigInt | null
|
||
symbols() // -> { count, load_base, path }
|
||
disasm(addr, n = 8) // -> same shape as state.disasm
|
||
|
||
// Breakpoints (`breakpoint` and `bp` are the same object)
|
||
bp.set(addr_or_symbol, name?) // -> id
|
||
bp.remove(id_or_name)
|
||
bp.list() // -> [{id, addr, name, hits, enabled}]
|
||
|
||
// User scratchpad (persistent across all evals)
|
||
ctx.something = whatever
|
||
```
|
||
|
||
## Prelude (`src/prelude.js`)
|
||
|
||
Pure JS, loaded last so it can reference the natives. Edit it freely; it gets
|
||
re-embedded into the binary on every `cargo build` via `include_str!`.
|
||
|
||
```js
|
||
hex(v) // -> "0x..." for Number or BigInt
|
||
unhex(s) // -> BigInt (handles "0x", case, sign)
|
||
dump(label, v) // -> print one terse "label\tvalue" line
|
||
repr(v) // -> string form; hex for numerics, JSON for objects
|
||
addrstr(v) // -> "0xN (sym+0xoff)" when sym is known, else "0xN"
|
||
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)
|
||
clearListeners([type]) // drop one type, or all if omitted
|
||
listeners() // -> { step: n, breakpoint: n, ... } counts
|
||
```
|
||
|
||
### Event listeners
|
||
|
||
The event object passed to each handler:
|
||
|
||
```js
|
||
e.state // live state (re-reads via ptrace), same as the `state` global
|
||
e.prev // pre-resume snapshot, or null on the very first event
|
||
e.stop_reason // "step" | "breakpoint:N" | "signal:SIGSEGV" | "exited:0" | ...
|
||
e.breakpoint_id // number — only on 'breakpoint' events
|
||
e.signal // string — only on 'signal' events
|
||
e.exit_code // number — only on 'exit' events
|
||
```
|
||
|
||
`step(n)` single-steps one instruction at a time, firing listeners after each
|
||
stop; `cont()` fires after the stop it lands on. When no listeners are
|
||
registered, both take the native fast path and pay nothing. Handler exceptions
|
||
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
|
||
|
||
```
|
||
src/
|
||
├── main.rs ─ stdin read loop, ---END--- framing, SIGKILL on exit
|
||
├── target.rs ─ ptrace child management: spawn, step, cont, regs,
|
||
│ process_vm_readv, breakpoint patching (read-byte+poke-byte
|
||
│ via PEEKDATA/POKEDATA word RMW)
|
||
├── symbols.rs ─ ELF parsing via `object`; PIE load-base from /proc/maps;
|
||
│ StackRegion from /proc/maps
|
||
├── breakpoint.rs ─ BreakpointSet storage (by id, by addr, by name)
|
||
├── disasm.rs ─ thin wrapper around iced-x86 FastFormatter; emits records
|
||
│ with branch_target for caller symbolisation
|
||
├── js.rs ─ Runtime + Context + Rc<RefCell<Option<Target>>>;
|
||
│ installs all native globals, then evals prelude.js
|
||
└── prelude.js ─ embedded at compile time
|
||
```
|
||
|
||
### A few decisions worth knowing
|
||
|
||
- **BigInt for u64.** A JS Number above 2⁵³ loses precision, which is exactly
|
||
the range we live in. `state.pc`, `addr(...)`, anything 64-bit goes in and
|
||
out as BigInt. `state.memory(addr).u8()` and `.u32()` stay as Number.
|
||
- **Lazy state.** Every access to `state.pc` does a fresh `PTRACE_GETREGS`;
|
||
every `state.memory(...)` does a fresh `process_vm_readv`. A script that
|
||
touches three fields makes three syscalls, not 20. Worth it for scripts; the
|
||
cache would just be a footgun once you have multi-threaded targets.
|
||
- **`continue()` doesn't exist.** `continue` is a reserved keyword in JS, so
|
||
the natural name is a parse error. The spec's `continue()` is `cont()` here.
|
||
- **Single QuickJS context for the daemon's lifetime.** `ctx` (the JS
|
||
scratchpad), registered listeners (when they land), and any user-defined
|
||
helpers all persist. Only resets on daemon restart.
|
||
- **Read-modify-write breakpoint patching.** `ptrace::write` writes a full
|
||
word. We `ptrace::read` first, modify byte 0, write the word back. The other
|
||
seven bytes stay unchanged because we just read them.
|
||
- **`read_mem_pristine`.** Used by the disassembler so it sees the *original*
|
||
byte, not 0xCC, at active breakpoints. Without this, a `disasm` over a bp'd
|
||
function shows `int3; ...` at the first byte.
|
||
- **PIE load base.** Computed as `mapping_base - mapping_file_offset` of the
|
||
*first* mapping of the executable in `/proc/<pid>/maps`. Earlier versions
|
||
used the first executable mapping and dropped the file offset, which is
|
||
wrong on toolchains that split read-only header pages.
|
||
|
||
## Caveats
|
||
|
||
- Linux x86-64 only. No multi-arch, no other OSes.
|
||
- Single-threaded debuggees. A pthread'd binary will behave oddly the moment a
|
||
non-main thread does anything.
|
||
- Symbols only cover the main executable. Shared-library functions (e.g.
|
||
`printf`, `malloc`) resolve to `null` until ld.so awareness lands.
|
||
- `finish()` assumes a frame pointer (`-fno-omit-frame-pointer`, which is the
|
||
default on most distros). It will throw if `rbp == 0`.
|
||
- The sandbox guard-page detection in `state.stack.guard_size` returns 0 on
|
||
Linux configurations where the kernel handles guard implicitly without an
|
||
anonymous `---p` mapping below the stack. That's most modern setups.
|
||
- No `attach(pid)` yet — only spawn.
|
||
|
||
## License
|
||
|
||
Personal experiment, no license declared. Treat as all-rights-reserved by the
|
||
author until otherwise noted.
|