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:
- Set up and configure Delve with dape
- Debug both standard applications and Ginkgo tests (this is what I’m using at
the moment 🤷)
- Optimize your debugging workflow with Emacs specific customizations
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
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,
}
}
|
REST API
The API should expose following endpoints:
POST /task/create
- Creates a new task
GET /task/get?id=<id>
- Retrieves a task by ID
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
}
|
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
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
)
|
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
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
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: [1m1731647885[0m
Will run [1m5[0m of [1m5[0m specs
[38;5;10m•[0m[38;5;10m•[0m[38;5;10m•[0m[38;5;10m•[0m[38;5;10m•[0m
[38;5;10m[1mRan 5 of 5 Specs in 0.001 seconds[0m
[38;5;10m[1mSUCCESS![0m -- [38;5;10m[1m5 Passed[0m | [38;5;9m[1m0 Failed[0m | [38;5;11m[1m0 Pending[0m | [38;5;14m[1m0 Skipped[0m
--- 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
- we can launch the application directly and debug it
- we can attach to a running process
- we can attach to a running debugging session
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 ".")
|
💡 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:
-
Create breakpoint using dape-breakpoint-toggle
:
-
Launch dape
In the dape-repl
buffer you should something like:
1
2
3
4
5
6
7
8
|
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.36.3
for arm64.
Got a connection, launched process /home/victor/repos/priv/blog/static/code/2024/emacs-golang-debugging/__debug_bin3666561508 (pid = 43984).
Type 'dlv help' for list of commands.
|
Code Snippet 22:
The debugger compiled the binary and also launched it
You might have noticed that we didn’t specify any binary/file to debug (we had :program "."
in .dir-locals.el
). delve
will automatically build the binary before it launches the application:
1
|
go build -gcflags=all="-N -l" .
|
Code Snippet 23:
How delve builds the application when you have :program "."
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")
|
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")
|
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)))))))
|
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 ".")
|
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]))))
|
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