textual-debugger (the package) provides tdb (the command-line tool and module),
a full-featured terminal-based debugger for Python and other languages
with a Debug Adapter Protocol (DAP) implementation. In addition to Python,
tdb comes with built-in support for
- C and C++ (via
gdborlldb-dap) - Rust (via
gdborlldb-dap, selected explicitly) - Perl (via
perl -d) - Bash (via bash's own
DEBUGtrap; bash ≥ 4.4) - Tcsh (via source instrumentation of a stock
tcsh) - Ruby (via the debug gem's
rdbg; debug ≥ 1.9) - OCaml (native via
lldb-dap/gdb, bytecode via ocamlearlybird) - Go (via Delve's
dlv dap) - PowerShell 7 (via PowerShell Editor Services; Linux/macOS, Windows experimental)
tdb is built with textual and speaks
DAP to a pluggable debug adapter.
It provides a rich interactive interface for stepping through
code, inspecting variables, managing breakpoints, and evaluating expressions in
complex programs.
MIT License. Copyright 2026 by Al Danial.
tdb:
-
debugs multiple languages through the Debug Adapter Protocol: Python (via
debugpy, the richest feature set), C/C++ (viagdb -i daporlldb-dap), Rust (viagdborlldb-dap), Perl (viaperl -d), Bash (via bash's ownDEBUGtrap), Tcsh (via source instrumentation of a stocktcsh), Ruby (via the debug gem'srdbg), OCaml (native vialldb-dap/gdb, bytecode via ocamlearlybird), Go (via Delve'sdlv dap), and PowerShell 7 (via PowerShell Editor Services). Most languages are auto-detected from the target; Rust intentionally requires--lang rust(ref. Multi-Language Debugging). -
supports debugging of synchronous, asynchronous, multi-threaded, and multi-process Python code. It specifically supports modules
asyncio(with a built-in async task inspector and task wait graph)threading(with a thread inspector)multiprocessing/concurrent.futures(with automatic child process attachment and a process inspector)
-
supports remote attachment to Python, Perl, Ruby, Rust, C/C++, and Go programs
-
includes a JSON-RPC server mode, an MCP (v2) mode, and a
SKILL.mdfile that enable programmatic debug control, making it suitable for automated, headless debugging workflows and AI-assisted debugging -
can spawn the debuggee in an external terminal to enable debugging TUI applications built with
textual,prompt-toolkit,urwid,curses,rich, and so on -
comes with a post-mortem exception hook that can be installed in Python programs to have
tdbpop open automatically at the first uncaught exception, and a live breakpoint hook (tdb.breakpoint()in Python,Devel::TdbRemote::breakpoint()in Perl,Tdb.breakpointin Ruby,tdb.Breakpoint()in Go,tdb_breakpoint()in C/C++,tdb::breakpoint()in Rust,Tdb.breakpoint ()in OCaml) that openstdbpaused at a line of your choosing -
can be entirely keyboard-driven making it suitable for operation in non-graphical environments (mouse support is available in graphical environments)
Videos:
- tdb basics views, keybindings, breakpoints, stepping, variable modification, call stack
- asyncio tasks inspect asyncio tasks and their wait graph; code mod to allow pause
- threads and processes inspect variables and call stacks in multiple threads and processes
- external terminal run the debuggee in a separate terminal--ideal for debugging TUI applications
pip install textual-debuggeror (better):
uv pip install textual-debuggeror run it without installing:
uvx --from textual-debugger tdb my_program.py
# show comprehensive documentation in a terminal-based Markdown viewer
tdb --doc
# show the Help > About text, where tdb and its config, breakpoints and
# log files live, and the path + version of each interpreter and native
# debugger it would use (python, gdb, lldb-dap, perl, ruby, dlv,
# ocamlearlybird, bash, tcsh, pwsh), whether gdb has its DAP interpreter
# (`gdb -i dap`), and whether perl has ExtUtils::MakeMaker (needed to
# build the bundled PadWalker)
tdb --info
# debug a Python program (stops at first line by default)
tdb my_program.py
# debug with arguments
tdb my_program.py arg1 arg2
# debug a C/C++ (or other native) executable built with -g. The ELF/Mach-O/PE
# binary is auto-detected and debugged through GDB's DAP mode (GDB >= 14)
tdb ./myprog arg1 arg2
# same, but using lldb-dap (LLVM >= 17) instead of gdb
tdb --adapter lldb-dap ./myprog
# force the language when auto-detection can't tell (e.g. an extensionless script)
tdb --lang python ./mytool
# add breakpoints at lines 20 and 35 of `my_program.py` and line 14
# of `module.py` (when -k is given, --no-stop-on-entry is set and the
# program runs to the first breakpoint)
tdb -k 20 -k 35 -k module.py:14 my_program.py arg1 arg2
# run straight to line 20 without saving the breakpoint for future
# sessions (-t is -k minus the persistence)
tdb -t 20 my_program.py
# no TUI: run at (almost) full speed, printing nr each time line 25 is
# reached, then continuing (-e may be repeated; see "Eval Mode" below)
tdb --eval my_program.py:25 "print(f'{nr=}')" my_program.py
# use a specific virtualenv
tdb --python /path/to/venv/bin/python my_program.py
# step into, or stop at tracebacks in library code
tdb --no-just-my-code --python /path/to/venv/bin/python my_program.py
# run until first breakpoint (persisted during a prior run) or exit
tdb --no-stop-on-entry my_program.py
# run the debuggee in an external terminal
tdb --terminal xterm my_program.py
# attach to a remote Python program that has a debugpy server on port 5678
# (source code is automatically downloaded from the remote host)
tdb -r remotehost:5678
# attach to a remote Python program that has a debugpy server on port 5678
# and set a breakpoint where tdb and the remote program have the same
# source code layout
tdb -r remotehost:5678 -k my_program.py:42
# attach to a remote Python program that has a debugpy server on port 5678
# and set a breakpoint where code on the local host is at a different location
# than code on the remote host
tdb -r remotehost:5678 --local-root /my/code/dir --remote-root /app -k my_program.py:42
# attach to a program that pauses itself (debugpy.breakpoint() right after
# debugpy.wait_for_client()); without this flag tdb pauses the program on
# attach, which can leave it suspended after tdb quits
tdb -r remotehost:5678 --no-pause-on-attach
# separate tdb arguments from debuggee arguments with `--`
tdb --python /path/to/venv/bin/python -- my_program.py -k 17 --max 23.3Alternatively, use the module entry point:
python -m tdb my_program.pytdb debugs any language that has a Debug Adapter Protocol backend. Ten
languages are supported out of the box:
| Language | Adapter(s) | Dependencies | Feature level |
|---|---|---|---|
| Python | debugpy (default) |
Python ≥ 3.11 | everything in this README |
| C / C++ (any native binary) | gdb (default), lldb-dap (alternate) |
gdb -i dap requires GDB ≥ 14 built with Python (RHEL 8/9: dnf install gcc-toolset-14-gdb, see C/C++ tips); lldb-dap ships with LLVM ≥ 17 (e.g. apt install lldb) |
core debugging: breakpoints, stepping, stack, variables, evaluate console + remote attach (gdbserver/lldb-server, local symbol-bearing executable required) |
Rust (explicit --lang rust) |
gdb (Linux default), lldb-dap (macOS default) |
any rustc building with debug info; GDB ≥ 14 or LLVM lldb-dap ≥ 17 (concurrency ownership evidence additionally requires stable Rust 1.98) |
core debugging + best-effort Rust concurrency inspection + remote attach |
| Perl | perl-tdb (bundled) | perl ≥ 5.18 on PATH | core debugging + remote attach |
| Bash | bash-tdb (bundled) | bash ≥ 4.4 on PATH | core debugging (no remote attach) |
| Tcsh | tcsh-tdb (bundled) | tcsh on PATH | core debugging (no remote attach, no conditional breakpoints, no pause) |
| Ruby | rdbg (the debug gem) |
rdbg ≥ 1.9 on PATH |
core debugging + remote attach |
| OCaml (native executable) | lldb-dap (default), gdb (alternate) |
lldb-dap ships with LLVM ≥ 17; gdb -i dap requires GDB ≥ 14 |
core debugging + domains-as-threads; Variables view shows no OCaml locals (upstream DWARF limitation); evaluate console is lldb/C-level, not OCaml |
| OCaml (bytecode executable) | ocamlearlybird (default) |
opam install earlybird |
core debugging + rich OCaml locals; no pause/--run, no evaluate responses, no fatal-error modal (ocamlearlybird 1.3.6 limitations); single-domain only |
| Go | dlv (the Delve DAP server) |
Delve ≥ 1.21 on PATH (go install github.com/go-delve/delve/cmd/dlv@latest) |
core debugging + goroutine inspection (wait graph, findings) + remote attach (no --terminal) |
| PowerShell 7 | pses (PowerShell Editor Services, driven by tdb's bundled proxy) |
pwsh ≥ 7.2 on PATH + the PSES module (see PowerShell) |
core debugging (breakpoints incl. conditional/hit-count/log, stepping, stack, variables, evaluate console) + --run; no --terminal, no remote attach; Windows untested |
The language is auto-detected from the debug target:
- A directory → Go, if it contains any
*.gofile (a Go package directory, e.g.tdb ./pkg); otherwise an error naming--lang. - File extension:
.py→ Python;.go→ Go;.pl/.pm/.t→ Perl;.sh/.bash→ Bash;.csh/.tcsh→ Tcsh;.rb→ Ruby;.ps1/.psm1→ PowerShell. (.ml/.mlido not auto-select OCaml, see point 6.) - Native executables (ELF, Mach-O, PE magic bytes) → C/C++, unless
byte-checking finds an OCaml marker (a native binary's
caml_program/caml_startupruntime symbols, a bytecode file's trailingCaml1999marker, or a#!...ocamlrunshebang) → OCaml (native or bytecode respectively), or a Go buildinfo blob (the markergo versionitself locates, scanned in the first 16MB of the file) → Go. A stripped native OCaml binary, or a Go binary whose buildinfo blob sits beyond the 16MB scan window, that byte-checking can't identify falls back to C/C++; force it with--lang ocamlor--lang gorespectively. Rust is never inferred from an executable; select it explicitly with--lang rust. - A
#!...python,#!...perl,#!...bash,#!...csh/#!...tcsh,#!...ruby, or#!...pwshshebang → Python / Perl / Bash / Tcsh / Ruby / PowerShell respectively. - C/C++/Rust source files (
.c,.cpp,.rs, …) produce an error with a hint: compile with debug info (g++ -g -O0) and debug the binary. .ml/.mli(OCaml source) produce an error too: build the project first (dune's dev profile keeps debug info) and pass the built executable totdb(tdb ./_build/default/bin/main.exe), never the.mlfile.- Anything else produces an error naming the
--langoverride.
--lang forces the language; --adapter picks a non-default adapter within
it (tdb --lang cpp --adapter lldb-dap ./myprog). Rust always requires the
explicit language selection (tdb --lang rust target/debug/app).
--adapter also accepts a full path to a gdb, lldb-dap, or dlv
executable (tdb --adapter /opt/llvm/bin/lldb-dap ./myprog,
tdb --adapter /usr/bin/gdb-multiarch ./myprog,
tdb --adapter ~/go/bin/dlv ./main.go). The adapter id is taken from the
file's basename — versioned and variant names such as lldb-dap-21 and
gdb-multiarch work — and that exact executable is used for the session,
ahead of anything on $PATH or in config.json's adapters map.
tdb --info shows which gdb and lldb-dap would be used otherwise.
gdb's DAP mode needs GDB ≥ 14, so tdb checks gdb --version before
starting a session. A gdb that is provably too old is refused with a
message naming its path and version; when that gdb came from PATH
(not from --adapter or config.json), tdb first looks for a newer one
installed by Red Hat's Software Collections
(/opt/rh/gcc-toolset-*/root/usr/bin/gdb, /opt/rh/devtoolset-*/...) and
uses the newest that qualifies. See C/C++ tips for RHEL 8.
Migration note: extensionless Python scripts without a
pythonshebang were previously assumed to be Python; they now require--lang python.
tdb does not download or bundle adapters. If the adapter executable isn't
found, the error names the package to install. To use an adapter from a
non-standard location, or change a language's default adapter, add to
config.json (see Configuration):
{
"adapters": {"lldb-dap": "/opt/llvm/bin/lldb-dap"},
"default_adapters": {"cpp": "lldb-dap"}
}Core debugging works identically for every language: breakpoints (incl. conditions and persistence), stepping, continue/pause, run-to-cursor, stack navigation, variable inspection, the evaluate console, syntax highlighting, and the JSON-RPC / MCP (v2) programmatic modes.
Python-specific features are hidden or return "not supported for this
language" message when debugging other languages: statement-granularity
stepping (non-Python languages step at their debugger's native granularity
— per line, except PowerShell, whose debugger stops per statement, see
PowerShell), the async task / process inspectors and wait
graph, the evaluate console's trailing-? help,
--python/--pv, --no-subprocess, automatic child-process attachment, and
the post-mortem / tdb.breakpoint() hooks (those hooks live inside Python
programs by nature). Remote attach (-r) also works for Perl (see
Perl, Devel::TdbRemote in place of debugpy.listen()), Ruby
(see Ruby, rdbg --open in place of debugpy.listen()), Go (see
Go, dlv dap --listen in place of debugpy.listen()), and for
Rust and C/C++ native binaries (against a gdbserver/lldb-server stub, with
a local symbol-bearing executable; see Rust — the same flags work
for a C/C++ target without --lang), but not for Bash, Tcsh, OCaml, or
PowerShell. --terminal works for every launch-mode
language — Python, Perl, Bash, Tcsh, Ruby, and C/C++, OCaml native, or Rust
sessions via --adapter lldb-dap — see
External Terminal Support. Go and PowerShell do
not support --terminal yet (dlv dap and PSES have no terminal-routing
mode). Typing into the Console view to feed the program's stdin works for
Python, Perl, Bash, Tcsh, Ruby, and PowerShell, but not for C/C++, Rust,
OCaml, or Go, whose adapters spawn the debuggee themselves — see
Console Output and Input.
Bash limitations (v1): the bash adapter uses bash's own DEBUG trap and
has a smaller feature set than Python:
- Debuggee code that installs its own
DEBUGtrap clobbers the harness; debugging silently degrades to free-running. - No stopping inside subshells
(...),$(...), or pipeline segments; they execute normally. - Child bash processes run uninstrumented.
- Outer-frame locals not inspectable (innermost frame only).
- Pause is deferred while blocked in an external command.
.shfiles that aren't bash are only diagnosed at launch, by bash itself or the harness version check.- The
DEBUGtrap never fires on a function-definition line, so a breakpoint on afunc() {line never hits; entry and step-in stops land on the first executable line of the function body instead. - The debug control channel occupies two inherited file descriptors
(typically high-numbered). A script that execs redirections onto those
exact fds (e.g.
exec 63>&-or reusing them for its own I/O) will silently break debugging.
See Bash below for launch details.
Tcsh limitations (v1): tcsh has no debug hooks at all, so the tcsh adapter debugs an instrumented temporary copy of the script (original paths and line numbers are preserved in everything tdb displays):
- Conditional breakpoints are not supported; a condition set in the Breakpoint View is ignored for tcsh (the breakpoint always stops).
- No asynchronous pause: a free-running script stops only at the next breakpoint (or when it exits).
- A breakpoint binds to the nearest safe statement at or after the requested line; unplaceable breakpoints are reported unverified.
- Stack frames represent
sourced files, not native call frames, and only literalsourcetargets resolvable at launch are instrumented (computed orcd-dependent sources run normally but are atomic to the debugger). - All frames show the same live shell state (Shell Variables, Environment, Aliases, Arguments). Stock tcsh keeps no per-frame history.
- Multiple commands on one physical line (
a ; b), command substitutions, and external commands are atomic stepping units. $0inside dynamically generated or evaluated text can expose the generated copy's path (ordinary lexical$0is rewritten correctly).- Requires Python ≥ 3.11.
See Tcsh below for launch details.
OCaml limitations (v1): OCaml has two independent adapters with different, non-overlapping gaps; see OCaml below for the full picture:
- Native (
lldb-dap/gdb): the Variables view shows no named OCaml locals in an OCaml frame (only aRegistersscope). This is an upstream OCaml/DWARF limitation on stock OCaml 5.4, not atdbbug; use the bytecode adapter when you need to inspect locals. The evaluate console evaluates lldb/C-level expressions (runtime spelunking), not OCaml expressions. - Bytecode (
ocamlearlybird): single-domain only (no multicore); nopausewhile running (so--runis unavailable); the evaluate console never gets a response for any expression (anocamlearlybird1.3.6 limitation); and the fatal-error modal doesn't appear for an uncaught exception (ocamlearlybirdswallows the real exception text before it reachestdb). Run the program outside the debugger to see the backtrace instead. - No Windows support, no remote attach, and a stripped native binary that
byte-checking can't identify needs
--lang ocaml. - No stdin forwarding from the Console view (both adapters spawn the
program themselves); a program that reads stdin needs
--terminal(native,--adapter lldb-dap) or input from a file.
Go limitations (v1):
--terminalis not supported (dlv daphas no terminal-routing mode).- No stdin forwarding from the Console view:
dlv dapspawns the program itself, so a program that reads stdin must get it from a file or pipe on its command line. - The Goroutines workspace's wait graph shows channel-send/-receive,
mutex, and
WaitGroupedges; a goroutine parked in aselectis classified but contributes no wait edge (Delve can't tell which of theselect's cases it's waiting on). - No mutex-holder identification: Go's
sync.Mutexdoesn't record an owner at the runtime level, so unlike Python's Lock/Semaphore inspector the wait graph can name what a goroutine is blocked on but never who holds it. - Goroutines spawned by
exec.Command(child OS processes) are not automatically attached or debugged, unlike Python's automatic multiprocessing child attach. - Detection of a Go binary via buildinfo check scans only the first
16MB of the file; a huge binary whose buildinfo blob sits beyond that
falls back to C/C++ — force it with
--lang go. -a/--attach's language auto-detection (reading the target pid's buildinfo from/proc/PID/exe) is Linux-only; on macOS/Windows pass--lang goalongside-a.
See Go below for launch details.
-
Compile with
-g(ideally-g -O0or-g3 -O0). If no breakpoint in a file can be bound,tdbprints a console warning suggesting the program may lack debug info. -
The Console view's stdin line is not available:
gdb -i dapandlldb-dapspawn the program themselves, sotdbhas no handle on its stdin. A program that reads stdin needs--terminal(with--adapter lldb-dap) or its input from a file. -
Stack frames pointing into system libraries often have no source on disk; the Code View shows a
<Could not read …>placeholder while the stack, variables, and evaluate console remain fully usable. -
For compiled executables, the Code View shows shows a brief note while it attempts to load the associated source file. The note will identify the failure reason if the source cannot be loaded (executable was built without
-g, or the adapter is not working, e.g. a GDB too old for DAP). -
GDB (the default adapter) has the most complete libstdc++ pretty-printing.
lldb-dap(via--adapter lldb-dap) also debugs GCC-built binaries fine. DWARF is compiler-neutral. -
STL containers in the Variables view. A distro
gdbshowsstd::vector<Result>asstd::vector of length 2, capacity 2with[0],[1]children because gcc installs libstdc++'s pretty-printers next to it andgdbauto-loads them. Agdbbuilt from source into its own prefix never finds that auto-load script and shows the same variable as_M_impl/_M_startpointer soup.tdbsources a small helper into everygdbsession that, on the firststd::value it sees, looks for gcc's printers (the distro auto-load script for the loadedlibstdc++.so,share/gcc*/pythonnear that library or near gdb's data directory, then the usual distro, Red Hat toolset, Homebrew and MacPorts locations) and uses them; when none are installed it falls back to its own bundled printers forstd::string,vector,deque,list,map/set(and multi-),unordered_map/set,unique_ptr,shared_ptr/weak_ptrandoptional. Printers gdb auto-loads itself or that your.gdbinitregisters still take precedence. The helper also shields gdb's DAP layer from gcc 15's map and set printers, whose child count is broken there (expanding astd::mapfails with "name 'self' is not defined" on an unpatched distro gdb).TDB_GDB_STL_PRINTERStunes it:offregisters nothing,bundledforces the bundled printers (and drops gdb's auto-loaded ones), a directory holdinglibstdcxx/names gcc's printers explicitly. Typetdb-stl-printersin the evaluate console to see which printers are in use. -
GDB evaluate-console quirk: GDB's DAP treats REPL input as CLI commands, so evaluate expressions with an explicit
print, e.g.print xrather than barex(barexcollides with GDB's examine-memory command).lldb-dapevaluates bare expressions directly. -
--terminal(see External Terminal Support) requires--adapter lldb-dap; GDB's DAP mode has no terminal integration andtdbrefuses--terminalwith the defaultgdbadapter. -
GDB source-location quirk: some GDB builds (RHEL 8 among them) report no source file for a stopped frame --
info sourceanswers "No current source file" -- until alistcommand has selected the default source file.tdbtherefore sendslistonce, before its first stack query, so the Code View can find the source. This is why a fresh gdb session showsCurrent source file is …ininfo sourcefrom the evaluate console without you having typedlist. -
RHEL 8 (and 9) gdb is too old for DAP: the stock
/usr/bin/gdbis 8.2 on RHEL 8 (10.2 on RHEL 9) and answersgdb -i dapwithInterpreter `dap' unrecognized. Install a newer one from the GCC Toolset, which leaves/usr/bin/gdbalone:sudo dnf install gcc-toolset-14-gdb # gdb 14.2 under /opt/rh/gcc-toolset-14tdbthen finds/opt/rh/gcc-toolset-14/root/usr/bin/gdbby itself whenever thegdbonPATHis missing or too old — noscl enableneeded (tdb --infoshows it asgdb used instead). Two things pin the old one and must be changed by hand:--adapter /usr/bin/gdb, and an"adapters": {"gdb": "/usr/bin/gdb"}entry inconfig.json(tdbseeds that entry with whatevergdbwas onPATHthe first time it wrote the file). Either delete the entry or point it at the toolset gdb. A GDB ≥ 14 that was built without Python support gives the sameInterpreter `dap' unrecognizederror (gdb --configurationshows--without-python);tdb --infoprobes for this and reportsGDB LACKS DAP CAPABILITY AND CANNOT BE USEDin itsDAP interpreterrow when the gdb it found rejects-i dap. Use--adapter lldb-dapor a gdb built with Python.
Attach to a running process: tdb -a PID attaches gdb (or --adapter lldb-dap) to a live native process and stops it. On Linux tdb reads
/proc/PID/exe to identify the language (Go, OCaml, Rust, otherwise
C/C++) and to load symbols; pass the executable path as well to use a
different symbol file, and pass --lang off Linux. --no-pause-on-attach
leaves the process running, for a program about to stop itself. An
interpreter process (python3, perl, ruby, bash) is attached as C/C++; for
Python, Perl, and Ruby programs use --remote-attach instead.
Live breakpoint hook: the C/C++ counterpart of Python's
tdb.breakpoint(). Include tdb.h (its directory
is the tdb.h dir row of tdb --info) and call tdb_breakpoint():
#include "tdb.h"
int compute(int n) {
int total = 0;
for (int i = 0; i < n; i++) total += i;
tdb_breakpoint(); /* tdb opens here, paused on the next line */
return total;
}gcc -g -O0 -I "$(tdb --info | awk -F': ' '/tdb.h dir/ {print $2}')" -o prog prog.c
./prog # run it directly, not under tdbWhen the call is reached, the program starts tdb --lang cpp -a <its pid> --no-pause-on-attach on its own terminal (the tdb on PATH, or the one
named by $TDB), waits for tdb's gdb or lldb to attach, and stops in the
hook; tdb steps out so the stop lands on the line after the call, in your
own frame. Later calls reuse the running tdb. Quitting tdb (Ctrl+q)
detaches and the program runs on; the next call opens a fresh tdb. The
call is a no-op when stdin or stdout is not a terminal, and it warns and
continues when tdb cannot be found, exits before attaching, or cannot
attach within 60 s (Yama ptrace_scope 2 or 3, or a container without
SYS_PTRACE, blocks attaching). If a tdb whose attach failed is still
running, later calls warn once and return rather than open another tdb
over it. Build with -g -O0 so locals stay
inspectable; a stripped binary attaches but never stops, because the hook's
stop symbol cannot be resolved. The header is C99 and C++ compatible and
header-only. Linux only: the hook grants ptrace access to tdb's debugger
with PR_SET_PTRACER; elsewhere it warns once and returns. See
examples/C/breakpoint_hook_demo.c and examples/C++/breakpoint_hook_demo.cpp.
Rust debugging is intentionally explicit: build a normal debug executable,
leave it unmodified, and pass that executable with --lang rust. Linux and
macOS are supported; Windows is not yet (MSVC-toolchain binaries carry PDB
debug info that neither gdb -i dap nor this release's probes read). tdb does
not compile, instrument, or auto-detect Rust programs. Core debugging
(breakpoints, stepping, stacks, variables, remote attach) works with any
rustc that emits debug info; the concurrency inspector's layout-specific
ownership evidence additionally requires the current stable Rust
1.98 standard-library layout — on other versions the workspace still
opens with stack-based wait classification and a compatibility warning.
Build with debug information and no optimization for the most useful
source locations:
cargo rustc -- -C debuginfo=2 -C opt-level=0
# Equivalent direct rustc settings: rustc -C debuginfo=2 -C opt-level=0 src/main.rsAs for C/C++, the Console view cannot feed the program's stdin (the gdb and
lldb adapters spawn it themselves); use --terminal with --adapter lldb-dap for interactive programs. A panic that terminates the debuggee opens the error modal with the parsed
panic message and backtrace; tdb injects RUST_BACKTRACE=1 into launched
programs (your own value wins if you set one) so the frames are present.
The common commands are:
cargo build
tdb --lang rust target/debug/app
tdb --lang rust --adapter lldb-dap --run target/debug/app
tdb --lang rust --adapter lldb-dap --terminal xterm target/debug/app
tdb --lang rust --adapter gdb --remote-attach host:2345 target/debug/app--run and normal stopped-on-entry launch work with both adapters. On Linux,
use GDB (the default) or lldb-dap; on macOS, use lldb-dap (the default).
External terminals are LLDB-only: GDB's DAP mode cannot provide terminal
integration. The mode/platform matrix is:
| Platform | Normal / --run |
--terminal |
--remote-attach |
|---|---|---|---|
| Linux | GDB or lldb-dap |
lldb-dap only |
GDB or lldb-dap |
| macOS | lldb-dap |
lldb-dap only |
lldb-dap |
Remote attach connects to a GDB-remote server already running on the target.
Pass the matching local, unmodified executable with debug symbols as the
program argument; tdb requires it for symbols and Rust concurrency evidence.
When source paths differ, pair --local-root with --remote-root. Do not
expose the remote debug port to an untrusted network: prefer an SSH tunnel,
for example ssh -L 2345:127.0.0.1:2345 host, then attach to 127.0.0.1:2345.
Attach to a running process: tdb --lang rust -a PID attaches gdb (or
--adapter lldb-dap) to a live Rust process and stops it; tdb loads
symbols from /proc/PID/exe, or from an executable path you pass as well.
Without --lang, tdb recognizes a non-stripped Rust binary by its runtime
symbols; a stripped one is debugged as C/C++.
Live breakpoint hook: the Rust counterpart of Python's
tdb.breakpoint(). Depend on the tdb crate from
this repository (it has no dependencies) and call tdb::breakpoint():
[dependencies]
tdb = { git = "https://github.com/AlDanial/tdb" } # or path = ".../rust/tdb"fn compute(n: u64) -> u64 {
let total: u64 = (0..n).sum();
tdb::breakpoint(); // tdb opens here
total
}cargo build && ./target/debug/prog # dev profile; run it directly, not under tdbWhen the call is reached, the program starts tdb --lang rust -a <its pid> --no-pause-on-attach on its own terminal (the tdb on PATH, or the one
named by $TDB), waits for tdb's gdb or lldb to attach, and stops in the
hook; tdb steps out so the stop lands in your own frame on, or just after,
the call line, as the compiler's line table attributes the return address.
Later calls reuse the running tdb. Quitting tdb (Ctrl+q)
detaches and the program runs on; the next call opens a fresh tdb. The
call is a no-op when stdin or stdout is not a terminal, and it warns and
continues when tdb cannot be found, exits before attaching, or cannot
attach within 60 s (Yama ptrace_scope 2 or 3, or a container without
SYS_PTRACE, blocks attaching). If a tdb whose attach failed is still
running, later calls warn once and return rather than open another tdb
over it. Use the dev profile so locals stay
inspectable. Linux only: the hook grants ptrace access to tdb's debugger
with PR_SET_PTRACER; elsewhere it warns once and returns. See
examples/Rust/breakpoint_hook_demo/.
In a Rust session the Threads action (Alt+T, or the Threads menu entry)
opens the Rust Concurrency workspace while the debuggee is stopped — a
fresh, bounded snapshot of threads, wait edges, and findings. The graph is
best effort: confirmed evidence is directly observed by the debugger,
probable is a supported inference, and unknown means ownership could not
be established. A deadlock is confirmed only when every edge in its cycle is
confirmed. A suspected cycle or whole-program stall is a diagnostic lead, not
a proof: it means a cycle has incomplete evidence, or all observed application
threads are blocked while the graph cannot safely invent an owner. Refresh
after the next stop to collect a new snapshot.
The built-in evidence recognizes current std synchronization layouts. A
future helper crate may expose stable, application-provided synchronization
metadata for broader primitives and stronger ownership evidence.
tdb bundles its own Perl adapter (perl-tdb) so only need
perl ≥ 5.18 on PATH. It drives stock perl5db under the
hood, so it works with any Perl already on the system.
The PadWalker module v2.5, by Robin Houston,
is bundled with tdb. PadWalker provides a more complete view of lexical variables
in outer/caller frames and therefore provides a richer debugging experience.
PadWalker is an XS (compiled) module, so the bundled copy has to be built
against the exact perl that runs your program. tdb does this for you on the
first Perl launch: if the perl in use cannot already load PadWalker (from CPAN
or a distro package), tdb compiles the bundled sources, caches the result
per interpreter under its config directory (~/.config/tdb/padwalker/ on
Linux/macOS, %APPDATA%\\tdb\\padwalker\\ on Windows), and prepends that
directory to the debuggee's PERL5LIB. The build takes about a second and
needs perl's headers (libperl-dev on Debian/Ubuntu, perl-devel on
Fedora/RHEL), a C compiler, and the core ExtUtils::MakeMaker module (some
minimal distro perls omit it; perl-ExtUtils-MakeMaker on Fedora/RHEL);
Strawberry Perl on Windows ships all three. When the build is not possible,
tdb prints a one-line notice on the console and falls back to a read-only
pad walk, so outer-frame lexicals degrade but debugging otherwise proceeds.
tdb --info reports the source directory, the cache directory, and the
ExtUtils::MakeMaker version of the perl it would use, with a warning when
that module is missing and PadWalker therefore cannot be built.
TDB_PADWALKER_CACHE overrides the cache location.
Launching a script:
tdb script.pl--terminal is supported (see
External Terminal Support).
Compile-time code (BEGIN blocks): Perl runs BEGIN blocks and the
use statements that are themselves BEGIN blocks while it is still
compiling your program, before stock perl5db ever stops. tdb arms the
debugger ahead of compilation so that code is debuggable too, which means the
first stop is the first compile-time statement of your file (typically
use strict; near the top) rather than the first runtime statement. Step from
there and you land inside your BEGIN blocks, with the stack and evaluate
views working normally (local variable listing is limited at a compile-time
stop); the Stack view shows the frame as main::BEGIN. Stepping through a
use line takes a few steps (the pragma's
own compile-time work happens in between) but you are never dragged into
another module's internals.
Two consequences worth knowing:
- Breakpoints are deferred while your program compiles. During the compile
phase Perl has only parsed part of your file, so its line table is
incomplete and a breakpoint can't be verified yet.
tdbholds such requests and, while it single-steps through the rest of compilation, checks each compile-time statement it lands on against them. A breakpoint placed inside aBEGINblock fires there directly, on the first run, without needing to be stepped into by hand. Conditional breakpoints work the same way at compile time; a condition that itself errors behaves exactly like a bad condition at runtime in that it does not fire. Two residual caveats: a breakpoint on a non-statement line (theBEGIN {line itself, or a blank line) never fires during the compile phase, since it's never actually trapped as a statement; andhitCondition(break on the Nth hit) isn't honored for a compile-time stop, only a plaincondition. - Startup is slower for large dependency graphs, because the debugger is
active throughout compilation. A script with a big
usetree takes noticeably longer to reach its first stop undertdb.
END blocks are entered with step-in (s). next and continue run
straight past them to program termination, which is standard perl5db
behavior, not a tdb limitation.
When your program dies: an uncaught Perl error (die, or a fatal runtime
error such as division by zero) opens the same error modal Python tracebacks
get: the message, the call stack parsed from Perl's at FILE line N. /
... called at FILE line N output, and Code View navigated to the failing
line. This works for compile-time failures too, including a die inside a
BEGIN block that aborts compilation. Press e in Code View to re-summon the
last error. tdb also reports the debuggee's real exit status rather than
assuming success.
Remote attach: useful when the Perl process is already running (a long-
lived service, a process started by something other than tdb) or lives on
another host/container. Add three lines to the target program, with the
use line first so the debugger is armed before any of your code compiles:
use Devel::TdbRemote; # first line of your program
...
Devel::TdbRemote::listen(5678); # non-blocking
print "Waiting for tdb to attach on port 5678\n";
Devel::TdbRemote::wait_for_client(); # blocks until tdb connectsThen attach from tdb, forcing the language since there's no local program
argument for tdb to detect it from:
tdb --lang perl -r host:5678Arming caveat: only code compiled after the debugger is armed can be
stepped into or breakpointed. That's why use Devel::TdbRemote; must be the
first line of the program. If you can't edit the first line (e.g. a wrapper
script controls startup), arm it before Perl even parses your file instead:
perl -d:TdbRemote prog.pl, or set PERL5OPT=-d:TdbRemote in the
environment that launches the debuggee.
Copying the adapter to a remote host: Devel::TdbRemote and its helper
script are plain files, not a CPAN install. Copy both onto the remote
machine and point PERL5LIB at the directory that contains them, for example:
# From a checkout or an installed wheel's site-packages/tdb/adapters/perl:
scp -r Devel/TdbRemote.pm helpers.pl remote-host:/opt/tdb-perl/
# On the remote host:
export PERL5LIB=/opt/tdb-perl:$PERL5LIB(Devel/TdbRemote.pm locates helpers.pl next to itself at runtime, so keep
the two files in the same relative layout shown above; helpers.pl is a
sibling of the Devel/ directory, not inside it.)
Pause in attach mode uses a control channel: after the debug connection
is up, tdb opens a second connection to the same port and asks
Devel::TdbRemote to accept it. From then on pause (or the p key) writes a
byte on that connection, the kernel delivers SIGIO to the debuggee, and the
program stops at its next statement -- the same mechanism perl5db uses for
Ctrl-C, so a program blocked in sleep or select is interrupted too. The
second connection reuses the debug port, so an SSH tunnel that forwards that
one port carries both. Requirements: the Devel/TdbRemote.pm copied to the
remote host must be at least the version shipped with this feature (an older
copy still attaches fine, but pause returns a "not available" error and the
Console view says why), and the remote perl must support O_ASYNC (Linux,
macOS, BSD; not Windows).
Live breakpoint hook: the Perl counterpart of Python's
tdb.breakpoint(). Run your program normally (not
under tdb) and have it open tdb on itself when it reaches a line of
interest:
use Devel::TdbRemote; # first line of your program
...
Devel::TdbRemote::breakpoint(); # tdb opens here, paused on the next lineOn the first call the program listens on an ephemeral loopback port, spawns
tdb --lang perl -r 127.0.0.1:PORT on the same terminal, waits for it to
attach, and stops on the statement after the call, in your own frame. Later
calls reuse the running tdb, which simply receives another stop. Quitting
tdb (Ctrl+q) detaches and lets the program run on; a breakpoint() reached
after that spawns a fresh tdb. The call is a no-op when stdin/stdout are not
a tty, so it is safe in code that sometimes runs headless, and if tdb cannot
be started (not on PATH, or it exits before attaching) it warns and the
program continues. tdb is found on PATH, or via the TDB environment
variable. The same arming caveat as above applies: use Devel::TdbRemote;
must be the first line (or use PERL5OPT=-d:TdbRemote), and Devel::TdbRemote
has to be on @INC, e.g.
PERL5LIB=$(tdb --info | sed -n 's/.*Devel::TdbRemote dir *: *//p') perl prog.plSee examples/Perl/breakpoint_hook_demo.pl.
tdb bundles its own bash adapter (bash-tdb) so no separate adapter install
needed, just a bash ≥ 4.4 on PATH. It drives stock bash's own DEBUG
trap (with extdebug for return-value control) under the hood via a small
harness script sourced through BASH_ENV, so it works with any bash already
on the system.
Launching a script:
tdb script.shCore debugging works as described above (breakpoints, stepping,
continue/pause, stack, variables, evaluate console), with the v1 caveats
listed in What works for non-Python languages.
Most notably, a breakpoint can't be set on a func() { line itself (the
DEBUG trap never fires there), stops never land inside subshells or
pipeline segments, child bash processes run uninstrumented, and only the
innermost frame's locals are inspectable. There is no remote-attach mode for
Bash. --terminal is supported (see
External Terminal Support).
The Variables view shows three scopes for bash: Locals (innermost frame
only), Globals (unexported shell variables), and Environment (exported
variables, both inherited and script-exported).
tdb bundles its own tcsh adapter (tcsh-tdb) so no separate adapter
is needed, just a stock tcsh on PATH (or
{"adapters": {"tcsh": "/path/to/tcsh"}} in config.json). Stock tcsh has
no debugger hooks, so the adapter instruments a temporary copy of the script
(and of any literal sourced files), runs it with tcsh -f, and
coordinates stops through private FIFOs. tdb always shows the original
source paths and line numbers; the generated copies are private adapter
details and are cleaned up when the session ends.
Launching a script:
tdb script.cshCore debugging works as described above: line breakpoints, stepping
(next steps over sourced files, stepIn steps into instrumented ones),
stack navigation across source nesting, variable inspection, and the
evaluate console with the v1 caveats listed in
What works for non-Python languages,
most notably: no conditional breakpoints, no pause of a free-running
script, and stepping is per logical source line. --terminal is supported
(see External Terminal Support).
The Variables view shows four scopes for tcsh: Shell Variables (set),
Environment (setenv), Aliases, and Arguments (argv). All of them show
the live state of the single tcsh process. The evaluate console executes
text directly in the paused shell. It can inspect and mutate state
(set name = value takes effect immediately), and a syntax error in
evaluated text can terminate the debuggee; there is no isolation.
tdb debugs Ruby via the debug gem's
rdbg (Ruby >= 3.1 ships it; otherwise gem install debug; tdb needs
debug >= 1.9). rdbg must be on PATH, or point tdb at it with
{"adapters": {"rdbg": "/path/to/rdbg"}} in config.json.
tdb script.rb # launch, stop at first line
tdb --run script.rb # run immediately, debug on demand
tdb --terminal script.rb # program I/O in its own terminalRemote attach: start the program with
rdbg --open --port 5678 --host 0.0.0.0 script.rb (add --nonstop to
let it run before you attach), then tdb -r HOST:5678 --lang ruby.
Note: rdbg's --cookie authentication is not part of DAP and is not
supported; bind to localhost and tunnel over SSH instead.
--local-root/--remote-root path mappings are not supported for Ruby
yet. Bundler projects work when your environment resolves rdbg
(gem install debug into the project's Ruby); there is no bundle exec integration yet.
Startup note: the debug gem's
rdbghas an occasional handshake race on launch; tdb detects a stalled/corrupted handshake and retries once automatically with a freshrdbg, so this is usually invisible.
Live breakpoint hook: the Ruby counterpart of Python's
tdb.breakpoint(). Run your program normally (not
under tdb) and have it open tdb on itself when it reaches a line of
interest:
require 'tdb'
...
Tdb.breakpoint # tdb opens here, paused on the next lineThe first call starts the debug gem's own debug server on an ephemeral
loopback port and plants the gem's usual one-shot breakpoint on the next
line. When that fires, the program parks and tdb --lang ruby -r 127.0.0.1:PORT
is spawned on the same terminal, attaches, and shows the stop in your own
frame. Later calls reuse the running tdb. Quitting tdb (Ctrl+q) detaches
and lets the program run on; a Tdb.breakpoint reached after that spawns a
fresh tdb. The call is a no-op when stdin/stdout are not a tty, and if tdb
is not on PATH (or in the TDB environment variable) it warns and the
program continues. Put tdb.rb on the load path, e.g.
RUBYLIB=$(tdb --info | sed -n 's/.*tdb.rb dir *: *//p') ruby prog.rbSee examples/Ruby/breakpoint_hook_demo.rb.
tdb debugs OCaml through two independent adapters, chosen automatically
from how the executable was built:
- Native (
ocamlopt/dune's default build) →lldb-dap(default;--adapter gdbalso works). OCaml 5's domains show up as threads. - Bytecode (
ocamlc -g) →ocamlearlybird(opam install earlybird). Single-domain only, but with rich, real OCaml locals.
Build requirement: compile with -g (dune's dev profile already
does). Always pass the built executable to tdb, never the .ml
source, as in tdb ./_build/default/bin/main.exe, not tdb main.ml (which
errors with this exact guidance).
Earlybird additionally as a fragile relative path matching so for best results provide a fully quallified path to the source files.
ocamlc -g -o my_program.byte "$PWD/my_program.ml"
Then, to ensure transition from bytecode to OCaml source in the
Code view, provide an explicit temporary breakpoint (-t)
at the first line of execution:
tdb --lang ocaml --adapter ocamlearlybird -t "$PWD/my_program.ml:2" ./my_program.byte
OCaml version: tdb requires OCaml ≥ 4.12. Older runtimes print
Printexc backtraces without function names, which the fatal-error
modal's parser does not understand (frames would be dropped and the
modal would wrongly suggest recompiling with -g).
With native GDB, the default entry stop is translated to the first
executable line in project OCaml source; the generated C/ELF startup code is
not shown.
Unfortunately OCaml's native debug information does not always identify
tihs line correctly.
--no-stop-on-entry and explicit CLI breakpoints retain their
usual behavior.
A stripped native binary that byte-checking can't identify as OCaml falls
back to C/C++; force it with --lang ocaml. Override the adapter or point
at a non-PATH install in config.json (see
Configuration):
{
"adapters": {"ocamlearlybird": "/path/to/ocamlearlybird"},
"default_adapters": {"ocaml": "gdb"}
}Domains as threads (native only): OCaml 5's domains are presented in
the Threads modal (Alt+T or the Threads (N) menu label) as Domain 0 (main), Domain 1, and so on, in creation order. The runtime's internal
"backup thread" (one per domain, used for I/O blocking) is hidden by
default since it's never running user code; press a in the Threads
modal to reveal it alongside the domains. Breakpoints, stepping, continue,
and pause all work normally against any domain. In both the Stack view
and the Threads modal, OCaml frame names are demangled from the
runtime's raw camlModule__name_NNN convention into readable
Module.name form (e.g. camlOcaml_domains.worker_297 displays as
Ocaml_domains.worker); runtime C frames (caml_start_program,
caml_callback_exn, and the like) are shown as-is, unchanged.
Variables view (native): stock OCaml 5.4 native DWARF shows no
named locals. The scopes/variables round trip succeeds, but every
OCaml frame's Locals and Globals scopes come back empty; only a
Registers scope has data (the raw register file, not decoded OCaml
values). This is an upstream OCaml compiler/DWARF limitation on the
verified toolchain (OCaml 5.4.0 / lldb 21), not a tdb bug. If you need
to inspect local variables, use the bytecode adapter instead.
Variables view (bytecode): ocamlearlybird reports real local names
and values, exactly like Python locals.
Evaluate console: the two adapters give fundamentally different
consoles. Native (lldb-dap) evaluates lldb/C-level expressions.
This is runtime spelunking (registers, raw memory, C symbol names), not
OCaml expression evaluation. Bytecode (ocamlearlybird) is meant to
evaluate real OCaml expressions in scope, but in the verified
ocamlearlybird 1.3.6 release its evaluate request never responds (a
confirmed upstream limitation, not specific to tdb); the console
silently returns nothing for any expression.
Uncaught exceptions: native sessions set a breakpoint on
caml_fatal_uncaught_exception so the debugger stops there, and tdb
also parses the OCAMLRUNPARAM=b backtrace it injects into the debuggee's
environment once the process exits, opening the same error modal Python
tracebacks get. Bytecode sessions do not get this: ocamlearlybird
intercepts the uncaught exception itself and only reports a generic
"Program exited due to Uncaught_exc" message, so the real exception text
never reaches tdb. Run the program outside the debugger
(OCAMLRUNPARAM=b ./main.byte) to see its backtrace.
Limitations (v1): no Windows support; no remote attach; bytecode
sessions can't pause a running program (so --run is unavailable for
bytecode. Native sessions support --run normally); Unix.fork and Eio
fibers are out of scope (only OCaml 5 domains are presented as threads).
Attach to a running process: tdb -a PID attaches lldb-dap (or
--adapter gdb) to a live native OCaml process and stops it; tdb
recognizes the OCaml runtime in /proc/PID/exe and always picks a native
adapter (bytecode programs cannot be attached to). --no-pause-on-attach
leaves the process running, for a program about to stop itself.
Live breakpoint hook: the OCaml counterpart of Python's
tdb.breakpoint(), for native code. Vendor the
ocaml/tdb directory from this repository (or opam pin it), add
(libraries tdb) to your executable's dune stanza, and call
Tdb.breakpoint ():
let compute n =
let total = ref 0 in
for i = 0 to n - 1 do total := !total + i done;
Tdb.breakpoint (); (* tdb opens paused on this line *)
!totaldune build && ./_build/default/prog.exe # dev profile; run it directly, not under tdbWithout dune: copy tdb.ml, tdb.mli, tdb_stubs.c, and tdb.h from
ocaml/tdb next to your source and build with
ocamlopt -g -o prog tdb_stubs.c tdb.mli tdb.ml prog.ml.
When the call is reached, the program starts tdb --lang ocaml -a <its pid> --no-pause-on-attach on its own terminal (the tdb on PATH, or the one
named by $TDB), waits for tdb's lldb or gdb to attach, and stops in the
hook, and tdb steps out of the hook's frames into your own. Unlike the
other languages' hooks, the stop lands on the Tdb.breakpoint () call
line itself rather than the line after: both lldb and gdb map the hook's
return address back to the call. ocamlopt also emits no debug info
for locals, so the Variables view shows none for OCaml frames (globals
and the stack are still available). Later calls reuse the running tdb.
Quitting tdb (Ctrl+q) detaches and the program runs on; the next call
opens a fresh tdb. The call is a no-op when stdin or stdout is not a
terminal, and it warns and continues when tdb cannot be found, exits
before attaching, or cannot attach within 60 s (Yama ptrace_scope 2 or
3, or a container without SYS_PTRACE, blocks attaching). Keep debug
info (-g, dune's dev profile). Linux only: the hook grants ptrace
access to tdb's debugger with PR_SET_PTRACER; elsewhere it warns once
and returns. See examples/OCaml/breakpoint_hook_demo.ml.
tdb debugs Go through Delve's own
DAP server, dlv dap (Delve ≥ 1.21). Install it with:
go install github.com/go-delve/delve/cmd/dlv@latestdlv must be on PATH, or point tdb at it with
{"adapters": {"dlv": "/path/to/dlv"}} in config.json.
Launch modes — tdb picks the right Delve mode automatically:
tdb main.go # source file: `go run`-style debug mode
tdb ./pkg # package directory: `go run`-style debug mode
tdb ./binary # pre-built Go binary (buildinfo-detected): exec mode
tdb --test ./pkg # Go test package: Delve test mode
tdb -a PID # attach to a running local Go process by pid
tdb -r host:port --lang go # attach to a `dlv dap --listen` already running remotelytdb --test ./pkg debugs the package's tests under Delve's test mode;
pass test-binary flags after --, e.g. tdb --test ./pkg -- -run TestFoo.
-a/--attach works for Go and, through gdb/lldb-dap, for C/C++, Rust,
and OCaml; on Linux tdb identifies the language from /proc/PID/exe
without --lang (elsewhere pass --lang go alongside -a). Attaching
stops the process immediately so you get control right away;
--no-pause-on-attach leaves it running instead, for a program that is
about to stop itself (see the live breakpoint hook below).
Entry stop: Delve's own stopOnEntry halts at the process entry point
before any goroutine exists (no stack, no source). tdb instead runs to
main.main, so the default entry stop lands on your program's first line
with its source on screen; --no-stop-on-entry skips it as usual.
Live breakpoint hook: the Go counterpart of Python's
tdb.breakpoint(). Import the tdb module from this repository and call
tdb.Breakpoint() where you want the debugger to open:
import "github.com/AlDanial/tdb/go/tdb"
func compute(n int) int {
total := 0
for i := 0; i < n; i++ {
total += i
}
tdb.Breakpoint() // tdb opens here, paused on the next line
return total
}go get github.com/AlDanial/tdb/go/tdb
go build -gcflags=all=-N -l -o prog . && ./prog # run it directly, not under tdbWhen the call is reached, the program starts tdb --lang go -a <its pid> --no-pause-on-attach on its own terminal (the tdb on PATH, or the one
named by $TDB), waits for tdb's Delve to attach, and traps with
runtime.Breakpoint(); tdb steps out of the trap so the stop lands on the
line after the call, in your own frame. Later tdb.Breakpoint() calls
reuse the running tdb. Quitting tdb (Ctrl+q) detaches and the program
runs on; the next call opens a fresh tdb. The call is a no-op when stdin
or stdout is not a terminal, and it warns and continues when tdb
cannot be found or exits before attaching. Build with
-gcflags=all=-N -l so locals stay inspectable. Linux only: the hook
grants ptrace access to tdb's Delve with PR_SET_PTRACER (Yama
ptrace_scope=1); elsewhere it warns once and returns. See
examples/Go/breakpoint_hook_demo/.
Remote attach: start Delve's own DAP server against your program first —
dlv dap --listen=host:port ./binary— then connect from tdb:
tdb -r host:port --lang goDo not expose the port to an untrusted network; prefer an SSH tunnel.
Goroutine inspection: in a Go session the Threads action (Alt+T, or
the Goroutines (N) menu label) opens the Goroutines workspace while
the debuggee is stopped — a fresh, bounded snapshot of goroutines, wait
edges, and findings, the same shape as the Rust Concurrency workspace.
Goroutines parked entirely in Go-runtime internals (GC workers,
finalizers, netpoll) are hidden by default; press a to reveal them
alongside the rest. The Wait Graph tab groups goroutines by the
channel, mutex, or WaitGroup they're blocked on; the Findings tab
surfaces stuck channels, mutex convoys, and likely leaks with a
confidence level (confirmed vs probable) — Go mutexes never record an
owner, so, unlike Python's Lock/Semaphore inspector, findings can name
what's blocked but not who holds it. The snapshot collects up to 150
goroutines; a busier program reports the header
Goroutines (150) — N more not collected rather than silently truncating.
Refresh (r) after the next stop to collect a new snapshot.
Limitations (v1): --terminal is not supported (dlv dap has no
terminal-routing mode); a goroutine parked in a select is classified but
contributes no wait edge (Delve can't tell which case it's waiting on);
no mutex-holder identification (impossible for Go's runtime); goroutines
spawned indirectly via exec.Command child OS processes are not
automatically attached; buildinfo-based binary detection scans only the
first 16MB of the file (--lang go overrides for anything larger); -a's
/proc-based language auto-detection is Linux-only (pass --lang go
alongside -a on macOS/Windows).
tdb debugs PowerShell 7 scripts through PowerShell Editor Services
(PSES), the Debug Adapter Protocol server behind the VS Code PowerShell
extension. You need pwsh (>= 7.2; https://aka.ms/powershell) on PATH
and the PSES module. tdb looks for the module in this order:
{"adapters": {"pses": "/path/to/PowerShellEditorServices"}}inconfig.json- the
TDB_PSES_PATHenvironment variable - the copy bundled with an installed VS Code PowerShell extension
(
~/.vscode/extensions/ms-vscode.powershell-*/modules/PowerShellEditorServices)
Without VS Code, download the release once:
mkdir -p ~/.local/share/tdb/pses && cd ~/.local/share/tdb/pses
curl -sLO https://github.com/PowerShell/PowerShellEditorServices/releases/download/v4.7.0/PowerShellEditorServices.zip
unzip -q PowerShellEditorServices.zip
export TDB_PSES_PATH=~/.local/share/tdb/pses/PowerShellEditorServicesA pwsh that isn't on PATH can be named with {"adapters": {"pwsh": "/path/to/pwsh"}}.
tdb script.ps1 # launch, stop at the first statement
tdb --run script.ps1 # run immediately, debug on demand (pause works)Notes:
- Entry stop lands on the first statement of your script (comments and
function definitions are skipped): PSES has no native
stopOnEntryfor this launch shape, so tdb sets a synthetic breakpoint on the bundled launcher, steps in, and clears it — no line-1 breakpoint appears in the Breakpoint view. - The script runs inside that small bundled launcher (
tdb_launch.ps1) so tdb can report the script's exit code. In the Stack view, top to bottom: PSES's<Breakpoint>marker frame (the frame to inspect — it tracks the accurate current position), your script's own frames, the launcher's<ScriptBlock>, and a source-lessInteractive Sessionframe at the bottom. - Stepping is statement-granular, as in VS Code: a line containing a
$( ... )subexpression takes twonextpresses to clear, andstepInfirst lands on the called function's declaration line before its body. - An uncaught terminating error ends the script with exit code 1 and opens
the fatal-error modal from pwsh's concise error text. Non-terminating
errors (
Write-Error, failing cmdlets without-ErrorAction Stop) print the same concise error block to the Console as stderr and let the script continue with exit code 0, as in pwsh itself; there is no break-on-error yet. The error block names the launcher's& $Scriptline rather than your script's actual line for some error kinds (a known cosmetic limitation). - The fatal-error modal is gated on a non-zero exit code, since pwsh prints
the identical block for terminating and non-terminating errors. A script
that reports a non-terminating error and then
exit 3can therefore still raise the modal, showing the last error block that names a file of yours; blocks attributed to tdb's owntdb_launch.ps1are skipped, so a bareWrite-Errorbefore theexitopens no modal at all. --terminaland remote attach are not supported for PowerShell yet.Read-Host,$Host.UI.ReadLine(), and[Console]::ReadLine()read from the Console view's stdin line (pwsh is no longer started with-NonInteractive, so mandatory-parameter and-Confirmprompts now wait for your input there instead of failing). pwsh echoes the prompt and the answer together once the line completes, so a bareRead-Hostprompt appears only after you answer it; a precedingWrite-Hostshows at once.- Windows PowerShell 5.1 is not supported (pwsh 7 only). Running tdb on Windows against pwsh is designed for but not yet verified (experimental).
┌─ Header ──────────────────────────────────────────────┐
├─ Menu Bar (File / Configure / Help)───────────────────┤
│ │ │
│ Code View │ Console View (stdio) │
│ (source + breakpoints) ├───────────────────────────┤
│ │ Variable View (tree) │
│ ├───────────────────────────┤
│ │ Stack View (call stack) │
├─ Status Bar ──────────────────────────────────────────┤
│ │ │
│ Evaluate Console (REPL) │ Breakpoint View (table) │
│ │ │
├─ Footer (keybindings) ────────────────────────────────┤
└───────────────────────────────────────────────────────┘
The status bar shows the current execution state (running, paused, breakpoint hit) and location. The footer shows the most relevant keybindings for the current mode.
The Code View shows syntax-highlighted source (lexer chosen per language) with line numbers. A cursor line (blue) tracks your position; the current execution line is highlighted in gold.
View focus shortcuts (global):
| Key | View |
|---|---|
Ctrl+C |
Code View |
Ctrl+O |
Console View |
Ctrl+E |
Evaluate Console |
Ctrl+V |
Variable View |
Ctrl+S |
Stack View |
Ctrl+B |
Breakpoint View |
Menu-bar shortcuts (global):
Alt+<first-letter> opens the corresponding tab in the menu bar.
| Key | Menu |
|---|---|
Alt+F |
File (open a different script to debug) |
Alt+E |
Edit (Save, Revert to Disk, Discard and Exit Edit Mode, Open in $EDITOR) |
Alt+C |
Configure (Color Theme, Keybindings, Step Mode) |
Alt+T |
Threads |
Alt+P |
Processes |
Alt+A |
Async Tasks |
Alt+H |
Help (Documentation, About) |
File > Open works for every supported language: the file picker filters to the current session's language (by extension) and refuses a pick that detects as a different language, so you can only switch to another script written in the same language as the one currently being debugged.
Navigation (vim-style by default):
By default the Code View is in Debug mode. Escape cycles Debug → Navigate → Edit → Debug.
In Navigate mode, you can move around the file with the following keys:
| Key | Action |
|---|---|
j / k |
Move cursor down / up |
5j, 10k |
Move N lines down / up with count prefix |
G |
Go to end of file (with count: 42G jumps to line 42) |
[ / ] |
Jump to previous / next paragraph boundary |
/ |
Search forward |
? |
Search backward |
n / N |
Next / previous search result |
PageUp / PageDown |
Scroll by page |
Press Escape again to enter Edit mode (below), and once more to return to Debug mode.
Edit mode:
Press Escape twice from Debug mode to open the current file in an editor
inside the Code View. The pane title shows [Edit], with * while there
are unsaved changes. Editing keys follow the keybinding scheme:
| Scheme | Editing style | Save | Leave Edit mode |
|---|---|---|---|
vim |
vim-lite: normal / insert modes, counts, h j k l w b e 0 ^ $ gg G, x dd dw D yy p P u Ctrl+R J, i a I A o O, / ? n N |
:w or Ctrl+S |
Esc (from normal mode), :q, :wq, :q! |
emacs |
Ctrl+N/P/F/B, Alt+F/B, Ctrl+A/E, Ctrl+K / Ctrl+Y, Ctrl+_ undo, Ctrl+S / Ctrl+R search |
Ctrl+X Ctrl+S |
Esc or Ctrl+X Ctrl+C |
default (Notepad-style) |
arrows, Home/End, PgUp/PgDn, Delete/Backspace, Shift+arrows to select, Ctrl+Z/Ctrl+Y, Ctrl+X/Ctrl+C/Ctrl+V |
Ctrl+S |
Esc |
Leaving Edit mode, quitting, restarting, or opening another file with
unsaved changes prompts: s save, d discard, Esc keep editing.
Saving does not change the running program. tdb shifts your breakpoints
to their new lines, clears the current-line marker, and (when restart is
available) reminds you to press R to restart with the new code. In
tdb --run sessions, which cannot restart, you will see only "Saved
<file>." Edit mode is unavailable when no file is loaded, for sources
that are not on this machine (remote attach), during replay, and in
post-mortem mode.
The Edit menu (Alt+E) offers Save, Revert to Disk, Discard and Exit
Edit Mode, and Open in $EDITOR, which suspends tdb, runs $VISUAL /
$EDITOR (notepad on Windows, vi otherwise) on the file, and reloads
it when the editor exits.
Syntax highlighting inside the editor needs tree-sitter, an optional
extra: uv pip install "textual-debugger[edit]" (highlights Python, Bash,
Go, and Rust; other languages edit as plain text). Highlighting in the
normal Code View is unaffected.
CRLF source files are written back with LF line endings after a save in Edit mode (a v1 limitation — no CRLF round-trip yet).
Note: Many terminals send the byte sequence
ESC+fforAlt+F, which Textual's ANSI parser rewrites toCtrl+Right(the readline "forward-word" convention).tdbbinds both soAlt+Fworks as expected.
Keybindings for stepping, continuing, pausing, and stack navigation match those for gdb/pdb, with some aliases and extras thrown in for convenience.
| Key | Action |
|---|---|
n |
Step over (next statement) |
s |
Step into function call |
o / f / r |
Step out of current function (also aliased as "finish" and "return") |
c |
Continue execution |
p |
Pause a running program |
t |
Run to cursor position |
u / d |
Navigate stack up (caller) / down (callee) |
j / k |
Move cursor down / up (with count: 5j, 10k) |
G |
Go to last line (with count: 42G jumps to line 42) |
e |
Re-display the last error (traceback) |
R |
Restart the debug session |
q q |
Quit |
Ctrl+q |
Quit |
Note:
f("finish") andr("return") are both aliases for step-out. DAP's only "exit-a-function" primitive isstepOut, which runs the rest of the current function normally and stops at the return point. A true gdb-style immediate-return (skipping remaining code in the function without executing side effects) is not supported by DAP/debugpy.
Step granularity (statement vs. line): by default, n (step over) and s (step into)
treat a multi-line source statement as a single step. For example, stepping over
results = await asyncio.gather(
fetch(1, 2),
fetch(2, 1),
fetch(3, 3),
)
print(results) # next stop lands here, not on each sub-line abovelands on print(results), not on each interior sub-line of the gather call. Switch to
Line mode (Configure > Step Mode) to get debugpy's native per-line behavior, which
stops on each physical line--useful for inspecting how a complex expression is built up.
The choice is saved to ~/.config/tdb/config.json.
Statement mode requires a source-language model and is currently Python-only; other languages step at their debugger's native granularity — per line, except PowerShell, whose debugger stops per statement, see PowerShell (the Step Mode menu says so if you try).
Click the gutter in the Code View to toggle a breakpoint, or press b in Debug mode.
Breakpoint indicators:
- Red dot: active breakpoint
- Yellow dot: conditional breakpoint
- Blue dot: disabled breakpoint
Conditional breakpoints: Double-click a breakpoint to open the condition editor.
Set a Python expression (e.g., x > 10) and/or a hit count (pause on the Nth hit).
Breakpoint View actions:
D: Disable / enable all breakpointsC: Clear all breakpoints
Breakpoints persist across session restarts.
Breakpoints set at the debugger's own prompt (gdb): when debugging native code
with gdb, commands typed in the Evaluate Console such as b 83 or delete 2
go straight to gdb. Switching to the Breakpoint View (Ctrl+B, or clicking it)
synchronizes tdb's table with gdb's list: breakpoints created at the gdb prompt
are adopted (with their conditions and enabled state) and become ordinary tdb
breakpoints, and breakpoints deleted at the prompt are dropped. Watchpoints,
catchpoints and tbreak temporaries are left to gdb. lldb-dap has no such
prompt-level breakpoint channel, so nothing to sync there.
The Variable View shows a tree of scopes with all variables in the current frame. The scopes themselves are language-dependent: Locals, Globals, plus Environment for bash; Lexicals, Globals, Specials for Perl (see the Bash and Perl sections above for details). Expand nodes to drill into complex objects. Children are loaded lazily on demand. Variable values can be changed in the Evaluate Console.
Double-click a variable, or highlight the variable with the text cursor in
the Variables View and press Enter
to display that variable in a modal. This simplifies inspection of
large or deeply nested data structures.
Like gdb's display command, a variable (or any expression) can be
pinned so its value is re-read at every stop. Pinned entries appear
under a Display scope at the top of the Variables View, but only
while they are defined in the current frame: step out of the function
that owns b and the entry vanishes, step back into it and the entry
returns. Two ways to pin, two ways to unpin:
| Evaluate Console | Variables View | |
|---|---|---|
| pin | display b |
right-click the variable's row |
| unpin | undisplay b |
left-click its row under Display |
>>> display b
▼ Display
b = 6
▼ Locals
a = 3
b = 6
Right-clicking a child of an expanded object pins the full path
(obj.count, items[2]), and a bare display lists everything pinned.
A click in the Variables View is recorded exactly as the typed command,
so --record/--replay sessions reproduce it. Some terminals keep
right-click for their own paste or context menu (Windows Terminal and
the VS Code terminal do by default); use the display command there.
The Stack View shows the full call stack. Click a frame to navigate to its source location and inspect its variables.
A read-evaluate-print loop (REPL) at the bottom left permits interactive evaluation of expressions in the current scope:
>>> len(items)
42
>>> sorted(data, key=lambda x: x.priority)[:3]
[Item(priority=1), Item(priority=2), Item(priority=3)]
- Up/Down arrows cycle through expression history
- Tab triggers DAP-based completion
- Trailing
?shows help (signature + docstring):
>>> os.path.join?
(a, *p) : Join two or more pathname components...
Variable values set here are reflected in the running code. The Variables View redraws after every entry, so an assignment typed at the prompt shows up immediately rather than at the next stop.
A variable created at the prompt is tracked and listed under an extra Interactive scope in the Variables View, alongside Locals, Globals and the other language-specific scopes. The value is re-read at every stop, so it works like a watch list of the names you introduced:
>>> newvar = 42
▼ Locals
a = 3
b = 6
▼ Interactive
newvar = 42
What counts as "created" depends on the language, and so does how long the variable lives:
| Language | Creates an interactive variable | Lifetime |
|---|---|---|
| Python | name = … |
the current frame (gone once it returns) |
| Perl | $name = …, @name = …, %name = …, our … |
the whole run (my lexicals vanish with the eval and are not tracked) |
| Bash | name=…, local/declare/export … |
the whole run; local follows the function |
| tcsh | set name …, setenv NAME … |
the whole run |
| Ruby | name = …, $name = …, @name = … |
globals and instance variables persist; plain locals live only in the eval's binding and read back as unavailable after the next step |
| PowerShell | $name = …, $global:name = … |
the function scope, or the whole run for $global: |
| C/C++/Rust/OCaml under gdb | set $name = … (a gdb convenience variable) |
the whole run |
| C/C++/Rust/OCaml under lldb-dap | int $name = … (an lldb persistent variable) |
the whole run |
| Go (dlv), OCaml under ocamlearlybird | not possible: their evaluate cannot create variables | — |
A tracked name that no longer resolves (a Python local after its frame
returned, for example) stays in the list and shows as <unavailable>.
Expression for the Evaluate Console are often copied from the Code View.
Doing this in tdb differs from traditional terminal behavior, because textual applications
capture mouse events for their own use.
Instead, hold the Shift key while performing your conventional cut/paste keystrokes or mouse
operation to get the expected behavior.
The Console View captures stdout (normal text) and stderr (red text) from the debuggee in real time.
Programs that read from stdin (input(), <STDIN>, read, gets, …) get
their input from the same view: while the program runs, an input line sits
under the output log. Ctrl+O focuses it, Enter sends the line (plus a
newline) to the program and echoes it in cyan next to the program's prompt,
and Ctrl+D sends end-of-file. A bare Enter sends an empty line, which is
a real answer to input(). The line disappears when the program exits or
when the session has no stdin to feed.
Stdin forwarding works for the languages whose program tdb launches on a
pipe it owns: Python, Perl, Bash, Tcsh, Ruby, and PowerShell. It is not
available for the compiled languages — C/C++, Rust, OCaml, and Go — because
their adapters (gdb -i dap, lldb-dap, dlv dap) spawn the debuggee
themselves and give tdb no handle on its stdin; the input line stays
hidden there. It is also unavailable for remote attach (-r), where the
program's stdin belongs to whoever started it, and under --terminal,
where the program reads from its own terminal window. For those cases run
the program in an external terminal with --terminal (launch mode only),
or give it its input another way (a file, a pipe on the command line).
Every line typed here is captured by --record as a stdin gesture and
replayed by --replay/--replay-tui, and the JSON-RPC server exposes the
same thing as the stdin / stdin_eof actions.
If your program prints a lot, or uses colors or
terminal control codes, run the program in an external terminal
with --terminal for a better experience.
The --terminal switch requires a graphical environment and a compatible
terminal emulator.
When the debuggee raises an unhandled exception, tdb:
- Shows a modal with the full traceback
- Navigates the Code View to the crash line
- Populates the Stack View with the exception's call stack
- Lets you press
Rto restart orEscapeto dismiss
Note: after dismissing the traceback modal, you can display it again by hitting
ewhen focus is in the Code View.
You can have tdb pop open automatically when any Python program crashes without the
need to launch through tdb up front. Install the hook once at the top of your program:
import sys
import tdb
sys.excepthook = tdb.exception_hookWhen an uncaught exception reaches the hook, tdb:
- Prints the standard Python traceback to stderr (so your scrollback still has a record)
- Snapshots every frame in the traceback. This includes locals, plus one level of
recursion into containers (
dict,list,tuple,set) and objects with__dict__ - Launches the TUI in post-mortem mode, inheriting the current terminal
In post-mortem mode you can:
- Navigate the call stack (
u/dor the Stack View) and see each frame's locals - Expand nested containers and object attributes in the Variables View
- Read the full traceback (including chained
cause/contextexceptions) in the Console View - Jump around the source with the full Code View (syntax highlighting, goto-line, etc.)
Stepping, continue, breakpoints, restart, and the Evaluate View are disabled. The original
interpreter is gone since the view is a frozen snapshot. Press q to exit.
The hook is a no-op when stdin/stdout aren't a tty (e.g. when your program is piped or
run from cron), so it is safe to leave installed in production code. Snapshots are
written to a temp file that is deleted as soon as tdb exits.
Snapshot depth / breadth is capped (5 levels, 50 children per container) to keep the capture cheap even for pathological object graphs; cycles are handled via identity memoization.
The textual-debugger GitHub repository's examples/ directory has three files
that show how to run a tdb-enabled Python program under tmux in a Docker
container so that you can attach to the container and inspect the program
in tdb post-mortem analysis mode if the program hits an unhandled exception:
tdb has an improved implemenation of the standard breakpoint() function (or equivalently,
pdb.set_trace()) used to pause at a specific line to inspect, then
continuing--use tdb.breakpoint():
import tdb
def compute(n):
total = sum(range(n))
tdb.breakpoint() # pause here and drop into tdb
return totalOr hook it into the builtin breakpoint() function for the whole program:
PYTHONBREAKPOINT=tdb.breakpoint python myscript.pyWhen the call is reached, tdb starts an in-process debugpy server on a loopback port,
spawns python -m tdb -r <port> as a subprocess so the TUI takes over the terminal,
and pauses the calling thread at the line that called tdb.breakpoint() (the hook
auto-steps out of its own helper so you land in your own frame, not inside
breakpoint_hook.py). Stepping (n/s/o), continue, and setting/removing breakpoints
all work normally; quitting tdb (Ctrl+q) detaches without killing the program, and
debugpy auto-resumes any threads still paused.
This differs from tdb.exception_hook in one way:
- Requires
debugpyas a runtime dependency for the debuggee (only imported when the hook actually fires).
Unlike the exception hook (which works on a frozen snapshot), the breakpoint hook leaves
the interpreter live: variable inspection reads real objects, and stepping/continue
drive the user's program forward.
As with exception_hook, the call is a no-op when stdin/stdout aren't a tty, so it's
safe to leave in code paths that sometimes run headless.
Quitting tdb while paused in a tdb.breakpoint() session detaches the debugger and
lets the program continue running normally.
This behavior matches hitting c while in a conventional (that is, the Python
standard library's) breakpoint() session.
If you want to kill the program instead, use Ctrl+c in the terminal running the debuggee.
Perl, Ruby, Go, C/C++, Rust, and OCaml programs get the same hook as
Devel::TdbRemote::breakpoint(), Tdb.breakpoint, tdb.Breakpoint(),
tdb_breakpoint(), tdb::breakpoint(), and Tdb.breakpoint (); see
Perl, Ruby, Go, C/C++ tips,
Rust, and OCaml.
For programs using asyncio, the menu bar shows an Async Tasks (N) label with the count of
active tasks (updated each time the program stops). Click it to open a full-screen modal:
- Left pane: list of all tasks with name, state (pending/done/cancelled), awaiting
primitive (
Lock.acquire,Queue.get,asyncio.sleep, …), and coroutine - Right pane: detail view with full stack trace and an expandable variable tree (same as the main Variables View) for the selected task
- Press
gto switch the right pane to the wait graph which is a tree showing each blocked task, the asyncio primitive it's parked on, and the task(s) holding that primitive. Cycles (deadlocks) are highlighted in red both in the task table and as a "Deadlock cycles" section at the top of the graph. Selecting a node in the tree highlights the corresponding task in the table. - Navigate with arrow keys; press
rto refresh,Escapeto close
Note: Async task relationships may be directed acyclic graphs (DAGs) rather than trees but I don't know of a way to visualize DAGs in textual.
RPC equivalents:
# List all tasks
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"list_tasks","params":[]}'
# Inspect a specific task by name
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"inspect_task","params":["Task-1"]}'
# Show wait graph and any deadlock cycles
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"wait_graph","params":[]}'The menu bar shows a Threads (N) label when the program has 2 or more threads. Click it to open a modal with:
- Left pane: list of threads with ID and name (current thread shown in bold)
- Right pane: full stack trace and expandable variable tree for the selected thread's top frame
- Navigate with arrow keys; press
rto refresh,Escapeto close
RPC equivalents:
# List all threads (* marks current)
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"list_threads","params":[]}'
# Inspect a specific thread by ID
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"inspect_thread","params":[1]}'For programs using multiprocessing, the menu bar shows a Processes (N) label when there
are 2 or more child processes. Click it to open a modal with:
- Left pane: list of child processes with PID, name, and status (alive/exited)
- Right pane: process details, full stack trace, and expandable variable tree for the selected process
tdb automatically attaches to child processes spawned via multiprocessing.Process, multiprocessing.Pool,
or concurrent.futures.ProcessPoolExecutor. Breakpoints set in the parent are propagated to all
child processes. When any process hits a breakpoint, all other processes are paused. Pressing p
pauses all processes; c continues all.
Stepping in multi-process programs: step commands (n, s, o, f, r) apply only to the
process whose stack is currently shown in the Code View (the one that hit the breakpoint).
Other processes remain paused throughout the step. To step in a different process, open
the Processes tab and select it first. The Code View then switches focus to that process,
and subsequent step commands operate on it.
RPC equivalents:
# List all child processes
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"list_processes","params":[]}'
# Inspect a specific process by name or PID
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"inspect_process","params":["ForkPoolWorker-1"]}'Remote attachment is useful in situations where you can't launch the debuggee directly
with tdb, for example, if it is launched from another program or runs in an environment
where you can't install tdb. Two requirements must still be met though:
- the
debugpypackage must be installed in the debuggee's Python environment - you need write access to the debuggee's code to add the following code at the point where you want to attach the debugger:
# In the target program:
import debugpy
debugpy.listen(("0.0.0.0", 5678))
print("Waiting for tdb to attach on port 5678...")
debugpy.wait_for_client() # optional: pause until debugger connects
print("tdb is attached!")When the debuggee runs and hits the debugpy.wait_for_client() line, it starts a
debugpy server listening on port 5678.
Attach tdb to it with the -r switch, specifying the host and port.
If the debuggee is on the same machine, you can omit the host or use localhost.
This example assumes the debuggee runs on 192.168.1.10 and listens on port 5678:
# Attach from tdb:
tdb -r 5678 # to localhost
tdb -r 192.168.1.10:5678
# With breakpoints:
tdb -r 5678 -k my_program.py:42All debugging features (breakpoints, stepping, variable inspection, threads, processes, async tasks) work in remote attach mode. The Code View automatically navigates to the source file when the program stops.
Mapping remote paths to local copies (--local-root / --remote-root): when the
debuggee lives on another machine, or in a container, or simply in a different directory
on the same machine, the source paths it reports (and the paths it expects breakpoints
to refer to) won't match anything on the tdb host. To bridge that gap, give tdb one
or more --local-root / --remote-root pairs. Each --local-root points at a local
directory containing a copy of the code; each --remote-root is the corresponding path
on the debuggee. The two flags must be supplied in equal numbers and are paired in CLI
order via zip(), so the first --local-root matches the first --remote-root, the
second matches the second, and so on. debugpy then translates paths bidirectionally:
breakpoints set on a local file land on the matching remote file, and source paths
returned in stopped-events / stack-traces are rewritten back to the local copy so the
Code View loads directly from disk (no DAP source round-trip needed).
These flags are required whenever you want to set a -k breakpoint against a remote
debuggee whose code lives at a different path than your local copy. For example, if the
remote runs program.py at /path/to/code/program.py and your local copy is at
/local/project/code/program.py, set a breakpoint at line 321 with:
tdb -r RHOST:15678 \
--local-root /local/project/code \
--remote-root /path/to/code \
-k program.py:321With --local-root set, a relative -k FILE:LINE is resolved by searching each
--local-root directory in CLI order (first match wins); absolute paths still work as
before. Multiple pairs can be supplied to mirror multiple source trees (e.g. an
application directory and a shared library directory) in one invocation.
Some programs, notably text user interfaces, use terminal control codes and
require direct access to the terminal to function properly. Such programs
can be debugged with tdb by having it launch the debuggee in a separate
terminal:
tdb --terminal xterm my_tui_app.py--terminal works for every language tdb can launch (as opposed to
attach to): Python, Perl, Bash, Tcsh, Ruby, and C/C++ or native OCaml
sessions via --adapter lldb-dap. Neither gdb -i dap (the default C/C++
adapter, and an alternate for native OCaml) nor ocamlearlybird (the
bytecode OCaml adapter) has any terminal integration -- tdb rejects
--terminal up front for both, with an error pointing at --adapter lldb-dap instead. Go and PowerShell don't support --terminal either
(dlv dap has no terminal-routing mode; PSES likewise). --terminal also
only applies when tdb launches the program itself; it is rejected for
remote-attach (-r), since there's no program for tdb to spawn a
terminal around.
The debuggee runs in a separate window of the specified terminal, including
all of its stdin/stdout/stderr. Keyboard input, program output, and
anything the program itself draws to the terminal all happen in that
window, not in tdb's Console View (whose stdin line is hidden in this
mode). Supported choices:
xterm, konsole, gnome-terminal, ghostty, kitty, iterm2, warp,
wezterm, terminator. The selected terminal must be on PATH. Debugging
(breakpoints, stepping, variable inspection, the evaluate console) proceeds
as usual in the terminal where tdb was invoked.
This feature only works in graphical environments where external terminals are available.
tdb's run mode acts as a job shepherd.
It runs your unmodified program at nearly full speed, without bringing up the TUI,
and listens for two interrupt signals:
-
Ctrl-Cpauses the program and brings up TUI, placing you at the currently active line of code. From there you can debug interactively as usual. When you quit the TUI, you can either detach and let the program continue running, or terminate it. -
Ctrl-\temporarily pauses the program and prints a JSON-formatted stack trace to stdout or a log file, then resumes the program.
tdb --run my_program.py args...The debuggee's stdout/stderr stream straight to the terminal, and for the
languages with stdin forwarding (see
Console Output and Input) the program reads
its stdin from that terminal too. If the program exits on
its own, tdb exits with the same code:
tdb --run my_program.py; echo $? # my_program.py's own exit codeWindows caveat: the Ctrl-Break trigger is untested. Unlike Ctrl-C, a console Ctrl-Break is delivered to every process attached to the console, including the debug adapter and the program itself, so it may terminate the program instead of snapshotting it. There is no signal-based alternative on Windows.
tdb pauses the program, writes one JSON line describing the call stack of every thread
(plus asyncio tasks and multiprocessing children for Python, goroutines for Go, and
the concurrency snapshot for Rust), and resumes the program. Ctrl-C behavior is
unchanged. By default the line goes to stdout; --examine-log DEST sends it to a file
instead (- means stdout; repeat the flag to write to several places):
tdb --run --examine-log hang.jsonl my_program.py # file only, appended
tdb --run --examine-log - --examine-log hang.jsonl my_program.py # bothEach record looks like (abridged):
{"schema":1,"seq":1,"trigger":"SIGQUIT","status":"ok",
"requested_at":"2026-09-07T14:02:11.482-07:00","landed_at":"2026-09-07T14:02:11.511-07:00",
"elapsed_s":87.3,"language":"python","program":"/home/al/work/app.py",
"processes":[{"pid":41213,"role":"parent","name":null,
"threads":[{"id":1,"name":"MainThread","frames":[
{"function":"wait","file":"/usr/lib/python3.13/threading.py","line":359},
{"function":"main","file":"/home/al/work/app.py","line":42}]}],
"tasks":[{"name":"worker-2","state":"PENDING","awaiting":"Lock.acquire","frames":["run (/home/al/work/app.py:18)"]}]}],
"errors":[]}Frames are innermost first. tasks appears only for Python programs with live asyncio
tasks; goroutines only for Go; rust_concurrency only for Rust. status is
"pending" when the pause could not land within a few seconds (the program is blocked
inside a single call) -- a second record with the same seq and status: "ok" follows
when it does land. If you press Ctrl-C or the program exits before it lands, no
completing record is written. errors lists any sub-collector that failed, so a partial
record is explicit about what is missing. A stderr line confirms each capture and where
it went.
Narrow a long capture with jq, for example only threads with a frame in your own code:
jq -c '.processes[].threads[] | select(any(.frames[]; .file != null and (.file|test("/work/"))))' hang.jsonlQuitting an adopted session adds a third choice to the usual quit dialog:
d(orq) -- detach and resume: the program keeps running headlessly; interrupt it again later the same way.t-- terminate the program and quittdb.Esc-- cancel, stay in the TUI.
A breakpoint set during one of these episodes stays live after you detach; if it's hit,
tdb reopens the TUI there, just like an interrupt.
If the program instead dies on an unhandled exception -- in Python this includes a
nonzero sys.exit(), which debugpy treats as an uncaught SystemExit -- tdb opens
the TUI at the point of failure instead of exiting silently, so you can inspect the
crash. Exit-code passthrough applies only to a clean exit during the headless phase.
Supported languages: Python, Perl, Bash, Tcsh, Ruby, PowerShell 7, C/C++
(both gdb and lldb-dap), and native OCaml (both gdb and lldb-dap;
bytecode/ocamlearlybird sessions don't support pause, so --run is
unavailable for those). --run is rejected up front for any other
language.
Limitation: pausing is cooperative. If the debuggee is blocked inside a single
blocking external call or syscall, the pause can't land until that call returns --
tdb prints a notice and opens the TUI once the stop actually arrives.
Incompatible flags: -r, -k/-t, --record, --replay, --replay-tui, --server,
--headless, --mcp, --terminal.
Eval mode is essentially a code injection mechanism. Rather than injecting native code though, only expressions that are valid in the Evaluate View can be injected (in Python, native expressions are processed correctly in the Evaluate View).
In this way, Eval mode automates the common debugging action of setting a
breakpoint, running to it, evaluating (and printing the result of) an expression, then
resuming execution, all without opening the TUI. -e takes two arguments, a location and an expression:
tdb --eval examples/digits_of_pi.py:25 "print(f'{nr=}')" examples/digits_of_pi.pysets a breakpoint at line 25, runs the program, and each time that line is
reached evaluates print(f'{nr=}') in the stopped frame, then continues.
Program output and evaluation output both stream to the terminal; when the
program exits, tdb exits with the same code (and with 1 if the session
ends without one--as with a crash or killed adapter--so a scripted tdb -e ... && next can trust the exit status). Ctrl-C stops the program and exits
130. Under multiprocessing, an eval point placed in child code is
evaluated in each child process that reaches it.
- The location is
FILE:LINE, or a bareLINEof the program being debugged, and is snapped to the start of a logical statement just like-k. -emay be repeated to plant several eval points in one run.- The expression is evaluated through the debug adapter (DAP
evaluate, contextrepl), just like the Evaluate console: side effects are real and persist in the running program, for example-e my_program.py:25 "limit = 10"changeslimitfrom line 25 onward. - A bare expression's result is printed to stdout; statement-style
expressions (
print(...), assignments) print nothing beyond their own output. An expression error is reported on stderr and the run continues -- a typo isn't fatal, matching the Evaluate console. - Saved breakpoints are ignored, and any other stop (e.g. an uncaught-exception stop) is continued untouched, so the program runs to completion unattended.
- For non-Python debuggees the expression must be in the debug adapter's
own syntax, for example gdb's
print x/set var x=3for C/C++, and not necessarily the debuggee's language. The adapter may bind the breakpoint to the next executable line (gdb moves one off a declaration or brace); the eval point matches that bound line, so the expression still runs.
Incompatible flags: --run, -r, -a, -k/-t, --record,
--replay, --replay-tui, --server, --headless, --mcp, --terminal.
tdb --keybindings vim my_program.py # default
tdb --keybindings emacs my_program.py
tdb --keybindings default my_program.py # Notepad-style editingThe keybinding choice is saved to ~/.config/tdb/config.json and remembered for subsequent
runs. View the full keybinding reference from the menu: Configure > Keybindings.
tdb includes a built-in debug server for programmatic control which is useful for scripted
debugging, CI pipelines, or AI-assisted debugging workflows.
python -m tdb --headless my_program.py &The server listens on http://127.0.0.1:8150/rpc (change with --server-port).
tdb --server my_program.pyBoth the interactive TUI and the JSON-RPC server run simultaneously.
Send POST requests with {"action": "...", "params": [...]}. Responses return
{"timestamp": "...", "success": true/false, "value": "..."}. Actions with a
structured result (today only rust_concurrency) additionally carry a data
object holding the full JSON payload; value then holds a one-line summary.
# Check status
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"status","params":[]}'
# Set a breakpoint
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"set_breakpoint","params":["/abs/path/to/file.py:42"]}'
# Continue execution
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"continue","params":[]}'
# Inspect variables
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"inspect","params":["x", "len(items)", "type(result)"]}'
# Shut down
curl -s -X POST http://127.0.0.1:8150/rpc \
-H 'Content-Type: application/json' \
-d '{"action":"quit","params":[]}'| Action | Params | Description |
|---|---|---|
help |
[] |
List all actions |
status |
[] |
Current state with location |
set_breakpoint |
["file:line"] or ["file:line", "condition", "hit_condition"] |
Set a breakpoint |
remove_breakpoint |
["file:line"] |
Remove a breakpoint |
list_breakpoints |
[] |
Show all breakpoints |
continue |
[] or [timeout_s] |
Resume execution; on timeout returns "still running--call pause or wait again" (success) |
next |
[] or [timeout_s] |
Step over |
step_in |
[] or [timeout_s] |
Step into |
step_out |
[] or [timeout_s] |
Step out |
pause |
[] |
Pause execution; bypasses the dispatch lock so it can interrupt an in-flight blocking action |
wait_for_stop |
[] or [timeout_s] |
Wait for the next stop without issuing a step (use after continue returns "still running" to keep waiting) |
inspect |
["expr1", "expr2", ...] |
Evaluate multiple expressions |
evaluate |
["expression"] |
Evaluate a single expression |
stack_up |
[] |
Move up the call stack |
stack_down |
[] |
Move down the call stack |
get_stack_trace |
[] |
Full call stack |
get_output |
[] |
Drain buffered stdout/stderr |
stdin |
["line"] |
Send a line (newline appended) to the program's stdin; errors when the session has no stdin (compiled languages, --terminal, remote attach) |
stdin_eof |
[] |
Close the program's stdin (Ctrl+D) |
get_source |
["file_path"] |
Read a source file |
list_threads |
[] |
List all threads |
inspect_thread |
[thread_id] |
Inspect a specific thread |
list_processes |
[] |
List child processes (multiprocessing) |
inspect_process |
["name_or_pid"] |
Inspect a specific child process |
list_tasks |
[] |
List all asyncio tasks |
inspect_task |
["task_name"] |
Inspect a specific asyncio task |
wait_graph |
[] |
Show wait graph + any deadlock cycles |
rust_concurrency |
[] |
Structured Rust thread/wait/deadlock snapshot in data (Rust sessions, stopped) |
restart |
[] |
Restart session (preserves breakpoints) |
quit |
[] |
Shut down |
Subscribe to real-time debug events:
curl -N http://127.0.0.1:8150/eventsEvents: initialized, stopped, continued, terminated, exited, output.
Each is JSON with event, data, and timestamp fields.
tdb --record session.jsonl prog.py runs a normal TUI session and captures
your debugging actions including breakpoints (including -k/-t and persisted
ones), stepping, continue/pause, Evaluate-console entries, lines typed into
the Console view for the program's stdin (and Ctrl+D), stack-frame
navigation, variable expansion, restart, quit. The session is
written to session.jsonl as
JSON-RPC commands. Works with launch mode (any language) and -r
remote attach.
Replay it three ways:
-
tdb --replay-tui session.jsonlopens the normal TUI on the recorded program and performs every recorded action in front of you, at the recorded pace: breakpoints appear in the Code View, steps and continues move the cursor, Evaluate entries echo into the console with their results, stack navigation and variable expansion play out in their panels. The title bar shows progress (tdb ▶ session.jsonl 3/11) and a toast announces each action (--replay-quietturns those toasts off). A failed action raises an error toast and replay continues. When the recording does not end withquit, the debugger stays open at the final stop for you to carry on by hand.--replay-interval Swaits a fixed S seconds before each action instead of reproducing the recorded gaps.--replay-timeout Sbounds each stop-wait (default 30 s). Exit code 0 iff every action succeeded. Don't type into the TUI while it is replaying: stray keystrokes interleave with the recorded actions. -
tdb --replay session.jsonllaunches the recorded program headless, feeds every recorded command through the same RPC dispatchtdb --serveruses, and prints a transcript (recorded time, command, verbatim result, interleaved program output). Exit code 0 iff every command succeeded. Add--timingto reproduce the original pacing or--replay-interval Sfor a fixed delay before each command,--replay-timeout Sto bound each stop-wait (default 30 s). -
Against a live server: start
tdb --server prog.py, then feed line 2 onward of the file toPOST /rpc. Each line is a valid request body:tail -n +2 session.jsonl | while read line; do curl -s -X POST -H 'Content-Type: application/json' \ -d "$line" http://127.0.0.1:8150/rpc done(On Windows, an equivalent loop in Python: read the file, skip the first line,
requests.posteach remaining line.)
Not captured: pure viewing (scrolling, search, modals, thread/task
lists), breakpoint enable/disable toggles, variable expansions when the
adapter reports no evaluateName (currently the Perl adapter), and
File > Open program switches.
tdb ships a Model Context Protocol (MCP), version 2,
server (tdb-mcp) that exposes
the debugger as a curated set of tools an AI agent can call. The MCP
server is a third in-process consumer of the same dispatch handlers the
TUI and the HTTP server use so an agent gets the same lock semantics,
including the pause-during-continue bypass, and the same DAP-backed
inspection surface.
For a worked end-to-end example (prompting an agent to find two
runtime bugs in a sample program) see
docs/tutorial-mcp-debugging.md and
its companion examples/sales_report_buggy.py.
Configure your MCP client (Claude Desktop, an IDE extension, etc.) to
launch tdb-mcp over stdio. Example claude_desktop_config.json:
{
"mcpServers": {
"tdb": {
"command": "tdb-mcp"
}
}
}Three equivalent invocation forms: tdb-mcp (the dedicated entry
point), tdb --mcp (the main CLI with the --mcp switch), and
python -m tdb.mcp (module form). Pick whichever matches how your MCP
client expects to launch servers.
| Cluster | Tools |
|---|---|
| Lifecycle | debug_launch, debug_attach, quit |
| Control | control(action, timeout_s=30) where action ∈ {continue, next, step_in, step_out, pause, wait_for_stop} |
| Inspection | inspect(expressions), read_source(file_path), stack_trace(), status(), get_output() |
| Breakpoints | set_breakpoint(spec, condition?, hit_condition?), remove_breakpoint(spec), list_breakpoints() |
| Differentiators | threads(thread_id?), tasks(task_name?), processes(name_or_pid?), wait_graph(), rust_concurrency() |
control is intentionally one tool that takes an action enum. The six
underlying RPC actions share a return shape, and agents perform
measurably better with a small surface than with one tool per action.
threads / tasks / processes overload list-vs-inspect via a
single optional argument for the same reason.
debug_launch accepts optional lang and adapter parameters mirroring the
CLI's --lang/--adapter; when omitted, the language is auto-detected from
program, so an agent can hand it a compiled binary directly. The
exception is Rust: compiled native binaries require explicit lang="rust".
The tasks/processes/wait_graph tools stay registered for every language but
return a structured "not supported when debugging C/C++"-style error for
non-Python debuggees; rust_concurrency does the same for non-Rust debuggees.
agent → control(action="continue", timeout_s=30)
mcp → "still running, call pause or wait again"
agent → control(action="pause") # OR: control(action="wait_for_stop", timeout_s=30)
mcp → "<file>:<line>"
agent → inspect(["x", "len(buf)"])
mcp → "x = 7\nlen(buf) = 1024"
pause bypasses the dispatch lock so it can interrupt a continue
that's still mid-flight (HTTP and MCP share the same NO_LOCK_ACTIONS
policy; see tdb/server/app.py).
inspect calls debugpy's evaluate, which is arbitrary Python
execution in the debuggee process. This is inherent to a debugger and
not a tdb-specific concern, but MCP clients (and the humans running
them) should apply appropriate permission models: don't auto-approve
inspect against untrusted expressions, and don't expose tdb-mcp on
a network (stdio transport only by design).
- SSE-style event push:
controlandwait_for_stopmake polling efficient enough; events would also need uneven MCP-client support. - HTTP / streamable-HTTP transports: would require auth (which the HTTP RPC server also currently lacks); stdio inherits the trust of the process that spawned it.
- Multi-session: one debug session per MCP process.
usage: tdb [-h] [-v] [-r [HOST:]PORT] [--cwd CWD] [--no-stop-on-entry]
[--no-just-my-code] [--no-subprocess] [--python PYTHON] [--pv]
[--lang LANGUAGE] [--adapter ADAPTER]
[--keybindings {default,vim,emacs}]
[--terminal {xterm,konsole,gnome-terminal,ghostty,kitty,iterm2,warp,wezterm,terminator}]
[--local-root PATH] [--remote-root PATH]
[--server] [--headless] [-k FILE:LINE|LINE] [--server-port SERVER_PORT] [-d] [--doc-text] [--info]
[program] [args ...]
| Flag | Description |
|---|---|
-r HOST:PORT |
Attach to a remote debugpy server |
-a, --attach PID |
Attach to a running local process by pid: Go (Delve) or a native C/C++, Rust, or OCaml program (gdb or lldb-dap). Linux identifies the language from /proc/PID/exe; elsewhere pass --lang and, for native programs, the executable path. |
--local-root PATH |
Local directory containing a copy of remote code (repeat to mirror multiple trees). Pair with --remote-root; counts must match. Required when -k sets a breakpoint against a remote debuggee whose code lives at a different path. |
--remote-root PATH |
Remote directory matched to --local-root (same CLI position via zip()). |
-k, `--breakpoint FILE:LINE |
LINE` |
-t, `--to-line FILE:LINE |
LINE` |
--no-stop-on-entry |
Do not pause at the first line (default: stop on entry; automatic when -k is given) |
--cwd DIR |
Working directory for the debuggee |
--python PATH |
Python interpreter for the debuggee (Python targets only) |
--pv |
Shorthand for --python .venv/bin/python |
--lang LANGUAGE |
Debuggee language (python, cpp, perl); default: auto-detect from the target |
--adapter ADAPTER |
Debug adapter within the language (e.g. --lang cpp --adapter lldb-dap), or a full path to a gdb/lldb-dap/dlv executable to use instead of the one on PATH; default: the language's standard adapter |
--no-just-my-code |
Step into stdlib/site-packages code instead of skipping it |
| (default: skipped). On uncaught exceptions, the crash modal always shows the full traceback | |
| including library frames, regardless of this flag. | |
--no-subprocess |
Disable debugpy's subprocess tracking (use when debugging tdb itself) |
--terminal TERM |
Run debuggee in the named external terminal: xterm, konsole, |
gnome-terminal, ghostty, kitty, iterm2, warp, wezterm, or terminator |
|
--keybindings SCHEME |
vim, emacs, or default (Notepad-style editing); saved to config |
--server |
Enable JSON-RPC server alongside TUI |
--headless |
JSON-RPC server only, no TUI |
--server-port PORT |
Server port (default: 8150) |
--record FILE |
Record this TUI session's debugging actions to FILE (JSON-RPC lines) |
--replay FILE |
Replay a recording headless, printing a transcript (no program argument) |
--replay-tui FILE |
Replay a recording inside the TUI at the recorded pace, so you can watch it |
--replay-quiet |
With --replay-tui: no per-action toasts (errors and the final summary still show) |
--timing |
With --replay: reproduce the recorded pacing (--replay-tui always does) |
--replay-interval S |
With --replay/--replay-tui: fixed S-second delay before each action instead of the recorded gaps |
--replay-timeout S |
With --replay/--replay-tui: per-action stop-wait timeout (default 30) |
--examine-log DEST |
With --run: write each Ctrl-\ / SIGUSR2 stack snapshot to DEST (- = stdout; repeatable) |
On UNIX-like systems (Linux, macOS, FreeBSD, etc.),
tdb stores configuration and breakpoints in ~/.config/tdb/.
On Windows, it uses %APPDATA%\tdb\.
| File | Contents |
|---|---|
config.json |
User preferences (keybinding scheme, color theme, step mode, adapter overrides) |
breakpoints.json |
Breakpoints from previous sessions, keyed by project directory |
Every key tdb reads, with its default or a representative value. Copy
this to restore a deleted or corrupted file, then delete the entries you
don't need: a missing key takes its default, an unknown key is ignored,
and an invalid value (say a misspelled step_mode) falls back to the
default instead of stopping tdb.
{
"keybindings": "vim",
"theme": null,
"step_mode": "statement",
"adapters": {
"gdb": "/usr/bin/gdb",
"lldb-dap": null,
"dlv": "/home/me/go/bin/dlv",
"perl": "/usr/bin/perl",
"rdbg": "/usr/local/bin/rdbg",
"bash": "/usr/bin/bash",
"tcsh": "/usr/bin/tcsh",
"pwsh": "/usr/bin/pwsh",
"pses": "/home/me/.local/share/PowerShellEditorServices",
"ocamlearlybird": "/home/me/.opam/default/bin/ocamlearlybird"
},
"default_adapters": {
"cpp": "gdb",
"rust": "gdb",
"ocaml": "lldb-dap"
}
}| Key | Values | Meaning |
|---|---|---|
keybindings |
"vim" (default), "emacs", "default" |
Code View keybinding scheme; also set by --keybindings |
theme |
null (default) or a Textual theme name such as "textual-dark", "textual-light", "nord", "gruvbox", "dracula", "monokai", "tokyo-night", "catppuccin-mocha" |
Color theme; the names offered under Configure > Theme are the valid ones, and an unknown name is ignored |
step_mode |
"statement" (default), "line" |
How n/s treat multi-line statements (see Step mode below) |
adapters |
adapter id → executable path, or null |
Where to find a debugger or interpreter instead of searching PATH. Ids: gdb, lldb-dap, dlv (debuggers); perl, rdbg, bash, tcsh, pwsh (interpreters tdb spawns); pses (the PowerShell Editor Services module directory); ocamlearlybird. Only list the ones you need |
default_adapters |
language id → adapter id | Which adapter a language uses when --adapter isn't given. Languages with a choice: cpp and rust (gdb or lldb-dap), ocaml (lldb-dap, gdb, or ocamlearlybird) |
The file is plain JSON: no comments, no trailing commas. tdb rewrites it
when you change a setting from the Configure menu, so hand edits made
while tdb is running may be overwritten.
Adapter-related keys in config.json: adapters maps an adapter id to an
executable path ({"adapters": {"lldb-dap": "/opt/llvm/bin/lldb-dap"}}), and
default_adapters picks a language's default adapter
({"default_adapters": {"cpp": "lldb-dap"}}). For a one-off run,
--adapter /path/to/gdb (or lldb-dap, dlv) overrides the adapters entry
without editing the file; tdb --info reports the gdb and lldb-dap
currently resolved (config override first, then PATH) with their versions.
When tdb creates config.json for the first time it seeds adapters with
gdb and lldb-dap entries pointing at the executables found on PATH
(null when one isn't installed) so the keys are there to edit. A null
entry behaves like a missing one: tdb falls back to PATH.
Perl is a special case: perl-tdb is tdb's own bundled adapter (always
found; it's Python code, not an external executable), so
adapters.perl doesn't select an adapter binary. Instead it names the
Perl interpreter tdb should spawn to run the debuggee:
{"adapters": {"perl": "/path/to/perl"}}. Use this when the perl on
PATH is too old (< 5.18) or you need a specific perlbrew/plenv version.
PowerShell follows the same pattern with two keys: adapters.pwsh names
the interpreter and adapters.pses names the PowerShell Editor Services
module directory (see PowerShell).
Breakpoints are saved on exit and restored when debugging a program in the same
directory. Each project's breakpoints are independent. Breakpoints set with
-t/--to-line are the exception: they behave like -k breakpoints during
the session but are never saved.
Step mode (step_mode in config.json) controls how n (step over) and s (step
into) handle multi-line source statements:
| Value | Behavior |
|---|---|
"statement" (default) |
A multi-line statement (e.g. a gather(...) call spanning five lines) is one step. The debugger keeps issuing DAP steps until execution leaves the statement, then stops on the next logical line. |
"line" |
debugpy's native per-line behavior (stops on every physical line, including each interior sub-line of a multi-line expression) |
Change it from the menu (Configure > Step Mode); the choice is saved immediately and applies to all future sessions. Breakpoint hits, exceptions, and pauses always interrupt a statement step, so a breakpoint set on a sub-line of a multi-line expression still fires as expected.
- textual : TUI framework
- debugpy : Debug Adapter Protocol implementation for Python
- gdb / lldb-dap : optional, user-installed DAP adapters for C/C++
- debug gem (
rdbg) : optional, user-installed DAP adapter for Ruby - ocamlearlybird : optional, user-installed DAP adapter for OCaml bytecode (native OCaml reuses
gdb/lldb-dap) - pygments : Syntax highlighting
- FastAPI + uvicorn : JSON-RPC server
MIT
Thank you:
-
Will McGugan for the amazing
textualmodule.tdbwould be a pale shadow of itself had I used any other TUI framework. Fantastic work, Will. -
Microsoft for the Debug Adapter Protocol (DAP) and releasing its implementation in
debugpyand the Python Debugger extension for Visual Studio Code as open source. -
Anthropic, for providing access to Claude Code through the Claude for Open Source program.
tdbwas made almost entirely with Claude Code. -
OpenAI, for providing access to Codex through the Codex for Open Source program. Codex was used to implement tcsh and Rust support.
This project was inspired by Andreas Klöckner's excellent pudb Python debugger.
This command
tdb --terminal gnome-terminal --python /path/to/venv/matplotlib/bin/python3 examples/double_pendulum.py
either ignores breakpoints or crashes after showing the first frame.
The --python argument must point to an installation with matplotlib.



