bc65566e1285ef56a6077ca7032ed87d47fae6a7
[platform/framework/web/crosswalk.git] / src / tools / bisect-builds.py
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', os.path.abspath(context.GetLaunchPath())) \
382                .replace('%s', ' '.join(testargs)))
383
384   results = []
385   for i in range(0, num_runs):
386     subproc = subprocess.Popen(runcommand,
387                                bufsize=-1,
388                                stdout=subprocess.PIPE,
389                                stderr=subprocess.PIPE)
390     (stdout, stderr) = subproc.communicate()
391     results.append((subproc.returncode, stdout, stderr))
392
393   os.chdir(cwd)
394   try:
395     shutil.rmtree(tempdir, True)
396   except Exception, e:
397     pass
398
399   for (returncode, stdout, stderr) in results:
400     if returncode:
401       return (returncode, stdout, stderr)
402   return results[0]
403
404
405 def AskIsGoodBuild(rev, official_builds, status, stdout, stderr):
406   """Ask the user whether build |rev| is good or bad."""
407   # Loop until we get a response that we can parse.
408   while True:
409     response = raw_input('Revision %s is ' \
410                          '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
411                          str(rev))
412     if response and response in ('g', 'b', 'r', 'u'):
413       return response
414     if response and response == 'q':
415       raise SystemExit()
416
417
418 class DownloadJob(object):
419   """DownloadJob represents a task to download a given Chromium revision."""
420   def __init__(self, context, name, rev, zipfile):
421     super(DownloadJob, self).__init__()
422     # Store off the input parameters.
423     self.context = context
424     self.name = name
425     self.rev = rev
426     self.zipfile = zipfile
427     self.quit_event = threading.Event()
428     self.progress_event = threading.Event()
429
430   def Start(self):
431     """Starts the download."""
432     fetchargs = (self.context,
433                  self.rev,
434                  self.zipfile,
435                  self.quit_event,
436                  self.progress_event)
437     self.thread = threading.Thread(target=FetchRevision,
438                                    name=self.name,
439                                    args=fetchargs)
440     self.thread.start()
441
442   def Stop(self):
443     """Stops the download which must have been started previously."""
444     self.quit_event.set()
445     self.thread.join()
446     os.unlink(self.zipfile)
447
448   def WaitFor(self):
449     """Prints a message and waits for the download to complete. The download
450     must have been started previously."""
451     print "Downloading revision %s..." % str(self.rev)
452     self.progress_event.set()  # Display progress of download.
453     self.thread.join()
454
455
456 def Bisect(base_url,
457            platform,
458            official_builds,
459            is_aura,
460            good_rev=0,
461            bad_rev=0,
462            num_runs=1,
463            command="%p %a",
464            try_args=(),
465            profile=None,
466            flash_path=None,
467            interactive=True,
468            evaluate=AskIsGoodBuild):
469   """Given known good and known bad revisions, run a binary search on all
470   archived revisions to determine the last known good revision.
471
472   @param platform Which build to download/run ('mac', 'win', 'linux64', etc.).
473   @param official_builds Specify build type (Chromium or Official build).
474   @param good_rev Number/tag of the known good revision.
475   @param bad_rev Number/tag of the known bad revision.
476   @param num_runs Number of times to run each build for asking good/bad.
477   @param try_args A tuple of arguments to pass to the test application.
478   @param profile The name of the user profile to run with.
479   @param interactive If it is false, use command exit code for good or bad
480                      judgment of the argument build.
481   @param evaluate A function which returns 'g' if the argument build is good,
482                   'b' if it's bad or 'u' if unknown.
483
484   Threading is used to fetch Chromium revisions in the background, speeding up
485   the user's experience. For example, suppose the bounds of the search are
486   good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
487   whether revision 50 is good or bad, the next revision to check will be either
488   25 or 75. So, while revision 50 is being checked, the script will download
489   revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
490   known:
491
492     - If rev 50 is good, the download of rev 25 is cancelled, and the next test
493       is run on rev 75.
494
495     - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
496       is run on rev 25.
497   """
498
499   if not profile:
500     profile = 'profile'
501
502   context = PathContext(base_url, platform, good_rev, bad_rev,
503                         official_builds, is_aura, flash_path)
504   cwd = os.getcwd()
505
506   print "Downloading list of known revisions..."
507   _GetDownloadPath = lambda rev: os.path.join(cwd,
508       '%s-%s' % (str(rev), context.archive_name))
509   if official_builds:
510     revlist = context.GetOfficialBuildsList()
511   else:
512     revlist = context.GetRevList()
513
514   # Get a list of revisions to bisect across.
515   if len(revlist) < 2:  # Don't have enough builds to bisect.
516     msg = 'We don\'t have enough builds to bisect. revlist: %s' % revlist
517     raise RuntimeError(msg)
518
519   # Figure out our bookends and first pivot point; fetch the pivot revision.
520   minrev = 0
521   maxrev = len(revlist) - 1
522   pivot = maxrev / 2
523   rev = revlist[pivot]
524   zipfile = _GetDownloadPath(rev)
525   fetch = DownloadJob(context, 'initial_fetch', rev, zipfile)
526   fetch.Start()
527   fetch.WaitFor()
528
529   # Binary search time!
530   while fetch and fetch.zipfile and maxrev - minrev > 1:
531     if bad_rev < good_rev:
532       min_str, max_str = "bad", "good"
533     else:
534       min_str, max_str = "good", "bad"
535     print 'Bisecting range [%s (%s), %s (%s)].' % (revlist[minrev], min_str, \
536                                                    revlist[maxrev], max_str)
537
538     # Pre-fetch next two possible pivots
539     #   - down_pivot is the next revision to check if the current revision turns
540     #     out to be bad.
541     #   - up_pivot is the next revision to check if the current revision turns
542     #     out to be good.
543     down_pivot = int((pivot - minrev) / 2) + minrev
544     down_fetch = None
545     if down_pivot != pivot and down_pivot != minrev:
546       down_rev = revlist[down_pivot]
547       down_fetch = DownloadJob(context, 'down_fetch', down_rev,
548                                _GetDownloadPath(down_rev))
549       down_fetch.Start()
550
551     up_pivot = int((maxrev - pivot) / 2) + pivot
552     up_fetch = None
553     if up_pivot != pivot and up_pivot != maxrev:
554       up_rev = revlist[up_pivot]
555       up_fetch = DownloadJob(context, 'up_fetch', up_rev,
556                              _GetDownloadPath(up_rev))
557       up_fetch.Start()
558
559     # Run test on the pivot revision.
560     status = None
561     stdout = None
562     stderr = None
563     try:
564       (status, stdout, stderr) = RunRevision(context,
565                                              rev,
566                                              fetch.zipfile,
567                                              profile,
568                                              num_runs,
569                                              command,
570                                              try_args)
571     except Exception, e:
572       print >> sys.stderr, e
573
574     # Call the evaluate function to see if the current revision is good or bad.
575     # On that basis, kill one of the background downloads and complete the
576     # other, as described in the comments above.
577     try:
578       if not interactive:
579         if status:
580           answer = 'b'
581           print 'Bad revision: %s' % rev
582         else:
583           answer = 'g'
584           print 'Good revision: %s' % rev
585       else:
586         answer = evaluate(rev, official_builds, status, stdout, stderr)
587       if answer == 'g' and good_rev < bad_rev or \
588           answer == 'b' and bad_rev < good_rev:
589         fetch.Stop()
590         minrev = pivot
591         if down_fetch:
592           down_fetch.Stop()  # Kill the download of the older revision.
593           fetch = None
594         if up_fetch:
595           up_fetch.WaitFor()
596           pivot = up_pivot
597           fetch = up_fetch
598       elif answer == 'b' and good_rev < bad_rev or \
599           answer == 'g' and bad_rev < good_rev:
600         fetch.Stop()
601         maxrev = pivot
602         if up_fetch:
603           up_fetch.Stop()  # Kill the download of the newer revision.
604           fetch = None
605         if down_fetch:
606           down_fetch.WaitFor()
607           pivot = down_pivot
608           fetch = down_fetch
609       elif answer == 'r':
610         pass  # Retry requires no changes.
611       elif answer == 'u':
612         # Nuke the revision from the revlist and choose a new pivot.
613         fetch.Stop()
614         revlist.pop(pivot)
615         maxrev -= 1  # Assumes maxrev >= pivot.
616
617         if maxrev - minrev > 1:
618           # Alternate between using down_pivot or up_pivot for the new pivot
619           # point, without affecting the range. Do this instead of setting the
620           # pivot to the midpoint of the new range because adjacent revisions
621           # are likely affected by the same issue that caused the (u)nknown
622           # response.
623           if up_fetch and down_fetch:
624             fetch = [up_fetch, down_fetch][len(revlist) % 2]
625           elif up_fetch:
626             fetch = up_fetch
627           else:
628             fetch = down_fetch
629           fetch.WaitFor()
630           if fetch == up_fetch:
631             pivot = up_pivot - 1  # Subtracts 1 because revlist was resized.
632           else:
633             pivot = down_pivot
634           zipfile = fetch.zipfile
635
636         if down_fetch and fetch != down_fetch:
637           down_fetch.Stop()
638         if up_fetch and fetch != up_fetch:
639           up_fetch.Stop()
640       else:
641         assert False, "Unexpected return value from evaluate(): " + answer
642     except SystemExit:
643       print "Cleaning up..."
644       for f in [_GetDownloadPath(revlist[down_pivot]),
645                 _GetDownloadPath(revlist[up_pivot])]:
646         try:
647           os.unlink(f)
648         except OSError:
649           pass
650       sys.exit(0)
651
652     rev = revlist[pivot]
653
654   return (revlist[minrev], revlist[maxrev])
655
656
657 def GetBlinkDEPSRevisionForChromiumRevision(rev):
658   """Returns the blink revision that was in REVISIONS file at
659   chromium revision |rev|."""
660   # . doesn't match newlines without re.DOTALL, so this is safe.
661   blink_re = re.compile(r'webkit_revision\D*(\d+)')
662   url = urllib.urlopen(DEPS_FILE % rev)
663   m = blink_re.search(url.read())
664   url.close()
665   if m:
666     return int(m.group(1))
667   else:
668     raise Exception('Could not get Blink revision for Chromium rev %d'
669                     % rev)
670
671
672 def GetBlinkRevisionForChromiumRevision(self, rev):
673   """Returns the blink revision that was in REVISIONS file at
674   chromium revision |rev|."""
675   file_url = "%s/%s%d/REVISIONS" % (self.base_url,
676                                     self._listing_platform_dir, rev)
677   url = urllib.urlopen(file_url)
678   data = json.loads(url.read())
679   url.close()
680   if 'webkit_revision' in data:
681     return data['webkit_revision']
682   else:
683     raise Exception('Could not get blink revision for cr rev %d' % rev)
684
685 def FixChromiumRevForBlink(revisions_final, revisions, self, rev):
686   """Returns the chromium revision that has the correct blink revision
687   for blink bisect, DEPS and REVISIONS file might not match since
688   blink snapshots point to tip of tree blink.
689   Note: The revisions_final variable might get modified to include
690   additional revisions."""
691
692   blink_deps_rev = GetBlinkDEPSRevisionForChromiumRevision(rev)
693
694   while (GetBlinkRevisionForChromiumRevision(self, rev) > blink_deps_rev):
695     idx = revisions.index(rev)
696     if idx > 0:
697       rev = revisions[idx-1]
698       if rev not in revisions_final:
699         revisions_final.insert(0, rev)
700
701   revisions_final.sort()
702   return rev
703
704 def GetChromiumRevision(url):
705   """Returns the chromium revision read from given URL."""
706   try:
707     # Location of the latest build revision number
708     return int(urllib.urlopen(url).read())
709   except Exception, e:
710     print('Could not determine latest revision. This could be bad...')
711     return 999999999
712
713
714 def main():
715   usage = ('%prog [options] [-- chromium-options]\n'
716            'Perform binary search on the snapshot builds to find a minimal\n'
717            'range of revisions where a behavior change happened. The\n'
718            'behaviors are described as "good" and "bad".\n'
719            'It is NOT assumed that the behavior of the later revision is\n'
720            'the bad one.\n'
721            '\n'
722            'Revision numbers should use\n'
723            '  Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
724            '  SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
725            '    Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
726            '    for earlier revs.\n'
727            '    Chrome\'s about: build number and omahaproxy branch_revision\n'
728            '    are incorrect, they are from branches.\n'
729            '\n'
730            'Tip: add "-- --no-first-run" to bypass the first run prompts.')
731   parser = optparse.OptionParser(usage=usage)
732   # Strangely, the default help output doesn't include the choice list.
733   choices = ['mac', 'win', 'linux', 'linux64', 'linux-arm']
734             # linux-chromiumos lacks a continuous archive http://crbug.com/78158
735   parser.add_option('-a', '--archive',
736                     choices = choices,
737                     help = 'The buildbot archive to bisect [%s].' %
738                            '|'.join(choices))
739   parser.add_option('-o', action="store_true", dest='official_builds',
740                     help = 'Bisect across official ' +
741                     'Chrome builds (internal only) instead of ' +
742                     'Chromium archives.')
743   parser.add_option('-b', '--bad', type = 'str',
744                     help = 'A bad revision to start bisection. ' +
745                     'May be earlier or later than the good revision. ' +
746                     'Default is HEAD.')
747   parser.add_option('-f', '--flash_path', type = 'str',
748                     help = 'Absolute path to a recent Adobe Pepper Flash ' +
749                     'binary to be used in this bisection (e.g. ' +
750                     'on Windows C:\...\pepflashplayer.dll and on Linux ' +
751                     '/opt/google/chrome/PepperFlash/libpepflashplayer.so).')
752   parser.add_option('-g', '--good', type = 'str',
753                     help = 'A good revision to start bisection. ' +
754                     'May be earlier or later than the bad revision. ' +
755                     'Default is 0.')
756   parser.add_option('-p', '--profile', '--user-data-dir', type = 'str',
757                     help = 'Profile to use; this will not reset every run. ' +
758                     'Defaults to a clean profile.', default = 'profile')
759   parser.add_option('-t', '--times', type = 'int',
760                     help = 'Number of times to run each build before asking ' +
761                     'if it\'s good or bad. Temporary profiles are reused.',
762                     default = 1)
763   parser.add_option('-c', '--command', type = 'str',
764                     help = 'Command to execute. %p and %a refer to Chrome ' +
765                     'executable and specified extra arguments respectively. ' +
766                     'Use %s to specify all extra arguments as one string. ' +
767                     'Defaults to "%p %a". Note that any extra paths ' +
768                     'specified should be absolute.',
769                     default = '%p %a')
770   parser.add_option('-l', '--blink', action='store_true',
771                     help = 'Use Blink bisect instead of Chromium. ')
772   parser.add_option('', '--not-interactive', action='store_true',
773                     help = 'Use command exit code to tell good/bad revision.',
774                     default=False)
775   parser.add_option('--aura',
776                     dest='aura',
777                     action='store_true',
778                     default=False,
779                     help='Allow the script to bisect aura builds')
780
781   (opts, args) = parser.parse_args()
782
783   if opts.archive is None:
784     print 'Error: missing required parameter: --archive'
785     print
786     parser.print_help()
787     return 1
788
789   if opts.aura:
790     if opts.archive != 'win' or not opts.official_builds:
791       print 'Error: Aura is supported only on Windows platform '\
792             'and official builds.'
793       return 1
794
795   if opts.blink:
796     base_url = WEBKIT_BASE_URL
797   else:
798     base_url = CHROMIUM_BASE_URL
799
800   # Create the context. Initialize 0 for the revisions as they are set below.
801   context = PathContext(base_url, opts.archive, 0, 0,
802                         opts.official_builds, opts.aura, None)
803   # Pick a starting point, try to get HEAD for this.
804   if opts.bad:
805     bad_rev = opts.bad
806   else:
807     bad_rev = '999.0.0.0'
808     if not opts.official_builds:
809       bad_rev = GetChromiumRevision(context.GetLastChangeURL())
810
811   # Find out when we were good.
812   if opts.good:
813     good_rev = opts.good
814   else:
815     good_rev = '0.0.0.0' if opts.official_builds else 0
816
817   if opts.flash_path:
818     flash_path = opts.flash_path
819     msg = 'Could not find Flash binary at %s' % flash_path
820     assert os.path.exists(flash_path), msg
821
822   if opts.official_builds:
823     good_rev = LooseVersion(good_rev)
824     bad_rev = LooseVersion(bad_rev)
825   else:
826     good_rev = int(good_rev)
827     bad_rev = int(bad_rev)
828
829   if opts.times < 1:
830     print('Number of times to run (%d) must be greater than or equal to 1.' %
831           opts.times)
832     parser.print_help()
833     return 1
834
835   (min_chromium_rev, max_chromium_rev) = Bisect(
836       base_url, opts.archive, opts.official_builds, opts.aura, good_rev,
837       bad_rev, opts.times, opts.command, args, opts.profile, opts.flash_path,
838       not opts.not_interactive)
839
840   # Get corresponding blink revisions.
841   try:
842     min_blink_rev = GetBlinkRevisionForChromiumRevision(context,
843                                                         min_chromium_rev)
844     max_blink_rev = GetBlinkRevisionForChromiumRevision(context,
845                                                         max_chromium_rev)
846   except Exception, e:
847     # Silently ignore the failure.
848     min_blink_rev, max_blink_rev = 0, 0
849
850   if opts.blink:
851     # We're done. Let the user know the results in an official manner.
852     if good_rev > bad_rev:
853       print DONE_MESSAGE_GOOD_MAX % (str(min_blink_rev), str(max_blink_rev))
854     else:
855       print DONE_MESSAGE_GOOD_MIN % (str(min_blink_rev), str(max_blink_rev))
856
857     print 'BLINK CHANGELOG URL:'
858     print '  ' + BLINK_CHANGELOG_URL % (max_blink_rev, min_blink_rev)
859
860   else:
861     # We're done. Let the user know the results in an official manner.
862     if good_rev > bad_rev:
863       print DONE_MESSAGE_GOOD_MAX % (str(min_chromium_rev),
864                                      str(max_chromium_rev))
865     else:
866       print DONE_MESSAGE_GOOD_MIN % (str(min_chromium_rev),
867                                      str(max_chromium_rev))
868     if min_blink_rev != max_blink_rev:
869       print ("NOTE: There is a Blink roll in the range, "
870              "you might also want to do a Blink bisect.")
871
872     print 'CHANGELOG URL:'
873     if opts.official_builds:
874       print OFFICIAL_CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
875     else:
876       print '  ' + CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
877
878 if __name__ == '__main__':
879   sys.exit(main())