Introduction

Since I’ve started developing in Golang I didn’t really use the debugger. Instead I was naively adding fmt.Print statements everywhere to validate my code 🙈. While print statements and logs might be also your first debugging instinct, they often fall short when dealing with large and complex code base, with sophisticated runtime behaviour and (of course!) complex concurrency issues that seem impossible to reproduce.

After starting working on more complex projects Like this one: https://github.com/cloudoperators/heureka I had to force myself to have a deeper look at delve (the Golang debugger) and see what Emacs offers for interacting with it. While the Go ecosystem offers excellent debugging tools, integrating them into a comfortable development workflow can be challenging.

In this post I’ll elaborate the powerful combination of Emacs, Delve, and dape. Together, these tools create a debugging experience that mimics (and often surpasses) traditional IDEs, while preserving the flexibility and extensibility that Emacs is famous for.

This is what you can expect:

Setting Up the Development Environment

In this post I assume you already have some Emacs experience and now how to configure packages and write small Elisp snippets. I personally use straight.el as a package manager, minimal-emacs.d as a minimal vanilla Emacs configuration (along with my own custommizations), dape as the debug adapter client and eglot as my LSP client.

Required Emacs Packages

For Emacs 29+ users, eglot is built-in. Check out configuring eglot for gopls and some more advanced gopls settings. We’ll first add dape:

1
2
3
4
5
6
7
8
9
(use-package dape
  :straight t
  :config
  ;; Pulse source line (performance hit)
  (add-hook 'dape-display-source-hook 'pulse-momentary-highlight-one-line)

  ;; To not display info and/or buffers on startup
  ;; (remove-hook 'dape-start-hook 'dape-info)
  (remove-hook 'dape-start-hook 'dape-repl))
Code Snippet 1: Configure dape

and go-mode:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(use-package go-mode
  :straight t
  :mode "\\.go\\'"
  :hook ((before-save . gofmt-before-save))
  :bind (:map go-mode-map
              ("M-?" . godoc-at-point)
              ("M-." . xref-find-definitions)
              ("M-_" . xref-find-references)
              ;; ("M-*" . pop-tag-mark) ;; Jump back after godef-jump
              ("C-c m r" . go-run))
  :custom
  (gofmt-command "goimports"))
Code Snippet 2: Configure go-mode

Installing Required Go Tools

Install Delve and gopls, the LSP server:

1
2
3
4
5
# Install Delve
go install github.com/go-delve/delve/cmd/dlv@latest

# Install gopls
go install golang.org/x/tools/gopls@latest
Code Snippet 3: Install the Golang debugger and LSP server

Additionally I have a bunch of other tools which I use from time to time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/onsi/ginkgo/v2/ginkgo@latest

go install -v golang.org/x/tools/cmd/godoc@latest
go install -v golang.org/x/tools/cmd/goimports@latest
go install -v github.com/stamblerre/gocode@latest
go install -v golang.org/x/tools/cmd/gorename@latest
go install -v golang.org/x/tools/cmd/guru@latest
go install -v github.com/cweill/gotests/...@latest

go install -v github.com/davidrjenni/reftools/cmd/fillstruct@latest
go install -v github.com/fatih/gomodifytags@latest
go install -v github.com/godoctor/godoctor@latest
go install -v github.com/haya14busa/gopkgs/cmd/gopkgs@latest
go install -v github.com/josharian/impl@latest
go install -v github.com/rogpeppe/godef@latest
Code Snippet 4: Additional Golang tools

Then you need to configure the corresponding Emacs packages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
(use-package ginkgo
  :straight (:type git :host github :repo "garslo/ginkgo-mode")
  :init
  (setq ginkgo-use-pwd-as-test-dir t
        ginkgo-use-default-keys t))

(use-package gotest
  :straight t
  :after go-mode
  :bind (:map go-mode-map
              ("C-c t f" . go-test-current-file)
              ("C-c t t" . go-test-current-test)
              ("C-c t j" . go-test-current-project)
              ("C-c t b" . go-test-current-benchmark)
              ("C-c t c" . go-test-current-coverage)
              ("C-c t x" . go-run)))

(use-package go-guru
  :straight t
  :hook
  (go-mode . go-guru-hl-identifier-mode))

(use-package go-projectile
  :straight t
  :after (projectile go-mode))

(use-package flycheck-golangci-lint
  :straight t
  :hook
  (go-mode . flycheck-golangci-lint-setup))

(use-package go-eldoc
  :straight t
  :hook
  (go-mode . go-eldoc-setup))

