Fixing Neovim Debugger UI
Configuring nvim-dap-ui without annoying layout shifts and with some simple notifications.
InfoIn the previous article I presented parts of my Neovim C/C++ IDE configuration. This article will show some quick and easy fixes to annoying layout shifts when using the debugger UI in Neovim. This is basically an added bonus to the previous article.
It is common to see in articles and videos explaining nvim-dap-ui – including in the official documentation – to use something like the following example to automatically open and close the UI when the debugger is started.
dap.listeners.before.launch.dapui_config = function(session, body)
ui.open()
end
dap.listeners.before.event_terminated.dapui_config = function(session, body)
ui.close()
end
dap.listeners.before.event_exited.dapui_config = function(session, body)
ui.close()
endIf this simple configuration is used to debug a simple program we will see the first layout shift issue. When the debugger is started it will open the UI and, when the program immediately exits (assuming no breakpoints), the UI will immediately close again. This causes a quick – and visible – layout shift. I find this super-annoying especially as my goal is to – like Carmack – always run my programs in the debugger.
There is a second oddity with the configuration. I am not sure why it closes
the UI on both event_exited and event_terminated. If the debuggee exits,
does it not always trigger event_terminated after? And, if event_exited
does not terminate the session, then shouldn’t the UI be left open? The session
is still ongoing!
Let’s edit the configuration so that the UI is opened when the debuggee is
stopped instead of when the debugger is launched and fix event_exited.
dap.listeners.before.launch.dapui_config = function(session, body)
end
dap.listeners.before.event_stopped.dapui_config = function(session, body)
ui.open()
end
dap.listeners.before.event_terminated.dapui_config = function(session, body)
ui.close()
end
dap.listeners.before.event_exited.dapui_config = function(session, body)
endA problem with this updated configuration is that there is now no visual feedback when running the debugger. If the debuggee never stops then the UI is never opened. You will not know whether it has started, whether it is running, or whether it has exited. I imagine that some people are fine with the layout shift simply because it provides some kind of feedback that it actually ran and completed.
But before adding feedback a second annoying layout shift must be fixed. This
one happens when the debugger is restarted as it is stopped at a breakpoint.
When the debugger is restarted it will first terminate the current session
(closing the UI) and then start a new session that will immediately stop at the
same breakpoint again (opening the UI). Let’s start by defining a global
variable IS_RESTARTING.
-- IS_RESTARTING is set to true when the debug session is restarted.
-- nvim-dap-ui will not close the UI if the session is being restarted. This
-- prevents an annoying layout shift if the debugger is restarted when stopped
-- at a breakpoint.
--
-- Possibly there could be a race condition here somewhere, but does that
-- really matter?
--
-- Tested with GDB in C/C++ but it might not work for debuggers that actually
-- supports restart requests?
IS_RESTARTING = falseThen configure a one-button recompile-and-restart keymap…
-- Recompiles using `:make` and restarts the current debug session.
-- Like dap.restart, it does nothing if no session exists.
local function recompile_and_restart()
if dap.session() then
local compiled = save_and_compile()
if compiled then
-- Set IS_RESTARTING so the UI is not closed.
IS_RESTARTING = true
dap.restart()
end
end
end
vim.keymap.set(
"n", "<F12>", recompile_and_restart, { desc = "(DAP) Restart" }
)…and configure the UI so that it is not closed if the session is restarting.
dap.listeners.before.launch.dapui_config = function(session, body)
IS_RESTARTING = false
end
dap.listeners.before.event_stopped.dapui_config = function(session, body)
ui.open()
end
dap.listeners.before.event_terminated.dapui_config = function(session, body)
if not IS_RESTARTING then
ui.close()
end
end
dap.listeners.before.event_exited.dapui_config = function(session, body)
endFinally, let’s add in some notifications. They notify when the debugger is started and when the debuggee exits normally, warn when the debuggee receives a signal (often for failed asserts), and error if the debuggee terminates with a non-zero exit status. These are just simple text-notifications that are printed in the command line, which is enough for me. It would be nice to have a throbber in the statusline while the debuggee is running. Maybe in the future.
local levels = vim.log.levels
dap.listeners.before.launch.dapui_config = function(session, body)
IS_RESTARTING = false
vim.notify("[DAP] Session launched", levels.INFO)
end
dap.listeners.before.event_stopped.dapui_config = function(session, body)
ui.open()
if body.reason == "signal" then
vim.notify("[DAP] Debuggee stopped due to signal", levels.WARN)
end
end
dap.listeners.before.event_terminated.dapui_config = function(session, body)
if not IS_RESTARTING then
ui.close()
end
end
dap.listeners.before.event_exited.dapui_config = function(session, body)
if body.exitCode == 0 then
vim.notify("[DAP] Debuggee exited normally", levels.INFO)
else
local message = string.format(
"[DAP] Debuggee exited with with code %d",
body.exitCode
)
vim.notify(message, level.ERROR)
end
endThere is one remaining annoyance that I have not managed to solve. When the
debuggee encounters a false assert it will correctly stop, warn that it
received a signal, and enable you to debug your program from that point. But
when the session is terminated (or if it is continued) the notifications will
print that the debuggee exited normally. Clearly, it did not. It terminated
abnormally due to SIGABRT.
It turns out that this is an issue with GDB’s built-in DAP adapter and it is a
bigger problem than I first thought. Apparently, it will send an exited-event
with exitCode=0 if the debuggee terminates abnormally (i.e. without an exit
status). Was your program killed by SIGKILL? It exited normally, according
to the adapter. Did it dereference a null pointer? It exited normally,
according to the adapter.
As a workaround, you could keep track of whether the debuggee received a signal before terminating the session but you quickly run into a second problem: the adapter does not tell you which signal stopped the debuggee. Sigh…