v0.7: event-listener system (addEventListener/step/breakpoint/signal/exit)
- Native step/cont moved to __step_native/__cont_native; public step()/cont()
defined in prelude.js wrap them and dispatch JS listeners on each stop.
- addEventListener/removeEventListener/clearListeners/listeners() registry on
globalThis, persistent for the daemon lifetime (independent of ctx.reset()).
- Event object carries {state, prev, stop_reason} plus breakpoint_id/signal/
exit_code as appropriate. Implements the SPEC 'Event Listeners' + 'Core Loop'.
- No-listener fast path keeps existing scripts zero-cost.
- examples/smarm_ctx_switch.js: worked session inspecting smarm's naked-asm
context switch; README updated (status table, listener API, deferred list).
This commit is contained in:
@@ -20,6 +20,7 @@ each step" is the natural interface. The full motivation is in `SPEC.md`.
|
||||
| v0.4 | INT3 software breakpoints with by-name/by-id ops; auto disarm-step-rearm; RIP rewind on hit |
|
||||
| v0.5 | x86-64 disassembly via `iced-x86`; `read_mem_pristine()` overlays original bytes through active INT3s; branch targets get symbolised |
|
||||
| v0.6 | `state.stack`; `until(loc)`; `finish()`; `show.{disasm,bp,regs}` prelude helpers |
|
||||
| v0.7 | Event-listener system: `addEventListener('step'\|'breakpoint'\|'signal'\|'exit', ...)`, `removeEventListener`, `clearListeners`, `listeners()`; `step`/`cont` fire listeners per stop with `{state, prev, stop_reason, ...}` events; native steppers moved to `__step_native`/`__cont_native` |
|
||||
|
||||
Each `vN` corresponds to one git commit. See `git log --oneline`.
|
||||
|
||||
@@ -27,17 +28,20 @@ Each `vN` corresponds to one git commit. See `git log --oneline`.
|
||||
|
||||
The spec calls for these; they're deliberately deferred:
|
||||
|
||||
- Event listener system (`addEventListener('step', ...)` etc.) — current model
|
||||
is synchronous send-script-get-output; events come next.
|
||||
- `attach(pid)` — only `spawn` works today.
|
||||
- Hardware watchpoints.
|
||||
- Hardware watchpoints. (The `watchpoint` event type exists in the listener
|
||||
registry but never fires until hardware watchpoints land.)
|
||||
- Source-level debugging via DWARF (`gimli`).
|
||||
- Multi-threaded targets; shared-library symbol resolution.
|
||||
- Browser visualisation / DOM API.
|
||||
- Conditional breakpoints.
|
||||
- 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.
|
||||
- Reverse execution.
|
||||
|
||||
The event-listener system (`addEventListener('step', ...)` etc.) landed in
|
||||
v0.7 — see the status table.
|
||||
|
||||
The complete "what not yet" list is in `SPEC.md`.
|
||||
|
||||
## Build
|
||||
@@ -174,8 +178,34 @@ finish() // until([rbp+8]) — assumes frame pointer
|
||||
show.disasm(n=8, [at])
|
||||
show.bp()
|
||||
show.regs([keys])
|
||||
|
||||
// Event listeners (v0.7). Handlers fire on each stop during step()/cont().
|
||||
addEventListener(type, handler) // type: 'step'|'breakpoint'|'signal'|'exit'
|
||||
removeEventListener(type, handler)
|
||||
clearListeners([type]) // drop one type, or all if omitted
|
||||
listeners() // -> { step: n, breakpoint: n, ... } counts
|
||||
```
|
||||
|
||||
### Event listeners
|
||||
|
||||
The event object passed to each handler:
|
||||
|
||||
```js
|
||||
e.state // live state (re-reads via ptrace), same as the `state` global
|
||||
e.prev // pre-resume snapshot, or null on the very first event
|
||||
e.stop_reason // "step" | "breakpoint:N" | "signal:SIGSEGV" | "exited:0" | ...
|
||||
e.breakpoint_id // number — only on 'breakpoint' events
|
||||
e.signal // string — only on 'signal' events
|
||||
e.exit_code // number — only on 'exit' events
|
||||
```
|
||||
|
||||
`step(n)` single-steps one instruction at a time, firing listeners after each
|
||||
stop; `cont()` fires after the stop it lands on. When no listeners are
|
||||
registered, both take the native fast path and pay nothing. Handler exceptions
|
||||
are caught and printed, so one bad listener won't abort a long step loop.
|
||||
A worked example against smarm's context switch is in
|
||||
`examples/smarm_ctx_switch.js`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// examples/smarm_ctx_switch.js
|
||||
//
|
||||
// Demonstrates the v0.7 event-listener system by inspecting smarm's naked-asm
|
||||
// context switch. smarm (https://git.kalsbeek.dev/Markk116/smarm) is a green-
|
||||
// thread actor runtime whose core is two `#[naked]` switch shims that swap rsp
|
||||
// between a scheduler stack and an actor's mmap'd stack.
|
||||
//
|
||||
// Build the probe binary (frame pointers on, single scheduler thread):
|
||||
// cd smarm
|
||||
// RUSTFLAGS="-C force-frame-pointers=yes" cargo build --example ctx_switch_probe
|
||||
//
|
||||
// Then pipe this script into llmdbg, substituting:
|
||||
// - SPAWN_PATH: absolute path to the built ctx_switch_probe-<hash> binary
|
||||
// - ACTOR_ASM: mangled name of switch_to_actor_asm (from `nm BIN | grep`)
|
||||
// - SCHED: mangled name of switch_to_scheduler
|
||||
//
|
||||
// What it shows:
|
||||
// 1. A 'breakpoint' listener that logs each switch with rsp + 16-alignment.
|
||||
// 2. A 'step' listener that single-steps the shim's tail (mov rsp,rax; the
|
||||
// six pops; ret) and proves the actor entry lands with rsp % 16 == 8 —
|
||||
// the x86-64 ABI state expected at a function entry reached via `call`.
|
||||
//
|
||||
// This was the session that caught the off-by-one-slot error in
|
||||
// init_actor_stack's layout comment (entry is at aligned_top-16, not -8).
|
||||
|
||||
spawn("SPAWN_PATH")
|
||||
|
||||
// --- (1) per-switch alignment trace via a breakpoint listener --------------
|
||||
ctx.sched = addr("SCHED")
|
||||
ctx.actor_asm = addr("ACTOR_ASM")
|
||||
|
||||
addEventListener('breakpoint', e => {
|
||||
const sp = e.state.sp;
|
||||
const where = (e.state.pc === ctx.sched) ? "to_sched" : "to_actor";
|
||||
ctx.hits = (ctx.hits ?? 0) + 1;
|
||||
print(`#${ctx.hits} ${where} rsp=${hex(sp)} rsp%16=${sp % 16n}`);
|
||||
});
|
||||
bp.set(ctx.sched, "to_sched")
|
||||
bp.set(ctx.actor_asm, "to_actor")
|
||||
---END---
|
||||
|
||||
// Drive through the first several switches. Each cont() fires the listener.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let r = cont();
|
||||
if (!r.startsWith("breakpoint")) { print("stop: " + r); break; }
|
||||
}
|
||||
---END---
|
||||
|
||||
// --- (2) prove the ret lands at rsp%16==8 in the actor entry ---------------
|
||||
// Remove the breakpoint listener; add a step listener for the tail trace.
|
||||
clearListeners('breakpoint')
|
||||
|
||||
// Reach `mov rsp,rax` (offset 0x17 in the shim) on the next switch_to_actor.
|
||||
ctx.movrsp = ctx.actor_asm + 0x17n
|
||||
bp.remove("to_sched")
|
||||
bp.remove("to_actor")
|
||||
bp.set(ctx.movrsp, "movrsp")
|
||||
cont()
|
||||
---END---
|
||||
|
||||
clearListeners()
|
||||
addEventListener('step', e => {
|
||||
const ins = e.state.disasm(1)[0];
|
||||
print(`${hex(e.state.pc)} rsp=${hex(e.state.sp)} %16=${e.state.sp % 16n} ${ins.text}`);
|
||||
});
|
||||
// mov rsp,rax ; pop r15..rbx (6) ; ret ; first insn of entry
|
||||
step(8)
|
||||
---END---
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! Native globals installed here:
|
||||
//! spawn(path, ...args) -> { pid, status }
|
||||
//! step(n=1) / cont() -> stop-reason string
|
||||
//! __step_native(n=1) / __cont_native() -> stop-reason string
|
||||
//! (public step()/cont() are defined in prelude.js and fire listeners)
|
||||
//! print(...args), ctx (persistent object)
|
||||
//! state.pc, state.sp -> BigInt (live ptrace)
|
||||
//! state.registers -> object of BigInts
|
||||
@@ -181,7 +182,12 @@ fn install_globals<'js>(
|
||||
globals.set("spawn", f)?;
|
||||
}
|
||||
|
||||
// ---------- step(n=1) ----------
|
||||
// ---------- __step_native(n=1) ----------
|
||||
// Raw single-stepper. Steps up to n instructions, stopping early if the
|
||||
// target dies. Returns the last stop-reason string. The public `step(n)`
|
||||
// is defined in prelude.js: it loops one instruction at a time so it can
|
||||
// fire 'step'/'breakpoint' listeners after each stop. We keep the native
|
||||
// n-loop too, for the fast path when no listeners are registered.
|
||||
{
|
||||
let target = target.clone();
|
||||
let f = Function::new(c.clone(), move |c: Ctx<'js>, n: rquickjs::function::Opt<i64>| -> rquickjs::Result<String> {
|
||||
@@ -204,10 +210,13 @@ fn install_globals<'js>(
|
||||
}
|
||||
Ok(last.map(|r| r.tag()).unwrap_or_else(|| "step".into()))
|
||||
})?;
|
||||
globals.set("step", f)?;
|
||||
globals.set("__step_native", f)?;
|
||||
}
|
||||
|
||||
// ---------- continue() / cont() ----------
|
||||
// ---------- __cont_native() ----------
|
||||
// Raw continue. Resumes until the next stop (breakpoint, signal, exit).
|
||||
// The public `cont()` in prelude.js wraps this to fire listeners on the
|
||||
// resulting stop.
|
||||
{
|
||||
let target = target.clone();
|
||||
let f = Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result<String> {
|
||||
@@ -219,8 +228,8 @@ fn install_globals<'js>(
|
||||
}
|
||||
})?;
|
||||
// NOTE: `continue` is a reserved keyword in JS — `continue()` as an
|
||||
// expression is a syntax error. We expose only `cont()` for v0.1.
|
||||
globals.set("cont", f)?;
|
||||
// expression is a syntax error. We expose only `cont()`.
|
||||
globals.set("__cont_native", f)?;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
|
||||
+159
@@ -183,4 +183,163 @@ globalThis.show = {
|
||||
},
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Event listener system (SPEC "Event Listeners" + "The Core Loop")
|
||||
//
|
||||
// The spec's model: on each debugger stop, the REPL determines the stop
|
||||
// reason, looks up registered listeners for that event type, and calls each
|
||||
// with an event object { state, prev, stop_reason }. The hook author scripts
|
||||
// "what to observe per step" once, then drives execution with step()/cont()
|
||||
// and lets the listeners fire.
|
||||
//
|
||||
// Implementation: the registry and dispatch live here in JS (the spec's
|
||||
// "the language is the interface" — the REPL drives the JS event loop
|
||||
// explicitly, no async). The native single-steppers are __step_native /
|
||||
// __cont_native; the public step()/cont() below wrap them so that after each
|
||||
// individual stop we synthesize the event and run the listeners.
|
||||
//
|
||||
// Event types: 'step', 'breakpoint', 'watchpoint' (reserved; no hw watch yet),
|
||||
// 'signal', and 'exit' (fired once when the target dies — not in the original
|
||||
// list but the natural terminal event; harmless if unused).
|
||||
//
|
||||
// The event object:
|
||||
// e.state — live state (same global `state`, re-reads via ptrace)
|
||||
// e.prev — pre-resume snapshot (same global `prev`)
|
||||
// e.stop_reason — raw reason string: "step" | "breakpoint:N" |
|
||||
// "signal:SIGSEGV" | "exited:0" | "killed:SIGKILL"
|
||||
// e.breakpoint_id — number, only on 'breakpoint' events
|
||||
// e.signal — string, only on 'signal' events
|
||||
// e.exit_code — number, only on 'exit' events (from "exited:N")
|
||||
// ===========================================================================
|
||||
|
||||
// Registry: type -> array of handlers. Persists for the REPL lifetime (it
|
||||
// lives on globalThis, same as ctx). Survives across evals; resets only on
|
||||
// daemon restart. We do NOT key this off ctx so that ctx.reset() doesn't
|
||||
// silently drop the user's listeners.
|
||||
globalThis.__listeners = globalThis.__listeners ?? {
|
||||
step: [], breakpoint: [], watchpoint: [], signal: [], exit: [],
|
||||
};
|
||||
|
||||
globalThis.addEventListener = function (type, handler) {
|
||||
if (!(type in globalThis.__listeners)) {
|
||||
throw new Error(`addEventListener: unknown event type '${type}' ` +
|
||||
`(known: ${Object.keys(globalThis.__listeners).join(", ")})`);
|
||||
}
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("addEventListener: handler must be a function");
|
||||
}
|
||||
globalThis.__listeners[type].push(handler);
|
||||
return handler; // so callers can keep a reference for removeEventListener
|
||||
};
|
||||
|
||||
globalThis.removeEventListener = function (type, handler) {
|
||||
const arr = globalThis.__listeners[type];
|
||||
if (!arr) return false;
|
||||
const i = arr.indexOf(handler);
|
||||
if (i >= 0) { arr.splice(i, 1); return true; }
|
||||
return false;
|
||||
};
|
||||
|
||||
// Drop all listeners of a type, or all listeners entirely. Convenience for
|
||||
// iterating on a hook script without restarting the daemon.
|
||||
globalThis.clearListeners = function (type) {
|
||||
if (type === undefined) {
|
||||
for (const k of Object.keys(globalThis.__listeners)) globalThis.__listeners[k] = [];
|
||||
return;
|
||||
}
|
||||
if (type in globalThis.__listeners) globalThis.__listeners[type] = [];
|
||||
};
|
||||
|
||||
globalThis.listeners = function () {
|
||||
const out = {};
|
||||
for (const k of Object.keys(globalThis.__listeners)) out[k] = globalThis.__listeners[k].length;
|
||||
return out;
|
||||
};
|
||||
|
||||
// Parse a raw stop-reason string into a structured event (without state/prev,
|
||||
// which the dispatcher attaches). Returns { type, ev } or null if the reason
|
||||
// is one we don't dispatch a primary event for (shouldn't happen).
|
||||
function _classifyReason(reason) {
|
||||
if (reason === "step") {
|
||||
return { type: "step", ev: { stop_reason: reason } };
|
||||
}
|
||||
if (reason.startsWith("breakpoint:")) {
|
||||
return { type: "breakpoint", ev: { stop_reason: reason, breakpoint_id: Number(reason.slice(11)) } };
|
||||
}
|
||||
if (reason.startsWith("signal:")) {
|
||||
return { type: "signal", ev: { stop_reason: reason, signal: reason.slice(7) } };
|
||||
}
|
||||
if (reason.startsWith("exited:")) {
|
||||
return { type: "exit", ev: { stop_reason: reason, exit_code: Number(reason.slice(7)) } };
|
||||
}
|
||||
if (reason.startsWith("killed:")) {
|
||||
return { type: "exit", ev: { stop_reason: reason, exit_code: null, signal: reason.slice(7) } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True while the target is still inspectable after a stop.
|
||||
function _aliveReason(reason) {
|
||||
return reason === "step" || reason.startsWith("breakpoint:") || reason.startsWith("signal:");
|
||||
}
|
||||
|
||||
// Fire all listeners registered for this stop. The `state`/`prev` globals are
|
||||
// already pointing at the just-stopped target, so we attach them directly.
|
||||
// Handler exceptions are caught and printed, so one bad listener doesn't abort
|
||||
// a long step loop.
|
||||
function _dispatch(reason) {
|
||||
const c = _classifyReason(reason);
|
||||
if (!c) return;
|
||||
const handlers = globalThis.__listeners[c.type];
|
||||
if (!handlers || handlers.length === 0) return;
|
||||
const e = c.ev;
|
||||
e.state = state;
|
||||
// prev throws if there's no snapshot yet; guard so the first event is fine.
|
||||
try { e.prev = prev; } catch (_) { e.prev = null; }
|
||||
for (const h of handlers) {
|
||||
try { h(e); }
|
||||
catch (err) { print(`listener(${c.type}) error: ${err && err.message ? err.message : err}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// Whether any listener at all is registered — lets step()/cont() take the
|
||||
// fast native path when the user just wants to advance without observing.
|
||||
function _anyListeners() {
|
||||
const L = globalThis.__listeners;
|
||||
return L.step.length || L.breakpoint.length || L.watchpoint.length ||
|
||||
L.signal.length || L.exit.length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public step()/cont() — wrap the natives and fire listeners on each stop.
|
||||
//
|
||||
// step(n=1): single-steps one instruction at a time, dispatching after each
|
||||
// stop. Stops early if the target dies. Returns the last stop reason.
|
||||
//
|
||||
// cont(): resumes; on the resulting stop, dispatches the matching event.
|
||||
//
|
||||
// When no listeners are registered, both fall through to the native fast path
|
||||
// so existing scripts pay nothing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
globalThis.step = function (n) {
|
||||
n = n ?? 1;
|
||||
if (n < 1) throw new Error("step(n): n must be >= 1");
|
||||
if (!_anyListeners()) return __step_native(n);
|
||||
let reason = "step";
|
||||
for (let i = 0; i < n; i++) {
|
||||
reason = __step_native(1);
|
||||
_dispatch(reason);
|
||||
if (!_aliveReason(reason)) break;
|
||||
}
|
||||
return reason;
|
||||
};
|
||||
|
||||
globalThis.cont = function () {
|
||||
if (!_anyListeners()) return __cont_native();
|
||||
const reason = __cont_native();
|
||||
_dispatch(reason);
|
||||
return reason;
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user