(use-package go-tag
  :straight t
  :bind (:map go-mode-map
              ("C-c t a" . go-tag-add)
              ("C-c t r" . go-tag-remove))
  :init (setq go-tag-args (list "-transform" "camelcase")))

(use-package go-fill-struct
  :straight t)

(use-package go-impl
  :straight t)

(use-package go-playground
  :straight t)
Code Snippet 5: Configure additional Golang related Emacs packages

Dape Configuration

There is no particular reason why I use dape instead of dap. When I was still using MinEmacs it was part of it and I just got used to it. As the documentation states:

  • Dape does not support launch.json files, if per project configuration is needed use dir-locals and dape-command.
  • Dape enhances ergonomics within the minibuffer by allowing users to modify or add PLIST entries to an existing configuration using options. For example dape-config :cwd default-directory :program "/home/user/b.out" compile "gcc -g -o b.out main.c".
  • No magic, no special variables like ${workspaceFolder}. Instead, functions and variables are resolved before starting a new session.
  • Tries to envision how debug adapter configurations would be implemented in Emacs if vscode never existed.

If you ever worked with VSCode you already know that it uses a launch.json to store different debugging profiles.

1
2
3
4
5
6
7
{
    "name": "Launch file",
    "type": "go",
    "request": "launch",
    "mode": "auto",
    "program": "${file}"
}
Code Snippet 6: Sample configuration to debug the current file

You have different fields/properties which according to this page you can tweak in your debugging configuration:

Table 1: Properties to use for the Golang debugger
Property Description
name Name for your configuration that appears in the drop down in the Debug viewlet
type Always set to “go”. This is used by VS Code to figure out which extension should be used for debugging your code
request Either of `launch` or `attach`. Use `attach` when you want to attach to an already running process.
mode For launch requests, either of `auto`, `debug`, `remote`, `test`, `exec`. For attach requests, use either `local` or `remote`
program Absolute path to the package or file to debug when in `debug` & `test` mode, or to the pre-built binary file to debug in `exec` mode. Not applicable to attach requests.
env Environment variables to use when debugging. Example: `{ “ENVNAME”: “ENVVALUE” }`
envFile Absolute path to a file containing environment variable definitions. The environment variables passed in the `env` property overrides the ones in this file.
args Array of command line arguments that will be passed to the program being debugged.
showLog Boolean indicating if logs from delve should be printed in the debug console
logOutput Comma separated list of delve components (`debugger`, `gdbwire`, `lldbout`, `debuglineerr`, `rpc`) that should produce debug output when `showLog` is set to `true`.
buildFlags Build flags to be passed to the Go compiler
remotePath Absolute path to the file being debugged on the remote machine in case of remote debugging i.e when `mode` is set to `remote`. See the section on Remote Debugging for details
processId Applicable only when using the `attach` request with `local` mode. This is the id of the process that is running your executable which needs debugging.

