Delve — Architecture#

Architectural style#

Layered + Strategy pattern with dual protocol surface

Delve is a monolithic binary built as a strict 4-layer system. Each layer depends only on the layer below it; no upward imports exist. The bottom layer (process abstraction) uses a textbook Strategy pattern: four pluggable backends all implement ProcessInternal, making every upper layer completely backend-agnostic. The service layer presents two parallel protocol implementations (JSON-RPC 2.0 and DAP) both behind a single Server interface, enabling the same debugger engine to power both CLI clients and IDEs without code duplication.

The architecture is justified by Delve’s dual purpose: it must be embeddable by IDE extensions (requiring the DAP/RPC server mode) while remaining a standalone interactive debugger (requiring the terminal REPL).

Component diagram (textual)#

┌─────────────────────────────────────────────────────────┐
│  cmd/dlv (CLI - Cobra command tree)                      │
│  - Parses flags, selects mode (headless/interactive)     │
│  - Calls execute() which wires up the service stack      │
└──────────────┬──────────────────────────────────────────┘
               │  net.Listener (TCP or in-process pipe)
               ▼
┌─────────────────────────────────────────────────────────┐
│  service layer  (service/)                               │
│  ┌──────────────────┐   ┌──────────────────────────┐    │
│  │  rpccommon/rpc2  │   │         dap/             │    │
│  │  JSON-RPC 2.0    │   │  Debug Adapter Protocol  │    │
│  │  server+client   │   │  server (VS Code, etc.)  │    │
│  └────────┬─────────┘   └────────────┬─────────────┘    │
│           └──────────────┬───────────┘                   │
│                 service.Server interface                  │
└──────────────────────────┼──────────────────────────────┘
                           │
               ┌───────────▼────────────┐
               │  service/debugger      │
               │  Debugger struct       │
               │  - launch/attach       │
               │  - breakpoints         │
               │  - stepping            │
               │  - variable eval       │
               │  wraps proc.TargetGroup│
               └───────────┬────────────┘
                           │
               ┌───────────▼────────────┐
               │  pkg/proc              │
               │  TargetGroup / Target  │
               │  Process interface     │
               │  ProcessInternal iface │
               └───────┬───────────────┘
      ┌────────┬────────┼────────┬────────────┐
      ▼        ▼        ▼        ▼            ▼
  native/  gdbserial/  core/  ebpf/       pkg/dwarf/
  ptrace   GDB remote  dumps  uprobes     DWARF parser
  Win APIs LLDB/rr            (eBPF)      (custom Go-aware)

Core components#

CLI / Command layer#

  • Package: cmd/dlv/cmds
  • Responsibility: Parses subcommands and flags; selects operating mode (interactive vs headless, RPC2 vs DAP); calls execute() to wire up the service stack; handles terminal mode by starting both server and terminal client in the same process using an in-process pipe listener.
  • Key types: cobra.Command tree; package-level flag variables; execute() function
  • Dependencies: service, service/debugger, service/rpc2, service/dap, service/rpccommon, pkg/terminal, pkg/config, pkg/gobuild

Service Server interface#

  • Package: service
  • Responsibility: Defines the single Server interface (Run() error, Stop() error) that all protocol servers implement. Also defines Config and ListenerPipe() for in-process communication.
  • Key types: service.Server, service.Config, service.ListenerPipe()
  • Dependencies: None (interface definition only)

JSON-RPC 2.0 Server#

  • Package: service/rpccommon, service/rpc2
  • Responsibility: Implements service.Server over TCP using Go’s net/rpc with custom JSON codec. Exposes the full RPCServer API to remote clients. Also provides an RPCClient for the terminal to use locally.
  • Key types: rpccommon.ServerImpl, rpc2.RPCServer, rpc2.RPCClient
  • Dependencies: service/debugger, service/api, service/internal/sameuser

DAP Server#

  • Package: service/dap
  • Responsibility: Implements service.Server over TCP using the Debug Adapter Protocol. Handles DAP request/response cycles (launch, setBreakpoints, stackTrace, variables, etc.) for IDE clients such as VS Code via the go-delve/dlv VS Code extension.
  • Key types: dap.Server, dap.Session
  • Dependencies: service/debugger, service/api, github.com/google/go-dap

