Skip to content

debug: add interactive debug mode - #203

Open
jow- wants to merge 22 commits into
masterfrom
debugger
Open

debug: add interactive debug mode#203
jow- wants to merge 22 commits into
masterfrom
debugger

Conversation

@jow-

@jow- jow- commented May 24, 2024

Copy link
Copy Markdown
Owner

Introduce a new interactive debug cli which can be started by invoking the ucode interpreter with the -x flag.

The main debugger functionality resides in the debug library while the cli frontend just gains some minimal amount of code to load and instantiate the debug library when the -x option is passed.

As a precondition for the new debug functionality, the ucode VM has been extended with low level breakpoint support.

@jow-
jow- marked this pull request as ready for review June 20, 2024 22:07
@jow-
jow- force-pushed the debugger branch 2 times, most recently from e0b5062 to 5454d3c Compare July 26, 2026 00:34
jow- added 22 commits July 27, 2026 00:46
Add a new dict type that extends ucode objects by allowing arbitrary
value keys (not limited to strings). Key uniqueness follows uc_uniq()
semantics:

  - Scalars (null, bool, int, double, string): compared by value
  - Non-scalars (arrays, objects, etc.): compared by pointer equality
  - NaN doubles are treated as equal

Dicts are distinguished from regular objects by their hash table
equal_fn function pointer, preserving ext_flag for is_constant semantics.

Provided functionality:
  - dict() stdlib constructor accepting optional source object/dict/array
  - keys() / values() returning actual value keys for dicts
  - for...in iteration yielding value keys
  - Spread operator support (dict->object converts keys to strings,
    object->dict preserves strings as string values)
  - Prototype chain lookup across dict/object boundaries
  - GC marking for dict value keys
  - JSON/stringification converting value keys to strings

Add stdlib test suite (tests/custom/03_stdlib/69_dict).

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
When an inner function invoked exit() in a ucode > native > ucode call stack
situation, the VM did end up with an empty callframe stack after the native
function returned, leading to subsequent invalid memory accesses.

Properly deal with situation similar to how we also check for an empty call
stack after processing I_RETURN and additionally translate the current
exception type to a status return value.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Introduce low level facilities for registering breakpoints in the running
VM context. The breakpoint primitives allow invoking provided callback
functions when the VM reaches an associated instruction address.

This functionality provides the foundation for building more thorough
interactive debug functionality on top.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Publicly export the instruction format table in order to make it useable
for libucode.so users, such as dynamically loaded debug libraries.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
When additional ucode scripts are loaded through require() or similar means,
and the required code invokes exit(), the VM will clear the stack before
returning, so we must ensure that the stack size matches our expectation
before we're trying to pop values from it after returning from require.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Implement an interactive command line debugger within the debug module
which can be started by invoking the `debugger()` function.

The debugger offers common features such as the ability to set breakpoints,
stepping through commands, examining call stacks and variables as well as
byte code disassembly source code highlighting.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Introduce a new command line switch `-x` which loads the debug module and
launches the given program or expression within the interactive debugger.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
- Add isatty() check to detect interactive vs non-interactive mode
- Implement term_getline_fallback() for simple line reading when piped
- Skip terminal raw mode and signal settings in non-interactive mode
- Add EOF handling in term_getc_raw() to return -1 in non-interactive mode
- Add quit -f flag for forced quit without confirmation in non-interactive mode
- Fix filename_matches_pattern() to handle basenames without path separators
- Fix BK_STEP breakpoint handling to properly free breakpoints after being hit
- Fix cmd_continue() to return false after processing one command

All 45 debugger tests pass.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
The remote debug protocol only exposed a hand-rolled subset of commands
(continue/quit/help/print) against a small line-based dispatcher, while
the local interactive debugger in lib/debug.c supports 16 commands
(break, delete, list, next, step, continue, return, backtrace,
variables, sources, print, lines, throw, disassemble, quit) with tab
completion, history and ANSI-highlighted source/backtrace rendering.

