diff --git a/python-packages/gdbrunner/Android.bp b/python-packages/gdbrunner/Android.bp new file mode 100644 index 000000000..0e480656e --- /dev/null +++ b/python-packages/gdbrunner/Android.bp @@ -0,0 +1,24 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +python_library_host { + name: "gdbrunner", + srcs: [ + "gdbrunner/__init__.py", + ], +} diff --git a/scripts/Android.bp b/scripts/Android.bp index 1fc41514b..45011d6eb 100644 --- a/scripts/Android.bp +++ b/scripts/Android.bp @@ -59,3 +59,23 @@ python_test_host { "pyfakefs", ] } + +python_test_host { + name: "gdbclient_test", + srcs: [ + "gdbclient.py", + "gdbclient_test.py", + ], + libs: [ + "adb_py", + "gdbrunner", + ], + test_options: { + unit_test: true, + }, + version: { + py3: { + embedded_launcher: true, + }, + }, +} diff --git a/scripts/gdbclient.py b/scripts/gdbclient.py index 9c602cc1d..ef32bf09c 100755 --- a/scripts/gdbclient.py +++ b/scripts/gdbclient.py @@ -28,7 +28,7 @@ import sys import tempfile import textwrap -from typing import BinaryIO +from typing import BinaryIO, Any # Shared functions across gdbclient.py and ndk-gdb.py. import gdbrunner @@ -98,8 +98,13 @@ def parse_args() -> argparse.Namespace: choices=["lldb", "vscode-lldb"], help=("Set up lldb-server and port forwarding. Prints commands or " + ".vscode/launch.json configuration needed to connect the debugging " + - "client to the server. 'vscode' with llbd and 'vscode-lldb' both " + + "client to the server. 'vscode' with lldb and 'vscode-lldb' both " + "require the 'vadimcn.vscode-lldb' extension.")) + parser.add_argument( + "--vscode-launch-props", default=None, + dest="vscode_launch_props", + help=("JSON with extra properties to add to launch parameters when using " + + "vscode-lldb forwarding.")) parser.add_argument( "--env", nargs=1, action="append", metavar="VAR=VALUE", @@ -244,7 +249,49 @@ def handle_switches(args, sysroot: str) -> tuple[BinaryIO, int | None, str | Non return (binary_file, pid, run_cmd) -def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str: +def merge_launch_dict(base: dict[str, Any], to_add: dict[str, Any] | None) -> None: + """Merges two dicts describing VSCode launch.json properties: base and + to_add. Base is modified in-place with items from to_add. + Items from to_add that are not present in base are inserted. Items that are + present are merged following these rules: + - Lists are merged with to_add elements appended to the end of base + list. Only a list can be merged with a list. + - dicts are merged recursively. Only a dict can be merged with a dict. + - Other present values in base get overwritten with values from to_add. + + The reason for these rules is that merging in new values should prefer to + expand the existing set instead of overwriting where possible. + """ + if to_add is None: + return + + for key, val in to_add.items(): + if key not in base: + base[key] = val + else: + if isinstance(base[key], list) and not isinstance(val, list): + raise ValueError(f'Cannot merge non-list into list at key={key}. ' + + 'You probably need to wrap your value into a list.') + if not isinstance(base[key], list) and isinstance(val, list): + raise ValueError(f'Cannot merge list into non-list at key={key}.') + if isinstance(base[key], dict) != isinstance(val, dict): + raise ValueError(f'Cannot merge dict and non-dict at key={key}') + + # We don't allow the user to overwrite or interleave lists and don't allow + # to delete dict entries. + # It can be done but would make the implementation a bit more complicated + # and provides less value than adding elements. + # We expect that the config generated by gdbclient doesn't contain anything + # the user would want to remove. + if isinstance(base[key], list): + base[key] += val + elif isinstance(base[key], dict): + merge_launch_dict(base[key], val) + else: + base[key] = val + + +def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str], extra_props: dict[str, Any] | None) -> str: # TODO It would be nice if we didn't need to copy this or run the # lldbclient.py program manually. Doing this would probably require # writing a vscode extension or modifying an existing one. @@ -262,6 +309,7 @@ def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: "target modules search-paths add / {}/".format(sysroot)], "processCreateCommands": ["gdb-remote {}".format(str(port))] } + merge_launch_dict(res, extra_props) return json.dumps(res, indent=4) def generate_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str: @@ -278,7 +326,7 @@ def generate_lldb_script(root: str, sysroot: str, binary_name: str, port: str | return '\n'.join(commands) -def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_name: str, is64bit: bool, port: str | int, debugger: str) -> str: +def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_name: str, is64bit: bool, port: str | int, debugger: str, vscode_launch_props: dict[str, Any] | None) -> str: # Generate a setup script. root = os.environ["ANDROID_BUILD_TOP"] symbols_dir = os.path.join(sysroot, "system", "lib64" if is64bit else "lib") @@ -294,7 +342,7 @@ def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_na if debugger == "vscode-lldb": return generate_vscode_lldb_script( - root, sysroot, binary_name, port, solib_search_path) + root, sysroot, binary_name, port, solib_search_path, vscode_launch_props) elif debugger == 'lldb': return generate_lldb_script( root, sysroot, binary_name, port, solib_search_path) @@ -332,6 +380,12 @@ def do_main() -> None: # Fetch binary for -p, -n. binary_file, pid, run_cmd = handle_switches(args, sysroot) + vscode_launch_props = None + if args.vscode_launch_props: + if args.setup_forwarding != "vscode-lldb": + raise ValueError('vscode_launch_props requires --setup-forwarding=vscode-lldb') + vscode_launch_props = json.loads(args.vscode_launch_props) + with binary_file: if sys.platform.startswith("linux"): platform_name = "linux-x86" @@ -381,7 +435,8 @@ def do_main() -> None: binary_name=binary_file.name, is64bit=is64bit, port=args.port, - debugger=debugger) + debugger=debugger, + vscode_launch_props=vscode_launch_props) if not args.setup_forwarding: # Print a newline to separate our messages from the GDB session. diff --git a/scripts/gdbclient_test.py b/scripts/gdbclient_test.py new file mode 100644 index 000000000..ef61bc958 --- /dev/null +++ b/scripts/gdbclient_test.py @@ -0,0 +1,165 @@ +# +# Copyright (C) 2023 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import gdbclient +import unittest +import copy +import json + +from typing import Any + +class LaunchConfigMergeTest(unittest.TestCase): + def merge_compare(self, base: dict[str, Any], to_add: dict[str, Any] | None, expected: dict[str, Any]) -> None: + actual = copy.deepcopy(base) + gdbclient.merge_launch_dict(actual, to_add) + self.assertEqual(actual, expected, f'base={base}, to_add={to_add}') + + def test_add_none(self) -> None: + base = { 'foo' : 1 } + to_add = None + expected = { 'foo' : 1 } + self.merge_compare(base, to_add, expected) + + def test_add_val(self) -> None: + base = { 'foo' : 1 } + to_add = { 'bar' : 2} + expected = { 'foo' : 1, 'bar' : 2 } + self.merge_compare(base, to_add, expected) + + def test_overwrite_val(self) -> None: + base = { 'foo' : 1 } + to_add = { 'foo' : 2} + expected = { 'foo' : 2 } + self.merge_compare(base, to_add, expected) + + def test_lists_get_appended(self) -> None: + base = { 'foo' : [1, 2] } + to_add = { 'foo' : [3, 4]} + expected = { 'foo' : [1, 2, 3, 4] } + self.merge_compare(base, to_add, expected) + + def test_add_elem_to_dict(self) -> None: + base = { 'foo' : { 'bar' : 1 } } + to_add = { 'foo' : { 'baz' : 2 } } + expected = { 'foo' : { 'bar' : 1, 'baz' : 2 } } + self.merge_compare(base, to_add, expected) + + def test_overwrite_elem_in_dict(self) -> None: + base = { 'foo' : { 'bar' : 1 } } + to_add = { 'foo' : { 'bar' : 2 } } + expected = { 'foo' : { 'bar' : 2 } } + self.merge_compare(base, to_add, expected) + + def test_merging_dict_and_value_raises(self) -> None: + base = { 'foo' : { 'bar' : 1 } } + to_add = { 'foo' : 2 } + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + def test_merging_value_and_dict_raises(self) -> None: + base = { 'foo' : 2 } + to_add = { 'foo' : { 'bar' : 1 } } + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + def test_merging_dict_and_list_raises(self) -> None: + base = { 'foo' : { 'bar' : 1 } } + to_add = { 'foo' : [1] } + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + def test_merging_list_and_dict_raises(self) -> None: + base = { 'foo' : [1] } + to_add = { 'foo' : { 'bar' : 1 } } + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + def test_adding_elem_to_list_raises(self) -> None: + base = { 'foo' : [1] } + to_add = { 'foo' : 2} + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + def test_adding_list_to_elem_raises(self) -> None: + base = { 'foo' : 1 } + to_add = { 'foo' : [2]} + with self.assertRaises(ValueError): + gdbclient.merge_launch_dict(base, to_add) + + +class VsCodeLaunchGeneratorTest(unittest.TestCase): + def setUp(self) -> None: + # These tests can generate long diffs, so we remove the limit + self.maxDiff = None + + def test_generate_script(self) -> None: + self.assertEqual(json.loads(gdbclient.generate_vscode_lldb_script(root='/root', + sysroot='/sysroot', + binary_name='test', + port=123, + solib_search_path=['/path1', + '/path2'], + extra_props=None)), + { + 'name': '(lldbclient.py) Attach test (port: 123)', + 'type': 'lldb', + 'request': 'custom', + 'relativePathBase': '/root', + 'sourceMap': { '/b/f/w' : '/root', '': '/root', '.': '/root' }, + 'initCommands': ['settings append target.exec-search-paths /path1 /path2'], + 'targetCreateCommands': ['target create test', + 'target modules search-paths add / /sysroot/'], + 'processCreateCommands': ['gdb-remote 123'] + }) + + def test_generate_script_with_extra_props(self) -> None: + extra_props = { + 'initCommands' : ['settings append target.exec-search-paths /path3'], + 'processCreateCommands' : ['break main', 'continue'], + 'sourceMap' : { '/test/' : '/root/test'}, + 'preLaunchTask' : 'Build' + } + self.assertEqual(json.loads(gdbclient.generate_vscode_lldb_script(root='/root', + sysroot='/sysroot', + binary_name='test', + port=123, + solib_search_path=['/path1', + '/path2'], + extra_props=extra_props)), + { + 'name': '(lldbclient.py) Attach test (port: 123)', + 'type': 'lldb', + 'request': 'custom', + 'relativePathBase': '/root', + 'sourceMap': { '/b/f/w' : '/root', + '': '/root', + '.': '/root', + '/test/' : '/root/test' }, + 'initCommands': [ + 'settings append target.exec-search-paths /path1 /path2', + 'settings append target.exec-search-paths /path3', + ], + 'targetCreateCommands': ['target create test', + 'target modules search-paths add / /sysroot/'], + 'processCreateCommands': ['gdb-remote 123', + 'break main', + 'continue'], + 'preLaunchTask' : 'Build' + }) + + +if __name__ == '__main__': + unittest.main(verbosity=2)