Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Snapshot Build Bisect Tool
      7 
      8 This script bisects a snapshot archive using binary search. It starts at
      9 a bad revision (it will try to guess HEAD) and asks for a last known-good
     10 revision. It will then binary search across this revision range by downloading,
     11 unzipping, and opening Chromium for you. After testing the specific revision,
     12 it will ask you whether it is good or bad before continuing the search.
     13 """
     14 
     15 # The root URL for storage.
     16 CHROMIUM_BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
     17 WEBKIT_BASE_URL = 'http://commondatastorage.googleapis.com/chromium-webkit-snapshots'
     18 
     19 # The root URL for official builds.
     20 OFFICIAL_BASE_URL = 'http://master.chrome.corp.google.com/official_builds'
     21 
     22 # Changelogs URL.
     23 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \
     24                 'perf/dashboard/ui/changelog.html?' \
     25                 'url=/trunk/src&range=%d%%3A%d'
     26 
     27 # Official Changelogs URL.
     28 OFFICIAL_CHANGELOG_URL = 'http://omahaproxy.appspot.com/'\
     29                          'changelog?old_version=%s&new_version=%s'
     30 
     31 # DEPS file URL.
     32 DEPS_FILE = 'http://src.chromium.org/viewvc/chrome/trunk/src/DEPS?revision=%d'
     33 # Blink Changelogs URL.
     34 BLINK_CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \
     35                       'perf/dashboard/ui/changelog_blink.html?' \
     36                       'url=/trunk&range=%d%%3A%d'
     37 
     38 DONE_MESSAGE_GOOD_MIN = 'You are probably looking for a change made after %s ' \
     39                         '(known good), but no later than %s (first known bad).'
     40 DONE_MESSAGE_GOOD_MAX = 'You are probably looking for a change made after %s ' \
     41                         '(known bad), but no later than %s (first known good).'
     42 
     43 ###############################################################################
     44 
     45 import json
     46 import optparse
     47 import os
     48 import re
     49 import shlex
     50 import shutil
     51 import subprocess
     52 import sys
     53 import tempfile
     54 import threading
     55 import urllib
     56 from distutils.version import LooseVersion
     57 from xml.etree import ElementTree
     58 import zipfile
     59 
     60 
     61 class PathContext(object):
     62   """A PathContext is used to carry the information used to construct URLs and
     63   paths when dealing with the storage server and archives."""
     64   def __init__(self, base_url, platform, good_revision, bad_revision,
     65                is_official, is_aura, flash_path = None):
     66     super(PathContext, self).__init__()
     67     # Store off the input parameters.
     68     self.base_url = base_url
     69     self.platform = platform  # What's passed in to the '-a/--archive' option.
     70     self.good_revision = good_revision
     71     self.bad_revision = bad_revision
     72     self.is_official = is_official
     73     self.is_aura = is_aura
     74     self.flash_path = flash_path
     75 
     76     # The name of the ZIP file in a revision directory on the server.
     77     self.archive_name = None
     78 
     79     # Set some internal members:
     80     #   _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
     81     #   _archive_extract_dir = Uncompressed directory in the archive_name file.
     82     #   _binary_name = The name of the executable to run.
     83     if self.platform in ('linux', 'linux64', 'linux-arm'):
     84       self._binary_name = 'chrome'
     85     elif self.platform == 'mac':
     86       self.archive_name = 'chrome-mac.zip'
     87       self._archive_extract_dir = 'chrome-mac'
     88     elif self.platform == 'win':
     89       self.archive_name = 'chrome-win32.zip'
     90       self._archive_extract_dir = 'chrome-win32'
     91       self._binary_name = 'chrome.exe'
     92     else:
     93       raise Exception('Invalid platform: %s' % self.platform)
     94 
     95     if is_official:
     96       if self.platform == 'linux':
     97         self._listing_platform_dir = 'precise32bit/'
     98         self.archive_name = 'chrome-precise32bit.zip'
     99         self._archive_extract_dir = 'chrome-precise32bit'
    100       elif self.platform == 'linux64':
    101         self._listing_platform_dir = 'precise64bit/'
    102         self.archive_name = 'chrome-precise64bit.zip'
    103         self._archive_extract_dir = 'chrome-precise64bit'
    104       elif self.platform == 'mac':
    105         self._listing_platform_dir = 'mac/'
    106         self._binary_name = 'Google Chrome.app/Contents/MacOS/Google Chrome'
    107       elif self.platform == 'win':
    108         if self.is_aura:
    109           self._listing_platform_dir = 'win-aura/'
    110         else:
    111           self._listing_platform_dir = 'win/'
    112     else:
    113       if self.platform in ('linux', 'linux64', 'linux-arm'):
    114         self.archive_name = 'chrome-linux.zip'
    115         self._archive_extract_dir = 'chrome-linux'
    116         if self.platform == 'linux':
    117           self._listing_platform_dir = 'Linux/'
    118         elif self.platform == 'linux64':
    119           self._listing_platform_dir = 'Linux_x64/'
    120         elif self.platform == 'linux-arm':
    121           self._listing_platform_dir = 'Linux_ARM_Cross-Compile/'
    122       elif self.platform == 'mac':
    123         self._listing_platform_dir = 'Mac/'
    124         self._binary_name = 'Chromium.app/Contents/MacOS/Chromium'
    125       elif self.platform == 'win':
    126         self._listing_platform_dir = 'Win/'
    127 
    128   def GetListingURL(self, marker=None):
    129     """Returns the URL for a directory listing, with an optional marker."""
    130     marker_param = ''
    131     if marker:
    132       marker_param = '&marker=' + str(marker)
    133     return self.base_url + '/?delimiter=/&prefix=' + \
    134         self._listing_platform_dir + marker_param
    135 
    136   def GetDownloadURL(self, revision):
    137     """Gets the download URL for a build archive of a specific revision."""
    138     if self.is_official:
    139       return "%s/%s/%s%s" % (
    140           OFFICIAL_BASE_URL, revision, self._listing_platform_dir,
    141           self.archive_name)
    142     else:
    143       return "%s/%s%s/%s" % (self.base_url, self._listing_platform_dir,
    144                              revision, self.archive_name)
    145 
    146   def GetLastChangeURL(self):
    147     """Returns a URL to the LAST_CHANGE file."""
    148     return self.base_url + '/' + self._listing_platform_dir + 'LAST_CHANGE'
    149 
    150   def GetLaunchPath(self):
    151     """Returns a relative path (presumably from the archive extraction location)
    152     that is used to run the executable."""
    153     return os.path.join(self._archive_extract_dir, self._binary_name)
    154 
    155   def IsAuraBuild(self, build):
    156     """Check the given build is Aura."""
    157     return build.split('.')[3] == '1'
    158 
    159   def IsASANBuild(self, build):
    160     """Check the given build is ASAN build."""
    161     return build.split('.')[3] == '2'
    162 
    163   def ParseDirectoryIndex(self):
    164     """Parses the Google Storage directory listing into a list of revision
    165     numbers."""
    166 
    167     def _FetchAndParse(url):
    168       """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
    169       next-marker is not None, then the listing is a partial listing and another
    170       fetch should be performed with next-marker being the marker= GET
    171       parameter."""
    172       handle = urllib.urlopen(url)
    173       document = ElementTree.parse(handle)
    174 
    175       # All nodes in the tree are namespaced. Get the root's tag name to extract
    176       # the namespace. Etree does namespaces as |{namespace}tag|.
    177       root_tag = document.getroot().tag
    178       end_ns_pos = root_tag.find('}')
    179       if end_ns_pos == -1:
    180         raise Exception("Could not locate end namespace for directory index")
    181       namespace = root_tag[:end_ns_pos + 1]
    182 
    183       # Find the prefix (_listing_platform_dir) and whether or not the list is
    184       # truncated.
    185       prefix_len = len(document.find(namespace + 'Prefix').text)
    186       next_marker = None
    187       is_truncated = document.find(namespace + 'IsTruncated')
    188       if is_truncated is not None and is_truncated.text.lower() == 'true':
    189         next_marker = document.find(namespace + 'NextMarker').text
    190 
    191       # Get a list of all the revisions.
    192       all_prefixes = document.findall(namespace + 'CommonPrefixes/' +
    193                                       namespace + 'Prefix')
    194       # The <Prefix> nodes have content of the form of
    195       # |_listing_platform_dir/revision/|. Strip off the platform dir and the
    196       # trailing slash to just have a number.
    197       revisions = []
    198       for prefix in all_prefixes:
    199         revnum = prefix.text[prefix_len:-1]
    200         try:
    201           revnum = int(revnum)
    202           revisions.append(revnum)
    203         except ValueError:
    204           pass
    205       return (revisions, next_marker)
    206 
    207     # Fetch the first list of revisions.
    208     (revisions, next_marker) = _FetchAndParse(self.GetListingURL())
    209 
    210     # If the result list was truncated, refetch with the next marker. Do this
    211     # until an entire directory listing is done.
    212     while next_marker:
    213       next_url = self.GetListingURL(next_marker)
    214       (new_revisions, next_marker) = _FetchAndParse(next_url)
    215       revisions.extend(new_revisions)
    216     return revisions
    217 
    218   def GetRevList(self):
    219     """Gets the list of revision numbers between self.good_revision and
    220     self.bad_revision."""
    221     # Download the revlist and filter for just the range between good and bad.
    222     minrev = min(self.good_revision, self.bad_revision)
    223     maxrev = max(self.good_revision, self.bad_revision)
    224     revlist_all = map(int, self.ParseDirectoryIndex())
    225 
    226     revlist = [x for x in revlist_all if x >= int(minrev) and x <= int(maxrev)]
    227     revlist.sort()
    228 
    229     # Set good and bad revisions to be legit revisions.
    230     if revlist:
    231       if self.good_revision < self.bad_revision:
    232         self.good_revision = revlist[0]
    233         self.bad_revision = revlist[-1]
    234       else:
    235         self.bad_revision = revlist[0]
    236         self.good_revision = revlist[-1]
    237 
    238       # Fix chromium rev so that the deps blink revision matches REVISIONS file.
    239       if self.base_url == WEBKIT_BASE_URL:
    240         revlist_all.sort()
    241         self.good_revision = FixChromiumRevForBlink(revlist,
    242                                                     revlist_all,
    243                                                     self,
    244                                                     self.good_revision)
    245         self.bad_revision = FixChromiumRevForBlink(revlist,
    246                                                    revlist_all,
    247                                                    self,
    248                                                    self.bad_revision)
    249     return revlist
    250 
    251   def GetOfficialBuildsList(self):
    252     """Gets the list of official build numbers between self.good_revision and
    253     self.bad_revision."""
    254     # Download the revlist and filter for just the range between good and bad.
    255     minrev = min(self.good_revision, self.bad_revision)
    256     maxrev = max(self.good_revision, self.bad_revision)
    257     handle = urllib.urlopen(OFFICIAL_BASE_URL)
    258     dirindex = handle.read()
    259     handle.close()
    260     build_numbers = re.findall(r'<a href="([0-9][0-9].*)/">', dirindex)
    261     final_list = []
    262     i = 0
    263     parsed_build_numbers = [LooseVersion(x) for x in build_numbers]
    264     for build_number in sorted(parsed_build_numbers):
    265       path = OFFICIAL_BASE_URL + '/' + str(build_number) + '/' + \
    266              self._listing_platform_dir + self.archive_name
    267       i = i + 1
    268       try:
    269         connection = urllib.urlopen(path)
    270         connection.close()
    271         if build_number > maxrev:
    272           break
    273         if build_number >= minrev:
    274           # If we are bisecting Aura, we want to include only builds which
    275           # ends with ".1".
    276           if self.is_aura:
    277             if self.IsAuraBuild(str(build_number)):
    278               final_list.append(str(build_number))
    279           # If we are bisecting only official builds (without --aura),
    280           # we can not include builds which ends with '.1' or '.2' since
    281           # they have different folder hierarchy inside.
    282           elif (not self.IsAuraBuild(str(build_number)) and
    283                 not self.IsASANBuild(str(build_number))):
    284             final_list.append(str(build_number))
    285       except urllib.HTTPError, e:
    286         pass
    287     return final_list
    288 
    289 def UnzipFilenameToDir(filename, directory):
    290   """Unzip |filename| to |directory|."""
    291   cwd = os.getcwd()
    292   if not os.path.isabs(filename):
    293     filename = os.path.join(cwd, filename)
    294   zf = zipfile.ZipFile(filename)
    295   # Make base.
    296   if not os.path.isdir(directory):
    297     os.mkdir(directory)
    298   os.chdir(directory)
    299   # Extract files.
    300   for info in zf.infolist():
    301     name = info.filename
    302     if name.endswith('/'):  # dir
    303       if not os.path.isdir(name):
    304         os.makedirs(name)
    305     else:  # file
    306       directory = os.path.dirname(name)
    307       if not os.path.isdir(directory):
    308         os.makedirs(directory)
    309       out = open(name, 'wb')
    310       out.write(zf.read(name))
    311       out.close()
    312     # Set permissions. Permission info in external_attr is shifted 16 bits.
    313     os.chmod(name, info.external_attr >> 16L)
    314   os.chdir(cwd)
    315 
    316 
    317 def FetchRevision(context, rev, filename, quit_event=None, progress_event=None):
    318   """Downloads and unzips revision |rev|.
    319   @param context A PathContext instance.
    320   @param rev The Chromium revision number/tag to download.
    321   @param filename The destination for the downloaded file.
    322   @param quit_event A threading.Event which will be set by the master thread to
    323                     indicate that the download should be aborted.
    324   @param progress_event A threading.Event which will be set by the master thread
    325                     to indicate that the progress of the download should be
    326                     displayed.
    327   """
    328   def ReportHook(blocknum, blocksize, totalsize):
    329     if quit_event and quit_event.isSet():
    330       raise RuntimeError("Aborting download of revision %s" % str(rev))
    331     if progress_event and progress_event.isSet():
    332       size = blocknum * blocksize
    333       if totalsize == -1:  # Total size not known.
    334         progress = "Received %d bytes" % size
    335       else:
    336         size = min(totalsize, size)
    337         progress = "Received %d of %d bytes, %.2f%%" % (
    338             size, totalsize, 100.0 * size / totalsize)
    339       # Send a \r to let all progress messages use just one line of output.
    340       sys.stdout.write("\r" + progress)
    341       sys.stdout.flush()
    342 
    343   download_url = context.GetDownloadURL(rev)
    344   try:
    345     urllib.urlretrieve(download_url, filename, ReportHook)
    346     if progress_event and progress_event.isSet():
    347       print
    348   except RuntimeError, e:
    349     pass
    350 
    351 
    352 def RunRevision(context, revision, zipfile, profile, num_runs, command, args):
    353   """Given a zipped revision, unzip it and run the test."""
    354   print "Trying revision %s..." % str(revision)
    355 
    356   # Create a temp directory and unzip the revision into it.
    357   cwd = os.getcwd()
    358   tempdir = tempfile.mkdtemp(prefix='bisect_tmp')
    359   UnzipFilenameToDir(zipfile, tempdir)
    360   os.chdir(tempdir)
    361 
    362   # Run the build as many times as specified.
    363   testargs = ['--user-data-dir=%s' % profile] + args
    364   # The sandbox must be run as root on Official Chrome, so bypass it.
    365   if ((context.is_official or context.flash_path) and
    366       context.platform.startswith('linux')):
    367     testargs.append('--no-sandbox')
    368   if context.flash_path:
    369     testargs.append('--ppapi-flash-path=%s' % context.flash_path)
    370     # We have to pass a large enough Flash version, which currently needs not
    371     # be correct. Instead of requiring the user of the script to figure out and
    372     # pass the correct version we just spoof it.
    373     testargs.append('--ppapi-flash-version=99.9.999.999')
    374 
    375   runcommand = []
    376   for token in shlex.split(command):
    377     if token == "%a":
    378       runcommand.extend(testargs)
    379     else:
    380       runcommand.append( \
    381           token.replace('%p', context.GetLaunchPath()) \
    382                .replace('%s', ' '.join(testargs)))
    383 
    384   for i in range(0, num_runs):
    385     subproc = subprocess.Popen(runcommand,
    386                                bufsize=-1,
    387                                stdout=subprocess.PIPE,
    388                                stderr=subprocess.PIPE)
    389     (stdout, stderr) = subproc.communicate()
    390 
    391   os.chdir(cwd)
    392   try:
    393     shutil.rmtree(tempdir, True)
    394   except Exception, e:
    395     pass
    396 
    397   return (subproc.returncode, stdout, stderr)
    398 
    399 
    400 def AskIsGoodBuild(rev, official_builds, status, stdout, stderr):
    401   """Ask the user whether build |rev| is good or bad."""
    402   # Loop until we get a response that we can parse.
    403   while True:
    404     response = raw_input('Revision %s is ' \
    405                          '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
    406                          str(rev))
    407     if response and response in ('g', 'b', 'r', 'u'):
    408       return response
    409     if response and response == 'q':
    410       raise SystemExit()
    411 
    412 
    413 class DownloadJob(object):
    414   """DownloadJob represents a task to download a given Chromium revision."""
    415   def __init__(self, context, name, rev, zipfile):
    416     super(DownloadJob, self).__init__()
    417     # Store off the input parameters.
    418     self.context = context
    419     self.name = name
    420     self.rev = rev
    421     self.zipfile = zipfile
    422     self.quit_event = threading.Event()
    423     self.progress_event = threading.Event()
    424 
    425   def Start(self):
    426     """Starts the download."""
    427     fetchargs = (self.context,
    428                  self.rev,
    429                  self.zipfile,
    430                  self.quit_event,
    431                  self.progress_event)
    432     self.thread = threading.Thread(target=FetchRevision,
    433                                    name=self.name,
    434                                    args=fetchargs)
    435     self.thread.start()
    436 
    437   def Stop(self):
    438     """Stops the download which must have been started previously."""
    439     self.quit_event.set()
    440     self.thread.join()
    441     os.unlink(self.zipfile)
    442 
    443   def WaitFor(self):
    444     """Prints a message and waits for the download to complete. The download
    445     must have been started previously."""
    446     print "Downloading revision %s..." % str(self.rev)
    447     self.progress_event.set()  # Display progress of download.
    448     self.thread.join()
    449 
    450 
    451 def Bisect(base_url,
    452            platform,
    453            official_builds,
    454            is_aura,
    455            good_rev=0,
    456            bad_rev=0,
    457            num_runs=1,
    458            command="%p %a",
    459            try_args=(),
    460            profile=None,
    461            flash_path=None,
    462            evaluate=AskIsGoodBuild):
    463   """Given known good and known bad revisions, run a binary search on all
    464   archived revisions to determine the last known good revision.
    465 
    466   @param platform Which build to download/run ('mac', 'win', 'linux64', etc.).
    467   @param official_builds Specify build type (Chromium or Official build).
    468   @param good_rev Number/tag of the known good revision.
    469   @param bad_rev Number/tag of the known bad revision.
    470   @param num_runs Number of times to run each build for asking good/bad.
    471   @param try_args A tuple of arguments to pass to the test application.
    472   @param profile The name of the user profile to run with.
    473   @param evaluate A function which returns 'g' if the argument build is good,
    474                   'b' if it's bad or 'u' if unknown.
    475 
    476   Threading is used to fetch Chromium revisions in the background, speeding up
    477   the user's experience. For example, suppose the bounds of the search are
    478   good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
    479   whether revision 50 is good or bad, the next revision to check will be either
    480   25 or 75. So, while revision 50 is being checked, the script will download
    481   revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
    482   known:
    483 
    484     - If rev 50 is good, the download of rev 25 is cancelled, and the next test
    485       is run on rev 75.
    486 
    487     - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
    488       is run on rev 25.
    489   """
    490 
    491   if not profile:
    492     profile = 'profile'
    493 
    494   context = PathContext(base_url, platform, good_rev, bad_rev,
    495                         official_builds, is_aura, flash_path)
    496   cwd = os.getcwd()
    497 
    498   print "Downloading list of known revisions..."
    499   _GetDownloadPath = lambda rev: os.path.join(cwd,
    500       '%s-%s' % (str(rev), context.archive_name))
    501   if official_builds:
    502     revlist = context.GetOfficialBuildsList()
    503   else:
    504     revlist = context.GetRevList()
    505 
    506   # Get a list of revisions to bisect across.
    507   if len(revlist) < 2:  # Don't have enough builds to bisect.
    508     msg = 'We don\'t have enough builds to bisect. revlist: %s' % revlist
    509     raise RuntimeError(msg)
    510 
    511   # Figure out our bookends and first pivot point; fetch the pivot revision.
    512   minrev = 0
    513   maxrev = len(revlist) - 1
    514   pivot = maxrev / 2
    515   rev = revlist[pivot]
    516   zipfile = _GetDownloadPath(rev)
    517   fetch = DownloadJob(context, 'initial_fetch', rev, zipfile)
    518   fetch.Start()
    519   fetch.WaitFor()
    520 
    521   # Binary search time!
    522   while fetch and fetch.zipfile and maxrev - minrev > 1:
    523     if bad_rev < good_rev:
    524       min_str, max_str = "bad", "good"
    525     else:
    526       min_str, max_str = "good", "bad"
    527     print 'Bisecting range [%s (%s), %s (%s)].' % (revlist[minrev], min_str, \
    528                                                    revlist[maxrev], max_str)
    529 
    530     # Pre-fetch next two possible pivots
    531     #   - down_pivot is the next revision to check if the current revision turns
    532     #     out to be bad.
    533     #   - up_pivot is the next revision to check if the current revision turns
    534     #     out to be good.
    535     down_pivot = int((pivot - minrev) / 2) + minrev
    536     down_fetch = None
    537     if down_pivot != pivot and down_pivot != minrev:
    538       down_rev = revlist[down_pivot]
    539       down_fetch = DownloadJob(context, 'down_fetch', down_rev,
    540                                _GetDownloadPath(down_rev))
    541       down_fetch.Start()
    542 
    543     up_pivot = int((maxrev - pivot) / 2) + pivot
    544     up_fetch = None
    545     if up_pivot != pivot and up_pivot != maxrev:
    546       up_rev = revlist[up_pivot]
    547       up_fetch = DownloadJob(context, 'up_fetch', up_rev,
    548                              _GetDownloadPath(up_rev))
    549       up_fetch.Start()
    550 
    551     # Run test on the pivot revision.
    552     status = None
    553     stdout = None
    554     stderr = None
    555     try:
    556       (status, stdout, stderr) = RunRevision(context,
    557                                              rev,
    558                                              fetch.zipfile,
    559                                              profile,
    560                                              num_runs,
    561                                              command,
    562                                              try_args)
    563     except Exception, e:
    564       print >> sys.stderr, e
    565 
    566     # Call the evaluate function to see if the current revision is good or bad.
    567     # On that basis, kill one of the background downloads and complete the
    568     # other, as described in the comments above.
    569     try:
    570       answer = evaluate(rev, official_builds, status, stdout, stderr)
    571       if answer == 'g' and good_rev < bad_rev or \
    572           answer == 'b' and bad_rev < good_rev:
    573         fetch.Stop()
    574         minrev = pivot
    575         if down_fetch:
    576           down_fetch.Stop()  # Kill the download of the older revision.
    577           fetch = None
    578         if up_fetch:
    579           up_fetch.WaitFor()
    580           pivot = up_pivot
    581           fetch = up_fetch
    582       elif answer == 'b' and good_rev < bad_rev or \
    583           answer == 'g' and bad_rev < good_rev:
    584         fetch.Stop()
    585         maxrev = pivot
    586         if up_fetch:
    587           up_fetch.Stop()  # Kill the download of the newer revision.
    588           fetch = None
    589         if down_fetch:
    590           down_fetch.WaitFor()
    591           pivot = down_pivot
    592           fetch = down_fetch
    593       elif answer == 'r':
    594         pass  # Retry requires no changes.
    595       elif answer == 'u':
    596         # Nuke the revision from the revlist and choose a new pivot.
    597         fetch.Stop()
    598         revlist.pop(pivot)
    599         maxrev -= 1  # Assumes maxrev >= pivot.
    600 
    601         if maxrev - minrev > 1:
    602           # Alternate between using down_pivot or up_pivot for the new pivot
    603           # point, without affecting the range. Do this instead of setting the
    604           # pivot to the midpoint of the new range because adjacent revisions
    605           # are likely affected by the same issue that caused the (u)nknown
    606           # response.
    607           if up_fetch and down_fetch:
    608             fetch = [up_fetch, down_fetch][len(revlist) % 2]
    609           elif up_fetch:
    610             fetch = up_fetch
    611           else:
    612             fetch = down_fetch
    613           fetch.WaitFor()
    614           if fetch == up_fetch:
    615             pivot = up_pivot - 1  # Subtracts 1 because revlist was resized.
    616           else:
    617             pivot = down_pivot
    618           zipfile = fetch.zipfile
    619 
    620         if down_fetch and fetch != down_fetch:
    621           down_fetch.Stop()
    622         if up_fetch and fetch != up_fetch:
    623           up_fetch.Stop()
    624       else:
    625         assert False, "Unexpected return value from evaluate(): " + answer
    626     except SystemExit:
    627       print "Cleaning up..."
    628       for f in [_GetDownloadPath(revlist[down_pivot]),
    629                 _GetDownloadPath(revlist[up_pivot])]:
    630         try:
    631           os.unlink(f)
    632         except OSError:
    633           pass
    634       sys.exit(0)
    635 
    636     rev = revlist[pivot]
    637 
    638   return (revlist[minrev], revlist[maxrev])
    639 
    640 
    641 def GetBlinkDEPSRevisionForChromiumRevision(rev):
    642   """Returns the blink revision that was in REVISIONS file at
    643   chromium revision |rev|."""
    644   # . doesn't match newlines without re.DOTALL, so this is safe.
    645   blink_re = re.compile(r'webkit_revision\D*(\d+)')
    646   url = urllib.urlopen(DEPS_FILE % rev)
    647   m = blink_re.search(url.read())
    648   url.close()
    649   if m:
    650     return int(m.group(1))
    651   else:
    652     raise Exception('Could not get Blink revision for Chromium rev %d'
    653                     % rev)
    654 
    655 
    656 def GetBlinkRevisionForChromiumRevision(self, rev):
    657   """Returns the blink revision that was in REVISIONS file at
    658   chromium revision |rev|."""
    659   file_url = "%s/%s%d/REVISIONS" % (self.base_url,
    660                                     self._listing_platform_dir, rev)
    661   url = urllib.urlopen(file_url)
    662   data = json.loads(url.read())
    663   url.close()
    664   if 'webkit_revision' in data:
    665     return data['webkit_revision']
    666   else:
    667     raise Exception('Could not get blink revision for cr rev %d' % rev)
    668 
    669 def FixChromiumRevForBlink(revisions_final, revisions, self, rev):
    670   """Returns the chromium revision that has the correct blink revision
    671   for blink bisect, DEPS and REVISIONS file might not match since
    672   blink snapshots point to tip of tree blink.
    673   Note: The revisions_final variable might get modified to include
    674   additional revisions."""
    675 
    676   blink_deps_rev = GetBlinkDEPSRevisionForChromiumRevision(rev)
    677 
    678   while (GetBlinkRevisionForChromiumRevision(self, rev) > blink_deps_rev):
    679     idx = revisions.index(rev)
    680     if idx > 0:
    681       rev = revisions[idx-1]
    682       if rev not in revisions_final:
    683         revisions_final.insert(0, rev)
    684 
    685   revisions_final.sort()
    686   return rev
    687 
    688 def GetChromiumRevision(url):
    689   """Returns the chromium revision read from given URL."""
    690   try:
    691     # Location of the latest build revision number
    692     return int(urllib.urlopen(url).read())
    693   except Exception, e:
    694     print('Could not determine latest revision. This could be bad...')
    695     return 999999999
    696 
    697 
    698 def main():
    699   usage = ('%prog [options] [-- chromium-options]\n'
    700            'Perform binary search on the snapshot builds to find a minimal\n'
    701            'range of revisions where a behavior change happened. The\n'
    702            'behaviors are described as "good" and "bad".\n'
    703            'It is NOT assumed that the behavior of the later revision is\n'
    704            'the bad one.\n'
    705            '\n'
    706            'Revision numbers should use\n'
    707            '  Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
    708            '  SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
    709            '    Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
    710            '    for earlier revs.\n'
    711            '    Chrome\'s about: build number and omahaproxy branch_revision\n'
    712            '    are incorrect, they are from branches.\n'
    713            '\n'
    714            'Tip: add "-- --no-first-run" to bypass the first run prompts.')
    715   parser = optparse.OptionParser(usage=usage)
    716   # Strangely, the default help output doesn't include the choice list.
    717   choices = ['mac', 'win', 'linux', 'linux64', 'linux-arm']
    718             # linux-chromiumos lacks a continuous archive http://crbug.com/78158
    719   parser.add_option('-a', '--archive',
    720                     choices = choices,
    721                     help = 'The buildbot archive to bisect [%s].' %
    722                            '|'.join(choices))
    723   parser.add_option('-o', action="store_true", dest='official_builds',
    724                     help = 'Bisect across official ' +
    725                     'Chrome builds (internal only) instead of ' +
    726                     'Chromium archives.')
    727   parser.add_option('-b', '--bad', type = 'str',
    728                     help = 'A bad revision to start bisection. ' +
    729                     'May be earlier or later than the good revision. ' +
    730                     'Default is HEAD.')
    731   parser.add_option('-f', '--flash_path', type = 'str',
    732                     help = 'Absolute path to a recent Adobe Pepper Flash ' +
    733                     'binary to be used in this bisection (e.g. ' +
    734                     'on Windows C:\...\pepflashplayer.dll and on Linux ' +
    735                     '/opt/google/chrome/PepperFlash/libpepflashplayer.so).')
    736   parser.add_option('-g', '--good', type = 'str',
    737                     help = 'A good revision to start bisection. ' +
    738                     'May be earlier or later than the bad revision. ' +
    739                     'Default is 0.')
    740   parser.add_option('-p', '--profile', '--user-data-dir', type = 'str',
    741                     help = 'Profile to use; this will not reset every run. ' +
    742                     'Defaults to a clean profile.', default = 'profile')
    743   parser.add_option('-t', '--times', type = 'int',
    744                     help = 'Number of times to run each build before asking ' +
    745                     'if it\'s good or bad. Temporary profiles are reused.',
    746                     default = 1)
    747   parser.add_option('-c', '--command', type = 'str',
    748                     help = 'Command to execute. %p and %a refer to Chrome ' +
    749                     'executable and specified extra arguments respectively. ' +
    750                     'Use %s to specify all extra arguments as one string. ' +
    751                     'Defaults to "%p %a". Note that any extra paths ' +
    752                     'specified should be absolute.',
    753                     default = '%p %a')
    754   parser.add_option('-l', '--blink', action='store_true',
    755                     help = 'Use Blink bisect instead of Chromium. ')
    756   parser.add_option('--aura',
    757                     dest='aura',
    758                     action='store_true',
    759                     default=False,
    760                     help='Allow the script to bisect aura builds')
    761 
    762   (opts, args) = parser.parse_args()
    763 
    764   if opts.archive is None:
    765     print 'Error: missing required parameter: --archive'
    766     print
    767     parser.print_help()
    768     return 1
    769 
    770   if opts.aura:
    771     if opts.archive != 'win' or not opts.official_builds:
    772       print 'Error: Aura is supported only on Windows platform '\
    773             'and official builds.'
    774       return 1
    775 
    776   if opts.blink:
    777     base_url = WEBKIT_BASE_URL
    778   else:
    779     base_url = CHROMIUM_BASE_URL
    780 
    781   # Create the context. Initialize 0 for the revisions as they are set below.
    782   context = PathContext(base_url, opts.archive, 0, 0,
    783                         opts.official_builds, opts.aura, None)
    784   # Pick a starting point, try to get HEAD for this.
    785   if opts.bad:
    786     bad_rev = opts.bad
    787   else:
    788     bad_rev = '999.0.0.0'
    789     if not opts.official_builds:
    790       bad_rev = GetChromiumRevision(context.GetLastChangeURL())
    791 
    792   # Find out when we were good.
    793   if opts.good:
    794     good_rev = opts.good
    795   else:
    796     good_rev = '0.0.0.0' if opts.official_builds else 0
    797 
    798   if opts.flash_path:
    799     flash_path = opts.flash_path
    800     msg = 'Could not find Flash binary at %s' % flash_path
    801     assert os.path.exists(flash_path), msg
    802 
    803   if opts.official_builds:
    804     good_rev = LooseVersion(good_rev)
    805     bad_rev = LooseVersion(bad_rev)
    806   else:
    807     good_rev = int(good_rev)
    808     bad_rev = int(bad_rev)
    809 
    810   if opts.times < 1:
    811     print('Number of times to run (%d) must be greater than or equal to 1.' %
    812           opts.times)
    813     parser.print_help()
    814     return 1
    815 
    816   (min_chromium_rev, max_chromium_rev) = Bisect(
    817       base_url, opts.archive, opts.official_builds, opts.aura, good_rev,
    818       bad_rev, opts.times, opts.command, args, opts.profile, opts.flash_path)
    819 
    820   # Get corresponding blink revisions.
    821   try:
    822     min_blink_rev = GetBlinkRevisionForChromiumRevision(context,
    823                                                         min_chromium_rev)
    824     max_blink_rev = GetBlinkRevisionForChromiumRevision(context,
    825                                                         max_chromium_rev)
    826   except Exception, e:
    827     # Silently ignore the failure.
    828     min_blink_rev, max_blink_rev = 0, 0
    829 
    830   if opts.blink:
    831     # We're done. Let the user know the results in an official manner.
    832     if good_rev > bad_rev:
    833       print DONE_MESSAGE_GOOD_MAX % (str(min_blink_rev), str(max_blink_rev))
    834     else:
    835       print DONE_MESSAGE_GOOD_MIN % (str(min_blink_rev), str(max_blink_rev))
    836 
    837     print 'BLINK CHANGELOG URL:'
    838     print '  ' + BLINK_CHANGELOG_URL % (max_blink_rev, min_blink_rev)
    839 
    840   else:
    841     # We're done. Let the user know the results in an official manner.
    842     if good_rev > bad_rev:
    843       print DONE_MESSAGE_GOOD_MAX % (str(min_chromium_rev),
    844                                      str(max_chromium_rev))
    845     else:
    846       print DONE_MESSAGE_GOOD_MIN % (str(min_chromium_rev),
    847                                      str(max_chromium_rev))
    848     if min_blink_rev != max_blink_rev:
    849       print ("NOTE: There is a Blink roll in the range, "
    850              "you might also want to do a Blink bisect.")
    851 
    852     print 'CHANGELOG URL:'
    853     if opts.official_builds:
    854       print OFFICIAL_CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
    855     else:
    856       print '  ' + CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
    857 
    858 if __name__ == '__main__':
    859   sys.exit(main())
    860