diff mbox series

[10/14] devtool: ide-sdk: attach via extended-remote

Message ID 20260816194127.86607-11-adrian.freihofer@siemens.com
State New
Headers show
Series devtool: ide-sdk: robust attach-mode debugging and test cleanup | expand

Commit Message

AdrianF Aug. 16, 2026, 7:40 p.m. UTC
From: Adrian Freihofer <adrian.freihofer@siemens.com>

The old ATTACH mode started gdbserver with "--attach :<port>
$(pidof <binary>)" and used a plain "request": "launch" config. This
is the classic cpptools attach setup, which is fragile and has several
long-standing, unfixed bugs, e.g.
https://github.com/microsoft/vscode-cpptools/issues/4166.

{
    "name": "Attach with GDB",
    "type": "cppdbg",
    "request": "launch",
    "program": "<program>",
    "stopAtEntry": true,
    "cwd": "${workspaceFolder}",
    "environment": [],
    "externalConsole": false,
    "MIMode": "gdb",
    "miDebuggerPath": "/usr/bin/gdb",
    "miDebuggerServerAddress": "<target>:<port>",
    "postDebugTask": "kill_gdbserver_..."
}

cppdbg also supports attaching over the extended-remote protocol:

{
    "name": "Attach with GDB",
    "type": "cppdbg",
    "request": "attach",
    "program": "<program>",
    "MIMode": "gdb",
    "miDebuggerPath": "/usr/bin/gdb",
    "miDebuggerServerAddress": "<target>:<port>",
    "useExtendedRemote": true
}

Switch to this instead: merge ATTACH into the same persistent
"gdbserver --multi" server already used for MULTI mode, since both are
now extended-remote sessions that only differ on the client side, and
stop it by PID instead of pgrep/killall. ONCE mode gets the same
readiness synchronization MULTI already had, since gdbserver now
always writes a PID file that the stop commands wait on.

Until https://github.com/microsoft/vscode-cpptools/pull/14684 is
merged, this comes with one downside (which was the blocker for using
extended-remote attach mode until now): the user is prompted to pick the
process to attach to, instead of it being selected automatically. But
this is a temporary limitation, and the new setup is more robust and
fixes several long-standing issues with the old ATTACH mode.

Signed-off-by: Adrian Freihofer <adrian.freihofer@siemens.com>
---
 meta/lib/oeqa/selftest/cases/devtool.py     | 19 +++--
 scripts/lib/devtool/ide_plugins/__init__.py | 67 ++++++++++-----
 scripts/lib/devtool/ide_plugins/ide_code.py | 92 ++++++++++++---------
 scripts/lib/devtool/ide_plugins/ide_none.py | 44 ++++++----
 4 files changed, 137 insertions(+), 85 deletions(-)
diff mbox series

Patch

diff --git a/meta/lib/oeqa/selftest/cases/devtool.py b/meta/lib/oeqa/selftest/cases/devtool.py
index b38011eac1..a07214f8b3 100644
--- a/meta/lib/oeqa/selftest/cases/devtool.py
+++ b/meta/lib/oeqa/selftest/cases/devtool.py
@@ -3342,18 +3342,27 @@  class DevtoolIdeSdkGccTests(DevtoolIdeSdkTests):
 
         for config in configurations:
             # Verify required fields exist
