docs: add SPEC.md (original vision) and README.md
This commit is contained in:
@@ -0,0 +1,239 @@
|
|||||||
|
# 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
|
||||||
|
implementation: enough to actually drive a debugged process from an agent
|
||||||
|
harness with breakpoints, disassembly, and symbol-aware addresses.
|
||||||
|
|
||||||
|
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
|
||||||
|
access, and a scripting layer rich enough that "describe what you want to see
|
||||||
|
each step" is the natural interface. The full motivation is in `SPEC.md`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Version | What landed |
|
||||||
|
|---|---|
|
||||||
|
| v0.1 | Spawn, step, continue, basic state (pc/sp/regs/memory), persistent `ctx`, `print()`, JS prelude loaded via `include_str!` |
|
||||||
|
| v0.2 | `prev` register snapshot before each resume; refactored register accessors |
|
||||||
|
| v0.3 | ELF symbol parsing of main exe; `sym(addr)` / `addr(name)` / `symbols()`; `addrstr` / `here` prelude helpers |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Each `vN` corresponds to one git commit. See `git log --oneline`.
|
||||||
|
|
||||||
|
## Not yet implemented
|
||||||
|
|
||||||
|
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.
|
||||||
|
- Source-level debugging via DWARF (`gimli`).
|
||||||
|
- Multi-threaded targets; shared-library symbol resolution.
|
||||||
|
- Browser visualisation / DOM API.
|
||||||
|
- Conditional breakpoints.
|
||||||
|
- The `--summary` mode for batch stepping.
|
||||||
|
- Reverse execution.
|
||||||
|
|
||||||
|
The complete "what not yet" list is in `SPEC.md`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires a Linux x86-64 host with `ptrace` permitted (no yama `ptrace_scope`
|
||||||
|
restriction in the way) and a recent stable Rust toolchain (1.87+).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # debug
|
||||||
|
cargo build --release # ~3-5x faster
|
||||||
|
```
|
||||||
|
|
||||||
|
QuickJS builds via `rquickjs-sys`'s pre-generated bindings; no `libclang` or
|
||||||
|
`bindgen` needed on the supported platforms.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
The binary is a daemon. It reads JS scripts from stdin and writes results to
|
||||||
|
stdout, with sentinels separating turns:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./target/debug/llmdbg <<'EOF'
|
||||||
|
spawn("/tmp/testprog")
|
||||||
|
bp.set("main")
|
||||||
|
cont()
|
||||||
|
show.disasm(5)
|
||||||
|
---END---
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
|
||||||
|
- Read lines until a line containing exactly `---END---`.
|
||||||
|
- Evaluate the accumulated buffer as one JS script in the persistent context.
|
||||||
|
- Print: any `print()` output, then the final expression's value (when not
|
||||||
|
`undefined`), or `error: ...` if the eval threw.
|
||||||
|
- Print `---DONE---` and flush.
|
||||||
|
- Repeat.
|
||||||
|
- EOF on stdin exits cleanly. If a target is still alive, it gets SIGKILL'd.
|
||||||
|
|
||||||
|
## Quick tour
|
||||||
|
|
||||||
|
A representative session against a small C program:
|
||||||
|
|
||||||
|
```js
|
||||||
|
spawn("/tmp/testprog")
|
||||||
|
bp.set("main")
|
||||||
|
bp.set("add", "add_bp")
|
||||||
|
show.bp()
|
||||||
|
// #1 0x55ca2862c1e8 (main) hits=0 enabled
|
||||||
|
// #2 "add_bp" 0x55ca2862c149 (add) hits=0 enabled
|
||||||
|
---END---
|
||||||
|
|
||||||
|
cont() // -> "breakpoint:1"
|
||||||
|
here() // -> 0x55ca2862c1e8 (main)
|
||||||
|
show.disasm(5)
|
||||||
|
// 0x55ca2862c1e8 f30f1efa endbr64
|
||||||
|
// 0x55ca2862c1ec 55 push rbp
|
||||||
|
// ...
|
||||||
|
---END---
|
||||||
|
|
||||||
|
cont() // -> "breakpoint:2"
|
||||||
|
here() // -> 0x55ca2862c149 (add)
|
||||||
|
show.regs(["rip","rdi","rsi"]) // (rdi, rsi are the first two args)
|
||||||
|
// rip=0x55... rdi=0x5 rsi=0x1
|
||||||
|
---END---
|
||||||
|
|
||||||
|
step(5)
|
||||||
|
finish() // -> "breakpoint:N", back at caller
|
||||||
|
state.registers.rax & 0xffffffffn // -> 0x6 (the eax return value)
|
||||||
|
---END---
|
||||||
|
|
||||||
|
cont() // -> "exited:0"
|
||||||
|
---END---
|
||||||
|
```
|
||||||
|
|
||||||
|
## The native surface
|
||||||
|
|
||||||
|
Everything below is installed in the global scope by the Rust side. The JS
|
||||||
|
prelude (`src/prelude.js`) adds the helpers listed at the bottom.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Process control
|
||||||
|
spawn(path, ...args) // -> { pid, status: "stopped_at_entry" }
|
||||||
|
step(n = 1) // -> stop-reason string
|
||||||
|
cont() // -> stop-reason string (NOT `continue` — JS keyword)
|
||||||
|
|
||||||
|
// Stop reasons (returned as strings):
|
||||||
|
// "step", "breakpoint:N", "signal:SIGSEGV", "exited:0", "killed:SIGKILL"
|
||||||
|
|
||||||
|
// Live state (each property re-reads via ptrace on access)
|
||||||
|
state.pc // -> BigInt
|
||||||
|
state.sp // -> BigInt
|
||||||
|
state.registers // -> { rax, rbx, ..., r15, eflags } all BigInt
|
||||||
|
state.memory(addr) // -> { u8(), u32(), u64(), i64(), bytes(n) }
|
||||||
|
state.disasm(n = 8) // -> [{addr, bytes, text, target?, target_sym?}]
|
||||||
|
state.stack // -> { top, usable_base, guard_size }
|
||||||
|
|
||||||
|
// Snapshot before the most recent step/cont — same shape as state for regs.
|
||||||
|
// Throws if no step has happened yet.
|
||||||
|
prev.pc / prev.sp / prev.registers
|
||||||
|
|
||||||
|
// Symbols (main exe only; shared libs deferred)
|
||||||
|
sym(addr) // -> "name+0xN" | "name" | null
|
||||||
|
addr(name) // -> BigInt | null
|
||||||
|
symbols() // -> { count, load_base, path }
|
||||||
|
disasm(addr, n = 8) // -> same shape as state.disasm
|
||||||
|
|
||||||
|
// Breakpoints (`breakpoint` and `bp` are the same object)
|
||||||
|
bp.set(addr_or_symbol, name?) // -> id
|
||||||
|
bp.remove(id_or_name)
|
||||||
|
bp.list() // -> [{id, addr, name, hits, enabled}]
|
||||||
|
|
||||||
|
// User scratchpad (persistent across all evals)
|
||||||
|
ctx.something = whatever
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prelude (`src/prelude.js`)
|
||||||
|
|
||||||
|
Pure JS, loaded last so it can reference the natives. Edit it freely; it gets
|
||||||
|
re-embedded into the binary on every `cargo build` via `include_str!`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
hex(v) // -> "0x..." for Number or BigInt
|
||||||
|
unhex(s) // -> BigInt (handles "0x", case, sign)
|
||||||
|
dump(label, v) // -> print one terse "label\tvalue" line
|
||||||
|
repr(v) // -> string form; hex for numerics, JSON for objects
|
||||||
|
addrstr(v) // -> "0xN (sym+0xoff)" when sym is known, else "0xN"
|
||||||
|
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
|
||||||
|
|
||||||
|
show.disasm(n=8, [at])
|
||||||
|
show.bp()
|
||||||
|
show.regs([keys])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs ─ stdin read loop, ---END--- framing, SIGKILL on exit
|
||||||
|
├── target.rs ─ ptrace child management: spawn, step, cont, regs,
|
||||||
|
│ process_vm_readv, breakpoint patching (read-byte+poke-byte
|
||||||
|
│ via PEEKDATA/POKEDATA word RMW)
|
||||||
|
├── symbols.rs ─ ELF parsing via `object`; PIE load-base from /proc/maps;
|
||||||
|
│ StackRegion from /proc/maps
|
||||||
|
├── breakpoint.rs ─ BreakpointSet storage (by id, by addr, by name)
|
||||||
|
├── disasm.rs ─ thin wrapper around iced-x86 FastFormatter; emits records
|
||||||
|
│ with branch_target for caller symbolisation
|
||||||
|
├── js.rs ─ Runtime + Context + Rc<RefCell<Option<Target>>>;
|
||||||
|
│ installs all native globals, then evals prelude.js
|
||||||
|
└── prelude.js ─ embedded at compile time
|
||||||
|
```
|
||||||
|
|
||||||
|
### A few decisions worth knowing
|
||||||
|
|
||||||
|
- **BigInt for u64.** A JS Number above 2⁵³ loses precision, which is exactly
|
||||||
|
the range we live in. `state.pc`, `addr(...)`, anything 64-bit goes in and
|
||||||
|
out as BigInt. `state.memory(addr).u8()` and `.u32()` stay as Number.
|
||||||
|
- **Lazy state.** Every access to `state.pc` does a fresh `PTRACE_GETREGS`;
|
||||||
|
every `state.memory(...)` does a fresh `process_vm_readv`. A script that
|
||||||
|
touches three fields makes three syscalls, not 20. Worth it for scripts; the
|
||||||
|
cache would just be a footgun once you have multi-threaded targets.
|
||||||
|
- **`continue()` doesn't exist.** `continue` is a reserved keyword in JS, so
|
||||||
|
the natural name is a parse error. The spec's `continue()` is `cont()` here.
|
||||||
|
- **Single QuickJS context for the daemon's lifetime.** `ctx` (the JS
|
||||||
|
scratchpad), registered listeners (when they land), and any user-defined
|
||||||
|
helpers all persist. Only resets on daemon restart.
|
||||||
|
- **Read-modify-write breakpoint patching.** `ptrace::write` writes a full
|
||||||
|
word. We `ptrace::read` first, modify byte 0, write the word back. The other
|
||||||
|
seven bytes stay unchanged because we just read them.
|
||||||
|
- **`read_mem_pristine`.** Used by the disassembler so it sees the *original*
|
||||||
|
byte, not 0xCC, at active breakpoints. Without this, a `disasm` over a bp'd
|
||||||
|
function shows `int3; ...` at the first byte.
|
||||||
|
- **PIE load base.** Computed as `mapping_base - mapping_file_offset` of the
|
||||||
|
*first* mapping of the executable in `/proc/<pid>/maps`. Earlier versions
|
||||||
|
used the first executable mapping and dropped the file offset, which is
|
||||||
|
wrong on toolchains that split read-only header pages.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Linux x86-64 only. No multi-arch, no other OSes.
|
||||||
|
- Single-threaded debuggees. A pthread'd binary will behave oddly the moment a
|
||||||
|
non-main thread does anything.
|
||||||
|
- Symbols only cover the main executable. Shared-library functions (e.g.
|
||||||
|
`printf`, `malloc`) resolve to `null` until ld.so awareness lands.
|
||||||
|
- `finish()` assumes a frame pointer (`-fno-omit-frame-pointer`, which is the
|
||||||
|
default on most distros). It will throw if `rbp == 0`.
|
||||||
|
- The sandbox guard-page detection in `state.stack.guard_size` returns 0 on
|
||||||
|
Linux configurations where the kernel handles guard implicitly without an
|
||||||
|
anonymous `---p` mapping below the stack. That's most modern setups.
|
||||||
|
- No `attach(pid)` yet — only spawn.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Personal experiment, no license declared. Treat as all-rights-reserved by the
|
||||||
|
author until otherwise noted.
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# LLM Debugger — Vision & Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Debuggers are built for humans at a terminal. They optimize for interactive, ad-hoc inspection: type a command, read output, think, repeat. LLMs operating as coding agents have fundamentally different needs:
|
||||||
|
|
||||||
|
- **Predictable, terse output** — token efficiency matters; verbose JSON or gdb's human-readable output wastes context window
|
||||||
|
- **Scriptable observation** — the agent defines *what it wants to see* per step, not just *what to do*
|
||||||
|
- **Stateful sessions** — the debugger holds state across many tool calls so the LLM's context window doesn't have to
|
||||||
|
- **Live human visibility** — a human watching the agent work needs a real-time view without being in the loop
|
||||||
|
|
||||||
|
Existing tools don't fit. gdb/lldb optimize for humans. gdb's MI protocol is unstable and incomplete. DAP is well-designed but IDE-shaped, not agent-shaped. There is no debugger built from first principles for an LLM agent as the primary user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
A **stateful debugger REPL process** that:
|
||||||
|
|
||||||
|
- Attaches to or spawns a Linux process via ptrace
|
||||||
|
- Exposes a rich, lazy `state` object representing the current stopped state
|
||||||
|
- Embeds a QuickJS interpreter — the LLM sends raw JavaScript, stdout comes back
|
||||||
|
- Hooks into debugger events (step, breakpoint, watchpoint) by letting the LLM register JS event listeners
|
||||||
|
- Communicates over stdin/stdout — making it trivially usable as a subprocess from any agent harness, and easily testable in a sandbox
|
||||||
|
|
||||||
|
The LLM is the primary author of scripts. A human may observe via a browser visualization (deferred — see *Not Yet*).
|
||||||
|
|
||||||
|
### The Core Loop
|
||||||
|
|
||||||
|
```
|
||||||
|
agent harness
|
||||||
|
│ raw JS (stdin)
|
||||||
|
▼
|
||||||
|
debugger REPL process
|
||||||
|
│ evaluates JS against live state
|
||||||
|
│ fires registered listeners on events
|
||||||
|
▼
|
||||||
|
stdout (terse output — tab/newline delimited)
|
||||||
|
```
|
||||||
|
|
||||||
|
No custom protocol. No structured command parser. The language is the interface.
|
||||||
|
|
||||||
|
### What the LLM Can Do
|
||||||
|
|
||||||
|
Anything expressible in JS against the `state` object. Examples ranging from trivial to sophisticated:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// One-liner inspection
|
||||||
|
state.pc
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Register a step hook
|
||||||
|
addEventListener('step', e => {
|
||||||
|
let dist = e.state.sp - e.state.stack.usable_base;
|
||||||
|
print(`pc=${hex(e.state.pc)} sp_dist=${dist}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Stateful tracking across steps via ctx
|
||||||
|
addEventListener('step', e => {
|
||||||
|
ctx.min_sp = Math.min(ctx.min_sp ?? e.state.sp, e.state.sp);
|
||||||
|
print(`pc=${hex(e.state.pc)} lowest_sp=${hex(ctx.min_sp)}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// DOM mutation for human visualization (future)
|
||||||
|
addEventListener('step', e => {
|
||||||
|
dom.id('pc').textContent = hex(e.state.pc);
|
||||||
|
print(hex(e.state.pc));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Debugger backend | `ptrace` (Linux) | Via `nix` crate |
|
||||||
|
| Host process | Rust | CLI binary |
|
||||||
|
| Scripting | QuickJS | Via `rquickjs` crate |
|
||||||
|
| Disassembly | `iced-x86` | x86-64 |
|
||||||
|
| DWARF / unwinding | `gimli` | Frame unwinding, debug info |
|
||||||
|
| ELF / symbols | `object` | Symbol table, TLS segments |
|
||||||
|
|
||||||
|
### Process Model
|
||||||
|
|
||||||
|
The REPL is a **CLI daemon** — launched by the agent harness as a subprocess, communicating over stdin/stdout. One REPL process per debug session. State (breakpoints, watchpoints, `ctx`, registered listeners) persists for the lifetime of the process.
|
||||||
|
|
||||||
|
On attach, the REPL:
|
||||||
|
1. Parses the ELF binary (symbols, TLS layout, section map)
|
||||||
|
2. Attaches via ptrace
|
||||||
|
3. Enters the read-eval-print loop
|
||||||
|
|
||||||
|
On detach, the target process is allowed to continue (Linux ptrace default).
|
||||||
|
|
||||||
|
### The `state` Object
|
||||||
|
|
||||||
|
Exposed to every JS evaluation. Access is **lazy** — each property triggers a ptrace/procfs read on demand. Accessed fields are **snapshotted** after each evaluation, making them available as `prev` on the next event.
|
||||||
|
|
||||||
|
```js
|
||||||
|
state.pc // current instruction pointer
|
||||||
|
state.sp // stack pointer
|
||||||
|
state.registers.{rax, rbx, ...} // all GP registers
|
||||||
|
state.flags.{z, c, n, v, ...} // condition flags
|
||||||
|
state.instruction.addr // address of current instruction
|
||||||
|
state.instruction.bytes // raw bytes (hex string)
|
||||||
|
state.instruction.mnemonic // e.g. "mov rsp, rax"
|
||||||
|
state.instruction.operands // array of operand strings
|
||||||
|
|
||||||
|
state.memory(addr).u8() // typed memory accessors
|
||||||
|
state.memory(addr).u32()
|
||||||
|
state.memory(addr).u64()
|
||||||
|
state.memory(addr).i64()
|
||||||
|
state.memory(addr).bytes(n) // raw hex string, n bytes
|
||||||
|
state.memory(addr).str() // null-terminated C string
|
||||||
|
|
||||||
|
state.disasm(n) // disassemble n instructions from pc
|
||||||
|
state.symbol_at(addr) // → string | null
|
||||||
|
state.thread_local(name) // → value (resolved via fs_base + ELF TLS offset)
|
||||||
|
|
||||||
|
state.stack.top // highest valid stack address
|
||||||
|
state.stack.usable_base // lowest usable address (above guard)
|
||||||
|
state.stack.guard_size // guard page size in bytes
|
||||||
|
|
||||||
|
state.unwind() // → [{frame, pc, sp, symbol}]
|
||||||
|
|
||||||
|
prev.pc // same shape, previous stop's snapshot
|
||||||
|
prev.sp
|
||||||
|
// ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
### The `ctx` Object
|
||||||
|
|
||||||
|
A plain mutable JS object, persistent across all evaluations for the lifetime of the REPL process. The LLM initialises it however it wants. Resets only on REPL restart.
|
||||||
|
|
||||||
|
```js
|
||||||
|
ctx.my_counter = (ctx.my_counter ?? 0) + 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Listeners
|
||||||
|
|
||||||
|
The REPL drives the QuickJS event loop explicitly — no libuv, no async. On each debugger stop, the REPL:
|
||||||
|
|
||||||
|
1. Determines stop reason (step, breakpoint, watchpoint, signal)
|
||||||
|
2. Looks up registered listeners for that event type
|
||||||
|
3. Calls each with an event object `{ state, prev, stop_reason }`
|
||||||
|
4. Collects printed output, returns it to stdout
|
||||||
|
|
||||||
|
```js
|
||||||
|
addEventListener('step', handler)
|
||||||
|
addEventListener('breakpoint', handler) // fires with e.breakpoint_id
|
||||||
|
addEventListener('watchpoint', handler) // fires with e.watchpoint_id, e.access_type
|
||||||
|
addEventListener('signal', handler) // fires with e.signal (SIGSEGV etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution Control (Built-in Globals)
|
||||||
|
|
||||||
|
Control flow and setup are plain JS functions available as globals — no special syntax, no parser. They compose naturally with the rest of the scripting layer.
|
||||||
|
|
||||||
|
```js
|
||||||
|
step(n?) // step n instructions (default 1)
|
||||||
|
continue() // resume execution
|
||||||
|
attach(pid) // attach to running process
|
||||||
|
spawn(binary, ...args) // spawn and attach
|
||||||
|
detach() // detach, target continues
|
||||||
|
|
||||||
|
breakpoint.set(addr_or_symbol, name?) // → id
|
||||||
|
breakpoint.remove(id_or_name)
|
||||||
|
breakpoint.list() // → [{id, addr, name, hits}]
|
||||||
|
|
||||||
|
watchpoint.set(addr, 'r'|'w'|'rw', name?)
|
||||||
|
watchpoint.remove(id_or_name)
|
||||||
|
watchpoint.list()
|
||||||
|
|
||||||
|
thread.list() // → [{tid, state, ...}]
|
||||||
|
thread.select(tid)
|
||||||
|
|
||||||
|
symbols() // dump loaded symbol table
|
||||||
|
ctx.reset() // clear ctx object
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is JS. A breakpoint can be set inside a conditional, `step()` can be called in a loop, return values are inspectable. No escape hatch needed.
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
|
||||||
|
Terse, tab-and-newline delimited. No JSON. Designed for token efficiency:
|
||||||
|
|
||||||
|
```
|
||||||
|
pc 0x8020
|
||||||
|
sp 0x7ffe0000
|
||||||
|
instr mov rsp, rax
|
||||||
|
dist 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
The LLM author of the script controls output entirely via `print()`. The format above is a convention, not enforced.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- JS exceptions → clean error string on stdout, no panic
|
||||||
|
- ptrace failures mid-script (bad address, process died) → error string on stdout with context
|
||||||
|
- Unknown `:command` → error string
|
||||||
|
|
||||||
|
### Symbol & TLS Resolution
|
||||||
|
|
||||||
|
On attach, the REPL parses the ELF binary and any loaded shared libraries (via `/proc/pid/maps`) to build:
|
||||||
|
- Address → symbol name map
|
||||||
|
- Symbol name → address map
|
||||||
|
- TLS variable name → `fs_base` offset map (from ELF TLS segments)
|
||||||
|
|
||||||
|
`state.thread_local("SCHEDULER_SP")` resolves to `*(fs_base + offset)` — a direct memory read, no runtime lookup overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
**Threading model during step/continue**
|
||||||
|
When multiple threads are running and a non-selected thread hits a breakpoint, does the REPL surface that as an event, silently stop that thread, or let it continue? The right answer probably depends on use case but needs a concrete default.
|
||||||
|
|
||||||
|
**Attach vs spawn lifecycle differences**
|
||||||
|
On spawn, the REPL owns the process and killing the REPL kills the target. On attach, detach should leave the target running. Are there other meaningful differences in what state is available?
|
||||||
|
|
||||||
|
**Batch stepping efficiency**
|
||||||
|
`:step 1000` fires the step listener 1000 times. For large step counts this may be slow. A future `--summary` mode (first, last, interesting events) would help, but the right shape of "interesting" is unclear without real usage data.
|
||||||
|
|
||||||
|
**Memory accessor return types**
|
||||||
|
`state.memory(addr).u64()` returns a JS number, which loses precision above 2^53. For addresses and large register values this matters. Options: BigInt (awkward), hex string (loses arithmetic), or a thin wrapper type. Needs a decision before implementation.
|
||||||
|
|
||||||
|
**Reverse execution**
|
||||||
|
ptrace doesn't support it natively. Would require record-and-replay (rr-style). Desirable but the implementation cost is high.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Not
|
||||||
|
|
||||||
|
**gdb/lldb as backend**
|
||||||
|
gdb MI is unstable across versions, incomplete, and not designed for programmatic use. The wrapper code is always a mess. lldb's C API is clean but pulls in all of LLVM. ptrace is 30 syscalls we own completely — less total work for a cleaner result.
|
||||||
|
|
||||||
|
**DAP (Debug Adapter Protocol)**
|
||||||
|
Well-designed, but IDE-shaped. Adds significant protocol surface for a benefit (editor integration) that isn't the goal. The LLM doesn't need a DAP client.
|
||||||
|
|
||||||
|
**Python as scripting layer**
|
||||||
|
Best LLM familiarity, but `pyo3` embedding is heavy, startup is slow, and multi-line string ergonomics in a single tool call are worse than JS. QuickJS wins on the embedded use case.
|
||||||
|
|
||||||
|
**Lua as scripting layer**
|
||||||
|
Lightweight and excellent for embedding. Loses to JS specifically because DOM manipulation is the other major scripting use case, and `dom.id('x').textContent = y` is native JS idiom. LLMs have also seen vastly more JS than Lua.
|
||||||
|
|
||||||
|
**Emulator/sandbox backend (Unicorn, qemu-user)**
|
||||||
|
Deterministic and safe, but no real syscalls, no real threads, no signals. The goal is debugging real programs in production-like conditions.
|
||||||
|
|
||||||
|
**Structured command protocol**
|
||||||
|
A JSON or XML command/response protocol between harness and REPL adds parsing complexity on both ends for no benefit. stdin/stdout with raw JS in and terse text out is simpler and more flexible.
|
||||||
|
|
||||||
|
**Eager state snapshots**
|
||||||
|
Snapshotting all ptrace-readable state on every stop wastes syscalls when scripts touch 3–5 fields. Lazy access with snapshot-on-read is the right default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Not Yet (MVP Scope)
|
||||||
|
|
||||||
|
**Browser visualization / DOM API**
|
||||||
|
The `dom` object and SSE transport to a browser (Datastar or otherwise) are architecturally clean to add later — the JS scripting layer is already there. Not in MVP.
|
||||||
|
|
||||||
|
**Rich browser inspection surfaces**
|
||||||
|
The hook author (LLM or human) knows the semantic meaning of memory that the compiler forgot. This opens up a class of visualization far beyond register tables and SP gauges. A pixel buffer becomes a canvas image (`putImageData`). A mesh becomes a Three.js render. A ring buffer becomes a waveform or spectrogram via Web Audio. An actor graph becomes a D3 force layout. The DOM API design should be rich enough to support canvas, WebGL, and Web Audio — not just `textContent` mutations — so this possibility isn't accidentally closed off. Implementation deferred until the basic visualization layer exists.
|
||||||
|
|
||||||
|
**DWARF locals and source-level debugging**
|
||||||
|
`gimli` can give us source lines and local variable names. High value, significant implementation work. Frame unwinding is in MVP; locals are not.
|
||||||
|
|
||||||
|
**Conditional breakpoints**
|
||||||
|
`:break <addr> --condition "state.sp < 0x1000"` is a natural extension. Defer until the core hook system is proven.
|
||||||
|
|
||||||
|
**Multi-threaded event routing**
|
||||||
|
Selecting threads and stepping them is in MVP. Simultaneous multi-thread event dispatch (all threads running, any can hit a breakpoint) is a harder scheduling problem — defer.
|
||||||
|
|
||||||
|
**Reverse execution / time-travel**
|
||||||
|
Requires record-and-replay infrastructure. Significant project in itself.
|
||||||
|
|
||||||
|
**Remote debugging**
|
||||||
|
gdbserver-style attach over a socket. The stdin/stdout model works locally; network transport is a later concern.
|
||||||
|
|
||||||
|
**Shared library / dynamic linker awareness**
|
||||||
|
Symbols from dynamically loaded libraries (via `dlopen`) won't be in the initial ELF parse. Watching `ld.so` events to update the symbol table at runtime is deferred.
|
||||||
Reference in New Issue
Block a user