Debug on the Moon

One of the cool features of the debugging integration of vscode is it’s modularity. While the extension provide a way to get deep integration automatically, vscode is designed to allow a lot of configuration by hand. We can use this, to build some more powerful commands as well.

In this case, I want to debug my binary. But not on my build machine, but an external host. I can connecto to this host via ssh and got a configuration that doesn’t ask for passwords to do so (public keys and ssh-agent are your friends).

The way we build this, builds on launch commans but also some custom stuff.

I think the best way to go through this, is with examples of two files we need for this. They are from a different project, but hey. The task.json provides the intermediate steps to be ready. And the launch.json provides the debug configuration itself.

The Debug configuration

We start with the debug configuration. This is what’s required to start the debugger itself. And if we wanted to, we could use just this. But only with some annoyances we automate with the tasks.

{
    "name": "Local: Debug conntracker-lldp-networkctl on wg-apu",
    "type": "cppdbg",
    "request": "launch",
    "preLaunchTask": "Local: Start gdbserver conntracker-lldp-networkctl on wg-apu",
    "program": "builddir/lldp/networkctl/conntracker-lldp-networkctl",
    "args": [],
    "stopAtEntry": false,
    "cwd": "${workspaceRoot}",
    "environment": [],
    "externalConsole": true,
    "MIMode": "gdb",
    "miDebuggerServerAddress": "10.0.8.1:3000",
    "setupCommands": [
        {
            "description": "Enable pretty-printing for gdb",
            "text": "-enable-pretty-printing",
            "ignoreFailures": true
        }
    ]
}

The type field here is important. This directs vscode to the debugging integration of the C/C++ integration, i.e. gdb support. The request field is an enum between "launch" and "attach". Looking at it now, attach might be more fitting? I only tried this way. The preLaunchTask field builds a dependency chain. This makes the magic happen to single-click debug on remote. More on that in the other tasks. The program field tells the debugger which binary to load. For local debugging this would also determine what’s launched. For remote debugging, this determines the debug information pickup. The miDebuggerServiceAddress field points to where we set up the gdb server.

Everything else is mosty boilerplate and IMO self explanatory.

When we run this configuration, gdb will be connected to a remote gdbserver instance. But if we want one-click debugging, this server needs to be spawned as well.

Start gdbserver

The next command in line needs to start the gdbserver on our remote machine. This one is in tasks.json:

{
    "type": "shell",
    "label": "Local: Start gdbserver conntracker-lldp-networkctl on wg-apu",
    "dependsOn": "Local: Deploy conntracker-lldp-networkctl to wg-apu",
    "command": "ssh",
    "args": [
        "wg-apu",
        "gdbserver localhost:3000 /tmp/conntracker-lldp-networkctl --db-host mario"
    ],
    "presentation": {
        "echo": true,
        "reveal": "always",
        "focus": false,
        "panel": "shared",
        "showReuseMessage": true,
        "clear": false
    },
    "detail": "Starts the gdbserver and waits for success",
    "isBackground": true,
    "problemMatcher": {
        "pattern": [
            {
                "regexp": ".",
                "file": 1,
                "location": 2,
                "message": 3
            }
        ],
        "background": {
            "activeOnStart": true,
            "beginsPattern": "^.*Process*",
            "endsPattern": "^.*Listening*"
        }
    }
}

We got a lot going on again. This time our type is "shell", because we don’t have anything special in terms of integration. We use the "ssh" command, and pass it the server we connect to (.ssh/config :)) and what we want to start. We start the gdbserver, with a port, and our binary. For convenience I also added my cmdline parameters configuration here.

The complicated stuff is everything else. We cannot wait for this task until it’s done running, since we connect to this instance of gdbserver in the depending launch configuration. At the same time, we cannot just start this in the background, or there would be race conditions between ssh+setup of gdb and gdb attaching to the server.

So we need to configure it in a way that allows us to wait just until it is ready. I honesty don’t know how much of this is releveant, since I found mot of it on the internet. But the background section looks most likely to me. I do think this could drop the "presentation" section, but it’s nice to see what’s going on, in case there’s any error.

Deploy the Binary

The last task that needs configuration by hand deploys the binary to the remote. We want exactly what we have locally. And without doing anything ourselves.

{
    "type": "shell",
    "problemMatcher": [],
    "dependsOn": "Meson: Build lldp/networkctl/conntracker-lldp-networkctl",
    "command": "scp",
    "args": ["builddir/lldp/networkctl/conntracker-lldp-networkctl", "wg-apu:/tmp"],
    "label": "Local: Deploy conntracker-lldp-networkctl to wg-apu"
}

It’s pretty similar to the previous one. You will need to find your executable path yourself (though it’s also needed in the launch config).

In general: the "label" of a task needs to match the dependsOn resp. preLaunchTask of the later step. Here the dependsOn points into an auto-generated task from the meson integration.

With this, a single click to debug (or press [F5]) will:

  • Build the specified target
  • scp the target onto my test device
  • Start a gdbserver on the test device and wait until it is ready
  • Connect to the server and provide integrated debugging support.

I have noticed that error handling isn’t perfect in all of this. so don’t be surprised if you have weird messages when something goes wrong.