Debugging Neovim with Neovim and nvim-dap
Recently a change got merged in Neovim that decoupled its TUI from the main process. A side effect of the change is that debugging it became a bit more troublesome. It now forks itself and you end up with two processes. Depending on what you want to debug you need to attach to that second process.
I thought this might be an interesting use-case for more advanced features of nvim-dap. This post explores using it to automate attaching to the second process.
Even if you’ll never debug Neovim you may find this interesting as it could give you some ideas on what you can do with a hackable debugger.
This post uses nvim-dap features of the upcoming 0.5 release. If you’re on 0.4 you’ll have to switch to the development branch. (If you are from the future, use 0.5+)
Setting up cpptools ¶
For this to work we’ll need to use vscode-cpptools. It’s a debug adapter for gdb. I tried using codelldb as well, but it doesn’t generate process
events, which are necessary for this to work. More about them later.
I have a little Ansible role that takes care of downloading and extracting it:
---
- name: Ensure cpptools folder exists
ansible.builtin.file:
path: ~/apps/cpptools
state: directory
mode: 0755
- name: Download cpptools
ansible.builtin.unarchive:
src: https://github.com/microsoft/vscode-cpptools/releases/download/1.13.9/cpptools-linux.vsix
dest: ~/apps/cpptools/
remote_src: true
- name: Set executable flag
ansible.builtin.file:
path: ~/apps/cpptools/extension/debugAdapters/bin/OpenDebugAD7
mode: 0755
If you don’t fancy Ansible you can download cpptools
from their Release page. The vsix
files are ZIP archives in disguise, you can unzip them. You also have to make sure that gdb
is in your $PATH
.
Installing & Configuring nvim-dap ¶
Next you’ll need to install nvim-dap and configure the cpptools adapter:
It’s a regular plugin, you can use your favourite plugin manager, or clone it into the packages path:
git clone \
https://github.com/mfussenegger/nvim-dap.git \
~/.config/nvim/pack/plugins/start/nvim-dap
Next configure the adapter. You can place this in ~/.config/nvim/init.lua
:
local dap = require("dap")
dap.adapters.cppdbg = {
id = 'cppdbg',
type = 'executable',
command = os.getenv("HOME") .. '/apps/cpptools/extension/debugAdapters/bin/OpenDebugAD7',
}
You’ll have to adapt the command
to point to wherever you’ve installed cpptools. If the binary is in your $PATH
environment variable you can use the command name instead of a full absolute path.
This little snippet tells nvim-dap how it can launch the debug adapter named cppdbg
. You can read more about the adapter configuration in :help dap-adapter
.
Next we need to add a configuration that tells nvim-dap
and the adapter what process it should launch. In our case that’s Neovim itself. Configurations go into dap.configurations.<filetype>
and are a list.
A simple configuration for Neovim could look like this:
local dap = require("dap")
dap.configurations.c = {
{
name = "Neovim",
-- ⬇️ References the `cppdbg` in the `dap.adapters.cppdbg` definition
type = "cppdbg",
-- ⬇️ Used to indicate that this should launch a process
request = "launch",
-- ⬇️ The program we want to debug, the path to the `nvim` binary in this case
-- Make sure it's built with debug symbols
program = "/full/path/to/neovim/build/bin/nvim",
-- ⬇️ Requires `external_terminal` configuration
externalConsole = true,
},
}
Starting Neovim inside Neovim can get confusing, and given that it requires a real terminal it’s best to launch it in an external terminal. cpptools
supports that using the externalConsole
property.
You’ll also need to tell nvim-dap what and how to launch an external terminal. Here is an example for alacritty:
local dap = require("dap")
dap.defaults.fallback.external_terminal = {
command = '/usr/bin/alacritty';
args = {'-e'};
}
You can read more about the use of terminals with nvim-dap in :help dap-terminal
.
You need to change one more option if you’re on Linux. Linux by default doesn’t allow any process to inspect other processes. That’s a security feature. You wouldn’t want to have malware read the memory of your key agent. For debugging this security feature can get in the way. You need to (temporarily) disable it:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
This will allow gdb
to inspect the processes you want to debug. The default setting would prevent that because gdb
isn’t launching the process itself. Neovim/nvim-dap is doing that.
With this in place you should be able to launch Neovim in the Neovim source tree, open a source file like src/nvim/main.c
and call :DapContinue
to start the debug session. If you got everything right this should start another Neovim instance. (If you have more than one configuration entry you’ll get a prompt and need to select Neovim
)
Compile Neovim before starting the debug session ¶
The setup so far is fairly standard and covered by the nvim-dap documentation and Wiki. Now one improvement we can make is to have it compile Neovim whenever we start the debug session.
To do that we can wrap the configuration into a Lua metatable with a __call
property:
local dap = require("dap")
dap.configurations.c = {
setmetatable(
-- ⬇️ This first table is the same as before. It's the actual "dap-configuration"
{
name = "Neovim",
type = "cppdbg",
request = "launch",
program = "/full/path/to/neovim/build/bin/nvim",
externalConsole = true,
},
{
__call = function(config)
-- Empty for now, we extend this in a moment
return config
end
}
),
}
This does exactly the same as before, except that nvim-dap calls the function under __call
when you run the configuration. One of the use-cases for this function is to generate extra properties for the configuration. It receives the initial configuration as argument and must return a (updated) configuration.
But we don’t use it like that here. Instead of extending the configuration with new properties we can use it as a hook to run arbitrary logic before starting the debug session.
To build Neovim we can call into make
:
__call = function(config)
vim.fn.system("CMAKE_BUILD_TYPE=RelWithDebInfo make")
return config
end
Sub-process auto-attach ¶
Now we’re getting into the really advanced territory that motivated this post. nvim-dap includes a listener system that allows to listen to any event or response generated by a debug adapter. When Neovim forks its process, cpptool and gdb generate a process event. This process event tells us the pid
of the new process. We can use this to start a second debug session that attaches to the child process. We’re going to update the __call
hook to add some event listeners. You can read more about the listener system under :help dap-extensions
.
__call = function(config)
vim.fn.system("CMAKE_BUILD_TYPE=RelWithDebInfo make")
-- ⬇️ listeners are indexed by a key.
-- This is like a namespace and must not conflict with what plugins
-- like nvim-dap-ui or nvim-dap itself uses.
-- It's best to not use anything starting with `dap`
local key = "a-unique-arbitrary-key"
-- ⬇️ `dap.listeners.<before | after>.<event_or_command>.<plugin_key>`
-- We listen to the `initialize` response. It indicates a new session got initialized
dap.listeners.after.initialize[key] = function(session)
-- ⬇️ immediately clear the listener, we don't want to run this logic for additional sessions
dap.listeners.after.initialize[key] = nil
-- The first argument to a event or response is always the session
-- A session contains a `on_close` table that allows us to register functions
-- that get called when the session closes.
-- We use this to ensure the listeners get cleaned up
session.on_close[key] = function()
for _, handler in pairs(dap.listeners.after) do
handler[key] = nil
end
end
end
-- We listen to `event_process` to get the pid:
dap.listeners.after.event_process[key] = function(_, body)
-- ⬇️ immediately clear the listener, we don't want to run this logic for additional sessions
dap.listeners.after.event_process[key] = nil
local ppid = body.systemProcessId
-- The pid is the parent pid, we need to attach to the child. This uses the `ps` tool to get it
-- It takes a bit for the child to arrive. This uses the `vim.wait` function to wait up to a second
-- to get the child pid.
vim.wait(1000, function()
return tonumber(vim.fn.system("ps -o pid= --ppid " .. tostring(ppid))) ~= nil
end)
local pid = tonumber(vim.fn.system("ps -o pid= --ppid " .. tostring(ppid)))
-- If we found it, spawn another debug session that attaches to the pid.
if pid then
dap.run({
name = "Neovim embedded",
type = "cppdbg",
request = "attach",
processId = pid,
-- ⬇️ Change paths as needed
program = os.getenv("HOME") .. "/dev/neovim/neovim/build/bin/nvim",
cwd = os.getenv("HOME") .. "/dev/neovim/neovim/",
externalConsole = false,
})
end
end
return config
end
With this in place you should be able to launch the Neovim
debug session to start Neovim in another terminal, and have it automatically attach to the sub-process. You can verify that by viewing the active debug sessions using something like this:
:lua local w = require('dap.ui.widgets'); w.sidebar(w.sessions, nil, '5 sp').open();
This should open a new split window listing the two active debug sessions.