#!/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 import platform SUPPORTED_MIME_TYPES = [ 'text/html', 'text/sky', 'text/plain', ] DEFAULT_SKY_COMMAND_PORT = 7777 GDB_PORT = 8888 SKY_SERVER_PORT = 9999 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 ANDROID_APK_NAME = 'MojoShell.apk' PID_FILE_PATH = "/tmp/skydb.pids" CACHE_LINKS_PATH = '/tmp/mojo_cache_links' SRC_ROOT = skypy.paths.Paths('ignored').src_root ADB_PATH = os.path.join(SRC_ROOT, 'third_party/android_tools/sdk/platform-tools/adb') # TODO(iansf): Fix undefined behavior when you have more than one device attached. SYSTEM_LIBS_ROOT_PATH = '/tmp/device_libs/%s' % (subprocess.check_output([ADB_PATH, 'get-serialno']).strip()) # 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 def ensure_assets_are_downloaded(build_dir): sky_pkg_dir = os.path.join(build_dir, 'gen', 'dart-pkg', 'sky') sky_pkg_lib_dir = os.path.join(sky_pkg_dir, 'lib') sky_icons_dir = \ os.path.join(sky_pkg_lib_dir, 'assets', 'material-design-icons') if not os.path.isdir(sky_icons_dir): logging.info('NOTE: sky/assets/material-design-icons missing, ' 'Running `download_material_design_icons` for you.') subprocess.check_call( [os.path.join(sky_pkg_lib_dir, 'download_material_design_icons')]) 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): # 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_PATH, '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_PATH, '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: # We could just run gdb_attach_command here, but when I do that # it auto-suspends in my zsh. Unclear why. # self.gdb_attach_command(args) print "Run 'skydb gdb_attach' to attach." 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._run_basic_command('/quit') self._kill_if_exists('sky_server_pid', 'sky_server') if 'remote_sky_server_port' in self.pids: port_string = 'tcp:%s' % self.pids['remote_sky_server_port'] subprocess.call([ADB_PATH, 'reverse', '--remove', port_string]) 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_PATH, 'forward', '--remove', port_string]) subprocess.call([ ADB_PATH, '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_PATH, 'forward', '--remove', port_string]) self.pids = {} # Clear out our pid file. def _url_from_args(self, args): 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 = self.pids.get('remote_sky_server_port', self.pids['sky_server_port']) return SkyServer.url_for_path(remote_sky_server_port, self.pids['sky_server_root'], args.url_or_path) def load_command(self, args): self._run_basic_command('/load', self._url_from_args(args)) def _read_mojo_map(self): # TODO(eseidel): Does not work for android. mojo_map_path = "/tmp/mojo_shell.%d.maps" % self.pids['mojo_shell_pid'] with open(mojo_map_path, 'r') as maps_file: lines = maps_file.read().strip().split('\n') return dict(map(lambda line: line.split(' '), lines)) def stop_tracing_command(self, args): file_name = args.file_name trace = self._send_command_to_sky('/stop_tracing').content with open(file_name, "wb") as trace_file: trace_file.write('{"traceEvents":[') trace_file.write(trace) trace_file.write(']}') print "Trace saved in %s" % file_name def stop_profiling_command(self, args): self._run_basic_command('/stop_profiling') mojo_map = self._read_mojo_map() # TODO(eseidel): We should have a helper for resolving urls, etc. remote_server_port = self.pids.get('remote_sky_server_port', self.pids['sky_server_port']) build_dir_url = SkyServer.url_for_path( remote_server_port, self.pids['sky_server_root'], self.pids['build_dir']) # Map /tmp cache paths to urls and then to local build_dir paths. def map_to_local_paths(match): path = match.group('mojo_path') url = mojo_map.get(path) if url and url.startswith(build_dir_url): return url.replace(build_dir_url, self.pids['build_dir']) return match.group(0) MOJO_PATH_RE = re.compile(r'(?P\S+\.mojo)') MOJO_NAME_RE = re.compile(r'(?P\w+)\.mojo') with open("sky_viewer.pprof", "rb+") as profile_file: # ISO-8859-1 can represent arbitrary binary while still keeping # ASCII characters in the ASCII range (allowing us to regexp). # http://en.wikipedia.org/wiki/ISO/IEC_8859-1 as_string = profile_file.read().decode('iso-8859-1') # Using the mojo_shell.PID.maps file tmp paths to build_dir paths. as_string = MOJO_PATH_RE.sub(map_to_local_paths, as_string) # In release foo.mojo is stripped but libfoo_library.so isn't. as_string = MOJO_NAME_RE.sub(r'lib\1_library.so', as_string) profile_file.seek(0) profile_file.write(as_string.encode('iso-8859-1')) 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) return response def _run_basic_command(self, command_path, payload=None): print self._send_command_to_sky(command_path, payload=payload).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._run_basic_command(url_path) parser.set_defaults(func=command) def _wait_for_sky_command_port(self): tries = 0 while True: try: self._run_basic_command('/') 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_PATH, 'logcat', '-d', '-s'] + TAGS) 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 _add_android_library_links(self, links_path): # TODO(eseidel): This might not match mojo_shell on the device? # TODO(eseidel): Should we pass libmojo_shell.so as 'file' to gdb? 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) def gdb_attach_command(self, args): self.paths = self._create_paths_for_build_dir(self.pids['build_dir']) if not os.path.exists(CACHE_LINKS_PATH): os.makedirs(CACHE_LINKS_PATH) cache_linker_path = os.path.join( self.paths.sky_tools_directory, 'mojo_cache_linker.py') subprocess.check_call([ cache_linker_path, CACHE_LINKS_PATH, self.paths.build_dir]) symbol_search_paths = [ self.pids['build_dir'], CACHE_LINKS_PATH, ] gdb_path = '/usr/bin/gdb' eval_commands = [ 'directory %s' % self.paths.src_root, 'file %s' % self.paths.mojo_shell_path, 'target remote localhost:%s' % GDB_PORT, ] # A bunch of extra work is needed for android: if 'remote_sky_server_port' in self.pids: self._add_android_library_links(CACHE_LINKS_PATH) system_lib_dirs = self._pull_system_libraries(SYSTEM_LIBS_ROOT_PATH) eval_commands.append( 'set solib-absolute-prefix %s' % SYSTEM_LIBS_ROOT_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. 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_PATH, '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 pids_command(self, args): print json.dumps(self.pids, indent=1) 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.add_argument('--trace-startup', action='store_true') 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) pids_parser = subparsers.add_parser('pids', help='dump the current skydb pids file') pids_parser.set_defaults(func=self.pids_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, 'start_tracing', '/start_tracing', 'starts tracing the running sky instance') self._add_basic_command(subparsers, 'reload', '/reload', 'reload the current page') self._add_basic_command(subparsers, 'start_profiling', '/start_profiling', 'starts profiling the running sky instance (Linux only)') stop_tracing_parser = subparsers.add_parser('stop_tracing', help='stops tracing the running sky instance') stop_tracing_parser.add_argument('file_name', type=str, default='sky_viewer.trace') stop_tracing_parser.set_defaults(func=self.stop_tracing_command) 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()