-            required_fields = ["name", "type", "request", "program", "cwd", "MIMode",
+            required_fields = ["name", "type", "request", "program", "MIMode",
                              "miDebuggerPath", "miDebuggerServerAddress"]
             for field in required_fields:
                 self.assertIn(field, config, f"Configuration '{config.get('name', 'Unknown')}' missing required field: {field}")
 
             # Verify common configuration values
             self.assertEqual(config["type"], "cppdbg", f"Configuration '{config['name']}' should use cppdbg type")
-            self.assertEqual(config["request"], "launch", f"Configuration '{config['name']}' should be launch type")
-            self.assertEqual(config["cwd"], "${workspaceFolder}", f"Configuration '{config['name']}' should use workspaceFolder as cwd")
             self.assertEqual(config["MIMode"], "gdb", f"Configuration '{config['name']}' should use gdb MIMode")
-            self.assertEqual(config.get("externalConsole", False), False, f"Configuration '{config['name']}' should not use external console")
-            self.assertEqual(config.get("stopAtEntry", True), True, f"Configuration '{config['name']}' should stop at entry")
+
+            if config["request"] == "launch":
+                self.assertEqual(config["cwd"], "${workspaceFolder}", f"Configuration '{config['name']}' should use workspaceFolder as cwd")
+                self.assertEqual(config.get("externalConsole", False), False, f"Configuration '{config['name']}' should not use external console")
+                self.assertEqual(config.get("stopAtEntry", True), True, f"Configuration '{config['name']}' should stop at entry")
+            elif config["request"] == "attach":
+                # Attaching to a process running on the target requires the
+                # extended-remote protocol. Stopping the session then detaches
+                # from the process instead of killing it.
+                self.assertTrue(config.get("useExtendedRemote"), f"Configuration '{config['name']}' should use useExtendedRemote")
+                self.assertNotIn("cwd", config, f"Configuration '{config['name']}' should not set cwd in attach mode")
+            else:
+                self.fail(f"Configuration '{config['name']}' has unexpected request type: {config['request']}")
 
             # Verify program path is absolute and exists conceptually
             program = config["program"]
diff --git a/scripts/lib/devtool/ide_plugins/__init__.py b/scripts/lib/devtool/ide_plugins/__init__.py
index 5211df5806..b302f419b1 100644
--- a/scripts/lib/devtool/ide_plugins/__init__.py
+++ b/scripts/lib/devtool/ide_plugins/__init__.py
@@ -104,6 +104,12 @@  class DebuggerCrossConfig:
         hex_port = "%04X" % self.debug_server_port
         return "grep -q :%s /proc/net/tcp /proc/net/tcp6 2>/dev/null" % hex_port
 
+    def get_debug_server_ready_marker(self, port):
+        return "%s ready on port %s" % (self.DEBUG_SERVER_NAME, port)
+
+    def get_debug_server_ready_marker_pattern(self):
+        return "^%s$" % self.get_debug_server_ready_marker("[0-9]+")
+
     def _target_wait_for_tcp_port_cmd(self, pid_var=None, log_file=None):
         """Shell fragment waiting until the debug server listens on its port.
 
@@ -120,6 +126,13 @@  class DebuggerCrossConfig:
             % (self._target_tcp_port_check_cmd(), self.TARGET_START_RETRIES,
                cleanup, self.DEBUG_SERVER_NAME, self.debug_server_port, dump_log))
 
+    def _target_wait_for_process_exit_cmd(self, pid_var):
+        return (
+            "_w=0; while kill -0 \\$_%s 2>/dev/null; do _w=\\$((_w+1)); "
+            "[ \\$_w -lt 100 ] || { echo %s did not stop >&2; exit 1; }; "
+            "sleep 0.1; done;"
+            % (pid_var, self.DEBUG_SERVER_NAME))
+
     def initialize(self):
         """Called after construction to generate any required config files."""
         pass
@@ -128,7 +141,7 @@  class DebuggerCrossConfig:
     def _target_start_cmd(self, mode):
         raise NotImplementedError
 
-    def _target_kill_cmd(self):
+    def _target_stop_cmd(self, mode):
         raise NotImplementedError
 
 
@@ -182,18 +195,19 @@  class GdbCrossConfig(DebuggerCrossConfig):
           "\"/bin/sh -c '/usr/bin/gdbserver --once :1234 /usr/bin/cmake-example'\""
         """
         if server_mode == DebuggerServerModes.ONCE:
-            gdbserver_cmd_start = "%s --once :%s %s" % (
+            gdbserver_cmd_start = "mkdir -p %s; " % self._gdbserver_tmp_dir(server_mode)
+            gdbserver_cmd_start += "%s --once :%s %s & " % (
                 self.debugger_cross.debug_server_path, self.debug_server_port, self.binary.binary_path)
-        elif server_mode == DebuggerServerModes.ATTACH:
-            pid_command = self.binary.pid_command
-            if pid_command:
-                gdbserver_cmd_start = "%s --attach :%s \\$(%s)" % (
-                    self.debugger_cross.debug_server_path,
-                    self.debug_server_port,
-                    pid_command)
-            else:
-                raise DevtoolError("Cannot use gdbserver attach mode for binary %s. No PID found." % self.binary.binary_path)
-        elif server_mode == DebuggerServerModes.MULTI:
+            gdbserver_cmd_start += "_gdbserver_pid=\\$!; "
+            gdbserver_cmd_start += "echo \\$_gdbserver_pid > %s; " % self._gdbserver_pid_file(server_mode)
+            gdbserver_cmd_start += self._target_wait_for_tcp_port_cmd(
+                "gdbserver_pid") + " "
+            gdbserver_cmd_start += "echo %s; wait \\$_gdbserver_pid" % (
+                self.get_debug_server_ready_marker(self.debug_server_port))
+        elif server_mode in (DebuggerServerModes.ATTACH, DebuggerServerModes.MULTI):
+            # Both modes run a persistent server speaking the extended-remote
+            # protocol. They differ on the client side only: ATTACH attaches to
+            # a process that is already running on the target.
             gdbserver_cmd_start = self._target_tcp_port_check_cmd() + " && exit 0; "
             gdbserver_cmd_start += "mkdir -p %s; " % self._gdbserver_tmp_dir(server_mode)
             gdbserver_cmd_start += "%s --multi :%s > %s 2>&1 & _gdbserver_pid=\\$!; " % (
@@ -205,9 +219,21 @@  class GdbCrossConfig(DebuggerCrossConfig):
             raise DevtoolError("Unsupported gdbserver mode: %s" % server_mode)
         return "\"/bin/sh -c '" + gdbserver_cmd_start + "'\""
 
-    def _target_kill_cmd(self):
-        """SSH command to kill gdbserver on the target device."""
-        return "\"kill \\$(pgrep -o -f 'gdbserver --attach :%s') 2>/dev/null || true\"" % self.debug_server_port
+    def _target_stop_cmd(self, server_mode):
+        """SSH command to stop gdbserver on the target device.
+
+        Stopping is based on the PID file written by the start command. Other
+        debug sessions run their own gdbserver on the target, so anything
+        matching by process name would hit them as well.
+        """
+        pid_file = self._gdbserver_pid_file(server_mode)
+        gdbserver_cmd_stop = "if test -f %s; then _gdbserver_pid=\\$(cat %s); " % (
+            pid_file, pid_file)
+        gdbserver_cmd_stop += "kill \\$_gdbserver_pid 2>/dev/null; "
+        gdbserver_cmd_stop += self._target_wait_for_process_exit_cmd(
+            "gdbserver_pid")
+        gdbserver_cmd_stop += " fi; rm -rf %s" % self._gdbserver_tmp_dir(server_mode)
+        return "\"/bin/sh -c '" + gdbserver_cmd_stop + "'\""
 
 
 class LldbServerConfig(DebuggerCrossConfig):
@@ -244,10 +270,7 @@  class LldbServerConfig(DebuggerCrossConfig):
         # lldb-server 21.x and the remote lldb client connects from the host.
         # Start from /tmp because lldb-server creates temp files in its cwd and
         # the SSH default cwd (/home/root) may not exist on a minimal image.
-        if mode == DebuggerServerModes.ONCE:
-            cmd = "cd /tmp && %s platform --one-shot --server --listen *:%s" % (
-                lldb_server, self.debug_server_port)
-        elif mode == DebuggerServerModes.MULTI:
+        if mode == DebuggerServerModes.MULTI:
             pid_file = self._lldb_server_pid_file(mode)
             tmp_dir = self._lldb_server_tmp_dir(mode)
             log_file = self._lldb_server_log_file(mode)
@@ -261,11 +284,11 @@  class LldbServerConfig(DebuggerCrossConfig):
                 "lldb_server_pid", log_file)
         else:
             raise DevtoolError(
-                "lldb-server does not support mode %s "
-                "(ATTACH is handled client-side with 'process attach')" % mode)
+                "lldb-server only supports MULTI mode; "
+                "ATTACH is handled client-side with 'process attach': %s" % mode)
         return "\"/bin/sh -c '" + cmd + "'\""
 
-    def _target_kill_cmd(self):
+    def _target_stop_cmd(self, server_mode):
         """SSH command to stop a MULTI-mode lldb-server on the target."""
         pid_file = self._lldb_server_pid_file(DebuggerServerModes.MULTI)
         tmp_dir = self._lldb_server_tmp_dir(DebuggerServerModes.MULTI)
diff --git a/scripts/lib/devtool/ide_plugins/ide_code.py b/scripts/lib/devtool/ide_plugins/ide_code.py
index d190858d44..c598772e3c 100644
--- a/scripts/lib/devtool/ide_plugins/ide_code.py
+++ b/scripts/lib/devtool/ide_plugins/ide_code.py
@@ -32,14 +32,12 @@  class GdbCrossConfigVSCode(GdbCrossConfig):
             self._target_start_cmd(mode)
         ]
 
