Fixing Neovim Debugger UI

Configuring nvim-dap-ui without annoying layout shifts and with some simple notifications.

Info

In 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()
end

If 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 Carmackalways 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)

end

A 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 = false

Then 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)

end

Finally, 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
end

There 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…