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:
llmdbg-dev
2026-05-28 05:50:09 +00:00
parent 98e3347b4c
commit ce96a98829
4 changed files with 276 additions and 10 deletions
+34 -4
View File
@@ -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.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.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.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`. 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: 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. - `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`). - Source-level debugging via DWARF (`gimli`).
- Multi-threaded targets; shared-library symbol resolution. - Multi-threaded targets; shared-library symbol resolution.
- Browser visualisation / DOM API. - 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. - The `--summary` mode for batch stepping.
- Reverse execution. - 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`. The complete "what not yet" list is in `SPEC.md`.
## Build ## Build
@@ -174,8 +178,34 @@ finish() // until([rbp+8]) — assumes frame pointer
show.disasm(n=8, [at]) show.disasm(n=8, [at])
show.bp() show.bp()
show.regs([keys]) 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 ## Architecture
``` ```
+68
View File
@@ -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---
+15 -6
View File
@@ -3,7 +3,8 @@
//! //!
//! Native globals installed here: //! Native globals installed here:
//! spawn(path, ...args) -> { pid, status } //! 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) //! print(...args), ctx (persistent object)
//! state.pc, state.sp -> BigInt (live ptrace) //! state.pc, state.sp -> BigInt (live ptrace)
//! state.registers -> object of BigInts //! state.registers -> object of BigInts
@@ -181,7 +182,12 @@ fn install_globals<'js>(
globals.set("spawn", f)?; 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 target = target.clone();
let f = Function::new(c.clone(), move |c: Ctx<'js>, n: rquickjs::function::Opt<i64>| -> rquickjs::Result<String> { 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())) 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 target = target.clone();
let f = Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result<String> { 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 // NOTE: `continue` is a reserved keyword in JS — `continue()` as an
// expression is a syntax error. We expose only `cont()` for v0.1. // expression is a syntax error. We expose only `cont()`.
globals.set("cont", f)?; globals.set("__cont_native", f)?;
} }
// ---------- state ---------- // ---------- state ----------
+159
View File
@@ -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;
};