blob: dc15b51d7f6058a4ef8f06256dd84ce8484d8be5 [file] [log] [blame]
#!/usr/bin/env python3
#===----------------------------------------------------------------------===##
#
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
#===----------------------------------------------------------------------===##
"""adb_run.py is a utility for running a libc++ test program via adb.
"""
import argparse
import hashlib
import os
import re
import shlex
import socket
import subprocess
import sys
from typing import List, Tuple
# Sync a host file /path/to/dir/file to ${REMOTE_BASE_DIR}/run-${HASH}/dir/file.
REMOTE_BASE_DIR = "/data/local/tmp/adb_run"
g_job_limit_socket = None
g_verbose = False
def run_adb_sync_command(command: List[str]) -> None:
"""Run an adb command and discard the output, unless the command fails. If
the command fails, dump the output instead, and exit the script with
failure.
"""
if g_verbose:
sys.stderr.write(f"running: {shlex.join(command)}\n")
proc = subprocess.run(command, universal_newlines=True,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, encoding="utf-8")
if proc.returncode != 0:
# adb's stdout (e.g. for adb push) should normally be discarded, but
# on failure, it should be shown. Print it to stderr because it's
# unrelated to the test program's stdout output. A common error caught
# here is "No space left on device".
sys.stderr.write(f"{proc.stdout}\n"
f"error: adb command exited with {proc.returncode}: "
f"{shlex.join(command)}\n")
sys.exit(proc.returncode)
def sync_test_dir(local_dir: str, remote_dir: str) -> None:
"""Sync the libc++ test directory on the host to the remote device."""
# Optimization: The typical libc++ test directory has only a single
# *.tmp.exe file in it. In that case, skip the `mkdir` command, which is
# normally necessary because we don't know if the target directory already
# exists on the device.
local_files = os.listdir(local_dir)
if len(local_files) == 1:
local_file = os.path.join(local_dir, local_files[0])
remote_file = os.path.join(remote_dir, local_files[0])
if not os.path.islink(local_file) and os.path.isfile(local_file):
run_adb_sync_command(["adb", "push", "--sync", local_file,
remote_file])
return
assert os.path.basename(local_dir) == os.path.basename(remote_dir)
run_adb_sync_command(["adb", "shell", "mkdir", "-p", remote_dir])
run_adb_sync_command(["adb", "push", "--sync", local_dir,
os.path.dirname(remote_dir)])
def build_env_arg(env_args: List[str], prepend_path_args: List[Tuple[str, str]]) -> str:
components = []
for arg in env_args:
k, v = arg.split("=", 1)
components.append(f"export {k}={shlex.quote(v)}; ")
for k, v in prepend_path_args:
components.append(f"export {k}={shlex.quote(v)}${{{k}:+:${k}}}; ")
return "".join(components)
def run_command(args: argparse.Namespace) -> int:
local_dir = args.execdir
assert local_dir.startswith("/")
assert not local_dir.endswith("/")
# Copy each execdir to a subdir of REMOTE_BASE_DIR. Name the directory using
# a hash of local_dir so that concurrent adb_run invocations don't create
# the same intermediate parent directory. At least `adb push` has trouble
# with concurrent mkdir syscalls on common parent directories. (Somehow
# mkdir fails with EAGAIN/EWOULDBLOCK, see internal Google bug,
# b/289311228.)
local_dir_hash = hashlib.sha1(local_dir.encode()).hexdigest()
remote_dir = f"{REMOTE_BASE_DIR}/run-{local_dir_hash}/{os.path.basename(local_dir)}"
sync_test_dir(local_dir, remote_dir)
adb_shell_command = (
# Set the environment early so that PATH can be overridden. Overriding
# PATH is useful for:
# - Replacing older shell utilities with toybox (e.g. on old devices).
# - Adding a `bash` command that delegates to `sh` (mksh).
f"{build_env_arg(args.env, args.prepend_path_env)}"
# Set a high oom_score_adj so that, if the test program uses too much
# memory, it is killed before anything else on the device. The default
# oom_score_adj is -1000, so a test using too much memory typically
# crashes the device.
"echo 1000 >/proc/self/oom_score_adj; "
# If we're running as root, switch to the shell user. The libc++
# filesystem tests require running without root permissions. Some x86
# emulator devices (before Android N) do not have a working `adb unroot`
# and always run as root. Non-debug builds typically lack `su` and only
# run as the shell user.
#
# Some libc++ tests create temporary files in the working directory,
# which might be owned by root. Before switching to shell, make the
# cwd writable (and readable+executable) to every user.
#
# N.B.:
# - Avoid "id -u" because it wasn't supported until Android M.
# - The `env` and `which` commands were also added in Android M.
# - Starting in Android M, su from root->shell resets PATH, so we need
# to modify it again in the new environment.
# - Avoid chmod's "a+rwx" syntax because it's not supported until
# Android N.
# - Defining this function allows specifying the arguments to the test
# program (i.e. "$@") only once.
"run_without_root() {"
" chmod 777 .;"
" case \"$(id)\" in"
" *\"uid=0(root)\"*)"
" if command -v env >/dev/null; then"
" su shell \"$(command -v env)\" PATH=\"$PATH\" \"$@\";"
" else"
" su shell \"$@\";"
" fi;;"
" *) \"$@\";;"
" esac;"
"}; "
)
# Older versions of Bionic limit the length of argv[0] to 127 bytes
# (SOINFO_NAME_LEN-1), and the path to libc++ tests tend to exceed this
# limit. Changing the working directory works around this limit. The limit
# is increased to 4095 (PATH_MAX-1) in Android M (API 23).
command_line = [arg.replace(local_dir + "/", "./") for arg in args.command]
# Prior to the adb feature "shell_v2" (added in Android N), `adb shell`
# always created a pty:
# - This merged stdout and stderr together.
# - The pty converts LF to CRLF.
# - The exit code of the shell command wasn't propagated.
# Work around all three limitations, unless "shell_v2" is present.
proc = subprocess.run(["adb", "features"], check=True,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
encoding="utf-8")
adb_features = set(proc.stdout.strip().split())
has_shell_v2 = "shell_v2" in adb_features
if has_shell_v2:
adb_shell_command += (
f"cd {remote_dir} && run_without_root {shlex.join(command_line)}"
)
else:
adb_shell_command += (
f"{{"
f" stdout=$("
f" cd {remote_dir} && run_without_root {shlex.join(command_line)};"
f" echo -n __libcxx_adb_exit__=$?"
f" ); "
f"}} 2>&1; "
f"echo -n __libcxx_adb_stdout__\"$stdout\""
)
adb_command_line = ["adb", "shell", adb_shell_command]
if g_verbose:
sys.stderr.write(f"running: {shlex.join(adb_command_line)}\n")
if has_shell_v2:
proc = subprocess.run(adb_command_line, shell=False, check=False,
encoding="utf-8")
return proc.returncode
else:
proc = subprocess.run(adb_command_line, shell=False, check=False,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
encoding="utf-8")
# The old `adb shell` mode used a pty, which converted LF to CRLF.
# Convert it back.
output = proc.stdout.replace("\r\n", "\n")
if proc.returncode:
sys.stderr.write(f"error: adb failed:\n"
f" command: {shlex.join(adb_command_line)}\n"
f" output: {output}\n")
return proc.returncode
match = re.match(r"(.*)__libcxx_adb_stdout__(.*)__libcxx_adb_exit__=(\d+)$",
output, re.DOTALL)
if not match:
sys.stderr.write(f"error: could not parse adb output:\n"
f" command: {shlex.join(adb_command_line)}\n"
f" output: {output}\n")
return 1
sys.stderr.write(match.group(1))
sys.stdout.write(match.group(2))
return int(match.group(3))
def connect_to_job_limiter_server(sock_addr: str) -> None:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(sock_addr)
except (FileNotFoundError, ConnectionRefusedError) as e:
# Copying-and-pasting an adb_run.py command-line from a lit test failure
# is likely to fail because the socket no longer exists (or is
# inactive), so just give a warning.
sys.stderr.write(f"warning: could not connect to {sock_addr}: {e}\n")
return
# The connect call can succeed before the server has called accept, because
# of the listen backlog, so wait for the server to send a byte.
sock.recv(1)
# Keep the socket open until this process ends, then let the OS close the
# connection automatically.
global g_job_limit_socket
g_job_limit_socket = sock
def main() -> int:
"""Main function (pylint wants this docstring)."""
parser = argparse.ArgumentParser()
parser.add_argument("--execdir", type=str, required=True)
parser.add_argument("--env", type=str, required=False, action="append",
default=[], metavar="NAME=VALUE")
parser.add_argument("--prepend-path-env", type=str, nargs=2, required=False,
action="append", default=[],
metavar=("NAME", "PATH"))
parser.add_argument("--job-limit-socket")
parser.add_argument("--verbose", "-v", default=False, action="store_true")
parser.add_argument("command", nargs=argparse.ONE_OR_MORE)
args = parser.parse_args()
global g_verbose
g_verbose = args.verbose
if args.job_limit_socket is not None:
connect_to_job_limiter_server(args.job_limit_socket)
return run_command(args)
if __name__ == '__main__':
sys.exit(main())