Rather than reimplementing all of that against the remote protocol,
reuse it directly: term_getline()/term_printf() only ever do plain
read()/write() on STDIN_FILENO/STDOUT_FILENO, with tty-specific
tcgetattr/tcsetattr calls isolated in term_raw()/term_isig()/
term_reset(). So once a client connects, debug_cli_run_remote_session()
dup2()s the socket onto stdin/stdout for the session and calls the same
bk_enter_cli() dispatcher used locally, skipping just the tty ioctls via
a new termstate.remote flag (a socket has no line discipline to
configure; the remote peer manages its own local raw mode). No PTY is
needed - raw single-key reads and ANSI rendering work identically over
a plain socket once those ioctls are skipped.

Breakpoints set during a session keep working across "continue" since
they're dispatched directly from uc_vm_execute_chunk()'s per-instruction
breakpoint check, nested inside the uc_vm_resume() call
debug_cli_run_remote_session() makes after the initial CLI call
returns - so they reenter bk_enter_cli() using the same fds.

lib/debug_remote.c is now transport-only (socket create/accept/cleanup
plus the EVENT push helpers); the old uloop-based line protocol
(debug_handle_command, debug_remote_loop, the uloop fd callbacks) is
gone. udbg.c changes from doing its own local line-editing to a
transparent raw byte pump in both directions, since the server now
drives all of the rendering - matching how a real terminal client
should behave once the full CLI is exposed remotely.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Add a script-callable debug.listen() covering three cases: no argument
arms SIGUSR1-triggered remote debugging on the PID-derived attach socket
without blocking; a truish argument does the same but also blocks right
here until a client connects (or a 30s timeout); a string argument binds
an arbitrary caller-chosen socket path and blocks indefinitely. This is
the counterpart to -X for host applications that embed the VM directly
(uhttpd, uwsd, ...) and have no -X flag of their own. The SIGUSR1 case is
dispatched through ucode's own signal() builtin rather than the
break_requested/STATUS_BREAK mechanism -X uses, since the latter unwinds
the entire C call stack back to whoever called uc_vm_execute()/
uc_vm_call(), which an embedding host has no way to handle safely.

Along the way, consolidate the previously separate, largely duplicated
debug.listen(path) (arbitrary path, blocking accept, inline bind/listen
code) into this single function, sharing one bind/listen helper
(debug_remote_bind_and_listen()) with the SIGUSR1 attach socket instead
of two near-identical copies. debug_remote.c is now purely socket
transport (bind/accept/cleanup, EVENT push helpers); the script-facing
API and the interactive session driver live in debug.c. Also drops the
now-redundant debug_remote_fns[]/uc_module_init_remote() registration
path (debug.listen was already being registered a second time via
debug_fns[] in debug.c).

Testing this against a real embedding host (uwsd, which calls
uc_vm_init(&ctx.vm, NULL)) surfaced a serious pre-existing bug: with no
config, setup_signal_handlers defaults to false, so
uc_vm_signal_handlers_setup() never wires up the signal self-pipe/handler
array. Installing a handler through signal() in that state silently ends
up with a NULL/SIG_DFL disposition, which terminates the process on the
next occurrence of that signal instead of invoking the handler -
confirmed by sending SIGUSR1 to a live uwsd worker and watching it die.
This affected not just the new debug.listen() but also the pre-existing
debug.attach() and the memory-dump signal handler (SIGUSR2 by default),
for any host that doesn't opt into setup_signal_handlers.

Fix: split the self-pipe/handler-array setup out of
uc_vm_signal_handlers_setup() into a new uc_vm_signal_handlers_ensure(),
exported so debug_setup() can call it unconditionally at debug module
load time regardless of what the host configured. uc_vm_signal_dispatch()
now checks whether the pipe was actually initialized rather than
re-checking the original config flag, so signals raised this way are
correctly dispatched too.

Verified end-to-end against an isolated build of uwsd (linked against a
temporary ucode install so as not to touch the system installation): a
debug.listen()-armed handler script paused mid-request on SIGUSR1, udbg
attached and ran backtrace/continue against it showing the real
onBody(request=<uwsd.connection ...>) call stack, and the worker process
remained healthy and resumed normally afterwards.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Fixes found while exercising the remote debugger end to end:

