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
Generated
+7
View File
@@ -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"
+1
View File
@@ -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"
+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
```
+68
View File
@@ -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-<hash> 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).
+16
View File
@@ -12,8 +12,16 @@ pub struct Decoded {
pub addr: u64,
/// The raw instruction bytes (length = end_ip - addr).
pub bytes: Vec<u8>,
/// 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<u64>,
@@ -63,10 +71,18 @@ pub fn decode(addr: u64, bytes: &[u8], max: usize) -> Vec<Decoded> {
_ => 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,
});
+20 -5
View File
@@ -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<Decoded> 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)) {
+270
View File
@@ -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:<sym>" 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 = "<err:" + (err && err.message || err) + ">"; }
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.
+102 -6
View File
@@ -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<String, u64>,
/// 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<String, u64>,
/// 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<String, Vec<u64>>,
/// 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<hash>` 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<String> {
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<String> {
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/<pid>/exe, applying the load base from
/// /proc/<pid>/maps if the binary is PIE.
@@ -50,6 +109,8 @@ impl SymbolTable {
let mut by_addr: BTreeMap<u64, Symbol> = BTreeMap::new();
let mut by_name: BTreeMap<String, u64> = BTreeMap::new();
let mut by_demangled: BTreeMap<String, u64> = BTreeMap::new();
let mut by_last: BTreeMap<String, Vec<u64>> = 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<String> {
// 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<u64> {
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<u64> {
self.by_last.get(name).cloned().unwrap_or_default()
}
pub fn len(&self) -> usize {