blob: dd51c64b04ef1adafee4e2eca6e63d8b382d703e [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from skypy.skyserver import SkyServer
import argparse
import hashlib
import json
import logging
import os
import pipes
import platform
import re
import signal
import subprocess
import sys
import tempfile
import time
import urlparse
SKY_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
SKY_ROOT = os.path.dirname(SKY_TOOLS_DIR)
SRC_ROOT = os.path.dirname(SKY_ROOT)
GDB_PORT = 8888
SKY_SERVER_PORT = 9888
OBSERVATORY_PORT = 8181
DEFAULT_URL = "https://domokit.github.io/home.dart"
APK_NAME = 'SkyShell.apk'
ADB_PATH = os.path.join(SRC_ROOT,
'third_party/android_tools/sdk/platform-tools/adb')
ANDROID_PACKAGE = "org.domokit.sky.shell"
ANDROID_COMPONENT = '%s/%s.SkyActivity' % (ANDROID_PACKAGE, ANDROID_PACKAGE)
SHA1_PATH = '/sdcard/%s/%s.sha1' % (ANDROID_PACKAGE, APK_NAME)
PID_FILE_PATH = "/tmp/shelldb.pids"
PID_FILE_KEYS = frozenset([
'remote_sky_server_port',
'sky_server_pid',
'sky_server_port',
'sky_server_root',
'build_dir',
'sky_shell_pid',
'remote_gdbserver_port',
])
SYSTEM_LIBS_ROOT_PATH = '/tmp/device_libs'
# This 'strict dictionary' approach is useful for catching typos.
class Pids(object):
def __init__(self, known_keys, contents=None):
self._known_keys = known_keys
self._dict = contents if contents is not None else {}
def __len__(self):
return len(self._dict)
def get(self, key, default=None):
assert key in self._known_keys, '%s not in known_keys' % key
return self._dict.get(key, default)
def __getitem__(self, key):
assert key in self._known_keys, '%s not in known_keys' % key
return self._dict[key]
def __setitem__(self, key, value):
assert key in self._known_keys, '%s not in known_keys' % key
self._dict[key] = value
def __delitem__(self, key):
assert key in self._known_keys, '%s not in known_keys' % key
del self._dict[key]
def __iter__(self):
return iter(self._dict)
def __contains__(self, key):
assert key in self._known_keys, '%s not in allowed_keys' % key
return key in self._dict
def clear(self):
self._dict = {}
def pop(self, key, default=None):
assert key in self._known_keys, '%s not in allowed_keys' % key
return self._dict.pop(key, default)
@classmethod
def read_from(cls, path, known_keys):
contents = {}
try:
with open(path, 'r') as pid_file:
contents = json.load(pid_file)
except:
if os.path.exists(path):
logging.warn('Failed to read pid file: %s' % path)
return cls(known_keys, contents)
def write_to(self, path):
try:
with open(path, 'w') as pid_file:
json.dump(self._dict, pid_file, indent=2, sort_keys=True)
except:
logging.warn('Failed to write pid file: %s' % path)
# A free function for possible future sharing with a 'load' command.
def _url_from_args(args, pids):
if urlparse.urlparse(args.url_or_path).scheme:
return args.url_or_path
# The load happens on the remote device, use the remote port.
remote_sky_server_port = pids.get('remote_sky_server_port',
pids['sky_server_port'])
return SkyServer.url_for_path(remote_sky_server_port,
pids['sky_server_root'], args.url_or_path)
def dev_packages_root(build_dir):
return os.path.join(build_dir, 'gen', 'dart-pkg', 'packages')
class SetBuildDir(object):
def add_subparser(self, subparsers):
start_parser = subparsers.add_parser('set_build_dir',
help='force the build_dir to a particular value without starting Sky')
start_parser.add_argument('build_dir', type=str)
start_parser.set_defaults(func=self.run)
def run(self, args, pids):
pids['build_dir'] = os.path.abspath(args.build_dir)
class StartSky(object):
def add_subparser(self, subparsers):
start_parser = subparsers.add_parser('start',
help='launch SkyShell.apk on the device')
start_parser.add_argument('build_dir', type=str)
start_parser.add_argument('--gdb', action="store_true")
start_parser.add_argument('url_or_path', nargs='?', type=str,
default=DEFAULT_URL)
start_parser.add_argument('--no_install', action="store_false",
default=True, dest="install",
help="Don't install SkyShell.apk before starting")
start_parser.set_defaults(func=self.run)
def _server_root_for_url(self, url_or_path):
path = os.path.abspath(url_or_path)
if os.path.commonprefix([path, SRC_ROOT]) == SRC_ROOT:
server_root = SRC_ROOT
else:
server_root = os.path.dirname(path)
logging.warn(
'%s is outside of mojo root, using %s as server root' %
(path, server_root))
return server_root
def _sky_server_for_args(self, args, packages_root):
server_root = self._server_root_for_url(args.url_or_path)
sky_server = SkyServer(SKY_SERVER_PORT, server_root, packages_root)
return sky_server
def _find_remote_pid_for_package(self, package):
ps_output = subprocess.check_output([ADB_PATH, 'shell', 'ps'])
for line in ps_output.split('\n'):
fields = line.split()
if fields and fields[-1] == package:
return fields[1]
return None
def _find_install_location_for_package(self, package):
pm_command = [ADB_PATH, 'shell', 'pm', 'path', package]
pm_output = subprocess.check_output(pm_command)
# e.g. package:/data/app/org.chromium.mojo.shell-1/base.apk
return pm_output.split(':')[-1]
def run(self, args, pids):
apk_path = os.path.join(args.build_dir, 'apks', APK_NAME)
if not os.path.exists(apk_path):
print "'%s' does not exist?" % apk_path
return 2
StopSky().run(args, pids)
packages_root = dev_packages_root(args.build_dir)
sky_server = self._sky_server_for_args(args, packages_root)
pids['sky_server_pid'] = sky_server.start()
pids['sky_server_port'] = sky_server.port
pids['sky_server_root'] = sky_server.root
pids['build_dir'] = os.path.abspath(args.build_dir)
if args.install:
# We might need to install a new APK, so check SHA1
source_sha1 = hashlib.sha1(open(apk_path, 'rb').read()).hexdigest()
dest_sha1 = subprocess.check_output([ADB_PATH, 'shell', 'cat', SHA1_PATH])
use_existing_apk = False
if source_sha1 == dest_sha1:
# Make sure that the APK didn't get uninstalled somehow
use_existing_apk = subprocess.check_output([
ADB_PATH, 'shell', 'pm', 'list', 'packages', ANDROID_PACKAGE
])
else:
# User is telling us not to bother installing an APK
use_existing_apk = True
if use_existing_apk:
# APK is already on the device, we only need to stop it
subprocess.check_call([
ADB_PATH, 'shell', 'am', 'force-stop', ANDROID_PACKAGE
])
else:
# Slow path, need to upload a new APK to the device
# -r to replace an existing apk, -d to allow version downgrade.
subprocess.check_call([ADB_PATH, 'install', '-r', '-d', apk_path])
# record the SHA1 of the APK we just pushed
with tempfile.NamedTemporaryFile() as fp:
fp.write(source_sha1)
fp.seek(0)
subprocess.check_call([ADB_PATH, 'push', fp.name, SHA1_PATH])
# Set up port forwarding for observatory
port_string = 'tcp:%s' % OBSERVATORY_PORT
subprocess.check_call([
ADB_PATH, 'forward', port_string, port_string
])
port_string = 'tcp:%s' % sky_server.port
subprocess.check_call([
ADB_PATH, 'reverse', port_string, port_string
])
pids['remote_sky_server_port'] = sky_server.port
subprocess.check_call([ADB_PATH, 'shell',
'am', 'start',
'-a', 'android.intent.action.VIEW',
'-d', _url_from_args(args, pids),
ANDROID_COMPONENT])
if not args.gdb:
return
# TODO(eseidel): am start -W does not seem to work?
pid_tries = 0
while True:
pid = self._find_remote_pid_for_package(ANDROID_PACKAGE)
if pid or pid_tries > 3:
break
logging.debug('No pid for %s yet, waiting' % ANDROID_PACKAGE)
time.sleep(5)
pid_tries += 1
if not pid:
logging.error('Failed to find pid on device!')
return
pids['sky_shell_pid'] = pid
# We push our own copy of gdbserver with the package since
# the default gdbserver is a different version from our gdb.
package_path = \
self._find_install_location_for_package(ANDROID_PACKAGE)
gdb_server_path = os.path.join(
os.path.dirname(package_path), 'lib/arm/gdbserver')
gdbserver_cmd = [
ADB_PATH, 'shell',
gdb_server_path, '--attach',
':%d' % GDB_PORT,
str(pid)
]
print ' '.join(map(pipes.quote, gdbserver_cmd))
subprocess.Popen(gdbserver_cmd)
port_string = 'tcp:%d' % GDB_PORT
subprocess.check_call([
ADB_PATH, 'forward', port_string, port_string
])
pids['remote_gdbserver_port'] = GDB_PORT
class GDBAttach(object):
def add_subparser(self, subparsers):
start_parser = subparsers.add_parser('gdb_attach',
help='attach to gdbserver running on device')
start_parser.set_defaults(func=self.run)
def _pull_system_libraries(self, pids, system_libs_root):
# Pull down the system libraries this pid has already mapped in.
# TODO(eseidel): This does not handle dynamic loads.
library_cacher_path = os.path.join(
SKY_TOOLS_DIR, 'android_library_cacher.py')
subprocess.call([
library_cacher_path, system_libs_root, pids['sky_shell_pid']
])
# TODO(eseidel): adb_gdb does, this, unclear why solib-absolute-prefix
# doesn't make this explicit listing not necessary?
return subprocess.check_output([
'find', system_libs_root,
'-mindepth', '1',
'-maxdepth', '4',
'-type', 'd',
]).strip().split('\n')
def run(self, args, pids):
symbol_search_paths = [
pids['build_dir'],
]
gdb_path = '/usr/bin/gdb'
eval_commands = [
'directory %s' % SRC_ROOT,
# TODO(eseidel): What file do I point it at? The apk?
#'file %s' % self.paths.mojo_shell_path,
'target remote localhost:%s' % GDB_PORT,
]
# TODO(iansf): Fix undefined behavior when you have more than one device attached.
device_id = subprocess.check_output([ADB_PATH, 'get-serialno']).strip()
device_libs_path = os.path.join(SYSTEM_LIBS_ROOT_PATH, device_id)
system_lib_dirs = self._pull_system_libraries(pids, device_libs_path)
eval_commands.append('set solib-absolute-prefix %s' % device_libs_path)
symbol_search_paths = system_lib_dirs + symbol_search_paths
# TODO(eseidel): We need to look up the toolchain somehow?
if platform.system() == 'Darwin':
gdb_path = os.path.join(SRC_ROOT, 'third_party/android_tools/ndk/'
'toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/'
'bin/arm-linux-androideabi-gdb')
else:
gdb_path = os.path.join(SRC_ROOT, 'third_party/android_tools/ndk/'
'toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/'
'bin/arm-linux-androideabi-gdb')
# Set solib-search-path after letting android modify symbol_search_paths
eval_commands.append(
'set solib-search-path %s' % ':'.join(symbol_search_paths))
exec_command = [gdb_path]
for command in eval_commands:
exec_command += ['--eval-command', command]
print " ".join(exec_command)
# Write out our pid file before we exec ourselves.
pids.write_to(PID_FILE_PATH)
# Exec gdb directly to avoid python intercepting symbols, etc.
os.execv(exec_command[0], exec_command)
class StopSky(object):
def add_subparser(self, subparsers):
stop_parser = subparsers.add_parser('stop',
help=('kill all running SkyShell.apk processes'))
stop_parser.set_defaults(func=self.run)
def _kill_if_exists(self, pids, key, name):
pid = pids.pop(key, None)
if not pid:
logging.info('No pid for %s, nothing to do.' % name)
return
logging.info('Killing %s (%d).' % (name, pid))
try:
os.kill(pid, signal.SIGTERM)
except OSError:
logging.info('%s (%d) already gone.' % (name, pid))
def _adb_reverse_remove(self, port):
port_string = 'tcp:%s' % port
subprocess.call([ADB_PATH, 'reverse', '--remove', port_string])
def _adb_forward_remove(self, port):
port_string = 'tcp:%s' % port
subprocess.call([ADB_PATH, 'forward', '--remove', port_string])
def run(self, args, pids):
self._kill_if_exists(pids, 'sky_server_pid', 'sky_server')
if 'remote_sky_server_port' in pids:
self._adb_reverse_remove(pids['remote_sky_server_port'])
if 'remote_gdbserver_port' in pids:
self._adb_forward_remove(pids['remote_gdbserver_port'])
subprocess.call([
ADB_PATH, 'shell', 'am', 'force-stop', ANDROID_PACKAGE])
pids.clear()
class StartTracing(object):
def add_subparser(self, subparsers):
start_tracing_parser = subparsers.add_parser('start_tracing',
help=('start tracing a running sky instance'))
start_tracing_parser.set_defaults(func=self.run)
def run(self, args, pids):
subprocess.check_output([ADB_PATH, 'shell',
'am', 'broadcast',
'-a', 'org.domokit.sky.shell.TRACING_START'])
TRACE_COMPLETE_REGEXP = re.compile('Trace complete')
TRACE_FILE_REGEXP = re.compile(r'Saving trace to (?P<path>\S+)')
class StopTracing(object):
def add_subparser(self, subparsers):
stop_tracing_parser = subparsers.add_parser('stop_tracing',
help=('stop tracing a running sky instance'))
stop_tracing_parser.set_defaults(func=self.run)
def run(self, args, pids):
subprocess.check_output([ADB_PATH, 'logcat', '-c'])
subprocess.check_output([ADB_PATH, 'shell',
'am', 'broadcast',
'-a', 'org.domokit.sky.shell.TRACING_STOP'])
device_path = None
is_complete = False
while not is_complete:
time.sleep(0.2)
log = subprocess.check_output([ADB_PATH, 'logcat', '-d'])
if device_path is None:
result = TRACE_FILE_REGEXP.search(log)
if result:
device_path = result.group('path')
is_complete = TRACE_COMPLETE_REGEXP.search(log) is not None
print 'Downloading trace %s ...' % os.path.basename(device_path)
if device_path:
subprocess.check_output([ADB_PATH, 'pull', device_path])
subprocess.check_output([ADB_PATH, 'shell', 'rm', device_path])
class SkyShellRunner(object):
def main(self):
logging.basicConfig(level=logging.WARNING)
parser = argparse.ArgumentParser(description='Sky Shell Runner')
subparsers = parser.add_subparsers(help='sub-command help')
commands = [
SetBuildDir(),
StartSky(),
StopSky(),
GDBAttach(),
StartTracing(),
StopTracing(),
]
for command in commands:
command.add_subparser(subparsers)
args = parser.parse_args()
pids = Pids.read_from(PID_FILE_PATH, PID_FILE_KEYS)
exit_code = args.func(args, pids)
# We could do this with an at-exit handler instead?
pids.write_to(PID_FILE_PATH)
sys.exit(exit_code)
if __name__ == '__main__':
SkyShellRunner().main()