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:
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# llmdbg
|
||||
|
||||
A scriptable, stateful debugger REPL with JavaScript as the interface language.
|
||||
Built from the spec in [`SPEC.md`](./SPEC.md). This repo holds the v0.1–v0.6
|
||||
Built from the spec in [`SPEC.md`](./SPEC.md). This repo holds the v0.1–v0.8
|
||||
implementation: enough to actually drive a debugged process from an agent
|
||||
harness with breakpoints, disassembly, and symbol-aware addresses.
|
||||
harness with breakpoints, disassembly, symbol-aware (demangled) addresses,
|
||||
event listeners, and step-over.
|
||||
|
||||
The premise: existing debuggers optimise for humans at a terminal. An LLM
|
||||
agent is a different user — it wants terse output it can parse, lazy state
|
||||
@@ -21,6 +22,7 @@ each step" is the natural interface. The full motivation is in `SPEC.md`.
|
||||
| v0.5 | x86-64 disassembly via `iced-x86`; `read_mem_pristine()` overlays original bytes through active INT3s; branch targets get symbolised |
|
||||
| v0.6 | `state.stack`; `until(loc)`; `finish()`; `show.{disasm,bp,regs}` prelude helpers |
|
||||
| v0.7 | Event-listener system: `addEventListener('step'\|'breakpoint'\|'signal'\|'exit', ...)`, `removeEventListener`, `clearListeners`, `listeners()`; `step`/`cont` fire listeners per stop with `{state, prev, stop_reason, ...}` events; native steppers moved to `__step_native`/`__cont_native` |
|
||||
| v0.8 | Step-over: `next(n)` and `trace(from, to, {over})`. Rust: symbol **demangling** (`addr("switch_to_scheduler")` resolves by demangled path or unique last component; `sym()` returns demangled names), disasm records gain `.len` and `.is_call`. Prelude: `state.insn`, `stackContains`/`stackDistance`/`region`, `watch(specs)`, `fmt()` (BigInt-safe), `step(n, {summary})` |
|
||||
|
||||
Each `vN` corresponds to one git commit. See `git log --oneline`.
|
||||
|
||||
@@ -34,13 +36,16 @@ The spec calls for these; they're deliberately deferred:
|
||||
- Source-level debugging via DWARF (`gimli`).
|
||||
- Multi-threaded targets; shared-library symbol resolution.
|
||||
- Browser visualisation / DOM API.
|
||||
- Conditional breakpoints. (Now trivial to express as a guard inside a
|
||||
`breakpoint` listener, but native `--condition` support is still deferred.)
|
||||
- The `--summary` mode for batch stepping.
|
||||
- Conditional breakpoints. (Expressible as a guard inside a `breakpoint`
|
||||
listener — see the idiom below; native `--condition` support is still
|
||||
deferred.)
|
||||
- Reverse execution.
|
||||
|
||||
The event-listener system (`addEventListener('step', ...)` etc.) landed in
|
||||
v0.7 — see the status table.
|
||||
A basic `step(n, {summary})` batch-step mode landed in v0.8 (first/last/count
|
||||
instead of N listener fires); the SPEC's fuller "interesting events" summary is
|
||||
still open. Symbol **demangling** also landed in v0.8: `addr("name")` resolves
|
||||
by demangled path or unique last component, and `sym()` returns demangled
|
||||
names. The event-listener system landed in v0.7 — see the status table.
|
||||
|
||||
The complete "what not yet" list is in `SPEC.md`.
|
||||
|
||||
@@ -175,10 +180,27 @@ here(label?) // -> print addrstr(state.pc), optionally labelled
|
||||
until(loc) // one-shot bp at addr-or-symbol, cont, cleanup
|
||||
finish() // until([rbp+8]) — assumes frame pointer
|
||||
|
||||
next(n=1) // step OVER calls (gdb `next`): bp at ret addr, cont
|
||||
trace(from,to,opts) // run to `from`, step (opts.over → over calls) to `to`,
|
||||
// capturing per-step records. opts: {over, max, capture,
|
||||
// print, range:[lo,hi]}. -> {steps, reason, trace:[...]}
|
||||
watch(specs) // register a 'step' listener printing named exprs each
|
||||
// step; specs = {label: e => value}. -> handler
|
||||
fmt(v) // BigInt-safe stringify (never throws on nested BigInt)
|
||||
|
||||
state.insn // disasm record at pc: {addr,bytes,len,text,is_call,...}
|
||||
stackContains(addr) // is addr within [usable_base, top)?
|
||||
stackDistance(addr) // top - addr (how deep into the stack)
|
||||
region(addr) // "stack" | "text:<sym>" | "0x.." — which mapping
|
||||
|
||||
show.disasm(n=8, [at])
|
||||
show.bp()
|
||||
show.regs([keys])
|
||||
|
||||
step(n, {summary:true}) // step n, print only first/last/count (no per-step
|
||||
// listeners) — for large N where you just want
|
||||
// the landing site
|
||||
|
||||
// Event listeners (v0.7). Handlers fire on each stop during step()/cont().
|
||||
addEventListener(type, handler) // type: 'step'|'breakpoint'|'signal'|'exit'
|
||||
removeEventListener(type, handler)
|
||||
@@ -206,6 +228,62 @@ are caught and printed, so one bad listener won't abort a long step loop.
|
||||
A worked example against smarm's context switch is in
|
||||
`examples/smarm_ctx_switch.js`.
|
||||
|
||||
### Step-over (`next`) and `trace` (v0.8)
|
||||
|
||||
`next(n)` steps *over* calls: if the instruction at pc is a `call`, it sets a
|
||||
one-shot breakpoint at the return address (`pc + insn.len`), `cont()`s to it,
|
||||
and cleans up; otherwise it single-steps. A stepped-over call fires the `step`
|
||||
listener exactly once, so it reads as one logical step.
|
||||
|
||||
**Caveat — calls that don't return to `pc+len`.** A `call` that swaps `rsp`
|
||||
and `ret`s into a *different* stack (the context-switch shims themselves, or a
|
||||
longjmp) never returns to the address right after the call. `next` does not
|
||||
hang on this: if the `cont()` lands on anything other than its own return
|
||||
breakpoint (a different bp, a signal, exit, or simply not coming back), it
|
||||
cleans up, dispatches the real event, and stops, returning that reason. The
|
||||
rule of thumb: use plain `step()` to walk *through* a switch shim instruction
|
||||
by instruction; use `next()` to skip the ordinary helper calls (TLS accessors
|
||||
etc.) that `step()` would dive into. `examples/smarm_ctx_switch_next.js` shows
|
||||
both, tracing a full `switch_to_scheduler` in 16 readable lines.
|
||||
|
||||
`trace(from, to, {over, range, capture, print, max})` collapses the common
|
||||
"run to A, step (over calls) to B, log each step" pattern into one call. With
|
||||
`range: [lo, hi]` it stops when pc leaves a half-open address range — handy for
|
||||
"trace until we leave this shim" without knowing the exact exit address. It
|
||||
drives execution with the native steppers directly, so it neither fires nor
|
||||
depends on user listeners.
|
||||
|
||||
### Conditional breakpoints (idiom, no new API)
|
||||
|
||||
A `breakpoint` listener is the guard. Inspect `e.state` and decide whether to
|
||||
act; to auto-resume until a condition holds, loop `cont()`:
|
||||
|
||||
```js
|
||||
const myBp = bp.set("worker_loop");
|
||||
function runUntilRdi(want) {
|
||||
while (true) {
|
||||
const r = cont();
|
||||
if (!r.startsWith("breakpoint:")) return r; // signal/exit — stop
|
||||
if (state.registers.rdi === want) return r; // condition met
|
||||
// else: fall through and cont() again
|
||||
}
|
||||
}
|
||||
runUntilRdi(0x10n);
|
||||
```
|
||||
|
||||
### BigInt ergonomics
|
||||
|
||||
All 64-bit values (`state.pc`, `addr(...)`, anything address-sized) are
|
||||
**BigInt**. Two things bite:
|
||||
|
||||
- `JSON.stringify` throws on a raw BigInt. Use `fmt()` / `repr()` / `dump()`,
|
||||
which route BigInt through a `"0x.."` replacer, instead of `JSON.stringify`
|
||||
on any object that might hold addresses.
|
||||
- Mixing BigInt with Number throws (`pc + 8` is an error if `pc` is BigInt).
|
||||
Suffix literals with `n` (`pc + 8n`) or wrap with `BigInt(...)`. The helpers
|
||||
that take addresses (`stackContains`, `stackDistance`, `region`, `trace`'s
|
||||
`range`) coerce via `BigInt(...)` for you, so a Number offset is fine there.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user