-    def target_ssh_gdbserver_kill_args(self):
-        """Get the ssh command arguments to kill gdbserver on the target device
-
-        returns something like:
-          ['-p', '2222', 'root@target', '"kill $(pgrep -o -f \'gdbserver --attach :1234\') 2>/dev/null || true"']
-        """
+    def target_ssh_gdbserver_stop_args(self, mode=None):
+        """Get the ssh command arguments to stop gdbserver on the target device"""
+        if mode is None:
+            mode = self.default_mode
         return self._target_ssh_args() + [
-            self._target_kill_cmd()
+            self._target_stop_cmd(mode)
         ]
 
 
@@ -59,10 +57,12 @@  class LldbServerConfigVSCode(LldbServerConfig):
             self._target_start_cmd(mode)
         ]
 
-    def target_ssh_gdbserver_kill_args(self):
+    def target_ssh_gdbserver_stop_args(self, mode=None):
         """SSH argument list to stop a running MULTI-mode lldb-server"""
+        if mode is None:
+            mode = self.default_mode
         return self._target_ssh_args() + [
-            self._target_kill_cmd()
+            self._target_stop_cmd(mode)
         ]
 
 class IdeVSCode(IdeBase):
@@ -338,25 +338,43 @@  class IdeVSCode(IdeBase):
             return self._vscode_launch_bin_dbg_lldb(cross_debug_config, server_mode)
         return self._vscode_launch_bin_dbg_gdb(cross_debug_config, server_mode)
 