Debugger (high-level ops)#

  • Package: service/debugger
  • Responsibility: Translates high-level client operations (launch, attach, set breakpoints, continue, step, evaluate expression) into calls on proc.TargetGroup. Handles type conversion between service/api wire types and pkg/proc internal types. Owns the backend selection logic (native, lldb/gdbserial, rr, core).
  • Key types: debugger.Debugger, debugger.Config
  • Dependencies: pkg/proc, pkg/proc/native, pkg/proc/gdbserial, pkg/proc/core, service/api, pkg/gobuild, pkg/locspec

Process Abstraction#

  • Package: pkg/proc
  • Responsibility: Defines the target abstraction: Process (read-only public interface), ProcessInternal (state-modifying backend interface), Target (wraps ProcessInternal + adds debugging state: breakpoints, goroutines, call injection), and TargetGroup (manages a group of targets for exec-following). Implements variable evaluation, expression evaluation, goroutine enumeration, stepping logic — all backend-agnostic.
  • Key types: Process, ProcessInternal, Target, TargetGroup, Thread, MemoryReadWriter, BinaryInfo, Breakpoint, LogicalBreakpoint
  • Dependencies: pkg/dwarf/*, pkg/goversion, pkg/locspec

Backends (four implementations of ProcessInternal)#

  • pkg/proc/native — ptrace (Linux/FreeBSD), Mach exception ports (macOS), Windows debug APIs. The default backend.
  • pkg/proc/gdbserial — GDB remote serial protocol client. Used with LLDB (macOS), rr (record-and-replay), and hardware/JTAG targets.
  • pkg/proc/core — ELF and Mach-O core dump reader. Read-only, post-mortem analysis.
  • pkg/proc/internal/ebpf — eBPF uprobe tracer. Non-stop: never halts the target; traces function entries via Linux uprobes + perf ring buffer.

DWARF Parser#

  • Package: pkg/dwarf/ (9 sub-packages)
  • Responsibility: Custom DWARF parser with Go-specific extensions. Handles goroutine metadata, Go interface types, generics, and compiler-generated variables that debug/dwarf (stdlib) mishandles. Sub-packages: godwarf (type reading), frame (.debug_frame), line (.debug_line), op (expression evaluator), leb128, loclist, reader, regnum (per-arch register numbers).
  • Key types: godwarf.Type hierarchy, frame.FrameDescriptionEntries, line.DebugLinePkg, op.DwarfRegisters
  • Dependencies: stdlib debug/dwarf; self-contained

Terminal / REPL#

  • Package: pkg/terminal
  • Responsibility: Interactive command-line REPL. Dispatches user commands to the service.Client interface (same interface used over TCP, but here backed by rpc2.RPCClient connected to the in-process server). Handles line editing (readline), output formatting, Starlark scripting (starbind/).
  • Key types: terminal.Term, terminal.Commands
  • Dependencies: service (Client interface), pkg/config, github.com/go-delve/readline

Data flow#

Interactive debugging session (dlv debug ./mypkg):

1. main() → cmds.New(false).Execute() → debugCmd()
2. debugCmd() → execute(0, args, conf, "", ExecutingGeneratedFile, ...)
3. execute():
   a. service.ListenerPipe() → (listener, clientConn) [in-process pipe]
   b. rpccommon.NewServer(Config{Listener, debugger.Config{...}})
   c. server.Run() [goroutine: starts JSON-RPC server on in-process pipe]
   d. rpc2.NewClient(clientConn) → terminal.New(client)
   e. terminal.Run() [blocks on user input]
4. User types "break main.main":
   terminal → RPCClient.CreateBreakpoint(api.Breakpoint{...})
   → JSON-RPC → RPCServer.CreateBreakpoint()
   → debugger.Debugger.CreateBreakpoint()
   → proc.TargetGroup → proc.Target.SetBreakpoint()
   → ProcessInternal.WriteBreakpoint() [native: writes INT3 to target memory]
5. User types "continue":
   terminal → RPCClient.Command(api.DebuggerCommand{Name:"continue"})
   → debugger.Command() → proc.TargetGroup.Continue()
   → ProcessInternal.ContinueOnce() → ptrace CONT syscall
   → target process resumes; stops at breakpoint
   → thread state propagated back up through proc → debugger → RPCServer → terminal

Headless DAP session (VS Code):

1. dlv dap → dapCmd() → dap.NewServer(Config{Listener: TCP socket})
2. VS Code connects; sends DAP "launch" request
3. dap.Server → dap.Session.handleLaunch()
   → debugger.New(config) → backend selection → proc.TargetGroup created
4. VS Code sends "setBreakpoints" → dap.Session translates to debugger.CreateBreakpoint()
5. VS Code sends "continue" → dap.Session → debugger.Command()
   → same path as interactive: proc → native → ptrace
6. Process stops → dap.Session sends DAP "stopped" event to VS Code

Initialization / Bootstrap#

main()
  ├── telemetry.Start()          # Go telemetry crash reporting
  ├── Set CGO_CFLAGS=-O0 -g     # Disable optimizations in debugged CGO
  └── cmds.New(false).Execute()  # Build Cobra tree and execute matched cmd
        └── execute() [shared by debug/exec/attach/test/trace cmds]
              ├── logflags.Setup()
              ├── net.Listener creation (TCP or in-process pipe)
              ├── service.Server creation (rpccommon.NewServer or dap.NewServer)
              │     └── internally: debugger.New(Config) is deferred until server.Run()
              └── server.Run()
                    ├── debugger.New() → backend selection → proc.TargetGroup.New()
                    │     ├── gobuild.GoBuild() [if debug/test command]
                    │     └── native.Launch() / gdbserial.LLDBLaunch() / core.OpenCore()
                    └── [headless: block on listener]
                        [interactive: terminal.New(client).Run()]

Dependency injection: Entirely manual via Config structs passed top-down. No DI framework. debugger.Config is the root configuration object; it propagates backend selection (Backend field), build flags, attach PID, etc. No global state beyond package-level logger setup.

Configuration#

  • Source: .delverc YAML config file (loaded by pkg/config) + CLI flags (persistent Cobra flags on root command)
  • Config struct: config.Config (user preferences: source paths, substitute paths, max string length, etc.) merged with per-invocation flags
  • Backend selection: --backend flag → debugger.Config.Backend → resolved to one of "native", "lldb", "rr", "default" in debugger.New()
  • No Viper: Config loading uses a custom YAML decoder (gopkg.in/yaml.v2) against a typed struct. No dynamic reconfiguration; config is read once at startup.
  • RPC-level config: api-version, accept-multiclient, only-same-user, listen address are all CLI flags only; not in .delverc.

Key design decisions#

  1. Two-level interface split (Process vs ProcessInternal). The Process interface is read-only and forms the public contract. ProcessInternal extends it with state-mutating methods only used inside pkg/proc itself. This prevents external callers from accidentally calling backend internals while keeping the implementation cohesive. The Target wrapper re-exposes only what upper layers need.

  2. In-process JSON-RPC pipe for interactive mode. When not headless, Delve creates an in-process net.Conn pair (service.ListenerPipe()) and runs both the RPC server and the terminal client in the same binary. This means the interactive CLI is just a regular RPC client — identical to a remote client — eliminating an entire separate code path and ensuring that the CLI and IDE paths stay in sync.

  3. Custom DWARF parser instead of stdlib. Go’s debug/dwarf doesn’t understand goroutine stacks, interface{} values, generics type parameters, or closures with multiple variables sharing a name. Delve’s pkg/dwarf/ implements Go-specific extensions on top of the raw DWARF binary format. This is a “bet on correctness”: it costs maintenance but ensures Delve keeps pace with Go compiler DWARF innovations.

  4. eBPF backend as an orthogonal tracing mode. The eBPF backend never halts the target — it attaches Linux uprobes that fire asynchronously and store results in a perf ring buffer. This is architecturally different from all other backends (which stop-inspect-resume). It fits the ProcessInternal interface only partially (SupportsBPF() / SetUProbe() / GetBufferedTracepoints()), making it a specialized mode rather than a full backend.

  5. Filename-based platform isolation, no runtime switches. Zero if runtime.GOOS checks in generic files. All platform-specific implementations live in *_linux.go, *_darwin.go, *_windows.go, regs_amd64.go, etc. This makes portability explicit at compile time, eliminates dead code in any given binary, and makes it trivially clear which files to touch when porting.