In dape you can use these properties to setup dape-configs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
;; Basic dape configuration
(with-eval-after-load 'dape
  ;; Add Go debug configuration
  (add-to-list 'dape-configs
               `(go-debug-main
                 modes (go-mode go-ts-mode)
                 command "dlv"
                 command-args ("dap" "--listen" "127.0.0.1::autoport")
                 command-cwd dape-command-cwd
                 port :autoport
                 :type "debug"
                 :request "launch"
                 :name "Debug Go Program"
                 :cwd "."
                 :program "."
                 :args [])))
Code Snippet 7: Basic dape config

Usually I like to store my different debugging profiles in directory variables (stored in .dir-locals.el). At the root of each project (you can as well have different configs per folder/package) I store my debugging profiles like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
((go-mode . ((dape-configs .
        ((go-debug-main
          modes (go-mode go-ts-mode)
          command "dlv"
          command-args ("dap" "--listen" "127.0.0.1:55878" "--log-dest" "/tmp/dlv.log")
          command-cwd "/home/victor/projects/repo1"
          host "127.0.0.1"
          port 55878
          :request "launch"
          :mode "debug"
          :type "go"
          :showLog "true"
          :program "/home/victor/projects/repo1/main.go")
         (go-test
          modes (go-mode go-ts-mode)
          command "dlv"
          command-args ("dap" "--listen" "127.0.0.1:55878")
          command-cwd "/home/victor/projects/repo1"
          host "127.0.0.1"
          port 55878
          :request "launch"
          :mode "test"
          :type "go"
          :program "/home/victor/projects/repo1/test/some_file_test.go")
         (go-test-ginkgo
          modes (go-mode go-ts-mode)
          command "dlv"
          command-args ("dap" "--listen" "127.0.0.1:55878")
          command-cwd "/home/victor/projects/repo1/"
          host "127.0.0.1"
          port 55878
          :request "launch"
          :mode "test"
          :type "go"
          :showLog "true"
          :args ["-ginkgo.v" "-ginkgo.focus" "MyGinkgoTest*"]
          :program "/home/victor/projects/repo1/package/"))))))
Code Snippet 8: Debugging profiles in .dir-locals.el

Sample Application

Now let’s put our knowledge into practice by debugging a real application implementint a REST API.

Project Structure

Our example is a REST API for task management with the following structure:

1
2
3
4
5
6
taskapi/
├── go.mod
├── go.sum
├── main.go
├── task_store.go
└── task_test.go
Code Snippet 9: Basic code structure

Core Components

Let’s have a look at the core components.

The Task represents our core domain model which we’ll use to demonstrate different debugging scenarios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import (
    "fmt"
)

type Task struct {
    ID          int    `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Done        bool   `json:"done"`
}

The TaskStore handles our in-memory data operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type TaskStore struct {
    tasks  map[int]Task
    nextID int
}

func NewTaskStore() *TaskStore {
    return &TaskStore{
        tasks:  make(map[int]Task),
        nextID: 1,
    }
}
Code Snippet 11: task_store.go: The TaskStore storing multiple tasks

REST API

The API should expose following endpoints:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// CreateTask stores a given Task internally
func (ts *TaskStore) CreateTask(task Task) Task {
    task.ID = ts.nextID
    ts.tasks[task.ID] = task
    ts.nextID++
    return task
}

// GetTask retrieves a Task by ID
func (ts *TaskStore) GetTask(id int) (Task, error) {
    task, exists := ts.tasks[id]
    if !exists {
        return Task{}, fmt.Errorf("task with id %d not found", id)
    }
    return task, nil
}

// UpdateTask updates task ID with a new Task object
func (ts *TaskStore) UpdateTask(id int, task Task) error {
    if _, exists := ts.tasks[id]; !exists {
        return fmt.Errorf("task with id %d not found", id)
    }
    task.ID = id
    ts.tasks[id] = task
    return nil
}
Code Snippet 12: task_store.go: Rest API handlers

Server

Let’s continue with the server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

// Server implements a web application for managing tasks
type Server struct {
    store *TaskStore
}

func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    createdTask := s.store.CreateTask(task)
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(createdTask)
}

func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    // For demonstration, we'll extract ID from query parameter
    id := 0
    fmt.Sscanf(r.URL.Query().Get("id"), "%d", &id)

    task, err := s.store.GetTask(id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(task)
}

main package

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import (
    "log"
    "net/http"
)