- bk_enter_cli() redid the whole listen/accept/splice dance on every
  breakpoint hit, tearing down the live connection on each "next"/"step"
  and requiring the client to reconnect within a 30s window or the script
  would silently run to completion. It now reuses an already-connected
  client across breakpoint hits, and on an unexpected disconnect (not an
  explicit "quit") re-arms the listen socket instead of resuming the
  paused script, so udbg can reconnect where it left off.
- insn_length()/cmd_disasm() mis-decoded the I_CALL operand's spread-count
  bits, corrupting bytecode offset math for any call site (method calls
  especially) and producing garbage disassembly.
- cmd_delete()'s "delete current breakpoint" path freed the very
  debug_breakpoint_t bk_enter_cli() was still using for the rest of the
  session (subsequent next/step reads ->depth; end-of-session cleanup
  reads ->kind/->bk.ip) - a use-after-free and, once the session ended, a
  double free. Deleting it now just unlinks it and defers the actual
  free() until bk_enter_cli() is done with it.
- uc_debug_attach() unconditionally put the target's own controlling
  terminal into raw mode at attach time, even though attach-mode sessions
  only ever interact over the spliced remote socket once a client
  connects - left the launching terminal echo-less with nothing to ever
  restore it.
- format_context_header_backtrace()/format_context_header_callframe()
  never terminated their breadcrumb-bar line, so the following source
  snippet ran on directly after it instead of starting on its own line.

Also adds:

- debug.notifyExit(), pushing a JSON "EVENT exit" message to an attached
  remote client right before the target exits (normal completion, exit(),
  or an uncaught error, with the full exception object incl. stacktrace)
  instead of leaving the client to infer it from the socket closing.
- debug_remote_notify_exception()/_notify_exit() now serialize the actual
  {type, message, stacktrace} exception object (vm.c's
  uc_vm_exception_object()) as JSON rather than a prose string.
- A dedicated BK_UNCAUGHT system breakpoint (vm.c) that fires right before
  an exception nothing would catch starts unwinding the stack, with
  callframes still fully intact - unlike hooking the existing exception
  handler chain, which only runs after the real (destructive) unwind has
  already popped the throwing frame. uc_vm_exception_would_be_caught()
  non-destructively predicts whether anything would handle the exception
  before deciding to break.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Replace the monolithic terminal debugger with a structured, line-based
protocol (VERB + optional JSON payload per line, see lib/debug_proto.h)
so the debug core never renders anything - no ANSI, no source text, no
formatted columns - and any client can drive it by speaking the wire
format alone.

- lib/debug_proto.c/h: protocol framing shared by the server.
- lib/debug.c: all CLI commands rewritten to emit structured responses;
  bk_enter_session() replaces the old bk_enter_cli(), dispatching on VERB
  over whatever fd it's handed (local socketpair or remote socket) rather
  than raw terminal I/O over dup2'd stdio.
- udbg.c: rewritten as a real protocol client (typed commands, rendered
  responses) instead of a dumb byte-forwarder; supports <pid>, a
  socket path, or --fd N (used internally by local -x mode).
- debug_highlight.c/h: the original regex-based ucode/utpl syntax
  highlighter and ANSI source renderer, ported out of lib/debug.c into a
  standalone module with no ucode dependencies, adapted to the protocol's
  per-line {file,line,col} coordinates instead of live source buffers.
- Local `-x` mode now forks and execs `udbg --fd 3` over a socketpair
  instead of raw-tty'ing its own stdio, converging local and remote
  sessions onto one code path.
- tests/custom/99_debugger: migrated to drive real subprocesses via
  `-X<file>:1` and assert on parsed protocol messages instead of
  rendered text.
