blob: 39763468a56c883e60f0c98fe8af00a7d948e3a6 [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2014 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 json
import logging
import os
import pipes
import re
import requests
import signal
import skypy.paths
import StringIO
import subprocess
import sys
import time
import urlparse
SRC_ROOT = skypy.paths.Paths('ignored').src_root
sys.path.insert(0, os.path.join(SRC_ROOT, 'build', 'android'))
from pylib import android_commands
from pylib import constants
from pylib import forwarder
SUPPORTED_MIME_TYPES = [
'text/html',
'text/sky',
'text/plain',
]
DEFAULT_SKY_COMMAND_PORT = 7777
GDB_PORT = 8888
SKY_SERVER_PORT = 9999
PID_FILE_PATH = "/tmp/skydb.pids"
DEFAULT_URL = "https://raw.githubusercontent.com/domokit/mojo/master/sky/examples/home.sky"
ANDROID_PACKAGE = "org.chromium.mojo.shell"
ANDROID_ACTIVITY = "%s/.MojoShellActivity" % ANDROID_PACKAGE
CACHE_LINKS_PATH = '/tmp/mojo_cache_links'
SYSTEM_LIBS_ROOT_PATH = '/tmp/device_libs'
# FIXME: Move this into mopy.config
def gn_args_from_build_dir(build_dir):
gn_cmd = [
'gn', 'args',
build_dir,
'--list', '--short'
]
config = {}
for line in subprocess.check_output(gn_cmd).strip().split('\n'):
# FIXME: This doesn't handle = in values.
key, value = line.split(' = ')
config[key] = value
return config
class SkyDebugger(object):
def __init__(self):
self.pids = {}
self.paths = None
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 _in_chromoting(self):
return os.environ.get('CHROME_REMOTE_DESKTOP_SESSION', False)
def _wrap_for_android(self, shell_args):
build_dir_url = SkyServer.url_for_path(
self.pids['remote_sky_server_port'],
self.pids['sky_server_root'],
self.pids['build_dir'])
shell_args += ['--origin=%s' % build_dir_url]
# am shell --esa: (someone shoot me now)
# [--esa <EXTRA_KEY> <EXTRA_STRING_VALUE>[,<EXTRA_STRING_VALUE...]]
# (to embed a comma into a string escape it using "\,")
escaped_args = map(lambda arg: arg.replace(',', '\\,'), shell_args)
return [
'adb', 'shell',
'am', 'start',
'-W',
'-S',
'-a', 'android.intent.action.VIEW',
'-n', ANDROID_ACTIVITY,
# FIXME: This quoting is very error-prone. Perhaps we should read
# our args from a file instead?
'--esa', 'parameters', ','.join(escaped_args),
]
def _build_mojo_shell_command(self, args, is_android):
content_handlers = ['%s,%s' % (mime_type, 'mojo:sky_viewer')
for mime_type in SUPPORTED_MIME_TYPES]
remote_command_port = self.pids.get('remote_sky_command_port', self.pids['sky_command_port'])
shell_args = [
'--v=1',
'--content-handlers=%s' % ','.join(content_handlers),
'--url-mappings=mojo:window_manager=mojo:sky_debugger',
'--args-for=mojo:sky_debugger_prompt %d' % remote_command_port,
'mojo:window_manager',
]
# Desktop-only work-around for mojo crashing under chromoting.
if not is_android and args.use_osmesa:
shell_args.append(
'--args-for=mojo:native_viewport_service --use-osmesa')
if is_android and args.gdb:
shell_args.append('--wait_for_debugger')
if 'remote_sky_server_port' in self.pids:
shell_command = self._wrap_for_android(shell_args)
else:
shell_command = [self.paths.mojo_shell_path] + shell_args
return shell_command
def _connect_to_device(self):
device = android_commands.AndroidCommands(
android_commands.GetAttachedDevices()[0])
device.EnableAdbRoot()
return device
def sky_server_for_args(self, args):
# FIXME: This is a hack. sky_server should just take a build_dir
# not a magical "configuration" name.
configuration = os.path.basename(os.path.normpath(self.paths.build_dir))
server_root = self._server_root_for_url(args.url_or_path)
sky_server = SkyServer(self.paths, SKY_SERVER_PORT,
configuration, server_root)
return sky_server
def _create_paths_for_build_dir(self, build_dir):
# skypy.paths.Paths takes a root-relative build_dir argument. :(
abs_build_dir = os.path.abspath(build_dir)
root_relative_build_dir = os.path.relpath(abs_build_dir, SRC_ROOT)
return skypy.paths.Paths(root_relative_build_dir)
def _find_remote_pid_for_package(self, package):
ps_output = subprocess.check_output(['adb', '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', '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 start_command(self, args):
# FIXME: Lame that we use self for a command-specific variable.
self.paths = self._create_paths_for_build_dir(args.build_dir)
self.stop_command(None) # Quit any existing process.
if not os.path.exists(self.paths.mojo_shell_path):
print "mojo_shell not found in build_dir '%s'" % args.build_dir
print "Are you sure you sure that's a valid build_dir location?"
print "See skydb start --help for more info"
sys.exit(2)
# FIXME: This is probably not the right way to compute is_android
# from the build directory?
gn_args = gn_args_from_build_dir(self.paths.build_dir)
is_android = 'android_sdk_version' in gn_args
sky_server = self.sky_server_for_args(args)
self.pids['sky_server_pid'] = sky_server.start()
self.pids['sky_server_port'] = sky_server.port
self.pids['sky_server_root'] = sky_server.root
self.pids['build_dir'] = self.paths.build_dir
self.pids['sky_command_port'] = args.command_port
if is_android:
# Pray to the build/android gods in their misspelled tongue.
constants.SetOutputDirectort(self.paths.build_dir)
device = self._connect_to_device()
self.pids['device_serial'] = device.GetDevice()
forwarder.Forwarder.Map([(0, sky_server.port)], device)
device_http_port = forwarder.Forwarder.DevicePortForHostPort(
sky_server.port)
self.pids['remote_sky_server_port'] = device_http_port
port_string = 'tcp:%s' % args.command_port
subprocess.check_call([
'adb', 'forward', port_string, port_string
])
self.pids['remote_sky_command_port'] = args.command_port
shell_command = self._build_mojo_shell_command(args, is_android)
# On android we can't launch inside gdb, but rather have to attach.
if not is_android and args.gdb:
shell_command = ['gdbserver', ':%d' % GDB_PORT] + shell_command
print ' '.join(map(pipes.quote, shell_command))
# This pid is meaningless on android (it's the adb shell pid)
start_command_pid = subprocess.Popen(shell_command).pid
if is_android:
# 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 mojo_shell pid on device!')
return
self.pids['mojo_shell_pid'] = pid
else:
self.pids['mojo_shell_pid'] = start_command_pid
if args.gdb and is_android:
# 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', 'shell',
gdb_server_path, '--attach',
':%d' % GDB_PORT,
str(self.pids['mojo_shell_pid'])
]
print ' '.join(map(pipes.quote, gdbserver_cmd))
self.pids['adb_shell_gdbserver_pid'] = \
subprocess.Popen(gdbserver_cmd).pid
port_string = 'tcp:%d' % GDB_PORT
subprocess.check_call([
'adb', 'forward', port_string, port_string
])
self.pids['remote_gdbserver_port'] = GDB_PORT
if not args.gdb:
if not self._wait_for_sky_command_port():
logging.error('Failed to start sky')
self.stop_command(None)
else:
self.load_command(args)
else:
print 'No load issued, connect with gdb first and then run load.'
def _kill_if_exists(self, key, name):
pid = self.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 stop_command(self, args):
# TODO(eseidel): mojo_shell crashes when attempting graceful shutdown.
# self._send_command_to_sky('/quit')
self._kill_if_exists('sky_server_pid', 'sky_server')
# We could be much more surgical here:
if 'remote_sky_server_port' in self.pids:
device = android_commands.AndroidCommands(
self.pids['device_serial'])
forwarder.Forwarder.UnmapAllDevicePorts(device)
if 'remote_sky_command_port' in self.pids:
# adb forward --remove takes the *host* port, not the remote port.
port_string = 'tcp:%s' % self.pids['sky_command_port']
subprocess.call(['adb', 'forward', '--remove', port_string])
subprocess.call([
'adb', 'shell', 'am', 'force-stop', ANDROID_PACKAGE])
else:
# Only try to kill mojo_shell if it's running locally.
self._kill_if_exists('mojo_shell_pid', 'mojo_shell')
if 'remote_gdbserver_port' in self.pids:
self._kill_if_exists('adb_shell_gdbserver_pid',
'adb shell gdbserver')
port_string = 'tcp:%s' % self.pids['remote_gdbserver_port']
subprocess.call(['adb', 'forward', '--remove', port_string])
self.pids = {} # Clear out our pid file.
self._kill_if_exists('mojo_cache_linker_pid', 'mojo cache linker')
def load_command(self, args):
if not urlparse.urlparse(args.url_or_path).scheme:
# The load happens on the remote device, use the remote port.
remote_sky_server_port = self.pids.get('remote_sky_server_port',
self.pids['sky_server_port'])
url = SkyServer.url_for_path(remote_sky_server_port,
self.pids['sky_server_root'], args.url_or_path)
else:
url = args.url_or_path
self._send_command_to_sky('/load', url)
def stop_profiling_command(self, args):
self._send_command_to_sky('/stop_profiling')
# We need to munge the profile to replace foo.mojo with libfoo.so so
# that pprof knows this represents an SO.
with open("sky_viewer.pprof", "r+") as profile_file:
data = profile_file.read()
profile_file.seek(0)
profile_file.write(re.sub(r'(\w+)\.mojo', r'lib\1_library.so', data))
profile_file.truncate()
def _command_base_url(self):
return 'http://localhost:%s' % self.pids['sky_command_port']
def _send_command_to_sky(self, command_path, payload=None):
url = 'http://localhost:%s%s' % (
self.pids['sky_command_port'], command_path)
if payload:
response = requests.post(url, payload)
else:
response = requests.get(url)
print response.text
# FIXME: These could be made into a context object with __enter__/__exit__.
def _load_pid_file(self, path):
try:
with open(path, 'r') as pid_file:
return json.load(pid_file)
except:
if os.path.exists(path):
logging.warn('Failed to read pid file: %s' % path)
return {}
def _write_pid_file(self, path, pids):
try:
with open(path, 'w') as pid_file:
json.dump(pids, pid_file, indent=2, sort_keys=True)
except:
logging.warn('Failed to write pid file: %s' % path)
def _add_basic_command(self, subparsers, name, url_path, help_text):
parser = subparsers.add_parser(name, help=help_text)
command = lambda args: self._send_command_to_sky(url_path)
parser.set_defaults(func=command)
def _wait_for_sky_command_port(self):
tries = 0
while True:
try:
self._send_command_to_sky('/')
return True
except:
tries += 1
if tries == 3:
logging.warn('Still waiting for sky on port %s' %
self.pids['sky_command_port'])
if tries > 10:
return False
time.sleep(1)
def logcat_command(self, args):
TAGS = [
'AndroidHandler',
'MojoMain',
'MojoShellActivity',
'MojoShellApplication',
'chromium',
]
subprocess.call(['adb', 'logcat', '-d', '-s'] + TAGS)
def _start_mojo_cache_linker(self, links_path):
self._kill_if_exists('mojo_cache_linker_pid', 'mojo cache linker')
if not os.path.exists(links_path):
os.makedirs(links_path)
shell_link_path = os.path.join(links_path, 'libmojo_shell.so')
if os.path.lexists(shell_link_path):
os.unlink(shell_link_path)
os.symlink(self.paths.mojo_shell_path, shell_link_path)
logcat_cmd = ['adb', 'logcat']
logcat = subprocess.Popen(logcat_cmd, stdout=subprocess.PIPE)
mojo_cache_linker_path = os.path.join(
self.paths.sky_tools_directory, 'mojo_cache_linker.py')
cache_linker_cmd = [
mojo_cache_linker_path,
links_path,
self.pids['build_dir'],
'http://localhost:%s' % self.pids['remote_sky_server_port']
]
return subprocess.Popen(cache_linker_cmd, stdin=logcat.stdout).pid
def _pull_system_libraries(self, 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(
self.paths.sky_tools_directory, 'android_library_cacher.py')
subprocess.call([
library_cacher_path, system_libs_root, self.pids['mojo_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 gdb_attach_command(self, args):
self.paths = self._create_paths_for_build_dir(self.pids['build_dir'])
symbol_search_paths = [self.pids['build_dir']]
gdb_path = '/usr/bin/gdb'
init_commands = [
'file %s' % self.paths.mojo_shell_path,
'directory %s' % self.paths.src_root,
'target remote localhost:%s' % GDB_PORT,
]
# A bunch of extra work is needed for android:
if 'remote_sky_server_port' in self.pids:
pid = self._start_mojo_cache_linker(CACHE_LINKS_PATH)
self.pids['mojo_cache_linker_pid'] = pid
system_lib_dirs = self._pull_system_libraries(SYSTEM_LIBS_ROOT_PATH)
init_commands.append(
'set solib-absolute-prefix %s' % SYSTEM_LIBS_ROOT_PATH)
symbol_search_paths = system_lib_dirs + symbol_search_paths
symbol_search_paths.append(CACHE_LINKS_PATH)
# TODO(eseidel): We need to look up the toolchain somehow?
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
init_commands.append(
'set solib-search-path %s' % ':'.join(symbol_search_paths))
exec_command = [gdb_path]
for command in init_commands:
exec_command += ['--eval-command', command]
print " ".join(exec_command)
# Write out our pid file before we exec ourselves.
self._write_pid_file(PID_FILE_PATH, self.pids)
# Exec gdb directly to avoid python intercepting symbols, etc.
os.execv(exec_command[0], exec_command)
def print_crash_command(self, args):
logcat_cmd = ['adb', 'logcat', '-d']
logcat = subprocess.Popen(logcat_cmd, stdout=subprocess.PIPE)
stack_path = os.path.join(SRC_ROOT,
'tools', 'android_stack_parser', 'stack')
stack = subprocess.Popen([stack_path, '-'], stdin=logcat.stdout)
logcat.wait()
stack.wait()
def main(self):
logging.basicConfig(level=logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
self.pids = self._load_pid_file(PID_FILE_PATH)
parser = argparse.ArgumentParser(description='Sky launcher/debugger')
subparsers = parser.add_subparsers(help='sub-command help')
start_parser = subparsers.add_parser('start',
help='launch a new mojo_shell with sky')
start_parser.add_argument('--gdb', action='store_true')
start_parser.add_argument('--command-port', type=int,
default=DEFAULT_SKY_COMMAND_PORT)
start_parser.add_argument('--use-osmesa', action='store_true',
default=self._in_chromoting())
start_parser.add_argument('build_dir', type=str)
start_parser.add_argument('url_or_path', nargs='?', type=str,
default=DEFAULT_URL)
start_parser.add_argument('--show-command', action='store_true',
help='Display the shell command and exit')
start_parser.set_defaults(func=self.start_command)
stop_parser = subparsers.add_parser('stop',
help=('stop sky (as listed in %s)' % PID_FILE_PATH))
stop_parser.set_defaults(func=self.stop_command)
logcat_parser = subparsers.add_parser('logcat',
help=('dump sky-related logs from device'))
logcat_parser.set_defaults(func=self.logcat_command)
print_crash_parser = subparsers.add_parser('print_crash',
help=('dump (and symbolicate) recent crash-stacks'))
print_crash_parser.set_defaults(func=self.print_crash_command)
gdb_attach_parser = subparsers.add_parser('gdb_attach',
help='launch gdb and attach to gdbserver launched from start --gdb')
gdb_attach_parser.set_defaults(func=self.gdb_attach_command)
self._add_basic_command(subparsers, 'trace', '/trace',
'toggle tracing')
self._add_basic_command(subparsers, 'reload', '/reload',
'reload the current page')
self._add_basic_command(subparsers, 'inspect', '/inspect',
'stop the running sky instance')
self._add_basic_command(subparsers, 'start_profiling', '/start_profiling',
'starts profiling the running sky instance (Linux only)')
stop_profiling_parser = subparsers.add_parser('stop_profiling',
help='stops profiling the running sky instance (Linux only)')
stop_profiling_parser.set_defaults(func=self.stop_profiling_command)
load_parser = subparsers.add_parser('load',
help='load a new page in the currently running sky')
load_parser.add_argument('url_or_path', type=str)
load_parser.set_defaults(func=self.load_command)
args = parser.parse_args()
args.func(args)
self._write_pid_file(PID_FILE_PATH, self.pids)
if __name__ == '__main__':
SkyDebugger().main()