func main() {
    store := NewTaskStore()
    server := &Server{store: store}
    http.HandleFunc("/task/create", server.handleCreateTask)
    http.HandleFunc("/task/get", server.handleGetTask)

    log.Printf("Starting server on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

go.mod

Create Go module:

1
2
go mod init taskapi
go mod tidy

Check dependencies:

1
cat go.mod
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module taskapi

go 1.23.0

require (
    github.com/onsi/ginkgo/v2 v2.21.0
    github.com/onsi/gomega v1.35.1
)

require (
    github.com/go-logr/logr v1.4.2 // indirect
    github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
    github.com/google/go-cmp v0.6.0 // indirect
    github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
    golang.org/x/net v0.30.0 // indirect
    golang.org/x/sys v0.26.0 // indirect
    golang.org/x/text v0.19.0 // indirect
    golang.org/x/tools v0.26.0 // indirect
    gopkg.in/yaml.v3 v3.0.1 // indirect
)
Code Snippet 15: go.mod

Build application

Let’s start the server:

1
2
go build -o taskapi *.go
ls -c

Now run it:

1
2
$ ./taskapi
2024/11/14 07:03:48 Starting server on :8080

Now from a different terminal create a new task:

1
2
3
curl -X POST -s http://localhost:8080/task/create \
-H "Content-Type: application/json" \
-d '{"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}'
1
{"id":3,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Code Snippet 16: Results

Let’s see if we can fetch it:

1
curl -X GET -s "http://localhost:8080/task/get?id=1"
1
{"id":1,"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}
Code Snippet 17: Results

Unit tests

Below are some unit tests (writen in Ginkgo) for the TaskStore:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main
import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "testing"
)

func TestTasks(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Task API Suite")
}


var _ = Describe("Task API", func() {
    var (
        store  *TaskStore
        server *Server
    )
    BeforeEach(func() {
        store = NewTaskStore()
        server = &Server{store: store}
    })

    Describe("POST /task/create", func() {
        Context("when creating a new task", func() {
            It("should create and return a task with an ID", func() {
                task := Task{
                    Title:       "Test Task",
                    Description: "Test Description",
                    Done:        false,
                }

                payload, err := json.Marshal(task)
                Expect(err).NotTo(HaveOccurred())

                req := httptest.NewRequest(http.MethodPost, "/task/create",
                    bytes.NewBuffer(payload))
                w := httptest.NewRecorder()

                server.handleCreateTask(w, req)

                Expect(w.Code).To(Equal(http.StatusOK))

                var response Task
                err = json.NewDecoder(w.Body).Decode(&response)
                Expect(err).NotTo(HaveOccurred())
                Expect(response.ID).To(Equal(1))
                Expect(response.Title).To(Equal("Test Task"))
            })

            It("should handle invalid JSON payload", func() {
                req := httptest.NewRequest(http.MethodPost, "/task/create",
                    bytes.NewBufferString("invalid json"))
                w := httptest.NewRecorder()

                server.handleCreateTask(w, req)

                Expect(w.Code).To(Equal(http.StatusBadRequest))
            })
        })
    })

    Describe("GET /task/get", func() {
        Context("when fetching an existing task", func() {
            var createdTask Task

            BeforeEach(func() {
                task := Task{
                    Title:       "Test Task",
                    Description: "Test Description",
                    Done:        false,
                }
                createdTask = store.CreateTask(task)
            })

            It("should return the correct task", func() {
                req := httptest.NewRequest(http.MethodGet, "/task/get?id=1", nil)
                w := httptest.NewRecorder()

                server.handleGetTask(w, req)

                Expect(w.Code).To(Equal(http.StatusOK))

                var response Task
                err := json.NewDecoder(w.Body).Decode(&response)
                Expect(err).NotTo(HaveOccurred())
                Expect(response).To(Equal(createdTask))
            })
        })

        Context("when fetching a non-existent task", func() {
            It("should return a 404 error", func() {
                req := httptest.NewRequest(http.MethodGet, "/task/get?id=999", nil)
                w := httptest.NewRecorder()

                server.handleGetTask(w, req)

                Expect(w.Code).To(Equal(http.StatusNotFound))
            })
        })

        Context("when using invalid task ID", func() {
            It("should handle non-numeric ID gracefully", func() {
                req := httptest.NewRequest(http.MethodGet, "/task/get?id=invalid", nil)
                w := httptest.NewRecorder()

                server.handleGetTask(w, req)

                Expect(w.Code).To(Equal(http.StatusNotFound))
            })
        })
    })
})

Add the Ginkgo dependencies to our go.mod file:

1
2
go get github.com/onsi/ginkgo/v2@latest
go get github.com/onsi/gomega

Now we should have a full list of dependencies in our go.mod file:

1
cat go.mod
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module taskapi

go 1.23.0

require (
    github.com/onsi/ginkgo/v2 v2.21.0
    github.com/onsi/gomega v1.35.1
)

require (
    github.com/go-logr/logr v1.4.2 // indirect
    github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
    github.com/google/go-cmp v0.6.0 // indirect
    github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
    golang.org/x/net v0.30.0 // indirect
    golang.org/x/sys v0.26.0 // indirect
    golang.org/x/text v0.19.0 // indirect
    golang.org/x/tools v0.26.0 // indirect
    gopkg.in/yaml.v3 v3.0.1 // indirect
)
Code Snippet 19: Results

Let’s run the tests:

1
go test -v
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
=== RUN   TestTasks
Running Suite: Task API Suite - /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging
======================================================================================================
Random Seed: 1731647885

Will run 5 of 5 specs
•••••

Ran 5 of 5 Specs in 0.001 seconds
SUCCESS! -- 5 Passed | 0 Failed | 0 Pending | 0 Skipped
--- PASS: TestTasks (0.00s)
PASS
ok  	taskapi	0.193s
Code Snippet 20: Results

In Emacs I would then call ginkgo-run-this-container as shown in this screenshot:

Basic Debugging with Delve and Dape

In order to debug our Task API we have following approaches

Table 2: Options for different request types (source)
request mode required optional
launch debug program dlvCwd, env, backend, args, cwd, buildFlags, output, noDebug
test program dlvCwd, env, backend, args, cwd, buildFlags, output, noDebug
exec program dlvCwd, env, backend, args, cwd, noDebug
core program dlvCwd, env
corefilePath
replay traceDirPath dlvCwd, env
attach local processId backend
remote

So for each request we have different modes. For the attach request type I’ll only focus on the remote mode which requires you to start the debugger externally and then use the DAP client (within Emacs) to connect to it. I couldn’t find any way how to interactively select the process ID before starting the debugger client. Now let’s delve into each workflow.

Profile 1: Launch application

As I’ve mentioned at the beginning I like to keep my debugging profiles in .dir-locals for each propject:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
;; Profile 1: Launch application and start DAP server
(go-debug-taskapi
  modes (go-mode go-ts-mode)
  command "dlv"
  command-args ("dap" "--listen" "127.0.0.1:55878")
  command-cwd default-directory
  host "127.0.0.1"
  port 55878
  :request "launch"
  :mode "debug"
  :type "go"
  :showLog "true"
  :program ".")
Code Snippet 21: .dir-locals.el (only the launch debug profile is configured)

💡 You may want to use a different value for command-cwd (the default setting is dape-cwd-fn). In my case I wanted to start the debugger in a directory which currently is not a project. default-directory is a variable which holds the working directory for the current buffer you’re currently in.

Start debugging:

Profile 2: Attach to an external debugger

Let’s say you now want to connect to an existing debugging session:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
;; Profile 2: Attach to external debugger
(go-attach-taskapi
 modes (go-mode go-ts-mode)
 command "dlv"
 command-cwd default-directory
 host "127.0.0.1"   ;; can also be skipped
 port 55878
 :request "attach"  ;; this will run "dlv attach ..."
 :mode "remote"     ;; connect to a running debugger session
 :type "go"
 :showLog "true")
Code Snippet 24: .dir-locals.el: Additional config for attaching to a running process

Now let’s start the debugger on the CLI:

1
2
3
4
5
6
~/emacs-golang-debugging $ go build -gcflags=all="-N -l" -o taskapi .
~/emacs-golang-debugging $ dlv debug taskapi --listen=localhost:55878 --headless
API server listening at: 127.0.0.1:55878
debugserver-@(#)PROGRAM:LLDB  PROJECT:lldb-1600.0.36.3
 for arm64.
Got a connection, launched process /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/__debug_bin794004190 (pid = 23979).

Now within Emacs you can launch dape and select the go-attach-taskapi profile:

Profile 3: Attach to a running process

In this scenario the application is already running but you want to attach the debugger to it and use Emacs to connect to the debugger session. First we launch the application:

1
2
$ ./taskapi
2024/11/20 06:34:29 Starting server on :8080
Code Snippet 25: Start the taskapi application

Find out its process ID (PID):

1
ps -A | grep -m1 taskapi | awk '{print $1}'

Now let’s add a 3rd debug profile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
;; Profile 3: Attach to running process (by PID)
(go-attach-pid
 modes (go-mode go-ts-mode)
 command "dlv"
 command-args ("dap" "--listen" "127.0.0.1:55878" "--log")
 command-cwd default-directory
 host "127.0.0.1"
 port 55878
 :request "attach"
 :mode "local"      ;; Attach to a running process local to the server
 :type "go"
 :processId (+get-process-id-by-name "taskapi")
 :showLog "true")
Code Snippet 26: .dir-locals.el: Attach to running process (by pid)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
;; Add helpful function
(eval . (progn
          (defun +get-process-id-by-name (process-name)
            "Return the process ID of a process specified by PROCESS-NAME. Works on Unix-like systems (Linux, MacOS)."
            (interactive)
            (let ((pid nil))
              (cond
               ((memq system-type '(gnu/linux darwin))
                (setq pid (shell-command-to-string
                           (format "pgrep -f %s"
                                   (shell-quote-argument process-name)))))
               (t
                (error "Unsupported system type: %s" system-type)))

              ;; Clean up the output and return first PID
              (when (and pid (not (string-empty-p pid)))
                (car (split-string pid "\n" t)))))))
Code Snippet 27: .dir-locals.el: Helper function

I’ve also added +get-process-id-by-name which will return the process ID for the taskapi application. Now you can use the same debug profile (go-attach-taskapi) to start a new DAP server and let it attach to a running processs.

Now I start the debugger:

If I now send a POST request like this one:

1
2
3
curl -X POST -s http://localhost:8080/task/create \
-H "Content-Type: application/json" \
-d '{"title":"Learn Debugging","description":"Master Emacs debugging with dape","done":false}'

The debugger should automatically halt at the set breakpoint:

Debugging Ginkgo Tests

Being able to debug tests in Golang is a crucial step in the development process. Until recently it was still a struggle for me, until I’ve decided to look into dape-mode and some other details.

Dape Configuration for Ginkgo

For running ginkgo tests I use ginkgo-mode which has several features:

And as an output I get:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Running Suite: Task API Suite - /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging
======================================================================================================
Random Seed: 1732600680

Will run 1 of 5 specs
•SSSS

Ran 1 of 5 Specs in 0.001 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 4 Skipped
PASS

Ginkgo ran 1 suite in 1.481440083s
Test Suite Passed
Code Snippet 28: Ginkgo output

This is the basic configuration for debugging Ginkgo tests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
;; Profile 4: Debug Ginkgo tests
(go-test-ginkgo
 modes (go-mode go-ts-mode)
 command "dlv"
 command-args ("dap" "--listen" "127.0.0.1:55878" "--log")
 command-cwd default-directory
 host "127.0.0.1"
 port 55878
 :request "launch"
 :mode "test"      ;; Debug tests
 :type "go"
 :args ["-ginkgo.v" "-ginkgo.focus" "should create and return a task with an ID"]
 :program ".")
Code Snippet 29: Debug profile for Ginkgo tests

If I chose the go-test-ginkgo debug profile I should be able to debug the tests as shown in this screenshot:

Now the configuration is quite static and therefore you cannot preselect the unit test / container. We need to somehow make the parameter -ginkgo.focus dynamic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
(defun my/dape-debug-ginkgo-focus (focus-string)
  "Start debugging Ginkgo tests with a specific focus string."
  (interactive "sEnter focus string: ")
  (make-local-variable 'dape-configs)  ; Make buffer-local copy of dape-configs
  (setq dape-configs
        (list
         `(debug-focused-test
           modes (go-mode)
           command "dlv"
           command-args ("dap" "--listen" "127.0.0.1:55878")
           command-cwd default-directory
           port 55878
           :request "launch"
           :name "Debug Focused Test"
           :mode "test"
           :program "."
           :args ["-ginkgo.v" "-ginkgo.focus" ,focus-string]))))
Code Snippet 30: Helper function

Afterwards If I have a look at the dape-configs variable I should see this value:

1
2
3
4
5
6
7
Value:
((debug-focused-test modes
                     (go-mode)
                     command "dlv" command-args
                     ("dap" "--listen" "127.0.0.1:55878")
                     command-cwd default-directory port 55878 :request "launch" :name "Debug Focused Test" :mode "test" :program "." :args
                     ["-ginkgo.v" "-ginkgo.focus" "when using invalid*"]))
Code Snippet 31: Value of dape-configs in the buffer

After starting the debugger (with the debug-focused-test profile) in the dape-repl buffer I get:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Welcome to Dape REPL!
Available Dape commands: debug, next, continue, pause, step, out, up, down, restart, kill, disconnect, quit
Empty input will rerun last command.

DAP server listening at: 127.0.0.1:55878
debugserver-@(#)PROGRAM:LLDB  PROJECT:lldb-1600.0.39.3
 for arm64.
Got a connection, launched process /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/__debug_bin2799839715 (pid = 31882).
Type 'dlv help' for list of commands.
Running Suite: Task API Suite - /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging
======================================================================================================
Random Seed: 1732685749

❶ Will run 1 of 5 specs
SSSS
------------------------------
❷ Task API GET /task/get when using invalid task ID should handle non-numeric ID gracefully
/home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/task_store_test.go:108
Code Snippet 32: The dape-repl buffer

💡Notice that just “1 of 5 specs” (❶) were ran, meaning that ginkgo only focussed on the container we have specified (❷).

Best Practices and Tips

Throughout my debugging experience, I have come to appreciate the importance of version control, as it enables me to track my debugging configurations and their evolution over time. To streamline this process, I now maintain my debug configurations in a separate file, such as .dir-locals.el, which helps keep them organized and easily accessible. Additionally, I make sure to use meaningful names for these configurations to avoid confusion and ensure that the correct settings are used when needed. Moreover, I have found it useful to create project-specific debugging helper functions that can be easily customized to suit my needs. Finally, by making customizations locally (bound to a specific buffer), I can ensure that these settings apply only to the relevant context and do not interfere with other parts of my workflow.

Resources and References