- docs/debugger.md: rewritten for the protocol/client-server split.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Fix a real hang-looking bug: the "dbg > " prompt only reappeared on a
fresh PAUSED message, never after other command responses (bt, lines,
...), and stdin could be read/sent before the connection's own initial
PAUSED had even been drained - racing ahead of and garbling in-flight
responses (most visibly, overlapping SOURCE fetches clobbering each
other's pending state). Both are now gated on one "actually at a prompt"
condition.

Port the remaining original rendering pieces from lib/debug.c's git
history into debug_highlight.c, adapted for the protocol:
- format_context_header_backtrace()/format_context_header_callframe()'s
  full-width "[file] breadcrumb "/"[file] signature " status bar, shown
  above a paused location and above each backtrace frame. The server now
  sends the full call-chain breadcrumb in PAUSED's payload since the
  client no longer has the raw callframe stack to derive it from itself.
- The single underlined "current instruction" character, in addition to
  the shaded statement span.

Backtrace frames now render their own highlighted source snippet, header
bar included - this needed a small async multi-file fetch queue in
udbg.c since a backtrace can span several source files at once, unlike
every other response which only ever needs one.

Async EVENT messages (exception/exit/signal) are now rendered as
human-readable, faint+italic lines instead of a raw JSON dump.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
udbg was unconditionally fetching source text from the server via
SOURCE, even though the common cases are either fully local debugging
(client and target share a filesystem) or a dev checkout driving a
remote target where the *client* has the better source access, not the
server. Try the exact reported path locally first; only fall back to
asking the server if that fails.

Add -s/--srcdir DIR for when the reported path doesn't exist as-is on
this machine (different checkout/build root): DIR joined with just the
reported path's basename is tried before the server fallback.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
…s gaps

- The protocol's "col"/"from_col"/"to_col" fields are 1-based (human
  "line:col" display, see uc_source_get_line() in source.c), but
  debug_highlight's span/ip columns are 0-based byte indices - every
  consumer except render_paused() was feeding the raw 1-based value
  straight in, shifting the underline/highlight one character to the
  right. Added col0() and applied it everywhere a "col" field feeds a
  debug_highlight_span_t.
- render_source_lines() passed the full terminal width to
  debug_highlight_print_source() without subtracting left_pad, so
  backtrace's indented (left_pad=2) snippets ran 2 columns past the
  terminal width instead of wrapping/padding to what's actually left.
- Ported format_context_statement()'s multi-range/ellipsis-gap layout
  (a window at the start, a "…" gap, a window around the current
  instruction/end) for statements too long to show in full, via a new
  debug_highlight_print_source_ranges() and a client-side
  compute_context_ranges() that mirrors the original's split heuristic.

Also:
- Fix the prompt never reappearing after an unrecognized command (or
  "quit" declined at its confirmation prompt): send_command() now
  reports whether it actually dispatched anything to the server, and
  the main loop only waits for a response when it did.
- Command matching is now shortest-unique-prefix against each command's
  full name (plus the existing short aliases that aren't literal
  prefixes, e.g. "bt", "ls"), matching the original interactive CLI's
  dispatch instead of requiring the full word or a fixed 1-letter alias.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
update_catchpoint() re-arms the exception catchpoint on every debugger
pause, via update_breakpoint()'s "already armed at this ip -> invoke
handler now" shortcut. That shortcut is fine for its BK_STEP use cases,
but unsound for BK_CATCH: whenever two pauses land inside the same
try/catch range (trivial to hit whenever a function's whole body is
wrapped in one try, e.g. a test runner's loop), the second pause finds
the target already armed and fires bk_handle_catch() -> bk_enter_session()
synchronously and recursively, without the VM ever advancing - overflowing
the stack. Arm the breakpoint directly instead of going through that
fast path.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
…ace width

Restores the pre-protocol interactive debugger's rendering richness on top
of the new client/server split, in debug_highlight.c so any client can
reuse it:

- DISASSEMBLE: color-coded hex byte dump, semantic operand annotations
  (constants, local/upval/global names, closure/arrow index with capture
  lines, and now a decoded CALL mcall-flag/argcount) - the server now also
  ships raw instruction bytes, operand format and call/closure detail
  needed to render this client-side.
- VARIABLES: kind shown via color only (bold cyan upvalue, faint this/
  internal) matching the old CLI, with values rendered compact/single-line
  and truncated the old way (ellipsis placed before a synthetic closing
  bracket/quote) instead of always pretty-printed across multiple lines.
- help: answered entirely client-side from a ported, word-wrapped usage
  table describing this client's own typed commands, instead of dumping
  the server's wire-protocol verb/payload reference.
- backtrace: fixed each frame's header bar overflowing the terminal width
  by the "#N " prefix's length.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
- New debug_lineedit.c/.h: raw-mode, non-blocking terminal line editing
  for udbg's "dbg > " prompt - history (up/down), word-jump (Ctrl-Left/
  Right), Ctrl-W, Tab command-name completion - ported from the
  pre-protocol interactive debugger's hand-rolled termline_t editor,
  with no external readline/editline dependency, matching that original
  choice. Falls back to plain fgets() when stdin isn't a tty.