+    @staticmethod
+    def _stop_task_label(cross_debug_config, server_mode):
+        return "stop_%s_%s" % (cross_debug_config.DEBUG_SERVER_NAME,
+                               cross_debug_config.id_pretty_mode(server_mode))
+
     def _vscode_launch_bin_dbg_gdb(self, cross_debug_config, server_mode):
         """Generate a cppdbg (GDB) launch configuration entry for launch.json."""
         modified_recipe = cross_debug_config.modified_recipe
 
+        is_attach = server_mode == DebuggerServerModes.ATTACH
+
         launch_config = {
             "name": cross_debug_config.id_pretty_mode(server_mode),
             "type": "cppdbg",
-            "request": "launch",
+            "request": "attach" if is_attach else "launch",
             "program": cross_debug_config.binary.binary_host_path,
-            "stopAtEntry": True,
-            "cwd": "${workspaceFolder}",
-            "environment": [],
-            "externalConsole": False,
             "MIMode": "gdb",
             "preLaunchTask": cross_debug_config.id_pretty_mode(server_mode),
             "miDebuggerPath": modified_recipe.debugger_cross.gdb,
             "miDebuggerServerAddress": "%s:%d" % (modified_recipe.debugger_cross.host, cross_debug_config.debug_server_port)
         }
 
