• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
8This script bisects a snapshot archive using binary search. It starts at
9a bad revision (it will try to guess HEAD) and asks for a last known-good
10revision. It will then binary search across this revision range by downloading,
11unzipping, and opening Chromium for you. After testing the specific revision,
12it will ask you whether it is good or bad before continuing the search.
13"""
14
15# The root URL for storage.
16CHROMIUM_BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
17WEBKIT_BASE_URL = 'http://commondatastorage.googleapis.com/chromium-webkit-snapshots'
18
19# The root URL for official builds.
20OFFICIAL_BASE_URL = 'http://master.chrome.corp.google.com/official_builds'
21
22# Changelogs URL.
23CHANGELOG_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.
28OFFICIAL_CHANGELOG_URL = 'http://omahaproxy.appspot.com/'\
29                         'changelog?old_version=%s&new_version=%s'
30
31# DEPS file URL.
32DEPS_FILE = 'http://src.chromium.org/viewvc/chrome/trunk/src/DEPS?revision=%d'
33# Blink Changelogs URL.
34BLINK_CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \
35                      'perf/dashboard/ui/changelog_blink.html?' \
36                      'url=/trunk&range=%d%%3A%d'
37
38DONE_MESSAGE_GOOD_MIN = 'You are probably looking for a change made after %s ' \
39                        '(known good), but no later than %s (first known bad).'
40DONE_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
45import json
46import optparse
47import os
48import re
49import shlex
50import shutil
51import subprocess
52import sys
53import tempfile
54import threading
55import urllib
56from distutils.version import LooseVersion
57from xml.etree import ElementTree
58import zipfile
59
60
61class 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
289def 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
317def 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
352def 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
400def 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
413class 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
451def 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
641def 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
656def 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
669def 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
688def 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
698def 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
858if __name__ == '__main__':
859  sys.exit(main())
860