Skip to content

Architecture

The shape of it

  Neovim            bugsaur (one process)                   adapter        program
 ┌────────┐   IPC   ┌─────────────────────────────┐  DAP   ┌─────────┐   ┌─────────┐
 │  Lua   │◄───────►│ nvim-ipc  →  debugger-core  │◄──────►│ dlv     │──►│ your    │
 │ source │  Unix   │              ↕              │  TCP / │ codelldb│   │ code    │
 │frontend│  socket │           debugger-ui       │  stdio │ debugpy │   │         │
 └────────┘         └─────────────────────────────┘        └─────────┘   └─────────┘

Everything inside the middle box is one process. The window is not a separate program, and the editor is not in the debug path at all.

Who owns what

Owner Owns
debugger-core session state — the only authority on what is true right now
dap-client the connection to the adapter and in-flight requests
the adapter the debuggee, and all language-specific knowledge
the editor the breakpoint set of a file, and the source view
debugger-ui how state is displayed, and nothing about what it is

Two rules follow, and most of the codebase's structure with them:

Nothing but debugger-core decides state. The UI renders a projection; the editor sends intentions. Neither mutates the model directly.

Nothing but the adapter interprets the language. Bugsaur never parses a value, never guesses a type, and never rewrites a request body. Where adapter specifics are unavoidable, they live in the adapter catalog as data, not as branches in shared code.

The command / event loop

debugger-core is a reducer, not a controller:

command  →  reduce(state, command)  →  effects
event    →  reduce(state, event)    →  new state + effects
  • Commands are intentions: continue, step, evaluate, expand a variable. They come from the UI or the editor.
  • Events are facts: the adapter stopped, a thread appeared, a request answered.
  • Effects are what should happen next: send a DAP request, tell the frontend to open a file, raise the window.

The reduction is pure, which is what makes the state model testable without an adapter, an editor or a window — see crates/debugger-core/tests/.

Stop generations

Every stop gets a generation number, and it is the mechanism that keeps stale data off the screen.

When the program moves, the generation advances. Responses that arrive afterwards — a variable page requested before the step, a scope that took too long — are discarded rather than displayed. A value from the previous stop shown next to the current line would be worse than no value at all.

The same generation bounds the variable cache, and it is why expanding a tree is cheap within one stop and starts fresh after the next.

Session phases

idle → building → starting → running ⇄ stopped → terminated
                                   ↘ failed

building exists because Bugsaur compiles some programs itself, before the adapter starts — see Languages → Go for the measurements that motivated it. A failed build never reaches the adapter.

The window is a view

debugger-ui is built on egui and holds no authority. It renders a projection of the state — a flattened, virtualised list of visible rows — and turns input into commands.

Two consequences visible to users:

  • panels dragged with the mouse are not persisted: the layout is rebuilt from configuration on every launch;
  • breakpoints cannot be removed in the window, because their owner is the editor.

Where to read more

Topic Page
The protocol layer DAP model
The editor boundary IPC
Why each decision ADRs