Files
llmdbg-dev f0698573cd 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).
2026-05-28 06:20:43 +00:00

16 KiB
Raw Permalink Blame History

llmdbg

A scriptable, stateful debugger REPL with JavaScript as the interface language. Built from the spec in SPEC.md. This repo holds the v0.1v0.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+).

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:

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.

// 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!.

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:

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 rets 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():

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.