From ce96a988299ed0a64c0446e9f536f7e708c2aee1 Mon Sep 17 00:00:00 2001 From: llmdbg-dev Date: Thu, 28 May 2026 05:50:09 +0000 Subject: [PATCH] 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). --- README.md | 38 ++++++++- examples/smarm_ctx_switch.js | 68 +++++++++++++++ src/js.rs | 21 +++-- src/prelude.js | 159 +++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 examples/smarm_ctx_switch.js diff --git a/README.md b/README.md index 4722115..dd02d98 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/examples/smarm_ctx_switch.js b/examples/smarm_ctx_switch.js new file mode 100644 index 0000000..e7ff522 --- /dev/null +++ b/examples/smarm_ctx_switch.js @@ -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- 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--- diff --git a/src/js.rs b/src/js.rs index 1061168..cf5340c 100644 --- a/src/js.rs +++ b/src/js.rs @@ -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| -> rquickjs::Result { @@ -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 { @@ -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 ---------- diff --git a/src/prelude.js b/src/prelude.js index 349077e..6f22365 100644 --- a/src/prelude.js +++ b/src/prelude.js @@ -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; +}; +