#!/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 # 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 [, 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 _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 gdb_attach_command(self, args): self.paths = self._create_paths_for_build_dir(self.pids['build_dir']) self._kill_if_exists('mojo_cache_linker_pid', 'mojo cache linker') links_path = '/tmp/mojo_cache_links' 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'] ] self.pids['mojo_cache_linker_pid'] = \ subprocess.Popen(cache_linker_cmd, stdin=logcat.stdout).pid # Write out our pid file before we exec ourselves. self._write_pid_file(PID_FILE_PATH, self.pids) # Pull down the system libraries this pid has already mapped in. # TODO(eseidel): This does not handle dynamic loads well. system_libs_root = '/tmp/device_libs' 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? system_lib_dirs = subprocess.check_output([ 'find', system_libs_root, '-mindepth', '1', '-maxdepth', '4', '-type', 'd', ]).strip().split('\n') # TODO(eseidel): Need to sync down system libraries into a directory. symbol_search_paths = system_lib_dirs + [ links_path, self.pids['build_dir'], ] # 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') gdb_command = [ gdb_path, '--eval-command', 'file %s' % self.paths.mojo_shell_path, '--eval-command', 'directory %s' % self.paths.src_root, '--eval-command', 'target remote localhost:%s' % GDB_PORT, '--eval-command', 'set solib-search-path %s' % ':'.join(symbol_search_paths), '--eval-command', 'set solib-absolute-prefix %s' % system_libs_root, ] print " ".join(gdb_command) # We don't want python listening for signals or anything, so exec # gdb and let it take the entire process. os.execv(gdb_command[0], gdb_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)') self._add_basic_command(subparsers, 'stop_profiling', '/stop_profiling', 'stios profiling the running sky instance (Linux only)') 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()