#!/usr/bin/env vpython3
#
# Copyright 2019 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.

# Fetches Crashpad dumps from a given device, walks and symbolizes the stacks.
# All the non-trivial operations are performed by generate_breakpad_symbols.py,
# dump_syms, minidump_dump and minidump_stackwalk.

import argparse
import logging
import os
import posixpath
import re
import sys
import shutil
import subprocess
import tempfile

_BUILD_ANDROID_PATH = os.path.abspath(
    os.path.join(os.path.dirname(__file__), '..'))
sys.path.append(_BUILD_ANDROID_PATH)
import devil_chromium
from devil.android import device_utils
from devil.utils import timeout_retry


def _CreateSymbolsDir(build_path, dynamic_library_names):
  generator = os.path.normpath(
      os.path.join(_BUILD_ANDROID_PATH, '..', '..', 'components', 'crash',
                   'content', 'tools', 'generate_breakpad_symbols.py'))
  syms_dir = os.path.join(build_path, 'crashpad_syms')
  shutil.rmtree(syms_dir, ignore_errors=True)
  os.mkdir(syms_dir)
  for lib in dynamic_library_names:
    unstripped_library_path = os.path.join(build_path, 'lib.unstripped', lib)
    if not os.path.exists(unstripped_library_path):
      continue
    logging.info('Generating symbols for: %s', unstripped_library_path)
    cmd = [
        generator,
        '--symbols-dir',
        syms_dir,
        '--build-dir',
        build_path,
        '--binary',
        unstripped_library_path,
        '--platform',
        'android',
    ]
    return_code = subprocess.call(cmd)
    if return_code != 0:
      logging.error('Could not extract symbols, command failed: %s',
                    ' '.join(cmd))
  return syms_dir


def _ChooseLatestCrashpadDump(device, crashpad_dump_path):
  if not device.PathExists(crashpad_dump_path):
    logging.warning('Crashpad dump directory does not exist: %s',
                    crashpad_dump_path)
    return None
  latest = None
  latest_timestamp = 0
  for crashpad_file in device.ListDirectory(crashpad_dump_path):
    if crashpad_file.endswith('.dmp'):
      stat = device.StatPath(posixpath.join(crashpad_dump_path, crashpad_file))
      current_timestamp = stat['st_mtime']
      if current_timestamp > latest_timestamp:
        latest_timestamp = current_timestamp
        latest = crashpad_file
  return latest


def _ExtractLibraryNamesFromDump(build_path, dump_path):
  default_library_name = 'libmonochrome.so'
  dumper_path = os.path.join(build_path, 'minidump_dump')
  if not os.access(dumper_path, os.X_OK):
    logging.warning(
        'Cannot extract library name from dump because %s is not found, '
        'default to: %s', dumper_path, default_library_name)
    return [default_library_name]
  p = subprocess.Popen([dumper_path, dump_path],
                       stdout=subprocess.PIPE,
                       stderr=subprocess.PIPE)
  stdout, stderr = p.communicate()
  if p.returncode != 0:
    # Dumper errors often do not affect stack walkability, just a warning.
    logging.warning('Reading minidump failed with output:\n%s', stderr)

  library_names = []
  module_library_line_re = re.compile(r'[(]code_file[)]\s+= '
                                      r'"(?P<library_name>lib[^. ]+.so)"')
  in_module = False
  for line in stdout.splitlines():
    line = line.lstrip().rstrip('\n')
    if line == 'MDRawModule':
      in_module = True
      continue
    if line == '':
      in_module = False
      continue
    if in_module:
      m = module_library_line_re.match(line)
      if m:
        library_names.append(m.group('library_name'))
  if not library_names:
    logging.warning(
        'Could not find any library name in the dump, '
        'default to: %s', default_library_name)
    return [default_library_name]
  return library_names


def main():
  logging.basicConfig(level=logging.INFO)
  parser = argparse.ArgumentParser(
      description='Fetches Crashpad dumps from a given device, '
      'walks and symbolizes the stacks.')
  parser.add_argument('--device', required=True, help='Device serial number')
  parser.add_argument('--adb-path', help='Path to the "adb" command')
  parser.add_argument(
      '--build-path',
      required=True,
      help='Build output directory, equivalent to CHROMIUM_OUTPUT_DIR')
  parser.add_argument(
      '--chrome-cache-path',
      required=True,
      help='Directory on the device where Chrome stores cached files,'
      ' crashpad stores dumps in a subdirectory of it')
  args = parser.parse_args()

  stackwalk_path = os.path.join(args.build_path, 'minidump_stackwalk')
  if not os.path.exists(stackwalk_path):
    logging.error('Missing minidump_stackwalk executable')
    return 1

  devil_chromium.Initialize(output_directory=args.build_path,
                            adb_path=args.adb_path)
  device = device_utils.DeviceUtils(args.device)

  device_crashpad_path = posixpath.join(args.chrome_cache_path, 'Crashpad',
                                        'pending')

  def CrashpadDumpExists():
    return _ChooseLatestCrashpadDump(device, device_crashpad_path)

  crashpad_file = timeout_retry.WaitFor(
      CrashpadDumpExists, wait_period=1, max_tries=9)
  if not crashpad_file:
    logging.error('Could not locate a crashpad dump')
    return 1

  dump_dir = tempfile.mkdtemp()
  symbols_dir = None
  try:
    device.PullFile(
        device_path=posixpath.join(device_crashpad_path, crashpad_file),
        host_path=dump_dir)
    dump_full_path = os.path.join(dump_dir, crashpad_file)
    library_names = _ExtractLibraryNamesFromDump(args.build_path,
                                                 dump_full_path)
    symbols_dir = _CreateSymbolsDir(args.build_path, library_names)
    stackwalk_cmd = [stackwalk_path, dump_full_path, symbols_dir]
    subprocess.call(stackwalk_cmd)
  finally:
    shutil.rmtree(dump_dir, ignore_errors=True)
    if symbols_dir:
      shutil.rmtree(symbols_dir, ignore_errors=True)
  return 0


if __name__ == '__main__':
  sys.exit(main())