+        if is_attach:
+            # Without useExtendedRemote, cppdbg rejects attaching to a remote
+            # target. It also makes cppdbg offer a picker listing the processes
+            # running on the target, so the PID does not have to be known when
+            # this configuration is generated. Stopping the session detaches
+            # from the process instead of killing it.
+            launch_config["useExtendedRemote"] = True
+        else:
+            # cwd, environment and externalConsole configure the process the
+            # debugger starts, they are not part of the attach schema.
+            launch_config["cwd"] = "${workspaceFolder}"
+            launch_config["environment"] = []
+            launch_config["externalConsole"] = False
+            launch_config["stopAtEntry"] = True
+
         # Search for header files in recipe-sysroot.
         src_file_map = {
             "/usr/include": os.path.join(modified_recipe.recipe_sysroot, "usr", "include")
@@ -415,10 +433,10 @@  class IdeVSCode(IdeBase):
         launch_config['sourceFileMap'] = src_file_map
         launch_config['setupCommands'] = setup_commands
 
-        # Add postDebugTask for attach mode to clean up gdbserver
-        if server_mode == DebuggerServerModes.ATTACH:
-            kill_task_label = "kill_gdbserver_" + cross_debug_config.id_pretty_mode(server_mode)
-            launch_config["postDebugTask"] = kill_task_label
+        if is_attach:
+            # The extended-remote server outlives the debug session
+            launch_config["postDebugTask"] = self._stop_task_label(
+                cross_debug_config, server_mode)
 
         return launch_config
 
@@ -574,8 +592,9 @@  class IdeVSCode(IdeBase):
             if cross_debug_config.modified_recipe is not modified_recipe:
                 continue
             for server_mode in cross_debug_config.server_modes():
-                if server_mode == DebuggerServerModes.MULTI:
-                    # MULTI mode: the SSH command blocks until the port is ready
+                if server_mode in (DebuggerServerModes.MULTI,
+                                   DebuggerServerModes.ATTACH):
+                    # The SSH command blocks until the port is ready
                     # (wait loop in _target_start_cmd), so VSCode treats this as
                     # a regular non-background task.
                     new_task = {
@@ -586,7 +605,7 @@  class IdeVSCode(IdeBase):
                         "problemMatcher": []
                     }
                 else:
-                    # ONCE / ATTACH: gdbserver runs in the foreground for the
+                    # ONCE: gdbserver runs in the foreground for the
                     # whole session, so VSCode needs isBackground + a pattern
                     # matcher to avoid waiting for the task to exit.
                     new_task = {
@@ -608,7 +627,7 @@  class IdeVSCode(IdeBase):
                                 "background": {
                                     "activeOnStart": True,
                                     "beginsPattern": ".",
-                                    "endsPattern": ".",
+                                    "endsPattern": cross_debug_config.get_debug_server_ready_marker_pattern(),
                                 }
                             }
                         ]
@@ -621,28 +640,20 @@  class IdeVSCode(IdeBase):
 
                 tasks_dict['tasks'].append(new_task)
 
-                # For attach mode, add a kill task to stop a previously running gdbserver
-                # This is a known issue with gdbserver --attach that it does not terminate
-                # after detaching. With this helper task, it is possible to:
-                # 1. Start debugging in attach mode
-                # 2. Add breakpoints, step, continue, etc.
-                # 3. Press the Continue button
-                # 4. Press the Stop button which detaches gdbserver from the debugged process
-                # 5. Start debugging again in attach mode
-                # Without this kill task, step 5 would fail because gdbserver is still running
+                # The extended-remote server used by attach mode keeps running
+                # after the debug session, launch.json refers to this task as
+                # postDebugTask.
                 if server_mode == DebuggerServerModes.ATTACH:
