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
+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).