1#!/usr/bin/env python3
2#
3# Copyright (C) 2015 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18import argparse
19import json
20import logging
21import os
22import pathlib
23import posixpath
24import re
25import shutil
26import subprocess
27import sys
28import tempfile
29import textwrap
30from typing import Any, BinaryIO
31
32import adb
33# Shared functions across gdbclient.py and ndk-gdb.py.
34import gdbrunner
35
36g_temp_dirs = []
37
38g_vscode_config_marker_begin = '// #lldbclient-generated-begin'
39g_vscode_config_marker_end = '// #lldbclient-generated-end'
40
41
42def read_toolchain_config(root: str) -> str:
43    """Finds out current toolchain version."""
44    version_output = subprocess.check_output(
45        f'{root}/build/soong/scripts/get_clang_version.py',
46        text=True)
47    return version_output.strip()
48
49
50def get_lldb_path(toolchain_path: str) -> str | None:
51    for lldb_name in ['lldb.sh', 'lldb.cmd', 'lldb', 'lldb.exe']:
52        debugger_path = os.path.join(toolchain_path, "bin", lldb_name)
53        if os.path.isfile(debugger_path):
54            return debugger_path
55    return None
56
57
58def get_lldb_server_path(root: str, clang_base: str, clang_version: str, arch: str) -> str:
59    arch = {
60        'arm': 'arm',
61        'arm64': 'aarch64',
62        'riscv64': 'riscv64',
63        'x86': 'i386',
64        'x86_64': 'x86_64',
65    }[arch]
66    return os.path.join(root, clang_base, "linux-x86",
67                        clang_version, "runtimes_ndk_cxx", arch, "lldb-server")
68
69
70def get_tracer_pid(device: adb.AndroidDevice, pid: int | str | None) -> int:
71    if pid is None:
72        return 0
73
74    line, _ = device.shell(["grep", "-e", "^TracerPid:", "/proc/{}/status".format(pid)])
75    tracer_pid = re.sub('TracerPid:\t(.*)\n', r'\1', line)
76    return int(tracer_pid)
77
78
79def parse_args() -> argparse.Namespace:
80    parser = gdbrunner.ArgumentParser()
81
82    group = parser.add_argument_group(title="attach target")
83    group = group.add_mutually_exclusive_group(required=True)
84    group.add_argument(
85        "-p", dest="target_pid", metavar="PID", type=int,
86        help="attach to a process with specified PID")
87    group.add_argument(
88        "-n", dest="target_name", metavar="NAME",
89        help="attach to a process with specified name")
90    group.add_argument(
91        "-r", dest="run_cmd", metavar="CMD", nargs=argparse.REMAINDER,
92        help="run a binary on the device, with args")
93
94    parser.add_argument(
95        "--port", nargs="?", default="5039",
96        help="Unused **host** port to forward the debug_socket to.[default: 5039]")
97    parser.add_argument(
98        "--user", nargs="?", default="root",
99        help="user to run commands as on the device [default: root]")
100    parser.add_argument(
101        "--setup-forwarding", default=None,
102        choices=["lldb", "vscode-lldb"],
103        help=("Set up lldb-server and port forwarding. Prints commands or " +
104              ".vscode/launch.json configuration needed to connect the debugging " +
105              "client to the server. 'vscode' with lldb and 'vscode-lldb' both " +
106              "require the 'vadimcn.vscode-lldb' extension."))
107    parser.add_argument(
108        "--vscode-launch-props", default=None,
109        dest="vscode_launch_props",
110        help=("JSON with extra properties to add to launch parameters when using " +
111              "vscode-lldb forwarding."))
112    parser.add_argument(
113        "--vscode-launch-file", default=None,
114        dest="vscode_launch_file",
115        help=textwrap.dedent(f"""Path to .vscode/launch.json file for the generated launch
116                     config when using vscode-lldb forwarding. The file needs to
117                     contain two marker lines: '{g_vscode_config_marker_begin}'
118                     and '{g_vscode_config_marker_end}'. The config will be written inline
119                     between these lines, replacing any text that is already there."""))
120
121    parser.add_argument(
122        "--env", nargs=1, action="append", metavar="VAR=VALUE",
123        help="set environment variable when running a binary")
124    parser.add_argument(
125        "--chroot", nargs='?', default="", metavar="PATH",
126        help="run command in a chroot in the given directory. Cannot be used with --cwd.")
127    parser.add_argument(
128        "--cwd", nargs='?', default="", metavar="PATH",
129        help="working directory for the command. Cannot be used with --chroot.")
130
131    return parser.parse_args()
132
133
134def verify_device(device: adb.AndroidDevice) -> None:
135    names = set([device.get_prop("ro.build.product"), device.get_prop("ro.product.name")])
136    target_device = os.environ["TARGET_PRODUCT"]
137    if target_device not in names:
138        msg = "You used the wrong lunch: TARGET_PRODUCT ({}) does not match attached device ({})"
139        sys.exit(msg.format(target_device, ", ".join(n if n else "None" for n in names)))
140
141
142def get_device_dir_exists(device: adb.AndroidDevice, dir: str) -> bool:
143    exit_code, _, _ = device.shell_nocheck(['[', '-d', dir, ']'])
144    return exit_code == 0
145
146
147def get_remote_pid(device: adb.AndroidDevice, process_name: str) -> int:
148    processes = gdbrunner.get_processes(device)
149    if process_name not in processes:
150        msg = "failed to find running process {}".format(process_name)
151        sys.exit(msg)
152    pids = processes[process_name]
153    if len(pids) > 1:
154        msg = "multiple processes match '{}': {}".format(process_name, pids)
155        sys.exit(msg)
156
157    # Fetch the binary using the PID later.
158    return pids[0]
159
160
161def make_temp_dir(prefix: str) -> str:
162    global g_temp_dirs
163    result = tempfile.mkdtemp(prefix='lldbclient-linker-')
164    g_temp_dirs.append(result)
165    return result
166
167
168def ensure_linker(device: adb.AndroidDevice, sysroot: str, interp: str | None) -> str | None:
169    """Ensure that the device's linker exists on the host.
170
171    PT_INTERP is usually /system/bin/linker[64], but on the device, that file is
172    a symlink to /apex/com.android.runtime/bin/linker[64]. The symbolized linker
173    binary on the host is located in ${sysroot}/apex, not in ${sysroot}/system,
174    so add the ${sysroot}/apex path to the solib search path.
175
176    PT_INTERP will be /system/bin/bootstrap/linker[64] for executables using the
177    non-APEX/bootstrap linker. No search path modification is needed.
178
179    For a tapas build, only an unbundled app is built, and there is no linker in
180    ${sysroot} at all, so copy the linker from the device.
181
182    Returns:
183        A directory to add to the soinfo search path or None if no directory
184        needs to be added.
185    """
186
187    # Static executables have no interpreter.
188    if interp is None:
189        return None
190
191    # lldb will search for the linker using the PT_INTERP path. First try to find
192    # it in the sysroot.
193    local_path = os.path.join(sysroot, interp.lstrip("/"))
194    if os.path.exists(local_path):
195        return None
196
197    # If the linker on the device is a symlink, search for the symlink's target
198    # in the sysroot directory.
199    interp_real, _ = device.shell(["realpath", interp])
200    interp_real = interp_real.strip()
201    local_path = os.path.join(sysroot, interp_real.lstrip("/"))
202    if os.path.exists(local_path):
203        if posixpath.basename(interp) == posixpath.basename(interp_real):
204            # Add the interpreter's directory to the search path.
205            return os.path.dirname(local_path)
206        else:
207            # If PT_INTERP is linker_asan[64], but the sysroot file is
208            # linker[64], then copy the local file to the name lldb expects.
209            result = make_temp_dir('lldbclient-linker-')
210            shutil.copy(local_path, os.path.join(result, posixpath.basename(interp)))
211            return result
212
213    # Pull the system linker.
214    result = make_temp_dir('lldbclient-linker-')
215    device.pull(interp, os.path.join(result, posixpath.basename(interp)))
216    return result
217
218
219def handle_switches(args, sysroot: str) -> tuple[BinaryIO, int | None, str | None]:
220    """Fetch the targeted binary and determine how to attach lldb.
221
222    Args:
223        args: Parsed arguments.
224        sysroot: Local sysroot path.
225
226    Returns:
227        (binary_file, attach_pid, run_cmd).
228        Precisely one of attach_pid or run_cmd will be None.
229    """
230
231    device = args.device
232    binary_file = None
233    pid = None
234    run_cmd = None
235
236    args.su_cmd = ["su", args.user] if args.user else []
237
238    if args.target_pid:
239        # Fetch the binary using the PID later.
240        pid = args.target_pid
241    elif args.target_name:
242        # Fetch the binary using the PID later.
243        pid = get_remote_pid(device, args.target_name)
244    elif args.run_cmd:
245        if not args.run_cmd[0]:
246            sys.exit("empty command passed to -r")
247        run_cmd = args.run_cmd
248        if not run_cmd[0].startswith("/"):
249            try:
250                run_cmd[0] = gdbrunner.find_executable_path(device, args.run_cmd[0],
251                                                            run_as_cmd=args.su_cmd)
252            except RuntimeError:
253              sys.exit("Could not find executable '{}' passed to -r, "
254                       "please provide an absolute path.".format(args.run_cmd[0]))
255
256        binary_file, local = gdbrunner.find_file(device, run_cmd[0], sysroot,
257                                                 run_as_cmd=args.su_cmd)
258    if binary_file is None:
259        assert pid is not None
260        try:
261            binary_file, local = gdbrunner.find_binary(device, pid, sysroot,
262                                                       run_as_cmd=args.su_cmd)
263        except adb.ShellError:
264            sys.exit("failed to pull binary for PID {}".format(pid))
265
266    if not local:
267        logging.warning("Couldn't find local unstripped executable in {},"
268                        " symbols may not be available.".format(sysroot))
269
270    return (binary_file, pid, run_cmd)
271
272def merge_launch_dict(base: dict[str, Any], to_add:  dict[str, Any] | None) -> None:
273    """Merges two dicts describing VSCode launch.json properties: base and
274    to_add. Base is modified in-place with items from to_add.
275    Items from to_add that are not present in base are inserted. Items that are
276    present are merged following these rules:
277        - Lists are merged with to_add elements appended to the end of base
278          list. Only a list can be merged with a list.
279        - dicts are merged recursively. Only a dict can be merged with a dict.
280        - Other present values in base get overwritten with values from to_add.
281
282    The reason for these rules is that merging in new values should prefer to
283    expand the existing set instead of overwriting where possible.
284    """
285    if to_add is None:
286        return
287
288    for key, val in to_add.items():
289        if key not in base:
290            base[key] = val
291        else:
292            if isinstance(base[key], list) and not isinstance(val, list):
293                raise ValueError(f'Cannot merge non-list into list at key={key}. ' +
294                'You probably need to wrap your value into a list.')
295            if not isinstance(base[key], list) and isinstance(val, list):
296                raise ValueError(f'Cannot merge list into non-list at key={key}.')
297            if isinstance(base[key], dict) != isinstance(val, dict):
298                raise ValueError(f'Cannot merge dict and non-dict at key={key}')
299
300            # We don't allow the user to overwrite or interleave lists and don't allow
301            # to delete dict entries.
302            # It can be done but would make the implementation a bit more complicated
303            # and provides less value than adding elements.
304            # We expect that the config generated by gdbclient doesn't contain anything
305            # the user would want to remove.
306            if isinstance(base[key], list):
307                base[key] += val
308            elif isinstance(base[key], dict):
309                merge_launch_dict(base[key], val)
310            else:
311                base[key] = val
312
313
314def 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:
315    # TODO It would be nice if we didn't need to copy this or run the
316    #      lldbclient.py program manually. Doing this would probably require
317    #      writing a vscode extension or modifying an existing one.
318    # TODO: https://code.visualstudio.com/api/references/vscode-api#debug and
319    #       https://code.visualstudio.com/api/extension-guides/debugger-extension and
320    #       https://github.com/vadimcn/vscode-lldb/blob/6b775c439992b6615e92f4938ee4e211f1b060cf/extension/pickProcess.ts#L6
321    res = {
322        "name": "(lldbclient.py) Attach {} (port: {})".format(binary_name.split("/")[-1], port),
323        "type": "lldb",
324        "request": "custom",
325        "relativePathBase": root,
326        "sourceMap": { "/b/f/w" : root, '': root, '.': root },
327        "initCommands": ['settings append target.exec-search-paths {}'.format(' '.join(solib_search_path))],
328        "targetCreateCommands": ["target create {}".format(binary_name),
329                                 "target modules search-paths add / {}/".format(sysroot)],
330        "processCreateCommands": ["gdb-remote {}".format(str(port))]
331    }
332    merge_launch_dict(res, extra_props)
333    return json.dumps(res, indent=4)
334
335def generate_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str:
336    commands = []
337    commands.append(
338        'settings append target.exec-search-paths {}'.format(' '.join(solib_search_path)))
339
340    commands.append('target create {}'.format(binary_name))
341    # For RBE support.
342    commands.append("settings append target.source-map '/b/f/w' '{}'".format(root))
343    commands.append("settings append target.source-map '' '{}'".format(root))
344    commands.append('target modules search-paths add / {}/'.format(sysroot))
345    commands.append('# If the below `gdb-remote` fails, run the command manually, '
346                    + 'as it may have raced with lldbserver startup.')
347    commands.append('gdb-remote {}'.format(str(port)))
348    return '\n'.join(commands)
349
350
351def 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:
352    # Generate a setup script.
353    root = os.environ["ANDROID_BUILD_TOP"]
354    symbols_dir = os.path.join(sysroot, "system", "lib64" if is64bit else "lib")
355    vendor_dir = os.path.join(sysroot, "vendor", "lib64" if is64bit else "lib")
356
357    solib_search_path = []
358    symbols_paths = ["", "hw", "ssl/engines", "drm", "egl", "soundfx"]
359    vendor_paths = ["", "hw", "egl"]
360    solib_search_path += [os.path.join(symbols_dir, x) for x in symbols_paths]
361    solib_search_path += [os.path.join(vendor_dir, x) for x in vendor_paths]
362    if linker_search_dir is not None:
363        solib_search_path += [linker_search_dir]
364
365    if debugger == "vscode-lldb":
366        return generate_vscode_lldb_script(
367            root, sysroot, binary_name, port, solib_search_path, vscode_launch_props)
368    elif debugger == 'lldb':
369        return generate_lldb_script(
370            root, sysroot, binary_name, port, solib_search_path)
371    else:
372        raise Exception("Unknown debugger type " + debugger)
373
374
375def insert_commands_into_vscode_config(dst_launch_config: str, setup_commands: str) -> str:
376    """Inserts setup commands into launch config between two marker lines.
377    Marker lines are set in global variables g_vscode_config_marker_end and g_vscode_config_marker_end.
378    The commands are inserted with the same indentation as the first marker line.
379
380    Args:
381        dst_launch_config: Config to insert commands into.
382        setup_commands: Commands to insert.
383    Returns:
384        Config with inserted commands.
385    Raises:
386        ValueError if the begin marker is not found or not terminated with an end marker.
387    """
388
389    # We expect the files to be small (~10s KB), so we use simple string concatenation
390    # for simplicity and readability even if it is slower.
391    output = ""
392    found_at_least_one_begin = False
393    unterminated_begin_line = None
394
395    # It might be tempting to rewrite this using find() or even regexes,
396    # but keeping track of line numbers, preserving whitespace, and detecting indent
397    # becomes tricky enough that this simple loop is more clear.
398    for linenum, line in enumerate(dst_launch_config.splitlines(keepends=True), start=1):
399       if unterminated_begin_line != None:
400           if line.strip() == g_vscode_config_marker_end:
401               unterminated_begin_line = None
402           else:
403               continue
404       output += line
405       if line.strip() == g_vscode_config_marker_begin:
406           found_at_least_one_begin = True
407           unterminated_begin_line = linenum
408           marker_indent = line[:line.find(g_vscode_config_marker_begin)]
409           output += textwrap.indent(setup_commands, marker_indent) + '\n'
410
411    if not found_at_least_one_begin:
412       raise ValueError(f"Did not find begin marker line '{g_vscode_config_marker_begin}' " +
413                        "in the VSCode launch file")
414
415    if unterminated_begin_line is not None:
416       raise ValueError(f"Unterminated begin marker at line {unterminated_begin_line} " +
417                        f"in the VSCode launch file. Add end marker line to file: '{g_vscode_config_marker_end}'")
418
419    return output
420
421
422def replace_file_contents(dst_path: os.PathLike, contents: str) -> None:
423    """Replaces the contents of the file pointed to by dst_path.
424
425    This function writes the new contents into a temporary file, then atomically swaps it with
426    the target file. This way if a write fails, the original file is not overwritten.
427
428    Args:
429        dst_path: The path to the file to be replaced.
430        contents: The new contents of the file.
431    Raises:
432        Forwards exceptions from underlying filesystem methods.
433    """
434    tempf = tempfile.NamedTemporaryFile('w', delete=False)
435    try:
436        tempf.write(contents)
437        os.replace(tempf.name, dst_path)
438    except:
439        os.remove(tempf.name)
440        raise
441
442
443def write_vscode_config(vscode_launch_file: pathlib.Path, setup_commands: str) -> None:
444    """Writes setup_commands into the file pointed by vscode_launch_file.
445
446    See insert_commands_into_vscode_config for the description of how the setup commands are written.
447    """
448    contents = insert_commands_into_vscode_config(vscode_launch_file.read_text(), setup_commands)
449    replace_file_contents(vscode_launch_file, contents)
450
451
452def do_main() -> None:
453    required_env = ["ANDROID_BUILD_TOP",
454                    "ANDROID_PRODUCT_OUT", "TARGET_PRODUCT"]
455    for env in required_env:
456        if env not in os.environ:
457            sys.exit(
458                "Environment variable '{}' not defined, have you run lunch?".format(env))
459
460    args = parse_args()
461    device = args.device
462
463    if device is None:
464        sys.exit("ERROR: Failed to find device.")
465
466    root = os.environ["ANDROID_BUILD_TOP"]
467    sysroot = os.path.join(os.environ["ANDROID_PRODUCT_OUT"], "symbols")
468
469    if args.cwd:
470        if not get_device_dir_exists(device, args.cwd):
471            raise ValueError('Working directory does not exist on device: {}'.format(args.cwd))
472        if args.chroot:
473            # See the comment in start_gdbserver about why this is not implemented.
474            raise ValueError('--cwd and --chroot cannot be used together')
475
476    # Make sure the environment matches the attached device.
477    # Skip when running in a chroot because the chroot lunch target may not
478    # match the device's lunch target.
479    if not args.chroot:
480        verify_device(device)
481
482    debug_socket = "/data/local/tmp/debug_socket"
483    pid = None
484    run_cmd = None
485
486    # Fetch binary for -p, -n.
487    binary_file, pid, run_cmd = handle_switches(args, sysroot)
488
489    vscode_launch_props = None
490    if args.vscode_launch_props:
491        if args.setup_forwarding != "vscode-lldb":
492            raise ValueError(
493                'vscode-launch-props requires --setup-forwarding=vscode-lldb')
494        vscode_launch_props = json.loads(args.vscode_launch_props)
495
496    vscode_launch_file = None
497    if args.vscode_launch_file:
498        if args.setup_forwarding != "vscode-lldb":
499            raise ValueError(
500                'vscode-launch-file requires --setup-forwarding=vscode-lldb')
501        vscode_launch_file = args.vscode_launch_file
502
503    with binary_file:
504        if sys.platform.startswith("linux"):
505            platform_name = "linux-x86"
506        elif sys.platform.startswith("darwin"):
507            platform_name = "darwin-x86"
508        else:
509            sys.exit("Unknown platform: {}".format(sys.platform))
510
511        arch = gdbrunner.get_binary_arch(binary_file)
512        is64bit = arch.endswith("64")
513
514        # Make sure we have the linker
515        clang_base = 'prebuilts/clang/host'
516        clang_version = read_toolchain_config(root)
517        toolchain_path = os.path.join(root, clang_base, platform_name,
518                                      clang_version)
519        llvm_readobj_path = os.path.join(toolchain_path, "bin", "llvm-readobj")
520        interp = gdbrunner.get_binary_interp(binary_file.name, llvm_readobj_path)
521        linker_search_dir = ensure_linker(device, sysroot, interp)
522
523        tracer_pid = get_tracer_pid(device, pid)
524        if tracer_pid == 0:
525            cmd_prefix = args.su_cmd
526            if args.env:
527                cmd_prefix += ['env'] + [v[0] for v in args.env]
528
529            # Start lldb-server.
530            server_local_path = get_lldb_server_path(root, clang_base, clang_version, arch)
531            server_remote_path = "/data/local/tmp/{}-lldb-server".format(arch)
532            gdbrunner.start_gdbserver(
533                device, server_local_path, server_remote_path,
534                target_pid=pid, run_cmd=run_cmd, debug_socket=debug_socket,
535                port=args.port, run_as_cmd=cmd_prefix, lldb=True, chroot=args.chroot, cwd=args.cwd)
536        else:
537            print(
538                "Connecting to tracing pid {} using local port {}".format(
539                    tracer_pid, args.port))
540            gdbrunner.forward_gdbserver_port(device, local=args.port,
541                                             remote="tcp:{}".format(args.port))
542
543        debugger_path = get_lldb_path(toolchain_path)
544        debugger = args.setup_forwarding or 'lldb'
545
546        # Generate the lldb script.
547        setup_commands = generate_setup_script(sysroot=sysroot,
548                                               linker_search_dir=linker_search_dir,
549                                               binary_name=binary_file.name,
550                                               is64bit=is64bit,
551                                               port=args.port,
552                                               debugger=debugger,
553                                               vscode_launch_props=vscode_launch_props)
554
555        if not args.setup_forwarding:
556            # Print a newline to separate our messages from the GDB session.
557            print("")
558
559            # Start lldb.
560            gdbrunner.start_gdb(debugger_path, setup_commands, lldb=True)
561        else:
562            if args.setup_forwarding == "vscode-lldb" and vscode_launch_file:
563                write_vscode_config(pathlib.Path(vscode_launch_file) , setup_commands)
564                print(f"Generated config written to '{vscode_launch_file}'")
565            else:
566                print("")
567                print(setup_commands)
568                print("")
569                if args.setup_forwarding == "vscode-lldb":
570                    print(textwrap.dedent("""
571                            Paste the above json into .vscode/launch.json and start the debugger as
572                            normal."""))
573                else:
574                    print(textwrap.dedent("""
575                            Paste the lldb commands above into the lldb frontend to set up the
576                            lldb-server connection."""))
577
578            print(textwrap.dedent("""
579                        Press enter in this terminal once debugging is finished to shut lldb-server
580                        down and close all the ports."""))
581            print("")
582            input("Press enter to shut down lldb-server")
583
584
585def main():
586    try:
587        do_main()
588    finally:
589        global g_temp_dirs
590        for temp_dir in g_temp_dirs:
591            shutil.rmtree(temp_dir)
592
593
594if __name__ == "__main__":
595    main()
596