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.
6 """Snapshot Build Bisect Tool
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.
15 # The base URL for stored build archives.
16 CHROMIUM_BASE_URL
= ('http://commondatastorage.googleapis.com'
17 '/chromium-browser-snapshots')
18 WEBKIT_BASE_URL
= ('http://commondatastorage.googleapis.com'
19 '/chromium-webkit-snapshots')
20 ASAN_BASE_URL
= ('http://commondatastorage.googleapis.com'
21 '/chromium-browser-asan')
24 GS_BUCKET_NAME
= 'chrome-unsigned/desktop-W15K3Y'
26 # Base URL for downloading official builds.
27 GOOGLE_APIS_URL
= 'commondatastorage.googleapis.com'
29 # The base URL for official builds.
30 OFFICIAL_BASE_URL
= 'http://%s/%s' % (GOOGLE_APIS_URL
, GS_BUCKET_NAME
)
32 # URL template for viewing changelogs between revisions.
33 CHANGELOG_URL
= ('https://chromium.googlesource.com/chromium/src/+log/%s..%s')
35 # URL to convert SVN revision to git hash.
36 CRREV_URL
= ('https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/')
38 # URL template for viewing changelogs between official versions.
39 OFFICIAL_CHANGELOG_URL
= ('https://chromium.googlesource.com/chromium/'
40 'src/+log/%s..%s?pretty=full')
43 DEPS_FILE
= 'http://src.chromium.org/viewvc/chrome/trunk/src/DEPS?revision=%d'
45 # Blink changelogs URL.
46 BLINK_CHANGELOG_URL
= ('http://build.chromium.org'
47 '/f/chromium/perf/dashboard/ui/changelog_blink.html'
48 '?url=/trunk&range=%d%%3A%d')
50 DONE_MESSAGE_GOOD_MIN
= ('You are probably looking for a change made after %s ('
51 'known good), but no later than %s (first known bad).')
52 DONE_MESSAGE_GOOD_MAX
= ('You are probably looking for a change made after %s ('
53 'known bad), but no later than %s (first known good).')
55 CHROMIUM_GITHASH_TO_SVN_URL
= (
56 'https://chromium.googlesource.com/chromium/src/+/%s?format=json')
58 BLINK_GITHASH_TO_SVN_URL
= (
59 'https://chromium.googlesource.com/chromium/blink/+/%s?format=json')
61 GITHASH_TO_SVN_URL
= {
62 'chromium': CHROMIUM_GITHASH_TO_SVN_URL
,
63 'blink': BLINK_GITHASH_TO_SVN_URL
,
66 # Search pattern to be matched in the JSON output from
67 # CHROMIUM_GITHASH_TO_SVN_URL to get the chromium revision (svn revision).
68 CHROMIUM_SEARCH_PATTERN_OLD
= (
69 r
'.*git-svn-id: svn://svn.chromium.org/chrome/trunk/src@(\d+) ')
70 CHROMIUM_SEARCH_PATTERN
= (
71 r
'Cr-Commit-Position: refs/heads/master@{#(\d+)}')
73 # Search pattern to be matched in the json output from
74 # BLINK_GITHASH_TO_SVN_URL to get the blink revision (svn revision).
75 BLINK_SEARCH_PATTERN
= (
76 r
'.*git-svn-id: svn://svn.chromium.org/blink/trunk@(\d+) ')
79 'chromium': CHROMIUM_SEARCH_PATTERN
,
80 'blink': BLINK_SEARCH_PATTERN
,
83 CREDENTIAL_ERROR_MESSAGE
= ('You are attempting to access protected data with '
84 'no configured credentials')
86 ###############################################################################
100 from distutils
.version
import LooseVersion
101 from xml
.etree
import ElementTree
105 class PathContext(object):
106 """A PathContext is used to carry the information used to construct URLs and
107 paths when dealing with the storage server and archives."""
108 def __init__(self
, base_url
, platform
, good_revision
, bad_revision
,
109 is_official
, is_asan
, use_local_repo
, flash_path
= None,
111 super(PathContext
, self
).__init
__()
112 # Store off the input parameters.
113 self
.base_url
= base_url
114 self
.platform
= platform
# What's passed in to the '-a/--archive' option.
115 self
.good_revision
= good_revision
116 self
.bad_revision
= bad_revision
117 self
.is_official
= is_official
118 self
.is_asan
= is_asan
119 self
.build_type
= 'release'
120 self
.flash_path
= flash_path
121 # Dictionary which stores svn revision number as key and it's
122 # corresponding git hash as value. This data is populated in
123 # _FetchAndParse and used later in GetDownloadURL while downloading
125 self
.githash_svn_dict
= {}
126 self
.pdf_path
= pdf_path
128 # The name of the ZIP file in a revision directory on the server.
129 self
.archive_name
= None
131 # If the script is run from a local Chromium checkout,
132 # "--use-local-repo" option can be used to make the script run faster.
133 # It uses "git svn find-rev <SHA1>" command to convert git hash to svn
135 self
.use_local_repo
= use_local_repo
137 # Set some internal members:
138 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
139 # _archive_extract_dir = Uncompressed directory in the archive_name file.
140 # _binary_name = The name of the executable to run.
141 if self
.platform
in ('linux', 'linux64', 'linux-arm'):
142 self
._binary
_name
= 'chrome'
143 elif self
.platform
in ('mac', 'mac64'):
144 self
.archive_name
= 'chrome-mac.zip'
145 self
._archive
_extract
_dir
= 'chrome-mac'
146 elif self
.platform
in ('win', 'win64'):
147 self
.archive_name
= 'chrome-win32.zip'
148 self
._archive
_extract
_dir
= 'chrome-win32'
149 self
._binary
_name
= 'chrome.exe'
151 raise Exception('Invalid platform: %s' % self
.platform
)
154 if self
.platform
== 'linux':
155 self
._listing
_platform
_dir
= 'precise32/'
156 self
.archive_name
= 'chrome-precise32.zip'
157 self
._archive
_extract
_dir
= 'chrome-precise32'
158 elif self
.platform
== 'linux64':
159 self
._listing
_platform
_dir
= 'precise64/'
160 self
.archive_name
= 'chrome-precise64.zip'
161 self
._archive
_extract
_dir
= 'chrome-precise64'
162 elif self
.platform
== 'mac':
163 self
._listing
_platform
_dir
= 'mac/'
164 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
165 elif self
.platform
== 'mac64':
166 self
._listing
_platform
_dir
= 'mac64/'
167 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
168 elif self
.platform
== 'win':
169 self
._listing
_platform
_dir
= 'win/'
170 self
.archive_name
= 'chrome-win.zip'
171 self
._archive
_extract
_dir
= 'chrome-win'
172 elif self
.platform
== 'win64':
173 self
._listing
_platform
_dir
= 'win64/'
174 self
.archive_name
= 'chrome-win64.zip'
175 self
._archive
_extract
_dir
= 'chrome-win64'
177 if self
.platform
in ('linux', 'linux64', 'linux-arm'):
178 self
.archive_name
= 'chrome-linux.zip'
179 self
._archive
_extract
_dir
= 'chrome-linux'
180 if self
.platform
== 'linux':
181 self
._listing
_platform
_dir
= 'Linux/'
182 elif self
.platform
== 'linux64':
183 self
._listing
_platform
_dir
= 'Linux_x64/'
184 elif self
.platform
== 'linux-arm':
185 self
._listing
_platform
_dir
= 'Linux_ARM_Cross-Compile/'
186 elif self
.platform
== 'mac':
187 self
._listing
_platform
_dir
= 'Mac/'
188 self
._binary
_name
= 'Chromium.app/Contents/MacOS/Chromium'
189 elif self
.platform
== 'win':
190 self
._listing
_platform
_dir
= 'Win/'
192 def GetASANPlatformDir(self
):
193 """ASAN builds are in directories like "linux-release", or have filenames
194 like "asan-win32-release-277079.zip". This aligns to our platform names
195 except in the case of Windows where they use "win32" instead of "win"."""
196 if self
.platform
== 'win':
201 def GetListingURL(self
, marker
=None):
202 """Returns the URL for a directory listing, with an optional marker."""
205 marker_param
= '&marker=' + str(marker
)
207 prefix
= '%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
208 return self
.base_url
+ '/?delimiter=&prefix=' + prefix
+ marker_param
210 return (self
.base_url
+ '/?delimiter=/&prefix=' +
211 self
._listing
_platform
_dir
+ marker_param
)
213 def GetDownloadURL(self
, revision
):
214 """Gets the download URL for a build archive of a specific revision."""
216 return '%s/%s-%s/%s-%d.zip' % (
217 ASAN_BASE_URL
, self
.GetASANPlatformDir(), self
.build_type
,
218 self
.GetASANBaseName(), revision
)
220 return '%s/%s/%s%s' % (
221 OFFICIAL_BASE_URL
, revision
, self
._listing
_platform
_dir
,
224 if str(revision
) in self
.githash_svn_dict
:
225 revision
= self
.githash_svn_dict
[str(revision
)]
226 return '%s/%s%s/%s' % (self
.base_url
, self
._listing
_platform
_dir
,
227 revision
, self
.archive_name
)
229 def GetLastChangeURL(self
):
230 """Returns a URL to the LAST_CHANGE file."""
231 return self
.base_url
+ '/' + self
._listing
_platform
_dir
+ 'LAST_CHANGE'
233 def GetASANBaseName(self
):
234 """Returns the base name of the ASAN zip file."""
235 if 'linux' in self
.platform
:
236 return 'asan-symbolized-%s-%s' % (self
.GetASANPlatformDir(),
239 return 'asan-%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
241 def GetLaunchPath(self
, revision
):
242 """Returns a relative path (presumably from the archive extraction location)
243 that is used to run the executable."""
245 extract_dir
= '%s-%d' % (self
.GetASANBaseName(), revision
)
247 extract_dir
= self
._archive
_extract
_dir
248 return os
.path
.join(extract_dir
, self
._binary
_name
)
250 def ParseDirectoryIndex(self
):
251 """Parses the Google Storage directory listing into a list of revision
254 def _FetchAndParse(url
):
255 """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
256 next-marker is not None, then the listing is a partial listing and another
257 fetch should be performed with next-marker being the marker= GET
259 handle
= urllib
.urlopen(url
)
260 document
= ElementTree
.parse(handle
)
262 # All nodes in the tree are namespaced. Get the root's tag name to extract
263 # the namespace. Etree does namespaces as |{namespace}tag|.
264 root_tag
= document
.getroot().tag
265 end_ns_pos
= root_tag
.find('}')
267 raise Exception('Could not locate end namespace for directory index')
268 namespace
= root_tag
[:end_ns_pos
+ 1]
270 # Find the prefix (_listing_platform_dir) and whether or not the list is
272 prefix_len
= len(document
.find(namespace
+ 'Prefix').text
)
274 is_truncated
= document
.find(namespace
+ 'IsTruncated')
275 if is_truncated
is not None and is_truncated
.text
.lower() == 'true':
276 next_marker
= document
.find(namespace
+ 'NextMarker').text
277 # Get a list of all the revisions.
279 githash_svn_dict
= {}
281 asan_regex
= re
.compile(r
'.*%s-(\d+)\.zip$' % (self
.GetASANBaseName()))
282 # Non ASAN builds are in a <revision> directory. The ASAN builds are
284 all_prefixes
= document
.findall(namespace
+ 'Contents/' +
286 for prefix
in all_prefixes
:
287 m
= asan_regex
.match(prefix
.text
)
290 revisions
.append(int(m
.group(1)))
294 all_prefixes
= document
.findall(namespace
+ 'CommonPrefixes/' +
295 namespace
+ 'Prefix')
296 # The <Prefix> nodes have content of the form of
297 # |_listing_platform_dir/revision/|. Strip off the platform dir and the
298 # trailing slash to just have a number.
299 for prefix
in all_prefixes
:
300 revnum
= prefix
.text
[prefix_len
:-1]
302 if not revnum
.isdigit():
304 revnum
= self
.GetSVNRevisionFromGitHash(git_hash
)
305 githash_svn_dict
[revnum
] = git_hash
306 if revnum
is not None:
308 revisions
.append(revnum
)
311 return (revisions
, next_marker
, githash_svn_dict
)
313 # Fetch the first list of revisions.
314 (revisions
, next_marker
, self
.githash_svn_dict
) = _FetchAndParse(
315 self
.GetListingURL())
316 # If the result list was truncated, refetch with the next marker. Do this
317 # until an entire directory listing is done.
319 next_url
= self
.GetListingURL(next_marker
)
320 (new_revisions
, next_marker
, new_dict
) = _FetchAndParse(next_url
)
321 revisions
.extend(new_revisions
)
322 self
.githash_svn_dict
.update(new_dict
)
325 def _GetSVNRevisionFromGitHashWithoutGitCheckout(self
, git_sha1
, depot
):
326 json_url
= GITHASH_TO_SVN_URL
[depot
] % git_sha1
327 response
= urllib
.urlopen(json_url
)
328 if response
.getcode() == 200:
330 data
= json
.loads(response
.read()[4:])
332 print 'ValueError for JSON URL: %s' % json_url
336 if 'message' in data
:
337 message
= data
['message'].split('\n')
338 message
= [line
for line
in message
if line
.strip()]
339 search_pattern
= re
.compile(SEARCH_PATTERN
[depot
])
340 result
= search_pattern
.search(message
[len(message
)-1])
342 return result
.group(1)
344 if depot
== 'chromium':
345 result
= re
.search(CHROMIUM_SEARCH_PATTERN_OLD
,
346 message
[len(message
)-1])
348 return result
.group(1)
349 print 'Failed to get svn revision number for %s' % git_sha1
352 def _GetSVNRevisionFromGitHashFromGitCheckout(self
, git_sha1
, depot
):
353 def _RunGit(command
, path
):
354 command
= ['git'] + command
356 original_path
= os
.getcwd()
358 shell
= sys
.platform
.startswith('win')
359 proc
= subprocess
.Popen(command
, shell
=shell
, stdout
=subprocess
.PIPE
,
360 stderr
=subprocess
.PIPE
)
361 (output
, _
) = proc
.communicate()
364 os
.chdir(original_path
)
365 return (output
, proc
.returncode
)
369 path
= os
.path
.join(os
.getcwd(), 'third_party', 'WebKit')
370 if os
.path
.basename(os
.getcwd()) == 'src':
371 command
= ['svn', 'find-rev', git_sha1
]
372 (git_output
, return_code
) = _RunGit(command
, path
)
374 return git_output
.strip('\n')
377 print ('Script should be run from src folder. ' +
378 'Eg: python tools/bisect-builds.py -g 280588 -b 280590' +
379 '--archive linux64 --use-local-repo')
382 def GetSVNRevisionFromGitHash(self
, git_sha1
, depot
='chromium'):
383 if not self
.use_local_repo
:
384 return self
._GetSVNRevisionFromGitHashWithoutGitCheckout
(git_sha1
, depot
)
386 return self
._GetSVNRevisionFromGitHashFromGitCheckout
(git_sha1
, depot
)
388 def GetRevList(self
):
389 """Gets the list of revision numbers between self.good_revision and
390 self.bad_revision."""
391 # Download the revlist and filter for just the range between good and bad.
392 minrev
= min(self
.good_revision
, self
.bad_revision
)
393 maxrev
= max(self
.good_revision
, self
.bad_revision
)
394 revlist_all
= map(int, self
.ParseDirectoryIndex())
396 revlist
= [x
for x
in revlist_all
if x
>= int(minrev
) and x
<= int(maxrev
)]
399 # Set good and bad revisions to be legit revisions.
401 if self
.good_revision
< self
.bad_revision
:
402 self
.good_revision
= revlist
[0]
403 self
.bad_revision
= revlist
[-1]
405 self
.bad_revision
= revlist
[0]
406 self
.good_revision
= revlist
[-1]
408 # Fix chromium rev so that the deps blink revision matches REVISIONS file.
409 if self
.base_url
== WEBKIT_BASE_URL
:
411 self
.good_revision
= FixChromiumRevForBlink(revlist
,
415 self
.bad_revision
= FixChromiumRevForBlink(revlist
,
421 def GetOfficialBuildsList(self
):
422 """Gets the list of official build numbers between self.good_revision and
423 self.bad_revision."""
425 def CheckDepotToolsInPath():
426 delimiter
= ';' if sys
.platform
.startswith('win') else ':'
427 path_list
= os
.environ
['PATH'].split(delimiter
)
428 for path
in path_list
:
429 if path
.find('depot_tools') != -1:
433 def RunGsutilCommand(args
):
434 gsutil_path
= CheckDepotToolsInPath()
435 if gsutil_path
is None:
436 print ('Follow the instructions in this document '
437 'http://dev.chromium.org/developers/how-tos/install-depot-tools'
438 ' to install depot_tools and then try again.')
440 gsutil_path
= os
.path
.join(gsutil_path
, 'third_party', 'gsutil', 'gsutil')
441 gsutil
= subprocess
.Popen([sys
.executable
, gsutil_path
] + args
,
442 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
,
444 stdout
, stderr
= gsutil
.communicate()
445 if gsutil
.returncode
:
446 if (re
.findall(r
'status[ |=]40[1|3]', stderr
) or
447 stderr
.startswith(CREDENTIAL_ERROR_MESSAGE
)):
448 print ('Follow these steps to configure your credentials and try'
449 ' running the bisect-builds.py again.:\n'
450 ' 1. Run "python %s config" and follow its instructions.\n'
451 ' 2. If you have a @google.com account, use that account.\n'
452 ' 3. For the project-id, just enter 0.' % gsutil_path
)
455 raise Exception('Error running the gsutil command: %s' % stderr
)
458 def GsutilList(bucket
):
459 query
= 'gs://%s/' % bucket
460 stdout
= RunGsutilCommand(['ls', query
])
461 return [url
[len(query
):].strip('/') for url
in stdout
.splitlines()]
463 # Download the revlist and filter for just the range between good and bad.
464 minrev
= min(self
.good_revision
, self
.bad_revision
)
465 maxrev
= max(self
.good_revision
, self
.bad_revision
)
466 build_numbers
= GsutilList(GS_BUCKET_NAME
)
467 revision_re
= re
.compile(r
'(\d\d\.\d\.\d{4}\.\d+)')
468 build_numbers
= filter(lambda b
: revision_re
.search(b
), build_numbers
)
470 parsed_build_numbers
= [LooseVersion(x
) for x
in build_numbers
]
471 connection
= httplib
.HTTPConnection(GOOGLE_APIS_URL
)
472 for build_number
in sorted(parsed_build_numbers
):
473 if build_number
> maxrev
:
475 if build_number
< minrev
:
477 path
= ('/' + GS_BUCKET_NAME
+ '/' + str(build_number
) + '/' +
478 self
._listing
_platform
_dir
+ self
.archive_name
)
479 connection
.request('HEAD', path
)
480 response
= connection
.getresponse()
481 if response
.status
== 200:
482 final_list
.append(str(build_number
))
487 def UnzipFilenameToDir(filename
, directory
):
488 """Unzip |filename| to |directory|."""
490 if not os
.path
.isabs(filename
):
491 filename
= os
.path
.join(cwd
, filename
)
492 zf
= zipfile
.ZipFile(filename
)
494 if not os
.path
.isdir(directory
):
498 for info
in zf
.infolist():
500 if name
.endswith('/'): # dir
501 if not os
.path
.isdir(name
):
504 directory
= os
.path
.dirname(name
)
505 if not os
.path
.isdir(directory
):
506 os
.makedirs(directory
)
507 out
= open(name
, 'wb')
508 out
.write(zf
.read(name
))
510 # Set permissions. Permission info in external_attr is shifted 16 bits.
511 os
.chmod(name
, info
.external_attr
>> 16L)
515 def FetchRevision(context
, rev
, filename
, quit_event
=None, progress_event
=None):
516 """Downloads and unzips revision |rev|.
517 @param context A PathContext instance.
518 @param rev The Chromium revision number/tag to download.
519 @param filename The destination for the downloaded file.
520 @param quit_event A threading.Event which will be set by the master thread to
521 indicate that the download should be aborted.
522 @param progress_event A threading.Event which will be set by the master thread
523 to indicate that the progress of the download should be
526 def ReportHook(blocknum
, blocksize
, totalsize
):
527 if quit_event
and quit_event
.isSet():
528 raise RuntimeError('Aborting download of revision %s' % str(rev
))
529 if progress_event
and progress_event
.isSet():
530 size
= blocknum
* blocksize
531 if totalsize
== -1: # Total size not known.
532 progress
= 'Received %d bytes' % size
534 size
= min(totalsize
, size
)
535 progress
= 'Received %d of %d bytes, %.2f%%' % (
536 size
, totalsize
, 100.0 * size
/ totalsize
)
537 # Send a \r to let all progress messages use just one line of output.
538 sys
.stdout
.write('\r' + progress
)
541 download_url
= context
.GetDownloadURL(rev
)
543 urllib
.urlretrieve(download_url
, filename
, ReportHook
)
544 if progress_event
and progress_event
.isSet():
550 def RunRevision(context
, revision
, zip_file
, profile
, num_runs
, command
, args
):
551 """Given a zipped revision, unzip it and run the test."""
552 print 'Trying revision %s...' % str(revision
)
554 # Create a temp directory and unzip the revision into it.
556 tempdir
= tempfile
.mkdtemp(prefix
='bisect_tmp')
557 UnzipFilenameToDir(zip_file
, tempdir
)
560 # Run the build as many times as specified.
561 testargs
= ['--user-data-dir=%s' % profile
] + args
562 # The sandbox must be run as root on Official Chrome, so bypass it.
563 if ((context
.is_official
or context
.flash_path
or context
.pdf_path
) and
564 context
.platform
.startswith('linux')):
565 testargs
.append('--no-sandbox')
566 if context
.flash_path
:
567 testargs
.append('--ppapi-flash-path=%s' % context
.flash_path
)
568 # We have to pass a large enough Flash version, which currently needs not
569 # be correct. Instead of requiring the user of the script to figure out and
570 # pass the correct version we just spoof it.
571 testargs
.append('--ppapi-flash-version=99.9.999.999')
573 # TODO(vitalybuka): Remove in the future. See crbug.com/395687.
575 shutil
.copy(context
.pdf_path
,
576 os
.path
.dirname(context
.GetLaunchPath(revision
)))
577 testargs
.append('--enable-print-preview')
580 for token
in shlex
.split(command
):
582 runcommand
.extend(testargs
)
585 token
.replace('%p', os
.path
.abspath(context
.GetLaunchPath(revision
))).
586 replace('%s', ' '.join(testargs
)))
589 for _
in range(num_runs
):
590 subproc
= subprocess
.Popen(runcommand
,
592 stdout
=subprocess
.PIPE
,
593 stderr
=subprocess
.PIPE
)
594 (stdout
, stderr
) = subproc
.communicate()
595 results
.append((subproc
.returncode
, stdout
, stderr
))
599 shutil
.rmtree(tempdir
, True)
603 for (returncode
, stdout
, stderr
) in results
:
605 return (returncode
, stdout
, stderr
)
609 # The arguments official_builds, status, stdout and stderr are unused.
610 # They are present here because this function is passed to Bisect which then
611 # calls it with 5 arguments.
612 # pylint: disable=W0613
613 def AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
):
614 """Asks the user whether build |rev| is good or bad."""
615 # Loop until we get a response that we can parse.
617 response
= raw_input('Revision %s is '
618 '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
620 if response
and response
in ('g', 'b', 'r', 'u'):
622 if response
and response
== 'q':
626 def IsGoodASANBuild(rev
, official_builds
, status
, stdout
, stderr
):
627 """Determine if an ASAN build |rev| is good or bad
629 Will examine stderr looking for the error message emitted by ASAN. If not
630 found then will fallback to asking the user."""
633 for line
in stderr
.splitlines():
635 if line
.find('ERROR: AddressSanitizer:') != -1:
638 print 'Revision %d determined to be bad.' % rev
640 return AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
)
642 class DownloadJob(object):
643 """DownloadJob represents a task to download a given Chromium revision."""
645 def __init__(self
, context
, name
, rev
, zip_file
):
646 super(DownloadJob
, self
).__init
__()
647 # Store off the input parameters.
648 self
.context
= context
651 self
.zip_file
= zip_file
652 self
.quit_event
= threading
.Event()
653 self
.progress_event
= threading
.Event()
657 """Starts the download."""
658 fetchargs
= (self
.context
,
663 self
.thread
= threading
.Thread(target
=FetchRevision
,
669 """Stops the download which must have been started previously."""
670 assert self
.thread
, 'DownloadJob must be started before Stop is called.'
671 self
.quit_event
.set()
673 os
.unlink(self
.zip_file
)
676 """Prints a message and waits for the download to complete. The download
677 must have been started previously."""
678 assert self
.thread
, 'DownloadJob must be started before WaitFor is called.'
679 print 'Downloading revision %s...' % str(self
.rev
)
680 self
.progress_event
.set() # Display progress of download.
690 evaluate
=AskIsGoodBuild
):
691 """Given known good and known bad revisions, run a binary search on all
692 archived revisions to determine the last known good revision.
694 @param context PathContext object initialized with user provided parameters.
695 @param num_runs Number of times to run each build for asking good/bad.
696 @param try_args A tuple of arguments to pass to the test application.
697 @param profile The name of the user profile to run with.
698 @param interactive If it is false, use command exit code for good or bad
699 judgment of the argument build.
700 @param evaluate A function which returns 'g' if the argument build is good,
701 'b' if it's bad or 'u' if unknown.
703 Threading is used to fetch Chromium revisions in the background, speeding up
704 the user's experience. For example, suppose the bounds of the search are
705 good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
706 whether revision 50 is good or bad, the next revision to check will be either
707 25 or 75. So, while revision 50 is being checked, the script will download
708 revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
711 - If rev 50 is good, the download of rev 25 is cancelled, and the next test
714 - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
721 good_rev
= context
.good_revision
722 bad_rev
= context
.bad_revision
725 print 'Downloading list of known revisions...',
726 if not context
.use_local_repo
and not context
.is_official
:
727 print '(use --use-local-repo for speed if you have a local checkout)'
730 _GetDownloadPath
= lambda rev
: os
.path
.join(cwd
,
731 '%s-%s' % (str(rev
), context
.archive_name
))
732 if context
.is_official
:
733 revlist
= context
.GetOfficialBuildsList()
735 revlist
= context
.GetRevList()
737 # Get a list of revisions to bisect across.
738 if len(revlist
) < 2: # Don't have enough builds to bisect.
739 msg
= 'We don\'t have enough builds to bisect. revlist: %s' % revlist
740 raise RuntimeError(msg
)
742 # Figure out our bookends and first pivot point; fetch the pivot revision.
744 maxrev
= len(revlist
) - 1
747 zip_file
= _GetDownloadPath(rev
)
748 fetch
= DownloadJob(context
, 'initial_fetch', rev
, zip_file
)
752 # Binary search time!
753 while fetch
and fetch
.zip_file
and maxrev
- minrev
> 1:
754 if bad_rev
< good_rev
:
755 min_str
, max_str
= 'bad', 'good'
757 min_str
, max_str
= 'good', 'bad'
758 print 'Bisecting range [%s (%s), %s (%s)].' % (revlist
[minrev
], min_str
,
759 revlist
[maxrev
], max_str
)
761 # Pre-fetch next two possible pivots
762 # - down_pivot is the next revision to check if the current revision turns
764 # - up_pivot is the next revision to check if the current revision turns
766 down_pivot
= int((pivot
- minrev
) / 2) + minrev
768 if down_pivot
!= pivot
and down_pivot
!= minrev
:
769 down_rev
= revlist
[down_pivot
]
770 down_fetch
= DownloadJob(context
, 'down_fetch', down_rev
,
771 _GetDownloadPath(down_rev
))
774 up_pivot
= int((maxrev
- pivot
) / 2) + pivot
776 if up_pivot
!= pivot
and up_pivot
!= maxrev
:
777 up_rev
= revlist
[up_pivot
]
778 up_fetch
= DownloadJob(context
, 'up_fetch', up_rev
,
779 _GetDownloadPath(up_rev
))
782 # Run test on the pivot revision.
787 (status
, stdout
, stderr
) = RunRevision(context
,
795 print >> sys
.stderr
, e
797 # Call the evaluate function to see if the current revision is good or bad.
798 # On that basis, kill one of the background downloads and complete the
799 # other, as described in the comments above.
804 print 'Bad revision: %s' % rev
807 print 'Good revision: %s' % rev
809 answer
= evaluate(rev
, context
.is_official
, status
, stdout
, stderr
)
810 if ((answer
== 'g' and good_rev
< bad_rev
)
811 or (answer
== 'b' and bad_rev
< good_rev
)):
815 down_fetch
.Stop() # Kill the download of the older revision.
821 elif ((answer
== 'b' and good_rev
< bad_rev
)
822 or (answer
== 'g' and bad_rev
< good_rev
)):
826 up_fetch
.Stop() # Kill the download of the newer revision.
833 pass # Retry requires no changes.
835 # Nuke the revision from the revlist and choose a new pivot.
838 maxrev
-= 1 # Assumes maxrev >= pivot.
840 if maxrev
- minrev
> 1:
841 # Alternate between using down_pivot or up_pivot for the new pivot
842 # point, without affecting the range. Do this instead of setting the
843 # pivot to the midpoint of the new range because adjacent revisions
844 # are likely affected by the same issue that caused the (u)nknown
846 if up_fetch
and down_fetch
:
847 fetch
= [up_fetch
, down_fetch
][len(revlist
) % 2]
853 if fetch
== up_fetch
:
854 pivot
= up_pivot
- 1 # Subtracts 1 because revlist was resized.
857 zip_file
= fetch
.zip_file
859 if down_fetch
and fetch
!= down_fetch
:
861 if up_fetch
and fetch
!= up_fetch
:
864 assert False, 'Unexpected return value from evaluate(): ' + answer
866 print 'Cleaning up...'
867 for f
in [_GetDownloadPath(revlist
[down_pivot
]),
868 _GetDownloadPath(revlist
[up_pivot
])]:
877 return (revlist
[minrev
], revlist
[maxrev
], context
)
880 def GetBlinkDEPSRevisionForChromiumRevision(rev
):
881 """Returns the blink revision that was in REVISIONS file at
882 chromium revision |rev|."""
883 # . doesn't match newlines without re.DOTALL, so this is safe.
884 blink_re
= re
.compile(r
'webkit_revision\D*(\d+)')
885 url
= urllib
.urlopen(DEPS_FILE
% rev
)
886 m
= blink_re
.search(url
.read())
889 return int(m
.group(1))
891 raise Exception('Could not get Blink revision for Chromium rev %d' % rev
)
894 def GetBlinkRevisionForChromiumRevision(context
, rev
):
895 """Returns the blink revision that was in REVISIONS file at
896 chromium revision |rev|."""
897 def _IsRevisionNumber(revision
):
898 if isinstance(revision
, int):
901 return revision
.isdigit()
902 if str(rev
) in context
.githash_svn_dict
:
903 rev
= context
.githash_svn_dict
[str(rev
)]
904 file_url
= '%s/%s%s/REVISIONS' % (context
.base_url
,
905 context
._listing
_platform
_dir
, rev
)
906 url
= urllib
.urlopen(file_url
)
907 if url
.getcode() == 200:
909 data
= json
.loads(url
.read())
911 print 'ValueError for JSON URL: %s' % file_url
916 if 'webkit_revision' in data
:
917 blink_rev
= data
['webkit_revision']
918 if not _IsRevisionNumber(blink_rev
):
919 blink_rev
= int(context
.GetSVNRevisionFromGitHash(blink_rev
, 'blink'))
922 raise Exception('Could not get blink revision for cr rev %d' % rev
)
925 def FixChromiumRevForBlink(revisions_final
, revisions
, self
, rev
):
926 """Returns the chromium revision that has the correct blink revision
927 for blink bisect, DEPS and REVISIONS file might not match since
928 blink snapshots point to tip of tree blink.
929 Note: The revisions_final variable might get modified to include
930 additional revisions."""
931 blink_deps_rev
= GetBlinkDEPSRevisionForChromiumRevision(rev
)
933 while (GetBlinkRevisionForChromiumRevision(self
, rev
) > blink_deps_rev
):
934 idx
= revisions
.index(rev
)
936 rev
= revisions
[idx
-1]
937 if rev
not in revisions_final
:
938 revisions_final
.insert(0, rev
)
940 revisions_final
.sort()
944 def GetChromiumRevision(context
, url
):
945 """Returns the chromium revision read from given URL."""
947 # Location of the latest build revision number
948 latest_revision
= urllib
.urlopen(url
).read()
949 if latest_revision
.isdigit():
950 return int(latest_revision
)
951 return context
.GetSVNRevisionFromGitHash(latest_revision
)
953 print 'Could not determine latest revision. This could be bad...'
956 def PrintChangeLog(min_chromium_rev
, max_chromium_rev
):
957 """Prints the changelog URL."""
959 def _GetGitHashFromSVNRevision(svn_revision
):
960 crrev_url
= CRREV_URL
+ str(svn_revision
)
961 url
= urllib
.urlopen(crrev_url
)
962 if url
.getcode() == 200:
963 data
= json
.loads(url
.read())
964 if 'git_sha' in data
:
965 return data
['git_sha']
967 print (' ' + CHANGELOG_URL
% (_GetGitHashFromSVNRevision(min_chromium_rev
),
968 _GetGitHashFromSVNRevision(max_chromium_rev
)))
972 usage
= ('%prog [options] [-- chromium-options]\n'
973 'Perform binary search on the snapshot builds to find a minimal\n'
974 'range of revisions where a behavior change happened. The\n'
975 'behaviors are described as "good" and "bad".\n'
976 'It is NOT assumed that the behavior of the later revision is\n'
979 'Revision numbers should use\n'
980 ' Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
981 ' SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
982 ' Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
983 ' for earlier revs.\n'
984 ' Chrome\'s about: build number and omahaproxy branch_revision\n'
985 ' are incorrect, they are from branches.\n'
987 'Tip: add "-- --no-first-run" to bypass the first run prompts.')
988 parser
= optparse
.OptionParser(usage
=usage
)
989 # Strangely, the default help output doesn't include the choice list.
990 choices
= ['mac', 'mac64', 'win', 'win64', 'linux', 'linux64', 'linux-arm']
991 # linux-chromiumos lacks a continuous archive http://crbug.com/78158
992 parser
.add_option('-a', '--archive',
994 help='The buildbot archive to bisect [%s].' %
996 parser
.add_option('-o',
998 dest
='official_builds',
999 help='Bisect across official Chrome builds (internal '
1000 'only) instead of Chromium archives.')
1001 parser
.add_option('-b', '--bad',
1003 help='A bad revision to start bisection. '
1004 'May be earlier or later than the good revision. '
1006 parser
.add_option('-f', '--flash_path',
1008 help='Absolute path to a recent Adobe Pepper Flash '
1009 'binary to be used in this bisection (e.g. '
1010 'on Windows C:\...\pepflashplayer.dll and on Linux '
1011 '/opt/google/chrome/PepperFlash/'
1012 'libpepflashplayer.so).')
1013 parser
.add_option('-d', '--pdf_path',
1015 help='Absolute path to a recent PDF plugin '
1016 'binary to be used in this bisection (e.g. '
1017 'on Windows C:\...\pdf.dll and on Linux '
1018 '/opt/google/chrome/libpdf.so). Option also enables '
1020 parser
.add_option('-g', '--good',
1022 help='A good revision to start bisection. ' +
1023 'May be earlier or later than the bad revision. ' +
1025 parser
.add_option('-p', '--profile', '--user-data-dir',
1028 help='Profile to use; this will not reset every run. '
1029 'Defaults to a clean profile.')
1030 parser
.add_option('-t', '--times',
1033 help='Number of times to run each build before asking '
1034 'if it\'s good or bad. Temporary profiles are reused.')
1035 parser
.add_option('-c', '--command',
1038 help='Command to execute. %p and %a refer to Chrome '
1039 'executable and specified extra arguments '
1040 'respectively. Use %s to specify all extra arguments '
1041 'as one string. Defaults to "%p %a". Note that any '
1042 'extra paths specified should be absolute.')
1043 parser
.add_option('-l', '--blink',
1044 action
='store_true',
1045 help='Use Blink bisect instead of Chromium. ')
1046 parser
.add_option('', '--not-interactive',
1047 action
='store_true',
1049 help='Use command exit code to tell good/bad revision.')
1050 parser
.add_option('--asan',
1052 action
='store_true',
1054 help='Allow the script to bisect ASAN builds')
1055 parser
.add_option('--use-local-repo',
1056 dest
='use_local_repo',
1057 action
='store_true',
1059 help='Allow the script to convert git SHA1 to SVN '
1060 'revision using "git svn find-rev <SHA1>" '
1061 'command from a Chromium checkout.')
1063 (opts
, args
) = parser
.parse_args()
1065 if opts
.archive
is None:
1066 print 'Error: missing required parameter: --archive'
1072 supported_platforms
= ['linux', 'mac', 'win']
1073 if opts
.archive
not in supported_platforms
:
1074 print 'Error: ASAN bisecting only supported on these platforms: [%s].' % (
1075 '|'.join(supported_platforms
))
1077 if opts
.official_builds
:
1078 print 'Error: Do not yet support bisecting official ASAN builds.'
1082 base_url
= ASAN_BASE_URL
1084 base_url
= WEBKIT_BASE_URL
1086 base_url
= CHROMIUM_BASE_URL
1088 # Create the context. Initialize 0 for the revisions as they are set below.
1089 context
= PathContext(base_url
, opts
.archive
, opts
.good
, opts
.bad
,
1090 opts
.official_builds
, opts
.asan
, opts
.use_local_repo
,
1091 opts
.flash_path
, opts
.pdf_path
)
1092 # Pick a starting point, try to get HEAD for this.
1094 context
.bad_revision
= '999.0.0.0'
1095 context
.bad_revision
= GetChromiumRevision(
1096 context
, context
.GetLastChangeURL())
1098 # Find out when we were good.
1100 context
.good_revision
= '0.0.0.0' if opts
.official_builds
else 0
1103 msg
= 'Could not find Flash binary at %s' % opts
.flash_path
1104 assert os
.path
.exists(opts
.flash_path
), msg
1107 msg
= 'Could not find PDF binary at %s' % opts
.pdf_path
1108 assert os
.path
.exists(opts
.pdf_path
), msg
1110 if opts
.official_builds
:
1111 context
.good_revision
= LooseVersion(context
.good_revision
)
1112 context
.bad_revision
= LooseVersion(context
.bad_revision
)
1114 context
.good_revision
= int(context
.good_revision
)
1115 context
.bad_revision
= int(context
.bad_revision
)
1118 print('Number of times to run (%d) must be greater than or equal to 1.' %
1124 evaluator
= IsGoodASANBuild
1126 evaluator
= AskIsGoodBuild
1128 # Save these revision numbers to compare when showing the changelog URL
1130 good_rev
= context
.good_revision
1131 bad_rev
= context
.bad_revision
1133 (min_chromium_rev
, max_chromium_rev
, context
) = Bisect(
1134 context
, opts
.times
, opts
.command
, args
, opts
.profile
,
1135 not opts
.not_interactive
, evaluator
)
1137 # Get corresponding blink revisions.
1139 min_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1141 max_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1144 # Silently ignore the failure.
1145 min_blink_rev
, max_blink_rev
= 0, 0
1148 # We're done. Let the user know the results in an official manner.
1149 if good_rev
> bad_rev
:
1150 print DONE_MESSAGE_GOOD_MAX
% (str(min_blink_rev
), str(max_blink_rev
))
1152 print DONE_MESSAGE_GOOD_MIN
% (str(min_blink_rev
), str(max_blink_rev
))
1154 print 'BLINK CHANGELOG URL:'
1155 print ' ' + BLINK_CHANGELOG_URL
% (max_blink_rev
, min_blink_rev
)
1158 # We're done. Let the user know the results in an official manner.
1159 if good_rev
> bad_rev
:
1160 print DONE_MESSAGE_GOOD_MAX
% (str(min_chromium_rev
),
1161 str(max_chromium_rev
))
1163 print DONE_MESSAGE_GOOD_MIN
% (str(min_chromium_rev
),
1164 str(max_chromium_rev
))
1165 if min_blink_rev
!= max_blink_rev
:
1166 print ('NOTE: There is a Blink roll in the range, '
1167 'you might also want to do a Blink bisect.')
1169 print 'CHANGELOG URL:'
1170 if opts
.official_builds
:
1171 print OFFICIAL_CHANGELOG_URL
% (min_chromium_rev
, max_chromium_rev
)
1173 PrintChangeLog(min_chromium_rev
, max_chromium_rev
)
1176 if __name__
== '__main__':