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_OLD
= ('http://src.chromium.org/viewvc/chrome/trunk/src/'
45 DEPS_FILE_NEW
= ('https://chromium.googlesource.com/chromium/src/+/%s/DEPS')
47 # Blink changelogs URL.
48 BLINK_CHANGELOG_URL
= ('http://build.chromium.org'
49 '/f/chromium/perf/dashboard/ui/changelog_blink.html'
50 '?url=/trunk&range=%d%%3A%d')
52 DONE_MESSAGE_GOOD_MIN
= ('You are probably looking for a change made after %s ('
53 'known good), but no later than %s (first known bad).')
54 DONE_MESSAGE_GOOD_MAX
= ('You are probably looking for a change made after %s ('
55 'known bad), but no later than %s (first known good).')
57 CHROMIUM_GITHASH_TO_SVN_URL
= (
58 'https://chromium.googlesource.com/chromium/src/+/%s?format=json')
60 BLINK_GITHASH_TO_SVN_URL
= (
61 'https://chromium.googlesource.com/chromium/blink/+/%s?format=json')
63 GITHASH_TO_SVN_URL
= {
64 'chromium': CHROMIUM_GITHASH_TO_SVN_URL
,
65 'blink': BLINK_GITHASH_TO_SVN_URL
,
68 # Search pattern to be matched in the JSON output from
69 # CHROMIUM_GITHASH_TO_SVN_URL to get the chromium revision (svn revision).
70 CHROMIUM_SEARCH_PATTERN_OLD
= (
71 r
'.*git-svn-id: svn://svn.chromium.org/chrome/trunk/src@(\d+) ')
72 CHROMIUM_SEARCH_PATTERN
= (
73 r
'Cr-Commit-Position: refs/heads/master@{#(\d+)}')
75 # Search pattern to be matched in the json output from
76 # BLINK_GITHASH_TO_SVN_URL to get the blink revision (svn revision).
77 BLINK_SEARCH_PATTERN
= (
78 r
'.*git-svn-id: svn://svn.chromium.org/blink/trunk@(\d+) ')
81 'chromium': CHROMIUM_SEARCH_PATTERN
,
82 'blink': BLINK_SEARCH_PATTERN
,
85 CREDENTIAL_ERROR_MESSAGE
= ('You are attempting to access protected data with '
86 'no configured credentials')
88 ###############################################################################
102 from distutils
.version
import LooseVersion
103 from xml
.etree
import ElementTree
107 class PathContext(object):
108 """A PathContext is used to carry the information used to construct URLs and
109 paths when dealing with the storage server and archives."""
110 def __init__(self
, base_url
, platform
, good_revision
, bad_revision
,
111 is_official
, is_asan
, use_local_repo
, flash_path
= None,
113 super(PathContext
, self
).__init
__()
114 # Store off the input parameters.
115 self
.base_url
= base_url
116 self
.platform
= platform
# What's passed in to the '-a/--archive' option.
117 self
.good_revision
= good_revision
118 self
.bad_revision
= bad_revision
119 self
.is_official
= is_official
120 self
.is_asan
= is_asan
121 self
.build_type
= 'release'
122 self
.flash_path
= flash_path
123 # Dictionary which stores svn revision number as key and it's
124 # corresponding git hash as value. This data is populated in
125 # _FetchAndParse and used later in GetDownloadURL while downloading
127 self
.githash_svn_dict
= {}
128 self
.pdf_path
= pdf_path
130 # The name of the ZIP file in a revision directory on the server.
131 self
.archive_name
= None
133 # If the script is run from a local Chromium checkout,
134 # "--use-local-repo" option can be used to make the script run faster.
135 # It uses "git svn find-rev <SHA1>" command to convert git hash to svn
137 self
.use_local_repo
= use_local_repo
139 # Set some internal members:
140 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
141 # _archive_extract_dir = Uncompressed directory in the archive_name file.
142 # _binary_name = The name of the executable to run.
143 if self
.platform
in ('linux', 'linux64', 'linux-arm'):
144 self
._binary
_name
= 'chrome'
145 elif self
.platform
in ('mac', 'mac64'):
146 self
.archive_name
= 'chrome-mac.zip'
147 self
._archive
_extract
_dir
= 'chrome-mac'
148 elif self
.platform
in ('win', 'win64'):
149 self
.archive_name
= 'chrome-win32.zip'
150 self
._archive
_extract
_dir
= 'chrome-win32'
151 self
._binary
_name
= 'chrome.exe'
153 raise Exception('Invalid platform: %s' % self
.platform
)
156 if self
.platform
== 'linux':
157 self
._listing
_platform
_dir
= 'precise32/'
158 self
.archive_name
= 'chrome-precise32.zip'
159 self
._archive
_extract
_dir
= 'chrome-precise32'
160 elif self
.platform
== 'linux64':
161 self
._listing
_platform
_dir
= 'precise64/'
162 self
.archive_name
= 'chrome-precise64.zip'
163 self
._archive
_extract
_dir
= 'chrome-precise64'
164 elif self
.platform
== 'mac':
165 self
._listing
_platform
_dir
= 'mac/'
166 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
167 elif self
.platform
== 'mac64':
168 self
._listing
_platform
_dir
= 'mac64/'
169 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
170 elif self
.platform
== 'win':
171 self
._listing
_platform
_dir
= 'win/'
172 self
.archive_name
= 'chrome-win.zip'
173 self
._archive
_extract
_dir
= 'chrome-win'
174 elif self
.platform
== 'win64':
175 self
._listing
_platform
_dir
= 'win64/'
176 self
.archive_name
= 'chrome-win64.zip'
177 self
._archive
_extract
_dir
= 'chrome-win64'
179 if self
.platform
in ('linux', 'linux64', 'linux-arm'):
180 self
.archive_name
= 'chrome-linux.zip'
181 self
._archive
_extract
_dir
= 'chrome-linux'
182 if self
.platform
== 'linux':
183 self
._listing
_platform
_dir
= 'Linux/'
184 elif self
.platform
== 'linux64':
185 self
._listing
_platform
_dir
= 'Linux_x64/'
186 elif self
.platform
== 'linux-arm':
187 self
._listing
_platform
_dir
= 'Linux_ARM_Cross-Compile/'
188 elif self
.platform
== 'mac':
189 self
._listing
_platform
_dir
= 'Mac/'
190 self
._binary
_name
= 'Chromium.app/Contents/MacOS/Chromium'
191 elif self
.platform
== 'win':
192 self
._listing
_platform
_dir
= 'Win/'
194 def GetASANPlatformDir(self
):
195 """ASAN builds are in directories like "linux-release", or have filenames
196 like "asan-win32-release-277079.zip". This aligns to our platform names
197 except in the case of Windows where they use "win32" instead of "win"."""
198 if self
.platform
== 'win':
203 def GetListingURL(self
, marker
=None):
204 """Returns the URL for a directory listing, with an optional marker."""
207 marker_param
= '&marker=' + str(marker
)
209 prefix
= '%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
210 return self
.base_url
+ '/?delimiter=&prefix=' + prefix
+ marker_param
212 return (self
.base_url
+ '/?delimiter=/&prefix=' +
213 self
._listing
_platform
_dir
+ marker_param
)
215 def GetDownloadURL(self
, revision
):
216 """Gets the download URL for a build archive of a specific revision."""
218 return '%s/%s-%s/%s-%d.zip' % (
219 ASAN_BASE_URL
, self
.GetASANPlatformDir(), self
.build_type
,
220 self
.GetASANBaseName(), revision
)
222 return '%s/%s/%s%s' % (
223 OFFICIAL_BASE_URL
, revision
, self
._listing
_platform
_dir
,
226 if str(revision
) in self
.githash_svn_dict
:
227 revision
= self
.githash_svn_dict
[str(revision
)]
228 return '%s/%s%s/%s' % (self
.base_url
, self
._listing
_platform
_dir
,
229 revision
, self
.archive_name
)
231 def GetLastChangeURL(self
):
232 """Returns a URL to the LAST_CHANGE file."""
233 return self
.base_url
+ '/' + self
._listing
_platform
_dir
+ 'LAST_CHANGE'
235 def GetASANBaseName(self
):
236 """Returns the base name of the ASAN zip file."""
237 if 'linux' in self
.platform
:
238 return 'asan-symbolized-%s-%s' % (self
.GetASANPlatformDir(),
241 return 'asan-%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
243 def GetLaunchPath(self
, revision
):
244 """Returns a relative path (presumably from the archive extraction location)
245 that is used to run the executable."""
247 extract_dir
= '%s-%d' % (self
.GetASANBaseName(), revision
)
249 extract_dir
= self
._archive
_extract
_dir
250 return os
.path
.join(extract_dir
, self
._binary
_name
)
252 def ParseDirectoryIndex(self
):
253 """Parses the Google Storage directory listing into a list of revision
256 def _FetchAndParse(url
):
257 """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
258 next-marker is not None, then the listing is a partial listing and another
259 fetch should be performed with next-marker being the marker= GET
261 handle
= urllib
.urlopen(url
)
262 document
= ElementTree
.parse(handle
)
264 # All nodes in the tree are namespaced. Get the root's tag name to extract
265 # the namespace. Etree does namespaces as |{namespace}tag|.
266 root_tag
= document
.getroot().tag
267 end_ns_pos
= root_tag
.find('}')
269 raise Exception('Could not locate end namespace for directory index')
270 namespace
= root_tag
[:end_ns_pos
+ 1]
272 # Find the prefix (_listing_platform_dir) and whether or not the list is
274 prefix_len
= len(document
.find(namespace
+ 'Prefix').text
)
276 is_truncated
= document
.find(namespace
+ 'IsTruncated')
277 if is_truncated
is not None and is_truncated
.text
.lower() == 'true':
278 next_marker
= document
.find(namespace
+ 'NextMarker').text
279 # Get a list of all the revisions.
281 githash_svn_dict
= {}
283 asan_regex
= re
.compile(r
'.*%s-(\d+)\.zip$' % (self
.GetASANBaseName()))
284 # Non ASAN builds are in a <revision> directory. The ASAN builds are
286 all_prefixes
= document
.findall(namespace
+ 'Contents/' +
288 for prefix
in all_prefixes
:
289 m
= asan_regex
.match(prefix
.text
)
292 revisions
.append(int(m
.group(1)))
296 all_prefixes
= document
.findall(namespace
+ 'CommonPrefixes/' +
297 namespace
+ 'Prefix')
298 # The <Prefix> nodes have content of the form of
299 # |_listing_platform_dir/revision/|. Strip off the platform dir and the
300 # trailing slash to just have a number.
301 for prefix
in all_prefixes
:
302 revnum
= prefix
.text
[prefix_len
:-1]
304 if not revnum
.isdigit():
306 revnum
= self
.GetSVNRevisionFromGitHash(git_hash
)
307 githash_svn_dict
[revnum
] = git_hash
308 if revnum
is not None:
310 revisions
.append(revnum
)
313 return (revisions
, next_marker
, githash_svn_dict
)
315 # Fetch the first list of revisions.
316 (revisions
, next_marker
, self
.githash_svn_dict
) = _FetchAndParse(
317 self
.GetListingURL())
318 # If the result list was truncated, refetch with the next marker. Do this
319 # until an entire directory listing is done.
321 next_url
= self
.GetListingURL(next_marker
)
322 (new_revisions
, next_marker
, new_dict
) = _FetchAndParse(next_url
)
323 revisions
.extend(new_revisions
)
324 self
.githash_svn_dict
.update(new_dict
)
327 def _GetSVNRevisionFromGitHashWithoutGitCheckout(self
, git_sha1
, depot
):
328 json_url
= GITHASH_TO_SVN_URL
[depot
] % git_sha1
329 response
= urllib
.urlopen(json_url
)
330 if response
.getcode() == 200:
332 data
= json
.loads(response
.read()[4:])
334 print 'ValueError for JSON URL: %s' % json_url
338 if 'message' in data
:
339 message
= data
['message'].split('\n')
340 message
= [line
for line
in message
if line
.strip()]
341 search_pattern
= re
.compile(SEARCH_PATTERN
[depot
])
342 result
= search_pattern
.search(message
[len(message
)-1])
344 return result
.group(1)
346 if depot
== 'chromium':
347 result
= re
.search(CHROMIUM_SEARCH_PATTERN_OLD
,
348 message
[len(message
)-1])
350 return result
.group(1)
351 print 'Failed to get svn revision number for %s' % git_sha1
354 def _GetSVNRevisionFromGitHashFromGitCheckout(self
, git_sha1
, depot
):
355 def _RunGit(command
, path
):
356 command
= ['git'] + command
358 original_path
= os
.getcwd()
360 shell
= sys
.platform
.startswith('win')
361 proc
= subprocess
.Popen(command
, shell
=shell
, stdout
=subprocess
.PIPE
,
362 stderr
=subprocess
.PIPE
)
363 (output
, _
) = proc
.communicate()
366 os
.chdir(original_path
)
367 return (output
, proc
.returncode
)
371 path
= os
.path
.join(os
.getcwd(), 'third_party', 'WebKit')
372 if os
.path
.basename(os
.getcwd()) == 'src':
373 command
= ['svn', 'find-rev', git_sha1
]
374 (git_output
, return_code
) = _RunGit(command
, path
)
376 return git_output
.strip('\n')
379 print ('Script should be run from src folder. ' +
380 'Eg: python tools/bisect-builds.py -g 280588 -b 280590' +
381 '--archive linux64 --use-local-repo')
384 def GetSVNRevisionFromGitHash(self
, git_sha1
, depot
='chromium'):
385 if not self
.use_local_repo
:
386 return self
._GetSVNRevisionFromGitHashWithoutGitCheckout
(git_sha1
, depot
)
388 return self
._GetSVNRevisionFromGitHashFromGitCheckout
(git_sha1
, depot
)
390 def GetRevList(self
):
391 """Gets the list of revision numbers between self.good_revision and
392 self.bad_revision."""
393 # Download the revlist and filter for just the range between good and bad.
394 minrev
= min(self
.good_revision
, self
.bad_revision
)
395 maxrev
= max(self
.good_revision
, self
.bad_revision
)
396 revlist_all
= map(int, self
.ParseDirectoryIndex())
398 revlist
= [x
for x
in revlist_all
if x
>= int(minrev
) and x
<= int(maxrev
)]
401 # Set good and bad revisions to be legit revisions.
403 if self
.good_revision
< self
.bad_revision
:
404 self
.good_revision
= revlist
[0]
405 self
.bad_revision
= revlist
[-1]
407 self
.bad_revision
= revlist
[0]
408 self
.good_revision
= revlist
[-1]
410 # Fix chromium rev so that the deps blink revision matches REVISIONS file.
411 if self
.base_url
== WEBKIT_BASE_URL
:
413 self
.good_revision
= FixChromiumRevForBlink(revlist
,
417 self
.bad_revision
= FixChromiumRevForBlink(revlist
,
423 def GetOfficialBuildsList(self
):
424 """Gets the list of official build numbers between self.good_revision and
425 self.bad_revision."""
427 def CheckDepotToolsInPath():
428 delimiter
= ';' if sys
.platform
.startswith('win') else ':'
429 path_list
= os
.environ
['PATH'].split(delimiter
)
430 for path
in path_list
:
431 if path
.find('depot_tools') != -1:
435 def RunGsutilCommand(args
):
436 gsutil_path
= CheckDepotToolsInPath()
437 if gsutil_path
is None:
438 print ('Follow the instructions in this document '
439 'http://dev.chromium.org/developers/how-tos/install-depot-tools'
440 ' to install depot_tools and then try again.')
442 gsutil_path
= os
.path
.join(gsutil_path
, 'third_party', 'gsutil', 'gsutil')
443 gsutil
= subprocess
.Popen([sys
.executable
, gsutil_path
] + args
,
444 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
,
446 stdout
, stderr
= gsutil
.communicate()
447 if gsutil
.returncode
:
448 if (re
.findall(r
'status[ |=]40[1|3]', stderr
) or
449 stderr
.startswith(CREDENTIAL_ERROR_MESSAGE
)):
450 print ('Follow these steps to configure your credentials and try'
451 ' running the bisect-builds.py again.:\n'
452 ' 1. Run "python %s config" and follow its instructions.\n'
453 ' 2. If you have a @google.com account, use that account.\n'
454 ' 3. For the project-id, just enter 0.' % gsutil_path
)
457 raise Exception('Error running the gsutil command: %s' % stderr
)
460 def GsutilList(bucket
):
461 query
= 'gs://%s/' % bucket
462 stdout
= RunGsutilCommand(['ls', query
])
463 return [url
[len(query
):].strip('/') for url
in stdout
.splitlines()]
465 # Download the revlist and filter for just the range between good and bad.
466 minrev
= min(self
.good_revision
, self
.bad_revision
)
467 maxrev
= max(self
.good_revision
, self
.bad_revision
)
468 build_numbers
= GsutilList(GS_BUCKET_NAME
)
469 revision_re
= re
.compile(r
'(\d\d\.\d\.\d{4}\.\d+)')
470 build_numbers
= filter(lambda b
: revision_re
.search(b
), build_numbers
)
472 parsed_build_numbers
= [LooseVersion(x
) for x
in build_numbers
]
473 connection
= httplib
.HTTPConnection(GOOGLE_APIS_URL
)
474 for build_number
in sorted(parsed_build_numbers
):
475 if build_number
> maxrev
:
477 if build_number
< minrev
:
479 path
= ('/' + GS_BUCKET_NAME
+ '/' + str(build_number
) + '/' +
480 self
._listing
_platform
_dir
+ self
.archive_name
)
481 connection
.request('HEAD', path
)
482 response
= connection
.getresponse()
483 if response
.status
== 200:
484 final_list
.append(str(build_number
))
489 def UnzipFilenameToDir(filename
, directory
):
490 """Unzip |filename| to |directory|."""
492 if not os
.path
.isabs(filename
):
493 filename
= os
.path
.join(cwd
, filename
)
494 zf
= zipfile
.ZipFile(filename
)
496 if not os
.path
.isdir(directory
):
500 for info
in zf
.infolist():
502 if name
.endswith('/'): # dir
503 if not os
.path
.isdir(name
):
506 directory
= os
.path
.dirname(name
)
507 if not os
.path
.isdir(directory
):
508 os
.makedirs(directory
)
509 out
= open(name
, 'wb')
510 out
.write(zf
.read(name
))
512 # Set permissions. Permission info in external_attr is shifted 16 bits.
513 os
.chmod(name
, info
.external_attr
>> 16L)
517 def FetchRevision(context
, rev
, filename
, quit_event
=None, progress_event
=None):
518 """Downloads and unzips revision |rev|.
519 @param context A PathContext instance.
520 @param rev The Chromium revision number/tag to download.
521 @param filename The destination for the downloaded file.
522 @param quit_event A threading.Event which will be set by the master thread to
523 indicate that the download should be aborted.
524 @param progress_event A threading.Event which will be set by the master thread
525 to indicate that the progress of the download should be
528 def ReportHook(blocknum
, blocksize
, totalsize
):
529 if quit_event
and quit_event
.isSet():
530 raise RuntimeError('Aborting download of revision %s' % str(rev
))
531 if progress_event
and progress_event
.isSet():
532 size
= blocknum
* blocksize
533 if totalsize
== -1: # Total size not known.
534 progress
= 'Received %d bytes' % size
536 size
= min(totalsize
, size
)
537 progress
= 'Received %d of %d bytes, %.2f%%' % (
538 size
, totalsize
, 100.0 * size
/ totalsize
)
539 # Send a \r to let all progress messages use just one line of output.
540 sys
.stdout
.write('\r' + progress
)
543 download_url
= context
.GetDownloadURL(rev
)
545 urllib
.urlretrieve(download_url
, filename
, ReportHook
)
546 if progress_event
and progress_event
.isSet():
552 def RunRevision(context
, revision
, zip_file
, profile
, num_runs
, command
, args
):
553 """Given a zipped revision, unzip it and run the test."""
554 print 'Trying revision %s...' % str(revision
)
556 # Create a temp directory and unzip the revision into it.
558 tempdir
= tempfile
.mkdtemp(prefix
='bisect_tmp')
559 UnzipFilenameToDir(zip_file
, tempdir
)
562 # Run the build as many times as specified.
563 testargs
= ['--user-data-dir=%s' % profile
] + args
564 # The sandbox must be run as root on Official Chrome, so bypass it.
565 if ((context
.is_official
or context
.flash_path
or context
.pdf_path
) and
566 context
.platform
.startswith('linux')):
567 testargs
.append('--no-sandbox')
568 if context
.flash_path
:
569 testargs
.append('--ppapi-flash-path=%s' % context
.flash_path
)
570 # We have to pass a large enough Flash version, which currently needs not
571 # be correct. Instead of requiring the user of the script to figure out and
572 # pass the correct version we just spoof it.
573 testargs
.append('--ppapi-flash-version=99.9.999.999')
575 # TODO(vitalybuka): Remove in the future. See crbug.com/395687.
577 shutil
.copy(context
.pdf_path
,
578 os
.path
.dirname(context
.GetLaunchPath(revision
)))
579 testargs
.append('--enable-print-preview')
582 for token
in shlex
.split(command
):
584 runcommand
.extend(testargs
)
587 token
.replace('%p', os
.path
.abspath(context
.GetLaunchPath(revision
))).
588 replace('%s', ' '.join(testargs
)))
591 for _
in range(num_runs
):
592 subproc
= subprocess
.Popen(runcommand
,
594 stdout
=subprocess
.PIPE
,
595 stderr
=subprocess
.PIPE
)
596 (stdout
, stderr
) = subproc
.communicate()
597 results
.append((subproc
.returncode
, stdout
, stderr
))
601 shutil
.rmtree(tempdir
, True)
605 for (returncode
, stdout
, stderr
) in results
:
607 return (returncode
, stdout
, stderr
)
611 # The arguments official_builds, status, stdout and stderr are unused.
612 # They are present here because this function is passed to Bisect which then
613 # calls it with 5 arguments.
614 # pylint: disable=W0613
615 def AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
):
616 """Asks the user whether build |rev| is good or bad."""
617 # Loop until we get a response that we can parse.
619 response
= raw_input('Revision %s is '
620 '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
622 if response
and response
in ('g', 'b', 'r', 'u'):
624 if response
and response
== 'q':
628 def IsGoodASANBuild(rev
, official_builds
, status
, stdout
, stderr
):
629 """Determine if an ASAN build |rev| is good or bad
631 Will examine stderr looking for the error message emitted by ASAN. If not
632 found then will fallback to asking the user."""
635 for line
in stderr
.splitlines():
637 if line
.find('ERROR: AddressSanitizer:') != -1:
640 print 'Revision %d determined to be bad.' % rev
642 return AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
)
644 class DownloadJob(object):
645 """DownloadJob represents a task to download a given Chromium revision."""
647 def __init__(self
, context
, name
, rev
, zip_file
):
648 super(DownloadJob
, self
).__init
__()
649 # Store off the input parameters.
650 self
.context
= context
653 self
.zip_file
= zip_file
654 self
.quit_event
= threading
.Event()
655 self
.progress_event
= threading
.Event()
659 """Starts the download."""
660 fetchargs
= (self
.context
,
665 self
.thread
= threading
.Thread(target
=FetchRevision
,
671 """Stops the download which must have been started previously."""
672 assert self
.thread
, 'DownloadJob must be started before Stop is called.'
673 self
.quit_event
.set()
675 os
.unlink(self
.zip_file
)
678 """Prints a message and waits for the download to complete. The download
679 must have been started previously."""
680 assert self
.thread
, 'DownloadJob must be started before WaitFor is called.'
681 print 'Downloading revision %s...' % str(self
.rev
)
682 self
.progress_event
.set() # Display progress of download.
692 evaluate
=AskIsGoodBuild
):
693 """Given known good and known bad revisions, run a binary search on all
694 archived revisions to determine the last known good revision.
696 @param context PathContext object initialized with user provided parameters.
697 @param num_runs Number of times to run each build for asking good/bad.
698 @param try_args A tuple of arguments to pass to the test application.
699 @param profile The name of the user profile to run with.
700 @param interactive If it is false, use command exit code for good or bad
701 judgment of the argument build.
702 @param evaluate A function which returns 'g' if the argument build is good,
703 'b' if it's bad or 'u' if unknown.
705 Threading is used to fetch Chromium revisions in the background, speeding up
706 the user's experience. For example, suppose the bounds of the search are
707 good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
708 whether revision 50 is good or bad, the next revision to check will be either
709 25 or 75. So, while revision 50 is being checked, the script will download
710 revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
713 - If rev 50 is good, the download of rev 25 is cancelled, and the next test
716 - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
723 good_rev
= context
.good_revision
724 bad_rev
= context
.bad_revision
727 print 'Downloading list of known revisions...',
728 if not context
.use_local_repo
and not context
.is_official
:
729 print '(use --use-local-repo for speed if you have a local checkout)'
732 _GetDownloadPath
= lambda rev
: os
.path
.join(cwd
,
733 '%s-%s' % (str(rev
), context
.archive_name
))
734 if context
.is_official
:
735 revlist
= context
.GetOfficialBuildsList()
737 revlist
= context
.GetRevList()
739 # Get a list of revisions to bisect across.
740 if len(revlist
) < 2: # Don't have enough builds to bisect.
741 msg
= 'We don\'t have enough builds to bisect. revlist: %s' % revlist
742 raise RuntimeError(msg
)
744 # Figure out our bookends and first pivot point; fetch the pivot revision.
746 maxrev
= len(revlist
) - 1
749 zip_file
= _GetDownloadPath(rev
)
750 fetch
= DownloadJob(context
, 'initial_fetch', rev
, zip_file
)
754 # Binary search time!
755 while fetch
and fetch
.zip_file
and maxrev
- minrev
> 1:
756 if bad_rev
< good_rev
:
757 min_str
, max_str
= 'bad', 'good'
759 min_str
, max_str
= 'good', 'bad'
760 print 'Bisecting range [%s (%s), %s (%s)].' % (revlist
[minrev
], min_str
,
761 revlist
[maxrev
], max_str
)
763 # Pre-fetch next two possible pivots
764 # - down_pivot is the next revision to check if the current revision turns
766 # - up_pivot is the next revision to check if the current revision turns
768 down_pivot
= int((pivot
- minrev
) / 2) + minrev
770 if down_pivot
!= pivot
and down_pivot
!= minrev
:
771 down_rev
= revlist
[down_pivot
]
772 down_fetch
= DownloadJob(context
, 'down_fetch', down_rev
,
773 _GetDownloadPath(down_rev
))
776 up_pivot
= int((maxrev
- pivot
) / 2) + pivot
778 if up_pivot
!= pivot
and up_pivot
!= maxrev
:
779 up_rev
= revlist
[up_pivot
]
780 up_fetch
= DownloadJob(context
, 'up_fetch', up_rev
,
781 _GetDownloadPath(up_rev
))
784 # Run test on the pivot revision.
789 (status
, stdout
, stderr
) = RunRevision(context
,
797 print >> sys
.stderr
, e
799 # Call the evaluate function to see if the current revision is good or bad.
800 # On that basis, kill one of the background downloads and complete the
801 # other, as described in the comments above.
806 print 'Bad revision: %s' % rev
809 print 'Good revision: %s' % rev
811 answer
= evaluate(rev
, context
.is_official
, status
, stdout
, stderr
)
812 if ((answer
== 'g' and good_rev
< bad_rev
)
813 or (answer
== 'b' and bad_rev
< good_rev
)):
817 down_fetch
.Stop() # Kill the download of the older revision.
823 elif ((answer
== 'b' and good_rev
< bad_rev
)
824 or (answer
== 'g' and bad_rev
< good_rev
)):
828 up_fetch
.Stop() # Kill the download of the newer revision.
835 pass # Retry requires no changes.
837 # Nuke the revision from the revlist and choose a new pivot.
840 maxrev
-= 1 # Assumes maxrev >= pivot.
842 if maxrev
- minrev
> 1:
843 # Alternate between using down_pivot or up_pivot for the new pivot
844 # point, without affecting the range. Do this instead of setting the
845 # pivot to the midpoint of the new range because adjacent revisions
846 # are likely affected by the same issue that caused the (u)nknown
848 if up_fetch
and down_fetch
:
849 fetch
= [up_fetch
, down_fetch
][len(revlist
) % 2]
855 if fetch
== up_fetch
:
856 pivot
= up_pivot
- 1 # Subtracts 1 because revlist was resized.
859 zip_file
= fetch
.zip_file
861 if down_fetch
and fetch
!= down_fetch
:
863 if up_fetch
and fetch
!= up_fetch
:
866 assert False, 'Unexpected return value from evaluate(): ' + answer
868 print 'Cleaning up...'
869 for f
in [_GetDownloadPath(revlist
[down_pivot
]),
870 _GetDownloadPath(revlist
[up_pivot
])]:
879 return (revlist
[minrev
], revlist
[maxrev
], context
)
882 def GetBlinkDEPSRevisionForChromiumRevision(self
, rev
):
883 """Returns the blink revision that was in REVISIONS file at
884 chromium revision |rev|."""
886 def _GetBlinkRev(url
, blink_re
):
887 m
= blink_re
.search(url
.read())
892 url
= urllib
.urlopen(DEPS_FILE_OLD
% rev
)
893 if url
.getcode() == 200:
894 # . doesn't match newlines without re.DOTALL, so this is safe.
895 blink_re
= re
.compile(r
'webkit_revision\D*(\d+)')
896 return int(_GetBlinkRev(url
, blink_re
))
898 url
= urllib
.urlopen(DEPS_FILE_NEW
% GetGitHashFromSVNRevision(rev
))
899 if url
.getcode() == 200:
900 blink_re
= re
.compile(r
'webkit_revision\D*\d+;\D*\d+;(\w+)')
901 blink_git_sha
= _GetBlinkRev(url
, blink_re
)
902 return self
.GetSVNRevisionFromGitHash(blink_git_sha
, 'blink')
903 raise Exception('Could not get Blink revision for Chromium rev %d' % rev
)
906 def GetBlinkRevisionForChromiumRevision(context
, rev
):
907 """Returns the blink revision that was in REVISIONS file at
908 chromium revision |rev|."""
909 def _IsRevisionNumber(revision
):
910 if isinstance(revision
, int):
913 return revision
.isdigit()
914 if str(rev
) in context
.githash_svn_dict
:
915 rev
= context
.githash_svn_dict
[str(rev
)]
916 file_url
= '%s/%s%s/REVISIONS' % (context
.base_url
,
917 context
._listing
_platform
_dir
, rev
)
918 url
= urllib
.urlopen(file_url
)
919 if url
.getcode() == 200:
921 data
= json
.loads(url
.read())
923 print 'ValueError for JSON URL: %s' % file_url
928 if 'webkit_revision' in data
:
929 blink_rev
= data
['webkit_revision']
930 if not _IsRevisionNumber(blink_rev
):
931 blink_rev
= int(context
.GetSVNRevisionFromGitHash(blink_rev
, 'blink'))
934 raise Exception('Could not get blink revision for cr rev %d' % rev
)
937 def FixChromiumRevForBlink(revisions_final
, revisions
, self
, rev
):
938 """Returns the chromium revision that has the correct blink revision
939 for blink bisect, DEPS and REVISIONS file might not match since
940 blink snapshots point to tip of tree blink.
941 Note: The revisions_final variable might get modified to include
942 additional revisions."""
943 blink_deps_rev
= GetBlinkDEPSRevisionForChromiumRevision(self
, rev
)
945 while (GetBlinkRevisionForChromiumRevision(self
, rev
) > blink_deps_rev
):
946 idx
= revisions
.index(rev
)
948 rev
= revisions
[idx
-1]
949 if rev
not in revisions_final
:
950 revisions_final
.insert(0, rev
)
952 revisions_final
.sort()
956 def GetChromiumRevision(context
, url
):
957 """Returns the chromium revision read from given URL."""
959 # Location of the latest build revision number
960 latest_revision
= urllib
.urlopen(url
).read()
961 if latest_revision
.isdigit():
962 return int(latest_revision
)
963 return context
.GetSVNRevisionFromGitHash(latest_revision
)
965 print 'Could not determine latest revision. This could be bad...'
968 def GetGitHashFromSVNRevision(svn_revision
):
969 crrev_url
= CRREV_URL
+ str(svn_revision
)
970 url
= urllib
.urlopen(crrev_url
)
971 if url
.getcode() == 200:
972 data
= json
.loads(url
.read())
973 if 'git_sha' in data
:
974 return data
['git_sha']
976 def PrintChangeLog(min_chromium_rev
, max_chromium_rev
):
977 """Prints the changelog URL."""
979 print (' ' + CHANGELOG_URL
% (GetGitHashFromSVNRevision(min_chromium_rev
),
980 GetGitHashFromSVNRevision(max_chromium_rev
)))
984 usage
= ('%prog [options] [-- chromium-options]\n'
985 'Perform binary search on the snapshot builds to find a minimal\n'
986 'range of revisions where a behavior change happened. The\n'
987 'behaviors are described as "good" and "bad".\n'
988 'It is NOT assumed that the behavior of the later revision is\n'
991 'Revision numbers should use\n'
992 ' Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
993 ' SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
994 ' Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
995 ' for earlier revs.\n'
996 ' Chrome\'s about: build number and omahaproxy branch_revision\n'
997 ' are incorrect, they are from branches.\n'
999 'Tip: add "-- --no-first-run" to bypass the first run prompts.')
1000 parser
= optparse
.OptionParser(usage
=usage
)
1001 # Strangely, the default help output doesn't include the choice list.
1002 choices
= ['mac', 'mac64', 'win', 'win64', 'linux', 'linux64', 'linux-arm']
1003 # linux-chromiumos lacks a continuous archive http://crbug.com/78158
1004 parser
.add_option('-a', '--archive',
1006 help='The buildbot archive to bisect [%s].' %
1008 parser
.add_option('-o',
1009 action
='store_true',
1010 dest
='official_builds',
1011 help='Bisect across official Chrome builds (internal '
1012 'only) instead of Chromium archives.')
1013 parser
.add_option('-b', '--bad',
1015 help='A bad revision to start bisection. '
1016 'May be earlier or later than the good revision. '
1018 parser
.add_option('-f', '--flash_path',
1020 help='Absolute path to a recent Adobe Pepper Flash '
1021 'binary to be used in this bisection (e.g. '
1022 'on Windows C:\...\pepflashplayer.dll and on Linux '
1023 '/opt/google/chrome/PepperFlash/'
1024 'libpepflashplayer.so).')
1025 parser
.add_option('-d', '--pdf_path',
1027 help='Absolute path to a recent PDF plugin '
1028 'binary to be used in this bisection (e.g. '
1029 'on Windows C:\...\pdf.dll and on Linux '
1030 '/opt/google/chrome/libpdf.so). Option also enables '
1032 parser
.add_option('-g', '--good',
1034 help='A good revision to start bisection. ' +
1035 'May be earlier or later than the bad revision. ' +
1037 parser
.add_option('-p', '--profile', '--user-data-dir',
1040 help='Profile to use; this will not reset every run. '
1041 'Defaults to a clean profile.')
1042 parser
.add_option('-t', '--times',
1045 help='Number of times to run each build before asking '
1046 'if it\'s good or bad. Temporary profiles are reused.')
1047 parser
.add_option('-c', '--command',
1050 help='Command to execute. %p and %a refer to Chrome '
1051 'executable and specified extra arguments '
1052 'respectively. Use %s to specify all extra arguments '
1053 'as one string. Defaults to "%p %a". Note that any '
1054 'extra paths specified should be absolute.')
1055 parser
.add_option('-l', '--blink',
1056 action
='store_true',
1057 help='Use Blink bisect instead of Chromium. ')
1058 parser
.add_option('', '--not-interactive',
1059 action
='store_true',
1061 help='Use command exit code to tell good/bad revision.')
1062 parser
.add_option('--asan',
1064 action
='store_true',
1066 help='Allow the script to bisect ASAN builds')
1067 parser
.add_option('--use-local-repo',
1068 dest
='use_local_repo',
1069 action
='store_true',
1071 help='Allow the script to convert git SHA1 to SVN '
1072 'revision using "git svn find-rev <SHA1>" '
1073 'command from a Chromium checkout.')
1075 (opts
, args
) = parser
.parse_args()
1077 if opts
.archive
is None:
1078 print 'Error: missing required parameter: --archive'
1084 supported_platforms
= ['linux', 'mac', 'win']
1085 if opts
.archive
not in supported_platforms
:
1086 print 'Error: ASAN bisecting only supported on these platforms: [%s].' % (
1087 '|'.join(supported_platforms
))
1089 if opts
.official_builds
:
1090 print 'Error: Do not yet support bisecting official ASAN builds.'
1094 base_url
= ASAN_BASE_URL
1096 base_url
= WEBKIT_BASE_URL
1098 base_url
= CHROMIUM_BASE_URL
1100 # Create the context. Initialize 0 for the revisions as they are set below.
1101 context
= PathContext(base_url
, opts
.archive
, opts
.good
, opts
.bad
,
1102 opts
.official_builds
, opts
.asan
, opts
.use_local_repo
,
1103 opts
.flash_path
, opts
.pdf_path
)
1104 # Pick a starting point, try to get HEAD for this.
1106 context
.bad_revision
= '999.0.0.0'
1107 context
.bad_revision
= GetChromiumRevision(
1108 context
, context
.GetLastChangeURL())
1110 # Find out when we were good.
1112 context
.good_revision
= '0.0.0.0' if opts
.official_builds
else 0
1115 msg
= 'Could not find Flash binary at %s' % opts
.flash_path
1116 assert os
.path
.exists(opts
.flash_path
), msg
1119 msg
= 'Could not find PDF binary at %s' % opts
.pdf_path
1120 assert os
.path
.exists(opts
.pdf_path
), msg
1122 if opts
.official_builds
:
1123 context
.good_revision
= LooseVersion(context
.good_revision
)
1124 context
.bad_revision
= LooseVersion(context
.bad_revision
)
1126 context
.good_revision
= int(context
.good_revision
)
1127 context
.bad_revision
= int(context
.bad_revision
)
1130 print('Number of times to run (%d) must be greater than or equal to 1.' %
1136 evaluator
= IsGoodASANBuild
1138 evaluator
= AskIsGoodBuild
1140 # Save these revision numbers to compare when showing the changelog URL
1142 good_rev
= context
.good_revision
1143 bad_rev
= context
.bad_revision
1145 (min_chromium_rev
, max_chromium_rev
, context
) = Bisect(
1146 context
, opts
.times
, opts
.command
, args
, opts
.profile
,
1147 not opts
.not_interactive
, evaluator
)
1149 # Get corresponding blink revisions.
1151 min_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1153 max_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1156 # Silently ignore the failure.
1157 min_blink_rev
, max_blink_rev
= 0, 0
1160 # We're done. Let the user know the results in an official manner.
1161 if good_rev
> bad_rev
:
1162 print DONE_MESSAGE_GOOD_MAX
% (str(min_blink_rev
), str(max_blink_rev
))
1164 print DONE_MESSAGE_GOOD_MIN
% (str(min_blink_rev
), str(max_blink_rev
))
1166 print 'BLINK CHANGELOG URL:'
1167 print ' ' + BLINK_CHANGELOG_URL
% (max_blink_rev
, min_blink_rev
)
1170 # We're done. Let the user know the results in an official manner.
1171 if good_rev
> bad_rev
:
1172 print DONE_MESSAGE_GOOD_MAX
% (str(min_chromium_rev
),
1173 str(max_chromium_rev
))
1175 print DONE_MESSAGE_GOOD_MIN
% (str(min_chromium_rev
),
1176 str(max_chromium_rev
))
1177 if min_blink_rev
!= max_blink_rev
:
1178 print ('NOTE: There is a Blink roll in the range, '
1179 'you might also want to do a Blink bisect.')
1181 print 'CHANGELOG URL:'
1182 if opts
.official_builds
:
1183 print OFFICIAL_CHANGELOG_URL
% (min_chromium_rev
, max_chromium_rev
)
1185 PrintChangeLog(min_chromium_rev
, max_chromium_rev
)
1188 if __name__
== '__main__':