- New SET command: the idiomatic way to change a variable's value while
  paused (`set x expr`), instead of misusing PRINT with an assignment
  expression. Writes straight into the resolved local/upvalue slot or
  the same undeclared-global fallback plain assignment uses.
- PRINT no longer rejects expressions that don't start with a variable/
  this load (eval_expr's old chunk->entries[0] check) - it only ever
  blocked bare-literal-first expressions like "1+2" while already
  letting through identifier-first mutations, so it wasn't a meaningful
  restriction to begin with. print/set now evaluate like GDB's print:
  any expression, side effects included.
- Fixed a real hang this unlocked: eval_expr() runs the compiled
  expression with a fresh, empty callframe/stack (so it can't see the
  paused program's real call stack), which makes an exception raised
  inside it look exactly like the whole program running out of
  callframes to unwind to - precisely what the debugger's "pause on
  uncaught exception" system breakpoint exists to catch. Left armed, a
  throwing print/set expression paused into a confusing nested session
  instead of just reporting the exception back as part of that
  command's own reply. eval_expr() now disarms that breakpoint (and
  BK_CATCH, defensively) for the duration of the call.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
- Replace the dedicated SET command with EVAL, mirroring the ucode CLI's
  -e/-p distinction: EVAL is PRINT's twin that discards the expression's
  result instead of reporting it. "set x.y 1" is just "eval x.y = 1" -
  ordinary assignment syntax already handles plain variables, property
  paths and array indices alike, so there is no separate name-resolution
  command needed for it.
- Fixed eval_expr() to make assignment (and other side effects) actually
  reach the paused frame's real local/upvalue storage. It runs the
  expression against a temporary scope object pre-populated with
  *referenced* variables only, which missed a bare "x = 1": the compiler
  emits that as a plain SVAR with no preceding LVAR read (nothing to read
  first), so unlike "x + 1" it never looked up "x" against that scope at
  all - falling through to the real global scope chain and silently
  creating an unrelated global instead of touching the real local. The
  scope is now pre-populated with every declared local/upvalue in range,
  not just referenced ones, and eval_expr() writes any of them straight
  back into the real stack slot/upvalue after the call, the same way
  I_SLOC/I_SUPV do.
- VARIABLES now marks a shadowed declaration (a same-named, less-nested
  local still holding a live value, just not what plain script code
  resolves the name to right now) instead of silently listing duplicates
  - rendered faint with a "(shadowed)" suffix, whose width is reserved
  from the value's truncation budget so it can't itself push the line
  past the terminal width.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Sitting at "dbg > " only ever happens while the debuggee is already
paused - there was no way to get its attention while it was actually
running (mid "continue"), short of waiting for the next breakpoint or
killing it outright.

- New BK_INTERRUPT system breakpoint: pre-created (inert) alongside
  BK_UNCAUGHT at session setup specifically so debug_break_signal_handler()
  never has to malloc from signal-handler context - arming it is just two
  field writes. Its "already attached" branch now arms it (fire on the
  very next instruction) instead of the old passive-only notification
  (debug_remote_notify_signal(), removed along with the now-dead "signal"
  client-side event case): any SIGUSR1 while attached is a deliberate act
  either way, and pausing for inspection is the reasonable universal
  response to it, from whoever sent it. bk_handle_interrupt() disarms
  itself before entering the session so it fires exactly once per
  request. eval_sandbox_enter()/_leave() (print/eval's exception
  sandboxing) also suspend and race-safely restore it, so a request
  arriving mid-eval isn't lost.
- udbg resolves the debuggee's PID via SO_PEERCRED right after
  connecting - uniform across all three connection modes (<pid>,
  <socket-path>, inherited --fd) - and now watches stdin continuously
  (not just while at a prompt) whenever raw-mode editing is active. A
  lone Ctrl-C while the debuggee is running sends it SIGUSR1; anything
  else typed while running is discarded, same as before this feature.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant