Hermit makes a program's execution a deterministic function of its inputs.
For debugging, that means two things. Given the same inputs, a bug that shows
up in a hermit run shows up again, at the same place, when you rerun it.
The inputs include the virtual clock's starting time, so pass a fixed
--epoch (otherwise each run starts from the host's current time), the files
the program reads, and anything it receives over a network, which Hermit
does not make deterministic. And a hermit record recording, which captures
the program's inputs, can be replayed as often as you like, so a debugger can
move backwards through it: the debug adapter restarts the deterministic
replay and runs it forward to the earlier point.
This page covers the debugger interfaces Hermit has today and how to drive them from GDB, from a Debug Adapter Protocol (DAP) client, and from an agent. Editor (VS Code) integration is a prototype; its section says exactly what works and what does not yet.
| Goal | Command | Reverse execution |
|---|---|---|
| GDB on a deterministic run | hermit run --gdbserver -- PROGRAM, then gdb PROGRAM -ex 'target remote :1234' |
No |
| GDB on a recording | hermit replay [ID] (starts GDB itself) |
No |
| DAP client on a deterministic run, over TCP | hermit run --dap-listen=4711 -- PROGRAM |
No |
| DAP client on a deterministic run, over stdio | hermit run --gdbserver -- PROGRAM, then a DAP client running hermit-dap |
No |
| DAP client on a recording, over TCP | hermit replay [ID] --dap-listen=4711 |
stepBack, reverseContinue |
| DAP client on a recording, over stdio | a DAP client running hermit-dap --replay ID |
stepBack, reverseContinue |
Everything here is experimental and has been tested on single-threaded C programs only. Multithreaded guests are not yet covered by the tests.
-
Build
hermitandhermit-daptogether.hermit replay --dap-listenruns thehermit-dapbeside thehermitexecutable, andhermit-dap --replayruns thehermitbeside itself:cargo build -p hermit-run --bin hermit --bin hermit-dap
This rebuilds
target/debug/hermitwith default features. If you built it with extra backends (see the README), repeat that build afterwards. The examples below runhermitandhermit-dapfromPATH; to use the ones you just built rather than an installed copy, put them first:export PATH="$PWD/target/debug:$PATH"
-
For DAP, a GDB with the DAP interpreter. The adapter is tested end to end with GDB 17.2 only.
hermit-dapuses/usr/bin/gdbwhen that file exists, and otherwisegdbonPATH; pass--gdb PATHto choose another. Check the GDB it will use, for example the default one:/usr/bin/gdb --batch -nx -ex 'python import gdb.dap.server'(or the
gdbonPATHif there is no/usr/bin/gdb, or the path you will pass to--gdb).A version string alone does not establish a working DAP session.
-
readelfandsetprivonPATHfor reverse execution (managed replay refuses to start without them, and says so). -
The guest's
programmust have debug information, for example a C program built withcc -g -O0.
The examples below use this program, squares.c:
#include <stdio.h>
static int square(int x) {
int y = x * x;
return y;
}
int main(void) {
int total = 0;
for (int i = 0; i < 3; i++) {
total += square(i);
}
printf("total=%d\n", total);
return 0;
}cc -g -O0 -fno-pie -no-pie squares.c -o squares--gdbserver makes Hermit serve the guest through the GDB remote protocol
and wait for a debugger before the guest's first instruction. It needs the
ptrace backend (the default) and switches the run to host networking, so
that GDB can reach the port; Hermit prints a warning when it does.
hermit run --epoch=2026-01-01T00:00:00Z --gdbserver --gdbserver-port=1234 -- ./squaresIn another terminal:
gdb -ex 'set sysroot /' -ex 'target remote 127.0.0.1:1234' ./squares
(gdb) break square
(gdb) continueset sysroot / makes GDB read shared libraries from the local file system
instead of requesting each one through the remote connection. With the same
--epoch, executable and input files, every rerun of this command stops at
the same breakpoint hits with the same values. A program that uses the
network is the exception: under --gdbserver it runs on the host network,
whose answers can change from run to run. Record such a program instead and
debug the recording.
hermit record start -- ./squares
hermit replayhermit record start prints the recording's ID and the commands to replay
it. A bare hermit replay replays the latest recording and starts GDB
attached to it; pass an ID to choose another recording, --data-dir if the
recording was made with one, and --gdbex 'CMD1;CMD2' to give GDB commands
to run first. hermit replay --autopilot replays to the end without a
debugger.
GDB's own reverse commands (reverse-step, reverse-continue) do not work
on Hermit's remote target. Reverse execution is provided by hermit-dap,
below.
hermit-dap is a Debug Adapter Protocol server for Hermit's GDB remote
target. It starts GDB's DAP interpreter (gdb --interpreter=dap), configured
so that GDB does not request shared libraries from Hermit's remote server.
Any DAP client can use it: an editor, Dapper,
or a test harness.
The client sends an attach request naming the guest executable as
program and Hermit's GDB server as target:
{"command": "attach", "arguments": {"program": "/path/to/squares", "target": "127.0.0.1:1234"}}By default the client speaks DAP to hermit-dap on its stdin and stdout,
which is how editors usually run an adapter. With --listen [HOST:]PORT,
hermit-dap instead waits for one client on a TCP port, which is how a
client attaches to an adapter it did not start. HOST defaults to
127.0.0.1, and port 0 lets the system pick a free port. hermit-dap
writes hermit-dap: listening on HOST:PORT to stderr, serves the first
client that connects, and closes the port, so a second client is refused.
The session ends when that client disconnects.
Anyone who can connect to the port controls a debugger running as you, so keep the default loopback address unless you mean otherwise. Hermit's GDB server port is exposed the same way.
hermit run --epoch=2026-01-01T00:00:00Z --dap-listen=4711 -- ./squareshermit run: starting hermit-dap to serve this run to one DAP client; its attach request needs "program": "/home/me/squares" and "target": "127.0.0.1:1234"
hermit-dap: listening on 127.0.0.1:4711
--dap-listen implies --gdbserver, so everything said above about
--gdbserver holds. Before anything starts, hermit run refuses a run it
could not serve: one whose --gdbserver would be refused (under --strict,
for example), one with --verify (which runs the program twice, and the
adapter serves one session), --namespace-only (which has no GDB server) or
--image (whose program lives in the image), --gdbserver-port=0 (it
must know the port to give the debugger), and an address that does not
parse or cannot be listened on. A program that does not exist is reported
as in any run, with status 127, and other refusals of the run come before
any complaint about a missing hermit-dap.
hermit run binds the address itself and supervises two children: the run,
and, once the run's GDB server listens, hermit-dap, to which it hands the
listening socket. hermit-dap runs beside hermit, outside the guest; the
program waits for the client's attach before its first instruction.
--dap-gdb PATH chooses the GDB the adapter runs.
The program it prints is the host file the guest is about to execute.
hermit run looks for it in the guest's own view, through the root of the
waiting guest process: the path the command names (against the guest's
working directory), or the first executable candidate on the guest's
PATH, so a mount that makes a candidate executable counts as it does for
the guest. It follows that path through the guest's symbolic links and maps
it through the run's mounts to a host file. It then checks that
answer against the waiting guest process: the file the guest opens at that
path must be the same file (device and inode), readable, and an ELF
executable, because a debugger can load nothing else. Otherwise it ends the
run with status 125, saying why. So it refuses a script that starts with
#!: debug its interpreter instead, with the script as its argument, as in
hermit run --dap-listen=4711 -- /bin/sh ./script.sh. It also refuses an
execute-only file, which Linux can run but nothing can read.
If hermit-dap ends, for any reason, before a debugger has connected to the
run's GDB server (GDB cannot start or fails, the DAP client leaves before
attaching, or hermit-dap is killed), no debugger can attach any more, so
hermit run ends the run and exits with status 125, saying why, instead of
leaving it waiting. If hermit run itself is killed, the run and
hermit-dap die with it.
When the run ends, hermit run waits up to 5 seconds for the adapter to
finish its session, then stops it, so a client that stays connected sees its
connection close; an adapter that no client connected to is stopped at once.
A debugger that connected to the GDB server and then went away still leaves
the program waiting: Hermit's GDB server takes one connection and then stops
listening (rrnewton#4313). This is true of
plain --gdbserver, and of hermit replay under GDB, too; stop the run with
Ctrl-C.
--dap-listen also combines with --from, so
hermit run --from last-run --dap-listen=4711 reruns a recorded run under a
DAP client; hermit replay --dap-listen on such a recording says so.
With --replay ID, hermit-dap starts and manages
hermit replay ID --serve-only itself, advertises supportsStepBack, and
implements DAP stepBack and reverseContinue. --data-dir DIR and
--gdbserver-port PORT (default 1234) are passed to that replay, and the
client's attach request names that port as its target.
The shortest way to start such a session is from hermit replay:
hermit record start -- ./squares
hermit replay --dap-listen=4711hermit replay: starting hermit-dap to serve recording cc474505... to one DAP client; its attach request needs "program": "/home/me/.cache/hermit/cc474505.../exe" and "target": "127.0.0.1:1234"
hermit-dap: listening on 127.0.0.1:4711
--dap-listen replaces the hermit replay process with
hermit-dap --listen ADDRESS --replay ID, passing on --data-dir and
--gdbserver-port. It cannot be combined with --autopilot,
--serve-only or --gdbex. Use the program it prints. For a native
executable that is the copy stored with the recording, whose debug
information points at the original source files. For a recorded script that
starts with #!, it is the script's interpreter, as for hermit replay
under GDB; reverse execution has been tested only on native C programs.
The same session over stdio, for a client that starts the adapter itself:
hermit-dap --replay ID --data-dir ~/.cache/hermit --gdbserver-port 1234If the GDB in use lacks the DAP server internals that reverse execution
hooks, or readelf or setpriv is missing, managed replay refuses to
start: the client's first request gets a failed response naming the
problem, and the adapter writes the same message to its stderr and exits.
With --replay, the adapter advertises supportsStepBack and implements DAP stepBack and reverseContinue by restarting the
deterministic replay and running forward to the requested earlier source
position. stepBack returns to the previous source line the replay passed or
the previous place the client stopped, whichever came later. When that place
does not start a source line, such as the return address a stepOut stops at,
stepBack restarts the replay once more to count the earlier passes through
it, and may first run the current replay forward to the next source line. This
first implementation favors correctness over speed.
stepBack goes back to the previous stop or line arrival, not to the previous
instruction GDB's own reverse-step would reach. When the client stops in a
one-line loop, for example by stepping out of the loop body's call, then
continues past the loop, stepBack returns to that stop. It skips the later
passes through the loop's line, which the client never stopped at and which
start no new source line. A stepOut from a function without debug
information reports the stop with reason stopped rather than step.
The adapter finds line arrivals with an internal breakpoint on every source line, and chooses which arrival at an address to restart to by counting the earlier ones. If the debugger stub loses one of those breakpoints, the count is wrong and the replay runs to another arrival at the same address. Reverie's gdbstub did lose them: it restored a whole 8-byte word when it removed a software breakpoint, so when GDB removed a breakpoint to step over it, a breakpoint up to 7 bytes after it was erased for the rest of that resume. That was fixed in rrnewton/reverie#890.
As a check on the count, the adapter records a frame identity with every stop
and line arrival: the program counter and stack pointer of the innermost
frame and of up to 7 of its callers, 8 frames at most (GDB does not unwind
past main by default, so a shallower stack gives fewer). When it replays to
a stop, it compares the identity where the replay landed. If they differ, it
puts the replay back at the client's current stop, sends a stopped event for
it, and fails the stepBack or reverseContinue with an error saying it
could not reach the earlier stop exactly. If it cannot put the replay back
either, the request fails and the adapter ends the session: it sends an
output event saying why, then terminated.
The frame identity tells apart two activations of a function at different
call depths, such as recursive calls, and two calls at the same depth from
different call sites, such as g(1) and g(2) on consecutive lines, as long
as the calling frame is among the 8. It cannot tell apart two passes through
the same activation, such as two iterations of a loop, two calls from the
same call site at the same depth, such as a call inside a loop, or two
arrivals whose 8 innermost frames are all equal and which differ only further
out, such as the same chain of 8 or more nested helper calls entered from
two call sites in one caller. Their identities are equal, so a wrong count
there would land at the wrong pass without an error. A test pins the limit:
with a chain of 7 helpers, the two activations differ only in the caller's
return address, frame 7, and a wrong count must be refused.
The identity is limited to 8 frames because the adapter takes it at every
line arrival while the replay runs forward, and unwinding every frame made
forward execution cost the square of the stack depth. Measured with GDB
17.2, one continue past a recursion 300 calls deep took 12.15 s
with every frame and 1.59 s with 8; past a recursion 1000 calls deep it took
121.79 s with every frame and 4.77 s with 8. A stepBack from there took
1.0 s at depth 300 and 1.3 s at depth 1000 either way.
Dapper is a DAP proxy with a
command-line client and an MCP server, so a person or an agent can drive a
session one request at a time. Its from-config mode starts a session from a
JSON file. This configuration connects to the hermit replay --dap-listen=4711
session above (replace RECORDING_ID and the paths):
{
"spawnConfig": {"type": "tcp", "addr": "127.0.0.1:4711"},
"debugRequest": {
"request": "attach",
"program": "/home/me/.cache/hermit/RECORDING_ID/exe",
"target": "127.0.0.1:1234"
},
"breakpoints": [{"type": "source", "path": "/path/to/squares.c", "line": 4}],
"installDefaultExceptionBreakpoints": false
}To have Dapper start the adapter over stdio instead, use
"spawnConfig": {"type": "stdio", "cmd": "/path/to/hermit/target/debug/hermit-dap", "args": ["--replay", "RECORDING_ID", "--gdbserver-port", "1234"]},
adding "--data-dir", "DIR" for a recording made with one. For a
hermit run --gdbserver target, leave out args.
dapper proxy --control-port 4712 from-config hermit-dap.json
dapper debug --control-port 4712 continue 1
dapper debug --control-port 4712 continue 1
dapper debug --control-port 4712 stack-trace 1
dapper debug --control-port 4712 step back 1
dapper debug --control-port 4712 reverse-continue 1With squares, the two continues stop in square with x = 1, step back
returns to line 11 in main with i = 1 and total = 0, and
reverse-continue returns to the first breakpoint hit, x = 0. Dapper's MCP
server offers the same operations to an agent (debug_navigate_command with
step_back and reverse_continue).
VS Code runs a debug adapter only for a debug type that some extension
contributes, so hermit-dap cannot be named in launch.json on its own. A
prototype extension in editors/vscode contributes a
hermit debug type with two attach configurations: one that starts
hermit-dap on stdio against hermit run --gdbserver, and one that connects
to hermit run --dap-listen or to hermit replay --dap-listen, the latter
with Step Back and Reverse Continue.
Its test, editors/vscode/test/run.sh, drives all three sessions through a real
VS Code (1.141.1) running headless, with no display: breakpoints, continue,
the call stack and variables, and on the recording stepBack and
reverseContinue. It also checks that VS Code's breakpoint list holds only
the breakpoint the test set. Nobody has yet used the extension in the VS Code
window itself, and it is not published.
What the extension relies on:
hermit-dap, over stdio (DebugAdapterExecutable) or TCP (--listen,DebugAdapterServer). Itsattachrequest needs onlyprogramandtarget.hermit run --dap-listenandhermit replay --dap-listen(reverse-capable), which start a session from a terminal and print the attach request'sprogramandtarget.hermit-dapnot reporting its hidden line breakpoints, so a client's breakpoint list holds only the breakpoints the user set.- When a managed replay fails to start, for example on a recording made by an
older Hermit, the client's error message includes what
hermit replaysaid.
The intended workflow is to run hermit run or hermit replay in VS Code's
integrated terminal with one extra flag and have VS Code attach. A sketch of
the remaining design, not implemented:
- The extension above provides the
hermitdebug type. - The terminal command (for example
hermit run --debug-ui -- PROGRAM) starts the GDB server and ahermit-dap --listenon a free port, then asks VS Code to attach, for example by writing a session description to a directory the extension watches, or through avscode://link the extension handles. In VS Code's terminal, including over Remote-SSH, theVSCODE_IPC_HOOK_CLIenvironment variable is set, so the command can tell that it runs inside VS Code. - VS Code becomes the debug client: breakpoints, the current line, the call stack, variables, and Step Back and Reverse Continue buttons for a recording.
Dapper can share one debug session between an editor and an agent. The
editor is the session's main client; an agent joins the same session through
Dapper's control plane (its command-line client or MCP server) and can set
breakpoints, step and inspect while the editor shows each stop. They take
turns. Dapper's documentation
describes wrapping an editor's adapter command in dapper proxy process;
dapper proxy --client-port PORT also accepts the editor's connection over
TCP. For Hermit this needs the VS Code debug type above. Conversation with
the agent happens in the agent's own interface, not in Dapper.
The hermit-dap tests in hermit-cli/tests/cli.rs drive real DAP sessions
through a real GDB, with no editor: initialize, attach,
setBreakpoints, configurationDone, continue, threads, stackTrace,
evaluate, next, stepOut, stepBack,
reverseContinue and disconnect, over stdio and, through
hermit run --dap-listen and hermit replay --dap-listen, over TCP.
HERMIT_REQUIRE_DAP=1 HERMIT_REQUIRE_DAP_GDB=1 \
cargo nextest run -p hermit-run --test cli dapWithout HERMIT_REQUIRE_DAP_GDB, the end-to-end tests skip, loudly, when no
GDB with a DAP interpreter is installed. The 33 tests take about 8 seconds
with GDB 17.2. The VS Code extension's test, which needs a VS Code
download and so is not part of validation, is described in
editors/vscode.
- Tested only on single-threaded C programs, with GDB 17.2.
- A
--listenadapter serves one client and one session, then exits. - Reverse execution is source-line granular and restarts the replay for each reverse request. See How stepBack and reverseContinue work.
--gdbserverand a gdb-served replay run on the host network, so their network behavior is not isolated or deterministic while debugging.