-                    new_task_kill_label = "kill_gdbserver_"+ cross_debug_config.id_pretty_mode(server_mode)
-                    new_task_kill = {
-                        "label": new_task_kill_label,
+                    tasks_dict['tasks'].append({
+                        "label": self._stop_task_label(cross_debug_config, server_mode),
                         "type": "shell",
                         "command": cross_debug_config.debugger_cross.target_device.ssh_sshexec,
-                        "args": cross_debug_config.target_ssh_gdbserver_kill_args(),
+                        "args": cross_debug_config.target_ssh_gdbserver_stop_args(server_mode),
                         "presentation": {
                             "close": True
                         },
                         "problemMatcher": []
-                    }
-                    tasks_dict['tasks'].append(new_task_kill)
+                    })
 
         tasks_file = 'tasks.json'
         IdeBase.update_json_file(
@@ -804,8 +815,9 @@  class IdeVSCode(IdeBase):
                 if cross_debug_config.modified_recipe is not modified_recipe:
                     continue
                 for server_mode in cross_debug_config.server_modes():
-                    if server_mode == DebuggerServerModes.MULTI:
-                        # MULTI mode: SSH command blocks until port is ready, treat as
+                    if server_mode in (DebuggerServerModes.MULTI,
+                                       DebuggerServerModes.ATTACH):
+                        # SSH command blocks until port is ready, treat as
                         # a regular non-background task (same as vscode_tasks_cpp).
                         new_task = {
                             "label": cross_debug_config.id_pretty_mode(server_mode),
@@ -815,7 +827,7 @@  class IdeVSCode(IdeBase):
                             "problemMatcher": []
                         }
                     else:
-                        # ONCE / ATTACH: server runs for the whole session, needs
+                        # ONCE: server runs for the whole session, needs
                         # isBackground so VSCode does not wait for the task to exit.
                         new_task = {
                             "label": cross_debug_config.id_pretty_mode(server_mode),
diff --git a/scripts/lib/devtool/ide_plugins/ide_none.py b/scripts/lib/devtool/ide_plugins/ide_none.py
index a8ddc3f39f..959140cedb 100644
--- a/scripts/lib/devtool/ide_plugins/ide_none.py
+++ b/scripts/lib/devtool/ide_plugins/ide_none.py
@@ -21,16 +21,19 @@  class GdbCrossConfigNone(GdbCrossConfig):
                          default_mode)
 
     def _target_gdbserver_stop_cmd(self, server_mode):
-        """Kill a gdbserver process"""
-        # This is the usual behavior: gdbserver is stopped on demand
-        if server_mode == DebuggerServerModes.MULTI:
-            gdbserver_cmd_stop = "test -f %s && kill \\$(cat %s);" % (
-                self._gdbserver_pid_file(server_mode), self._gdbserver_pid_file(server_mode))
-            gdbserver_cmd_stop += " rm -rf %s" % self._gdbserver_tmp_dir(server_mode)
-        # This is unexpected since gdbserver should terminate after each debug session
-        # Just kill all gdbserver instances to keep it simple
-        else:
-            gdbserver_cmd_stop = "killall gdbserver"
+        """Kill a gdbserver process
+
+        Stopping is based on the PID file written by the start command. Other
+        debug sessions run their own gdbserver on the target, so anything
+        matching by process name would hit them as well.
+        """
+        pid_file = self._gdbserver_pid_file(server_mode)
+        gdbserver_cmd_stop = "if test -f %s; then _gdbserver_pid=\\$(cat %s); " % (
+            pid_file, pid_file)
+        gdbserver_cmd_stop += "kill \\$_gdbserver_pid 2>/dev/null; "
+        gdbserver_cmd_stop += self._target_wait_for_process_exit_cmd(
+            "gdbserver_pid")
+        gdbserver_cmd_stop += " fi; rm -rf %s" % self._gdbserver_tmp_dir(server_mode)
         return "\"/bin/sh -c '" + gdbserver_cmd_stop + "'\""
 
     def _gen_gdbserver_start_script(self, server_mode=None):
@@ -165,14 +168,19 @@  class LldbServerConfigNone(LldbServerConfig):
         return os.path.join(self.script_dir, 'lldb_' + self.id_pretty)
 
     def _target_lldb_server_stop_cmd(self, server_mode):
-        """SSH command to stop lldb-server on the target."""
-        if server_mode == DebuggerServerModes.MULTI:
-            pid_file = self._lldb_server_pid_file(server_mode)
-            tmp_dir = self._lldb_server_tmp_dir(server_mode)
-            cmd = ("test -f %(pf)s && kill \\$(cat %(pf)s) 2>/dev/null; rm -rf %(td)s"
-                   % {'pf': pid_file, 'td': tmp_dir})
-        else:
-            cmd = "killall lldb-server 2>/dev/null || true"
+        """SSH command to stop lldb-server on the target.
+
+        Stopping is based on the PID file written by the start command. Other
+        debug sessions run their own lldb-server on the target, so anything
+        matching by process name would hit them as well.
+        """
+        pid_file = self._lldb_server_pid_file(server_mode)
+        tmp_dir = self._lldb_server_tmp_dir(server_mode)
+        cmd = "if test -f %s; then _lldb_server_pid=\\$(cat %s); " % (
+            pid_file, pid_file)
+        cmd += "kill \\$_lldb_server_pid 2>/dev/null; "
+        cmd += self._target_wait_for_process_exit_cmd("lldb_server_pid")
+        cmd += " fi; rm -rf %s" % tmp_dir
         return "\"/bin/sh -c '" + cmd + "'\""
 
     def _gen_lldb_server_start_script(self, server_mode=None):