From f0698573cd6cd56266f8bdc40b0cf3cf9bc3e104 Mon Sep 17 00:00:00 2001 From: llmdbg-dev Date: Thu, 28 May 2026 06:20:43 +0000 Subject: [PATCH] v0.8: step-over (next/trace), symbol demangling, disasm len/is_call, ergonomics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 92 +++++++++- examples/smarm_ctx_switch_next.js | 68 ++++++++ src/disasm.rs | 16 ++ src/js.rs | 25 ++- src/prelude.js | 270 ++++++++++++++++++++++++++++++ src/symbols.rs | 108 +++++++++++- 8 files changed, 569 insertions(+), 18 deletions(-) create mode 100644 examples/smarm_ctx_switch_next.js diff --git a/Cargo.lock b/Cargo.lock index f4ca80a..f08fc50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,7 @@ dependencies = [ "nix", "object", "rquickjs", + "rustc-demangle", ] [[package]] @@ -180,6 +181,12 @@ dependencies = [ "cc", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index f4cad59..589ddb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ rquickjs = { version = "0.12", features = ["std"] } libc = "0.2" object = { version = "0.36", default-features = false, features = ["read", "std"] } iced-x86 = { version = "1.21", default-features = false, features = ["std", "decoder", "nasm", "fast_fmt", "instr_info"] } +rustc-demangle = "0.1.27" diff --git a/README.md b/README.md index dd02d98..d33ca0f 100644 --- a/README.md +++ b/README.md @@ -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:" | "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 ``` diff --git a/examples/smarm_ctx_switch_next.js b/examples/smarm_ctx_switch_next.js new file mode 100644 index 0000000..1e364dc --- /dev/null +++ b/examples/smarm_ctx_switch_next.js @@ -0,0 +1,68 @@ +// examples/smarm_ctx_switch_next.js +// +// v0.8 worked session: trace one full smarm context-switch shim cleanly using +// step-over (next) and the trace() helper. This is the workflow that was +// awkward in v0.7 — single-stepping the shim dove into the two thread-local +// accessor calls (set_actor_sp / get_scheduler_sp), so a flat step(N) +// overshot and you hand-rolled return-address breakpoints. v0.8 makes it a +// one-liner. +// +// Two v0.8 wins on display: +// 1. Symbol demangling — addr("switch_to_scheduler") Just Works now; no more +// `nm BIN | grep` for the mangled _ZN..E name whose hash changes every +// rebuild. Pass the readable name straight to addr()/bp.set(). +// 2. next()/trace({over:true}) — step OVER the helper calls instead of into +// them, so the trace stays on the shim's own instructions. +// +// Build the probe (frame pointers on, single-scheduler example): +// cd smarm +// RUSTFLAGS="-C force-frame-pointers=yes" cargo build --example ctx_switch_probe +// +// Then pipe this in, substituting SPAWN_PATH with the absolute path to the +// built ctx_switch_probe- binary. Note: NO mangled-name substitution +// needed anymore — that's the point. + +spawn("SPAWN_PATH") + +// Resolve by readable name (v0.8 demangling). Ambiguous short names raise a +// clear "matches N symbols" error; these three are unique. +ctx.sched = addr("switch_to_scheduler") +print(`switch_to_scheduler @ ${hex(ctx.sched)}`) +---END--- + +// Stop at the scheduler-bound shim entry. +bp.set("switch_to_scheduler", "to_sched") +cont() +here("entered shim:") +---END--- + +// The breakpoint disarm-step-rearm consumes the first instruction (push rbx), +// so the first thing we observe is the second instruction. Step once to settle +// onto a clean boundary, then trace OVER calls until pc leaves the shim's +// address range. We don't know the exact exit address (the shim ends in `ret` +// into a different stack), so use a range stop sized generously past the body. +step(1) +ctx.lo = ctx.sched +ctx.hi = ctx.sched + 0x60n +const r = trace(null, null, { + over: true, + range: [ctx.lo, ctx.hi], + capture: e => ({ pc: hex(e.pc), insn: e.insn.text, sp_dist: hex(stackDistance(e.sp)) }), +}) +print(`traced ${r.steps} insns, stopped: ${r.reason}`) +for (const t of r.trace) print(` ${t.pc} ${t.insn.padEnd(20)} sp_dist=${t.sp_dist}`) +---END--- + +// The trace above shows the two `call` lines (set_actor_sp / get_scheduler_sp) +// as single entries — stepped over, not descended into — then the pop chain +// and the final `ret` that swaps to the scheduler stack and leaves the range. +// +// Compare: walking the SAME shim with plain step() would have produced dozens +// of lines diving through the TLS accessors. next()/trace({over:true}) is the +// difference between a readable 16-line trace and an unreadable one. +// +// To step INTO a specific call (e.g. to inspect get_scheduler_sp itself), use +// plain step() at that instruction instead of next(). And to walk *through* +// the stack-switching `ret`, use step() — next() over a call that swaps rsp +// would wait on a return that never arrives (next() detects this and falls +// back / stops rather than hanging; see the README caveat). diff --git a/src/disasm.rs b/src/disasm.rs index 54f6172..d04bfec 100644 --- a/src/disasm.rs +++ b/src/disasm.rs @@ -12,8 +12,16 @@ pub struct Decoded { pub addr: u64, /// The raw instruction bytes (length = end_ip - addr). pub bytes: Vec, + /// Instruction length in bytes (== bytes.len() for a complete decode). + /// First-class so step-over can compute the return address as addr+len + /// without recomputing bytes.length/2 in JS. + pub len: usize, /// Disassembly text (NASM-ish), e.g. "mov rax, qword ptr [rsi+8]". pub text: String, + /// True if this instruction is a `call` (any form). Lets step-over decide + /// whether to breakpoint the return address vs. plain single-step without + /// re-parsing the mnemonic text in JS. + pub is_call: bool, /// The destination if this is a branch or call instruction (for symbol /// resolution by the caller); None otherwise. pub branch_target: Option, @@ -63,10 +71,18 @@ pub fn decode(addr: u64, bytes: &[u8], max: usize) -> Vec { _ => None, }; + // A `call` for step-over purposes is anything iced classifies as a + // call flow — near/far, direct or indirect. Indirect calls (e.g. + // `call qword ptr [rax]`) have no static branch_target but still need + // step-over to set a bp at the return address. + let is_call = matches!(instr.flow_control(), iced_x86::FlowControl::Call); + out.push(Decoded { addr: start, bytes: raw, + len, text: text_buf.clone(), + is_call, branch_target, }); diff --git a/src/js.rs b/src/js.rs index cf5340c..7d35dbd 100644 --- a/src/js.rs +++ b/src/js.rs @@ -9,7 +9,7 @@ //! state.pc, state.sp -> BigInt (live ptrace) //! state.registers -> object of BigInts //! state.memory(addr) -> { u8/u32/u64/i64/bytes } -//! state.disasm(n=8) -> [{addr, bytes, text, target?, target_sym?}] +//! state.disasm(n=8) -> [{addr, bytes, len, text, is_call, target?, target_sym?}] //! state.stack -> { top, usable_base, guard_size } //! prev -> register snapshot from before last resume //! sym(addr), addr(name), symbols() @@ -448,7 +448,18 @@ fn install_globals<'js>( let addr = if let Some(s) = location.clone().into_string() { let s = s.to_string()?; let sym = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?; - sym.addr_for_name(&s).ok_or_else(|| throw(&c, &format!("unknown symbol: {}", s)))? + match sym.addr_for_name(&s) { + Some(a) => a, + None => { + let cands = sym.candidates_for_name(&s); + if cands.len() > 1 { + return Err(throw(&c, &format!( + "ambiguous symbol '{}': matches {} symbols; use the full path or address", + s, cands.len()))); + } + return Err(throw(&c, &format!("unknown symbol: {}", s))); + } + } } else { coerce_addr(&c, &location)? }; @@ -524,9 +535,11 @@ fn install_globals<'js>( // --- helpers -------------------------------------------------------------- /// Convert a Vec from the disassembler into a JS Array of records: -/// [{addr: BigInt, bytes: "ffce..", text: "mov rax, ...", target?: BigInt, target_sym?: "name"}] -/// `bytes` is a hex string for compactness; `target` and `target_sym` are -/// only present for branch/call instructions. +/// [{addr: BigInt, bytes: "ffce..", len: N, text: "mov rax, ...", +/// is_call: bool, target?: BigInt, target_sym?: "name"}] +/// `bytes` is a hex string for compactness; `len` is the instruction byte +/// length; `is_call` flags call instructions for step-over; `target` and +/// `target_sym` are only present for branch/call instructions. fn decoded_to_array<'js>( c: &Ctx<'js>, decoded: &[crate::disasm::Decoded], @@ -539,7 +552,9 @@ fn decoded_to_array<'js>( let mut bytes_hex = String::with_capacity(d.bytes.len() * 2); for b in &d.bytes { bytes_hex.push_str(&format!("{:02x}", b)); } obj.set("bytes", bytes_hex)?; + obj.set("len", d.len as u32)?; obj.set("text", d.text.as_str())?; + obj.set("is_call", d.is_call)?; if let Some(t) = d.branch_target { obj.set("target", u64_to_bigint(c, t)?)?; if let Some(s) = symbols.and_then(|s| s.name_for_addr(t)) { diff --git a/src/prelude.js b/src/prelude.js index 6f22365..ad3f834 100644 --- a/src/prelude.js +++ b/src/prelude.js @@ -342,4 +342,274 @@ globalThis.cont = function () { return reason; }; +// =========================================================================== +// v0.8 — step-over (next), trace/watch helpers, ergonomics +// +// All pure JS, in the spirit of until()/finish(). The only Rust support these +// lean on is the new disasm fields `.len` and `.is_call` (v0.8), so step-over +// can find the return address and detect calls without re-parsing text. +// =========================================================================== + +// --------------------------------------------------------------------------- +// fmt(v) — BigInt-safe stringifier. Like repr() but guaranteed never to throw +// on a BigInt anywhere in a nested structure (JSON.stringify throws on raw +// BigInt; this routes through _bigintReplacer). Use it whenever you'd reach +// for JSON.stringify on an object that might hold addresses. +globalThis.fmt = function (v) { + if (typeof v === "bigint" || typeof v === "number") return hex(v); + if (v === null || v === undefined) return String(v); + if (typeof v === "object") { + try { return JSON.stringify(v, _bigintReplacer); } catch (_) { return String(v); } + } + return String(v); +}; + +// --------------------------------------------------------------------------- +// state.insn — the instruction at pc as a one-shot disasm record +// { addr, bytes, len, text, is_call, target?, target_sym? } +// Shortcut for state.disasm(1)[0], which is the innermost line of every trace. +// `state` is a fresh native object; we add a new own getter (the native +// properties are non-configurable, but `state` itself is extensible, so +// *adding* `insn` is fine — we just must not try to redefine `stack`/`pc`). +Object.defineProperty(globalThis.state, "insn", { + configurable: true, + get() { return this.disasm(1)[0]; }, +}); + +// --------------------------------------------------------------------------- +// Stack-region helpers. The native `state.stack` getter is non-configurable, +// so rather than decorate it we provide free functions that read it fresh: +// +// stackContains(addr): is addr within [usable_base, top)? +// stackDistance(addr): bytes from top-of-stack down to addr (top - addr) — +// how deep into the stack addr is. Negative if addr is above top. +// Both accept Number or BigInt; comparisons run in BigInt. +globalThis.stackContains = function (addr) { + const a = BigInt(addr); + const s = state.stack; + return a >= s.usable_base && a < s.top; +}; + +globalThis.stackDistance = function (addr) { + return state.stack.top - BigInt(addr); +}; + +// --------------------------------------------------------------------------- +// region(addr) — which mapped region does an address fall in? +// Returns "stack" if inside the main stack, "text:" if it resolves to a +// known code symbol, or just the bare hex otherwise. Cheap legibility helper +// for spotting cross-stack handoffs during a context switch: when rsp jumps +// from one region to another between two steps, you can see it named. +globalThis.region = function (addr) { + const a = BigInt(addr); + try { + if (stackContains(a)) return "stack"; + } catch (_) {} + try { + const name = sym(a); + if (name) return "text:" + name; + } catch (_) {} + return hex(a); +}; + +// --------------------------------------------------------------------------- +// next(n=1) — step OVER n instructions (a.k.a. "step over" / gdb `next`). +// +// For each of n steps: if the instruction at pc is a `call`, set a one-shot +// breakpoint at the return address (pc + insn.len), cont() to it, and clean +// up; otherwise single-step. After each logical step we fire the `step` +// listener exactly ONCE (a stepped-over call counts as one step), matching +// the agreed semantics. +// +// Caveat — calls that don't return to pc+len: the context-switch shims swap +// rsp and `ret` into a *different* stack, so a `call` that performs a switch +// never returns to the address right after it. next() guards this with a +// `cont()` whose stop might not be our return bp (it could be a different bp, +// a signal, or exit). If the stop ISN'T our return bp, we DON'T loop waiting — +// we clean up, dispatch the real event, and stop early, returning that reason. +// In practice: use plain step() to walk *through* a switch shim instruction by +// instruction; use next() to skip over the ordinary helper calls (TLS +// accessors etc.) that step() would otherwise dive into. README documents this. +globalThis.next = function (n) { + n = n ?? 1; + if (n < 1) throw new Error("next(n): n must be >= 1"); + let reason = "step"; + for (let i = 0; i < n; i++) { + const insn = state.disasm(1)[0]; + if (insn && insn.is_call) { + const ret_addr = insn.addr + BigInt(insn.len); + const id = bp.set(ret_addr); + try { + reason = __cont_native(); + } finally { + try { bp.remove(id); } catch (_) {} + } + if (reason === ("breakpoint:" + id)) { + // Normal return: present this as a single 'step' event so a + // stepped-over call looks like one logical step to listeners. + _dispatch("step"); + } else { + // The call didn't return where we expected (stack switch, + // longjmp, another bp, signal, or exit). Surface the real + // event and stop the loop — don't hang on a return that + // won't come. + _dispatch(reason); + break; + } + } else { + reason = __step_native(1); + _dispatch(reason); + } + if (!_aliveReason(reason)) break; + } + return reason; +}; + +// --------------------------------------------------------------------------- +// trace(from, to, opts) — run to `from`, then step (optionally OVER calls) to +// `to`, capturing a compact per-step record along the way. This collapses the +// "bp + clearListeners + step-listener + step(N)" dance into one call. +// +// from, to : address or symbol name (whatever bp.set/until accept). `from` +// may be null/undefined to start tracing from the current pc. +// opts: +// over : step over calls (use next()) instead of into them. Default false. +// max : safety cap on steps (default 10000) so a missed `to` can't spin. +// capture: e => record. Default captures {pc, sp, insn} terse-formatted. +// Return any value; it's pushed onto the result array. +// print : if true, dump each captured record as it happens. Default false. +// range : [lo, hi] — alternative stop condition: stop when pc leaves the +// half-open range [lo, hi). Handy for "trace until we leave this +// shim" without knowing the exact exit address. Overrides `to`. +// +// Returns { steps, reason, trace: [...] }. Restores the listener state it +// found (it drives execution with the native steppers directly, so it neither +// fires nor depends on user listeners). +globalThis.trace = function (from, to, opts) { + opts = opts || {}; + const over = !!opts.over; + const max = opts.max ?? 10000; + const doPrint = !!opts.print; + const capture = opts.capture || ((e) => ({ + pc: hex(e.pc), sp: hex(e.sp), insn: e.insn ? e.insn.text : "?", + })); + + if (from !== null && from !== undefined) { + until(from); + } + + let toAddr = null; + let lo = null, hi = null; + if (opts.range) { + lo = BigInt(opts.range[0]); + hi = BigInt(opts.range[1]); + } else if (to !== null && to !== undefined) { + toAddr = (typeof to === "string") ? addr(to) : BigInt(to); + if (toAddr === null || toAddr === undefined) { + throw new Error("trace(): could not resolve `to` = " + to); + } + } + + const out = []; + let reason = "step"; + let steps = 0; + const inRange = (pc) => pc >= lo && pc < hi; + + // If we're using a range stop and we're already outside it, nothing to do. + for (; steps < max; steps++) { + const pc = state.pc; + // Stop conditions checked BEFORE stepping, so `to`/range exit is + // reported at the moment pc satisfies it. + if (toAddr !== null && pc === toAddr) break; + if (lo !== null && !inRange(pc)) break; + + const rec = capture({ pc: state.pc, sp: state.sp, insn: state.disasm(1)[0] }); + out.push(rec); + if (doPrint) print(fmt(rec)); + + // Advance one logical instruction. + if (over) { + const insn = state.disasm(1)[0]; + if (insn && insn.is_call) { + const ret_addr = insn.addr + BigInt(insn.len); + const id = bp.set(ret_addr); + try { reason = __cont_native(); } + finally { try { bp.remove(id); } catch (_) {} } + if (reason !== ("breakpoint:" + id)) break; // switch/longjmp/other + } else { + reason = __step_native(1); + } + } else { + reason = __step_native(1); + } + if (!_aliveReason(reason)) break; + } + return { steps, reason, trace: out }; +}; + +// --------------------------------------------------------------------------- +// watch(specs) — register a 'step' listener that prints a terse aligned table +// of named expressions every step. specs is { label: e => value, ... }; values +// are rendered with fmt() (BigInt-safe). Returns the handler so you can +// removeEventListener('step', h) to stop watching. +// +// const w = watch({ pc: e => e.state.pc, sp: e => e.state.sp }); +// step(20); +// removeEventListener('step', w); +// +// Column widths are derived from the labels; each line is "lbl=val lbl=val". +globalThis.watch = function (specs) { + const keys = Object.keys(specs); + const handler = function (e) { + const parts = keys.map((k) => { + let v; + try { v = specs[k](e); } catch (err) { v = ""; } + return `${k}=${fmt(v)}`; + }); + print(parts.join(" ")); + }; + return addEventListener("step", handler); +}; + +// --------------------------------------------------------------------------- +// step(n) gains an optional summary mode: step(n, {summary:true}) prints only +// the first and last stop plus a count, instead of firing every listener n +// times. Addresses the SPEC "Open Question" on large step counts: when you +// just want to know where N steps lands (and where it began) without N lines +// of output. With summary, the per-step `step` listeners are NOT fired (the +// whole point is to skip the per-step work); breakpoint/signal/exit stops +// still break early and are reported. Without opts, step() is unchanged. +const _stepBase = globalThis.step; +globalThis.step = function (n, opts) { + if (!opts || !opts.summary) return _stepBase(n); + n = n ?? 1; + if (n < 1) throw new Error("step(n): n must be >= 1"); + const first = { pc: hex(state.pc), insn: state.disasm(1)[0].text }; + let reason = "step"; + let done = 0; + for (let i = 0; i < n; i++) { + reason = __step_native(1); + done++; + if (!_aliveReason(reason)) break; + } + const last = _aliveReason(reason) + ? { pc: hex(state.pc), insn: state.disasm(1)[0].text } + : { reason }; + print(`step summary: ${done} steps first=${first.pc} ${first.insn} ` + + `last=${last.pc ? last.pc + " " + last.insn : last.reason}`); + return reason; +}; + +// --------------------------------------------------------------------------- +// Conditional breakpoints (no new API needed — documented idiom). A 'breakpoint' +// listener is a guard: inspect e.state and decide whether to act. Example: +// addEventListener('breakpoint', e => { +// if (e.breakpoint_id !== myBp) return; // not ours +// if (e.state.registers.rdi !== 0x10n) return; // condition not met +// print("hit with rdi==0x10 at " + hex(e.state.pc)); +// }); +// cont(); // fires the listener on each hit; the guard filters. +// To *resume automatically* until the condition holds, loop: cont() in a while +// that re-checks the register and conts again on a miss. See README. + diff --git a/src/symbols.rs b/src/symbols.rs index 7f53c62..44b7d95 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -30,6 +30,16 @@ pub struct SymbolTable { /// remember exact capitalisation. Stores the *first* address seen for /// a name (binaries can have duplicates after stripping). by_name: BTreeMap, + /// Demangled-name → addr. Covers both the full demangled path + /// (e.g. "smarm::context::switch_to_scheduler") and a hash-stripped form. + /// Lets callers use readable names instead of the mangled `_ZN..E` form, + /// whose trailing hash changes on every rebuild. + by_demangled: BTreeMap, + /// Final path component → list of addrs (e.g. "switch_to_scheduler" or a + /// much-repeated "new"). A Vec because short names are frequently + /// ambiguous across modules; we resolve only when exactly one matches and + /// report the ambiguity otherwise rather than guessing. + by_last: BTreeMap>, /// Load base offset that was applied to file-relative addresses. 0 for /// non-PIE binaries. pub load_base: u64, @@ -37,6 +47,55 @@ pub struct SymbolTable { pub path: PathBuf, } +/// Demangle a (possibly mangled) Rust symbol. Returns the full demangled path +/// and, when a trailing `::h` disambiguator is present, a hash-stripped +/// variant too. For non-Rust / already-demangled symbols the demangler is a +/// near-identity and we just return the single form. +fn demangled_forms(raw: &str) -> Vec { + let full = format!("{:#}", rustc_demangle::demangle(raw)); // {:#} drops the hash + let with_hash = format!("{}", rustc_demangle::demangle(raw)); + let mut out = Vec::new(); + if with_hash != raw { + out.push(with_hash); + } + if full != raw && !out.contains(&full) { + out.push(full); + } + out +} + +/// The final `::`-separated component of a demangled path. For +/// "smarm::context::switch_to_scheduler" → "switch_to_scheduler". Generic +/// args / trait-impl brackets are left intact; we just split on the last `::` +/// that isn't inside angle brackets (cheap heuristic: depth count). +fn last_component(demangled: &str) -> Option { + let mut depth: i32 = 0; + let mut last_sep = None; + let bytes = demangled.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'<' => depth += 1, + b'>' => depth -= 1, + b':' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b':' => { + last_sep = Some(i + 2); + i += 1; + } + _ => {} + } + i += 1; + } + let comp = match last_sep { + Some(p) => &demangled[p..], + None => demangled, + }; + if comp.is_empty() || comp.contains('<') { + None // skip things like a bare "{{closure}}" tail or generic soup + } else { + Some(comp.to_string()) + } +} + impl SymbolTable { /// Load symbols from /proc//exe, applying the load base from /// /proc//maps if the binary is PIE. @@ -50,6 +109,8 @@ impl SymbolTable { let mut by_addr: BTreeMap = BTreeMap::new(); let mut by_name: BTreeMap = BTreeMap::new(); + let mut by_demangled: BTreeMap = BTreeMap::new(); + let mut by_last: BTreeMap> = BTreeMap::new(); for sym in elf.symbols().chain(elf.dynamic_symbols()) { if !matches!(sym.kind(), SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown) { @@ -82,6 +143,20 @@ impl SymbolTable { if lc != raw_name { by_name.entry(lc).or_insert(addr); } + + // Demangled aliases: full path (with and without hash) plus the + // final path component. The first two go in by_demangled (1:1, + // first-wins like by_name); the last component is many:1 so it + // accumulates into by_last for ambiguity-aware resolution. + for d in demangled_forms(raw_name) { + by_demangled.entry(d.clone()).or_insert(addr); + if let Some(last) = last_component(&d) { + let v = by_last.entry(last).or_default(); + if !v.contains(&addr) { + v.push(addr); + } + } + } } // For diagnostic: also note executable section ranges (might help us @@ -91,31 +166,52 @@ impl SymbolTable { .map(|s| (s.address().wrapping_add(load_base), s.size())) .collect(); - Ok(SymbolTable { by_addr, by_name, load_base, path: exe_path }) + Ok(SymbolTable { by_addr, by_name, by_demangled, by_last, load_base, path: exe_path }) } /// Find the symbol containing or starting at `addr`. /// Returns "name" if exact, "name+0xN" if interior, None if no match. + /// The name is demangled (hash stripped) for readability; mangled lookups + /// still work via `addr_for_name`. pub fn name_for_addr(&self, addr: u64) -> Option { // Look at the symbol whose addr ≤ ours, and check whether ours falls // within [sym.addr, sym.addr + sym.size). For size==0 symbols, only // match an exact equality. let (&_, sym) = self.by_addr.range(..=addr).next_back()?; let off = addr - sym.addr; + let name = format!("{:#}", rustc_demangle::demangle(&sym.name)); if sym.size == 0 { - if off == 0 { Some(sym.name.clone()) } else { None } + if off == 0 { Some(name) } else { None } } else if off < sym.size { - if off == 0 { Some(sym.name.clone()) } else { Some(format!("{}+0x{:x}", sym.name, off)) } + if off == 0 { Some(name) } else { Some(format!("{}+0x{:x}", name, off)) } } else { None } } - /// Resolve a name to a single address. Tries exact case first, then - /// lowercase. Returns None if not found or ambiguous. + /// Resolve a name to a single address. Resolution order, first hit wins: + /// 1. exact mangled / case-insensitive (the historical behaviour) + /// 2. exact demangled path, e.g. "smarm::context::switch_to_scheduler" + /// 3. unique final path component, e.g. "switch_to_scheduler" + /// Returns None if not found. For an *ambiguous* short name (step 3 with + /// more than one match) returns None too; callers wanting the ambiguity + /// list can use `candidates_for_name`. pub fn addr_for_name(&self, name: &str) -> Option { if let Some(&a) = self.by_name.get(name) { return Some(a); } - self.by_name.get(&name.to_lowercase()).copied() + if let Some(&a) = self.by_name.get(&name.to_lowercase()) { return Some(a); } + if let Some(&a) = self.by_demangled.get(name) { return Some(a); } + if let Some(v) = self.by_last.get(name) { + if v.len() == 1 { return Some(v[0]); } + } + None + } + + /// All addresses a name could resolve to via the final-path-component + /// index. Empty if the name isn't a known last component. Used to give a + /// helpful "ambiguous: matches N symbols" error rather than silently + /// picking one. + pub fn candidates_for_name(&self, name: &str) -> Vec { + self.by_last.get(name).cloned().unwrap_or_default() } pub fn len(&self) -> usize {