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_cache
, flash_path
= None):
112 super(PathContext
, self
).__init
__()
113 # Store off the input parameters.
114 self
.base_url
= base_url
115 self
.platform
= platform
# What's passed in to the '-a/--archive' option.
116 self
.good_revision
= good_revision
117 self
.bad_revision
= bad_revision
118 self
.is_official
= is_official
119 self
.is_asan
= is_asan
120 self
.build_type
= 'release'
121 self
.flash_path
= flash_path
122 # Dictionary which stores svn revision number as key and it's
123 # corresponding git hash as value. This data is populated in
124 # _FetchAndParse and used later in GetDownloadURL while downloading
126 self
.githash_svn_dict
= {}
127 # The name of the ZIP file in a revision directory on the server.
128 self
.archive_name
= None
130 # Whether to cache and use the list of known revisions in a local file to
131 # speed up the initialization of the script at the next run.
132 self
.use_local_cache
= use_local_cache
134 # Locate the local checkout to speed up the script by using locally stored
136 abs_file_path
= os
.path
.abspath(os
.path
.realpath(__file__
))
137 local_src_path
= os
.path
.join(os
.path
.dirname(abs_file_path
), '..')
138 if abs_file_path
.endswith(os
.path
.join('tools', 'bisect-builds.py')) and\
139 os
.path
.exists(os
.path
.join(local_src_path
, '.git')):
140 self
.local_src_path
= os
.path
.normpath(local_src_path
)
142 self
.local_src_path
= None
144 # Set some internal members:
145 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
146 # _archive_extract_dir = Uncompressed directory in the archive_name file.
147 # _binary_name = The name of the executable to run.
148 if self
.platform
in ('linux', 'linux64', 'linux-arm', 'chromeos'):
149 self
._binary
_name
= 'chrome'
150 elif self
.platform
in ('mac', 'mac64'):
151 self
.archive_name
= 'chrome-mac.zip'
152 self
._archive
_extract
_dir
= 'chrome-mac'
153 elif self
.platform
in ('win', 'win64'):
154 self
.archive_name
= 'chrome-win32.zip'
155 self
._archive
_extract
_dir
= 'chrome-win32'
156 self
._binary
_name
= 'chrome.exe'
158 raise Exception('Invalid platform: %s' % self
.platform
)
161 if self
.platform
== 'linux':
162 self
._listing
_platform
_dir
= 'precise32/'
163 self
.archive_name
= 'chrome-precise32.zip'
164 self
._archive
_extract
_dir
= 'chrome-precise32'
165 elif self
.platform
== 'linux64':
166 self
._listing
_platform
_dir
= 'precise64/'
167 self
.archive_name
= 'chrome-precise64.zip'
168 self
._archive
_extract
_dir
= 'chrome-precise64'
169 elif self
.platform
== 'mac':
170 self
._listing
_platform
_dir
= 'mac/'
171 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
172 elif self
.platform
== 'mac64':
173 self
._listing
_platform
_dir
= 'mac64/'
174 self
._binary
_name
= 'Google Chrome.app/Contents/MacOS/Google Chrome'
175 elif self
.platform
== 'win':
176 self
._listing
_platform
_dir
= 'win/'
177 self
.archive_name
= 'chrome-win.zip'
178 self
._archive
_extract
_dir
= 'chrome-win'
179 elif self
.platform
== 'win64':
180 self
._listing
_platform
_dir
= 'win64/'
181 self
.archive_name
= 'chrome-win64.zip'
182 self
._archive
_extract
_dir
= 'chrome-win64'
184 if self
.platform
in ('linux', 'linux64', 'linux-arm', 'chromeos'):
185 self
.archive_name
= 'chrome-linux.zip'
186 self
._archive
_extract
_dir
= 'chrome-linux'
187 if self
.platform
== 'linux':
188 self
._listing
_platform
_dir
= 'Linux/'
189 elif self
.platform
== 'linux64':
190 self
._listing
_platform
_dir
= 'Linux_x64/'
191 elif self
.platform
== 'linux-arm':
192 self
._listing
_platform
_dir
= 'Linux_ARM_Cross-Compile/'
193 elif self
.platform
== 'chromeos':
194 self
._listing
_platform
_dir
= 'Linux_ChromiumOS_Full/'
195 elif self
.platform
== 'mac':
196 self
._listing
_platform
_dir
= 'Mac/'
197 self
._binary
_name
= 'Chromium.app/Contents/MacOS/Chromium'
198 elif self
.platform
== 'win':
199 self
._listing
_platform
_dir
= 'Win/'
201 def GetASANPlatformDir(self
):
202 """ASAN builds are in directories like "linux-release", or have filenames
203 like "asan-win32-release-277079.zip". This aligns to our platform names
204 except in the case of Windows where they use "win32" instead of "win"."""
205 if self
.platform
== 'win':
210 def GetListingURL(self
, marker
=None):
211 """Returns the URL for a directory listing, with an optional marker."""
214 marker_param
= '&marker=' + str(marker
)
216 prefix
= '%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
217 return self
.base_url
+ '/?delimiter=&prefix=' + prefix
+ marker_param
219 return (self
.base_url
+ '/?delimiter=/&prefix=' +
220 self
._listing
_platform
_dir
+ marker_param
)
222 def GetDownloadURL(self
, revision
):
223 """Gets the download URL for a build archive of a specific revision."""
225 return '%s/%s-%s/%s-%d.zip' % (
226 ASAN_BASE_URL
, self
.GetASANPlatformDir(), self
.build_type
,
227 self
.GetASANBaseName(), revision
)
229 return '%s/%s/%s%s' % (
230 OFFICIAL_BASE_URL
, revision
, self
._listing
_platform
_dir
,
233 if str(revision
) in self
.githash_svn_dict
:
234 revision
= self
.githash_svn_dict
[str(revision
)]
235 return '%s/%s%s/%s' % (self
.base_url
, self
._listing
_platform
_dir
,
236 revision
, self
.archive_name
)
238 def GetLastChangeURL(self
):
239 """Returns a URL to the LAST_CHANGE file."""
240 return self
.base_url
+ '/' + self
._listing
_platform
_dir
+ 'LAST_CHANGE'
242 def GetASANBaseName(self
):
243 """Returns the base name of the ASAN zip file."""
244 if 'linux' in self
.platform
:
245 return 'asan-symbolized-%s-%s' % (self
.GetASANPlatformDir(),
248 return 'asan-%s-%s' % (self
.GetASANPlatformDir(), self
.build_type
)
250 def GetLaunchPath(self
, revision
):
251 """Returns a relative path (presumably from the archive extraction location)
252 that is used to run the executable."""
254 extract_dir
= '%s-%d' % (self
.GetASANBaseName(), revision
)
256 extract_dir
= self
._archive
_extract
_dir
257 return os
.path
.join(extract_dir
, self
._binary
_name
)
259 def ParseDirectoryIndex(self
, last_known_rev
):
260 """Parses the Google Storage directory listing into a list of revision
263 def _GetMarkerForRev(revision
):
265 return '%s-%s/%s-%d.zip' % (
266 self
.GetASANPlatformDir(), self
.build_type
,
267 self
.GetASANBaseName(), revision
)
268 return '%s%d' % (self
._listing
_platform
_dir
, revision
)
270 def _FetchAndParse(url
):
271 """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
272 next-marker is not None, then the listing is a partial listing and another
273 fetch should be performed with next-marker being the marker= GET
275 handle
= urllib
.urlopen(url
)
276 document
= ElementTree
.parse(handle
)
278 # All nodes in the tree are namespaced. Get the root's tag name to extract
279 # the namespace. Etree does namespaces as |{namespace}tag|.
280 root_tag
= document
.getroot().tag
281 end_ns_pos
= root_tag
.find('}')
283 raise Exception('Could not locate end namespace for directory index')
284 namespace
= root_tag
[:end_ns_pos
+ 1]
286 # Find the prefix (_listing_platform_dir) and whether or not the list is
288 prefix_len
= len(document
.find(namespace
+ 'Prefix').text
)
290 is_truncated
= document
.find(namespace
+ 'IsTruncated')
291 if is_truncated
is not None and is_truncated
.text
.lower() == 'true':
292 next_marker
= document
.find(namespace
+ 'NextMarker').text
293 # Get a list of all the revisions.
295 githash_svn_dict
= {}
297 asan_regex
= re
.compile(r
'.*%s-(\d+)\.zip$' % (self
.GetASANBaseName()))
298 # Non ASAN builds are in a <revision> directory. The ASAN builds are
300 all_prefixes
= document
.findall(namespace
+ 'Contents/' +
302 for prefix
in all_prefixes
:
303 m
= asan_regex
.match(prefix
.text
)
306 revisions
.append(int(m
.group(1)))
310 all_prefixes
= document
.findall(namespace
+ 'CommonPrefixes/' +
311 namespace
+ 'Prefix')
312 # The <Prefix> nodes have content of the form of
313 # |_listing_platform_dir/revision/|. Strip off the platform dir and the
314 # trailing slash to just have a number.
315 for prefix
in all_prefixes
:
316 revnum
= prefix
.text
[prefix_len
:-1]
318 if not revnum
.isdigit():
319 # During the svn-git migration, some items were stored by hash.
320 # These items may appear anywhere in the list of items.
321 # If |last_known_rev| is set, assume that the full list has been
322 # retrieved before (including the hashes), so we can safely skip
323 # all git hashes and focus on the numeric revision numbers.
328 revnum
= self
.GetSVNRevisionFromGitHash(git_hash
)
329 githash_svn_dict
[revnum
] = git_hash
330 if revnum
is not None:
332 revisions
.append(revnum
)
335 return (revisions
, next_marker
, githash_svn_dict
)
337 # Fetch the first list of revisions.
340 # Optimization: Start paging at the last known revision (local cache).
341 next_marker
= _GetMarkerForRev(last_known_rev
)
342 # Optimization: Stop paging at the last known revision (remote).
343 last_change_rev
= GetChromiumRevision(self
, self
.GetLastChangeURL())
344 if last_known_rev
== last_change_rev
:
347 (revisions
, next_marker
, new_dict
) = _FetchAndParse(self
.GetListingURL())
348 self
.githash_svn_dict
.update(new_dict
)
349 last_change_rev
= None
351 # If the result list was truncated, refetch with the next marker. Do this
352 # until an entire directory listing is done.
354 sys
.stdout
.write('\rFetching revisions at marker %s' % next_marker
)
357 next_url
= self
.GetListingURL(next_marker
)
358 (new_revisions
, next_marker
, new_dict
) = _FetchAndParse(next_url
)
359 revisions
.extend(new_revisions
)
360 self
.githash_svn_dict
.update(new_dict
)
361 if last_change_rev
and last_change_rev
in new_revisions
:
363 sys
.stdout
.write('\r')
367 def _GetSVNRevisionFromGitHashWithoutGitCheckout(self
, git_sha1
, depot
):
368 json_url
= GITHASH_TO_SVN_URL
[depot
] % git_sha1
369 response
= urllib
.urlopen(json_url
)
370 if response
.getcode() == 200:
372 data
= json
.loads(response
.read()[4:])
374 print 'ValueError for JSON URL: %s' % json_url
378 if 'message' in data
:
379 message
= data
['message'].split('\n')
380 message
= [line
for line
in message
if line
.strip()]
381 search_pattern
= re
.compile(SEARCH_PATTERN
[depot
])
382 result
= search_pattern
.search(message
[len(message
)-1])
384 return result
.group(1)
386 if depot
== 'chromium':
387 result
= re
.search(CHROMIUM_SEARCH_PATTERN_OLD
,
388 message
[len(message
)-1])
390 return result
.group(1)
391 print 'Failed to get svn revision number for %s' % git_sha1
394 def _GetSVNRevisionFromGitHashFromGitCheckout(self
, git_sha1
, depot
):
395 def _RunGit(command
, path
):
396 command
= ['git'] + command
397 shell
= sys
.platform
.startswith('win')
398 proc
= subprocess
.Popen(command
, shell
=shell
, stdout
=subprocess
.PIPE
,
399 stderr
=subprocess
.PIPE
, cwd
=path
)
400 (output
, _
) = proc
.communicate()
401 return (output
, proc
.returncode
)
403 path
= self
.local_src_path
405 path
= os
.path
.join(self
.local_src_path
, 'third_party', 'WebKit')
408 command
= ['svn', 'find-rev', git_sha1
]
409 (git_output
, return_code
) = _RunGit(command
, path
)
411 revision
= git_output
.strip('\n')
415 command
= ['log', '-n1', '--format=%s', git_sha1
]
416 (git_output
, return_code
) = _RunGit(command
, path
)
418 revision
= re
.match('SVN changes up to revision ([0-9]+)', git_output
)
419 revision
= revision
.group(1) if revision
else None
424 def GetSVNRevisionFromGitHash(self
, git_sha1
, depot
='chromium'):
425 if not self
.local_src_path
:
426 return self
._GetSVNRevisionFromGitHashWithoutGitCheckout
(git_sha1
, depot
)
428 return self
._GetSVNRevisionFromGitHashFromGitCheckout
(git_sha1
, depot
)
430 def GetRevList(self
):
431 """Gets the list of revision numbers between self.good_revision and
432 self.bad_revision."""
435 # The cache is stored in the same directory as bisect-builds.py
436 cache_filename
= os
.path
.join(
437 os
.path
.abspath(os
.path
.dirname(__file__
)),
438 '.bisect-builds-cache.json')
439 cache_dict_key
= self
.GetListingURL()
441 def _LoadBucketFromCache():
442 if self
.use_local_cache
:
444 with
open(cache_filename
) as cache_file
:
445 for (key
, value
) in json
.load(cache_file
).items():
447 revisions
= cache
.get(cache_dict_key
, [])
448 githash_svn_dict
= cache
.get('githash_svn_dict', {})
450 print 'Loaded revisions %d-%d from %s' % (revisions
[0],
451 revisions
[-1], cache_filename
)
452 return (revisions
, githash_svn_dict
)
453 except (EnvironmentError, ValueError):
457 def _SaveBucketToCache():
458 """Save the list of revisions and the git-svn mappings to a file.
459 The list of revisions is assumed to be sorted."""
460 if self
.use_local_cache
:
461 cache
[cache_dict_key
] = revlist_all
462 cache
['githash_svn_dict'] = self
.githash_svn_dict
464 with
open(cache_filename
, 'w') as cache_file
:
465 json
.dump(cache
, cache_file
)
466 print 'Saved revisions %d-%d to %s' % (
467 revlist_all
[0], revlist_all
[-1], cache_filename
)
468 except EnvironmentError:
471 # Download the revlist and filter for just the range between good and bad.
472 minrev
= min(self
.good_revision
, self
.bad_revision
)
473 maxrev
= max(self
.good_revision
, self
.bad_revision
)
475 (revlist_all
, self
.githash_svn_dict
) = _LoadBucketFromCache()
476 last_known_rev
= revlist_all
[-1] if revlist_all
else 0
477 if last_known_rev
< maxrev
:
478 revlist_all
.extend(map(int, self
.ParseDirectoryIndex(last_known_rev
)))
479 revlist_all
= list(set(revlist_all
))
483 revlist
= [x
for x
in revlist_all
if x
>= int(minrev
) and x
<= int(maxrev
)]
485 # Set good and bad revisions to be legit revisions.
487 if self
.good_revision
< self
.bad_revision
:
488 self
.good_revision
= revlist
[0]
489 self
.bad_revision
= revlist
[-1]
491 self
.bad_revision
= revlist
[0]
492 self
.good_revision
= revlist
[-1]
494 # Fix chromium rev so that the deps blink revision matches REVISIONS file.
495 if self
.base_url
== WEBKIT_BASE_URL
:
497 self
.good_revision
= FixChromiumRevForBlink(revlist
,
501 self
.bad_revision
= FixChromiumRevForBlink(revlist
,
507 def GetOfficialBuildsList(self
):
508 """Gets the list of official build numbers between self.good_revision and
509 self.bad_revision."""
511 def CheckDepotToolsInPath():
512 delimiter
= ';' if sys
.platform
.startswith('win') else ':'
513 path_list
= os
.environ
['PATH'].split(delimiter
)
514 for path
in path_list
:
515 if path
.rstrip(os
.path
.sep
).endswith('depot_tools'):
519 def RunGsutilCommand(args
):
520 gsutil_path
= CheckDepotToolsInPath()
521 if gsutil_path
is None:
522 print ('Follow the instructions in this document '
523 'http://dev.chromium.org/developers/how-tos/install-depot-tools'
524 ' to install depot_tools and then try again.')
526 gsutil_path
= os
.path
.join(gsutil_path
, 'third_party', 'gsutil', 'gsutil')
527 gsutil
= subprocess
.Popen([sys
.executable
, gsutil_path
] + args
,
528 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
,
530 stdout
, stderr
= gsutil
.communicate()
531 if gsutil
.returncode
:
532 if (re
.findall(r
'status[ |=]40[1|3]', stderr
) or
533 stderr
.startswith(CREDENTIAL_ERROR_MESSAGE
)):
534 print ('Follow these steps to configure your credentials and try'
535 ' running the bisect-builds.py again.:\n'
536 ' 1. Run "python %s config" and follow its instructions.\n'
537 ' 2. If you have a @google.com account, use that account.\n'
538 ' 3. For the project-id, just enter 0.' % gsutil_path
)
541 raise Exception('Error running the gsutil command: %s' % stderr
)
544 def GsutilList(bucket
):
545 query
= 'gs://%s/' % bucket
546 stdout
= RunGsutilCommand(['ls', query
])
547 return [url
[len(query
):].strip('/') for url
in stdout
.splitlines()]
549 # Download the revlist and filter for just the range between good and bad.
550 minrev
= min(self
.good_revision
, self
.bad_revision
)
551 maxrev
= max(self
.good_revision
, self
.bad_revision
)
552 build_numbers
= GsutilList(GS_BUCKET_NAME
)
553 revision_re
= re
.compile(r
'(\d\d\.\d\.\d{4}\.\d+)')
554 build_numbers
= filter(lambda b
: revision_re
.search(b
), build_numbers
)
556 parsed_build_numbers
= [LooseVersion(x
) for x
in build_numbers
]
557 connection
= httplib
.HTTPConnection(GOOGLE_APIS_URL
)
558 for build_number
in sorted(parsed_build_numbers
):
559 if build_number
> maxrev
:
561 if build_number
< minrev
:
563 path
= ('/' + GS_BUCKET_NAME
+ '/' + str(build_number
) + '/' +
564 self
._listing
_platform
_dir
+ self
.archive_name
)
565 connection
.request('HEAD', path
)
566 response
= connection
.getresponse()
567 if response
.status
== 200:
568 final_list
.append(str(build_number
))
573 def UnzipFilenameToDir(filename
, directory
):
574 """Unzip |filename| to |directory|."""
576 if not os
.path
.isabs(filename
):
577 filename
= os
.path
.join(cwd
, filename
)
578 zf
= zipfile
.ZipFile(filename
)
580 if not os
.path
.isdir(directory
):
584 for info
in zf
.infolist():
586 if name
.endswith('/'): # dir
587 if not os
.path
.isdir(name
):
590 directory
= os
.path
.dirname(name
)
591 if not os
.path
.isdir(directory
):
592 os
.makedirs(directory
)
593 out
= open(name
, 'wb')
594 out
.write(zf
.read(name
))
596 # Set permissions. Permission info in external_attr is shifted 16 bits.
597 os
.chmod(name
, info
.external_attr
>> 16L)
601 def FetchRevision(context
, rev
, filename
, quit_event
=None, progress_event
=None):
602 """Downloads and unzips revision |rev|.
603 @param context A PathContext instance.
604 @param rev The Chromium revision number/tag to download.
605 @param filename The destination for the downloaded file.
606 @param quit_event A threading.Event which will be set by the master thread to
607 indicate that the download should be aborted.
608 @param progress_event A threading.Event which will be set by the master thread
609 to indicate that the progress of the download should be
612 def ReportHook(blocknum
, blocksize
, totalsize
):
613 if quit_event
and quit_event
.isSet():
614 raise RuntimeError('Aborting download of revision %s' % str(rev
))
615 if progress_event
and progress_event
.isSet():
616 size
= blocknum
* blocksize
617 if totalsize
== -1: # Total size not known.
618 progress
= 'Received %d bytes' % size
620 size
= min(totalsize
, size
)
621 progress
= 'Received %d of %d bytes, %.2f%%' % (
622 size
, totalsize
, 100.0 * size
/ totalsize
)
623 # Send a \r to let all progress messages use just one line of output.
624 sys
.stdout
.write('\r' + progress
)
626 download_url
= context
.GetDownloadURL(rev
)
628 urllib
.urlretrieve(download_url
, filename
, ReportHook
)
629 if progress_event
and progress_event
.isSet():
636 def RunRevision(context
, revision
, zip_file
, profile
, num_runs
, command
, args
):
637 """Given a zipped revision, unzip it and run the test."""
638 print 'Trying revision %s...' % str(revision
)
640 # Create a temp directory and unzip the revision into it.
642 tempdir
= tempfile
.mkdtemp(prefix
='bisect_tmp')
643 UnzipFilenameToDir(zip_file
, tempdir
)
645 # Hack: Chrome OS archives are missing icudtl.dat; try to copy it from
646 # the local directory.
647 if context
.platform
== 'chromeos':
648 icudtl_path
= 'third_party/icu/source/data/in/icudtl.dat'
649 if not os
.access(icudtl_path
, os
.F_OK
):
650 print 'Couldn\'t find: ' + icudtl_path
652 os
.system('cp %s %s/chrome-linux/' % (icudtl_path
, tempdir
))
656 # Run the build as many times as specified.
657 testargs
= ['--user-data-dir=%s' % profile
] + args
658 # The sandbox must be run as root on Official Chrome, so bypass it.
659 if ((context
.is_official
or context
.flash_path
) and
660 context
.platform
.startswith('linux')):
661 testargs
.append('--no-sandbox')
662 if context
.flash_path
:
663 testargs
.append('--ppapi-flash-path=%s' % context
.flash_path
)
664 # We have to pass a large enough Flash version, which currently needs not
665 # be correct. Instead of requiring the user of the script to figure out and
666 # pass the correct version we just spoof it.
667 testargs
.append('--ppapi-flash-version=99.9.999.999')
670 for token
in shlex
.split(command
):
672 runcommand
.extend(testargs
)
675 token
.replace('%p', os
.path
.abspath(context
.GetLaunchPath(revision
))).
676 replace('%s', ' '.join(testargs
)))
679 for _
in range(num_runs
):
680 subproc
= subprocess
.Popen(runcommand
,
682 stdout
=subprocess
.PIPE
,
683 stderr
=subprocess
.PIPE
)
684 (stdout
, stderr
) = subproc
.communicate()
685 results
.append((subproc
.returncode
, stdout
, stderr
))
688 shutil
.rmtree(tempdir
, True)
692 for (returncode
, stdout
, stderr
) in results
:
694 return (returncode
, stdout
, stderr
)
698 # The arguments official_builds, status, stdout and stderr are unused.
699 # They are present here because this function is passed to Bisect which then
700 # calls it with 5 arguments.
701 # pylint: disable=W0613
702 def AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
):
703 """Asks the user whether build |rev| is good or bad."""
704 # Loop until we get a response that we can parse.
706 response
= raw_input('Revision %s is '
707 '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
709 if response
and response
in ('g', 'b', 'r', 'u'):
711 if response
and response
== 'q':
715 def IsGoodASANBuild(rev
, official_builds
, status
, stdout
, stderr
):
716 """Determine if an ASAN build |rev| is good or bad
718 Will examine stderr looking for the error message emitted by ASAN. If not
719 found then will fallback to asking the user."""
722 for line
in stderr
.splitlines():
724 if line
.find('ERROR: AddressSanitizer:') != -1:
727 print 'Revision %d determined to be bad.' % rev
729 return AskIsGoodBuild(rev
, official_builds
, status
, stdout
, stderr
)
731 class DownloadJob(object):
732 """DownloadJob represents a task to download a given Chromium revision."""
734 def __init__(self
, context
, name
, rev
, zip_file
):
735 super(DownloadJob
, self
).__init
__()
736 # Store off the input parameters.
737 self
.context
= context
740 self
.zip_file
= zip_file
741 self
.quit_event
= threading
.Event()
742 self
.progress_event
= threading
.Event()
746 """Starts the download."""
747 fetchargs
= (self
.context
,
752 self
.thread
= threading
.Thread(target
=FetchRevision
,
758 """Stops the download which must have been started previously."""
759 assert self
.thread
, 'DownloadJob must be started before Stop is called.'
760 self
.quit_event
.set()
762 os
.unlink(self
.zip_file
)
765 """Prints a message and waits for the download to complete. The download
766 must have been started previously."""
767 assert self
.thread
, 'DownloadJob must be started before WaitFor is called.'
768 print 'Downloading revision %s...' % str(self
.rev
)
769 self
.progress_event
.set() # Display progress of download.
779 evaluate
=AskIsGoodBuild
):
780 """Given known good and known bad revisions, run a binary search on all
781 archived revisions to determine the last known good revision.
783 @param context PathContext object initialized with user provided parameters.
784 @param num_runs Number of times to run each build for asking good/bad.
785 @param try_args A tuple of arguments to pass to the test application.
786 @param profile The name of the user profile to run with.
787 @param interactive If it is false, use command exit code for good or bad
788 judgment of the argument build.
789 @param evaluate A function which returns 'g' if the argument build is good,
790 'b' if it's bad or 'u' if unknown.
792 Threading is used to fetch Chromium revisions in the background, speeding up
793 the user's experience. For example, suppose the bounds of the search are
794 good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
795 whether revision 50 is good or bad, the next revision to check will be either
796 25 or 75. So, while revision 50 is being checked, the script will download
797 revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
800 - If rev 50 is good, the download of rev 25 is cancelled, and the next test
803 - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
810 good_rev
= context
.good_revision
811 bad_rev
= context
.bad_revision
814 print 'Downloading list of known revisions...',
815 if not context
.use_local_cache
and not context
.is_official
:
816 print '(use --use-local-cache to cache and re-use the list of revisions)'
819 _GetDownloadPath
= lambda rev
: os
.path
.join(cwd
,
820 '%s-%s' % (str(rev
), context
.archive_name
))
821 if context
.is_official
:
822 revlist
= context
.GetOfficialBuildsList()
824 revlist
= context
.GetRevList()
826 # Get a list of revisions to bisect across.
827 if len(revlist
) < 2: # Don't have enough builds to bisect.
828 msg
= 'We don\'t have enough builds to bisect. revlist: %s' % revlist
829 raise RuntimeError(msg
)
831 # Figure out our bookends and first pivot point; fetch the pivot revision.
833 maxrev
= len(revlist
) - 1
836 zip_file
= _GetDownloadPath(rev
)
837 fetch
= DownloadJob(context
, 'initial_fetch', rev
, zip_file
)
841 # Binary search time!
842 while fetch
and fetch
.zip_file
and maxrev
- minrev
> 1:
843 if bad_rev
< good_rev
:
844 min_str
, max_str
= 'bad', 'good'
846 min_str
, max_str
= 'good', 'bad'
847 print 'Bisecting range [%s (%s), %s (%s)].' % (revlist
[minrev
], min_str
,
848 revlist
[maxrev
], max_str
)
850 # Pre-fetch next two possible pivots
851 # - down_pivot is the next revision to check if the current revision turns
853 # - up_pivot is the next revision to check if the current revision turns
855 down_pivot
= int((pivot
- minrev
) / 2) + minrev
857 if down_pivot
!= pivot
and down_pivot
!= minrev
:
858 down_rev
= revlist
[down_pivot
]
859 down_fetch
= DownloadJob(context
, 'down_fetch', down_rev
,
860 _GetDownloadPath(down_rev
))
863 up_pivot
= int((maxrev
- pivot
) / 2) + pivot
865 if up_pivot
!= pivot
and up_pivot
!= maxrev
:
866 up_rev
= revlist
[up_pivot
]
867 up_fetch
= DownloadJob(context
, 'up_fetch', up_rev
,
868 _GetDownloadPath(up_rev
))
871 # Run test on the pivot revision.
876 (status
, stdout
, stderr
) = RunRevision(context
,
884 print >> sys
.stderr
, e
886 # Call the evaluate function to see if the current revision is good or bad.
887 # On that basis, kill one of the background downloads and complete the
888 # other, as described in the comments above.
893 print 'Bad revision: %s' % rev
896 print 'Good revision: %s' % rev
898 answer
= evaluate(rev
, context
.is_official
, status
, stdout
, stderr
)
899 if ((answer
== 'g' and good_rev
< bad_rev
)
900 or (answer
== 'b' and bad_rev
< good_rev
)):
904 down_fetch
.Stop() # Kill the download of the older revision.
910 elif ((answer
== 'b' and good_rev
< bad_rev
)
911 or (answer
== 'g' and bad_rev
< good_rev
)):
915 up_fetch
.Stop() # Kill the download of the newer revision.
922 pass # Retry requires no changes.
924 # Nuke the revision from the revlist and choose a new pivot.
927 maxrev
-= 1 # Assumes maxrev >= pivot.
929 if maxrev
- minrev
> 1:
930 # Alternate between using down_pivot or up_pivot for the new pivot
931 # point, without affecting the range. Do this instead of setting the
932 # pivot to the midpoint of the new range because adjacent revisions
933 # are likely affected by the same issue that caused the (u)nknown
935 if up_fetch
and down_fetch
:
936 fetch
= [up_fetch
, down_fetch
][len(revlist
) % 2]
942 if fetch
== up_fetch
:
943 pivot
= up_pivot
- 1 # Subtracts 1 because revlist was resized.
946 zip_file
= fetch
.zip_file
948 if down_fetch
and fetch
!= down_fetch
:
950 if up_fetch
and fetch
!= up_fetch
:
953 assert False, 'Unexpected return value from evaluate(): ' + answer
955 print 'Cleaning up...'
956 for f
in [_GetDownloadPath(revlist
[down_pivot
]),
957 _GetDownloadPath(revlist
[up_pivot
])]:
966 return (revlist
[minrev
], revlist
[maxrev
], context
)
969 def GetBlinkDEPSRevisionForChromiumRevision(self
, rev
):
970 """Returns the blink revision that was in REVISIONS file at
971 chromium revision |rev|."""
973 def _GetBlinkRev(url
, blink_re
):
974 m
= blink_re
.search(url
.read())
979 url
= urllib
.urlopen(DEPS_FILE_OLD
% rev
)
980 if url
.getcode() == 200:
981 # . doesn't match newlines without re.DOTALL, so this is safe.
982 blink_re
= re
.compile(r
'webkit_revision\D*(\d+)')
983 return int(_GetBlinkRev(url
, blink_re
))
985 url
= urllib
.urlopen(DEPS_FILE_NEW
% GetGitHashFromSVNRevision(rev
))
986 if url
.getcode() == 200:
987 blink_re
= re
.compile(r
'webkit_revision\D*\d+;\D*\d+;(\w+)')
988 blink_git_sha
= _GetBlinkRev(url
, blink_re
)
989 return self
.GetSVNRevisionFromGitHash(blink_git_sha
, 'blink')
990 raise Exception('Could not get Blink revision for Chromium rev %d' % rev
)
993 def GetBlinkRevisionForChromiumRevision(context
, rev
):
994 """Returns the blink revision that was in REVISIONS file at
995 chromium revision |rev|."""
996 def _IsRevisionNumber(revision
):
997 if isinstance(revision
, int):
1000 return revision
.isdigit()
1001 if str(rev
) in context
.githash_svn_dict
:
1002 rev
= context
.githash_svn_dict
[str(rev
)]
1003 file_url
= '%s/%s%s/REVISIONS' % (context
.base_url
,
1004 context
._listing
_platform
_dir
, rev
)
1005 url
= urllib
.urlopen(file_url
)
1006 if url
.getcode() == 200:
1008 data
= json
.loads(url
.read())
1010 print 'ValueError for JSON URL: %s' % file_url
1015 if 'webkit_revision' in data
:
1016 blink_rev
= data
['webkit_revision']
1017 if not _IsRevisionNumber(blink_rev
):
1018 blink_rev
= int(context
.GetSVNRevisionFromGitHash(blink_rev
, 'blink'))
1021 raise Exception('Could not get blink revision for cr rev %d' % rev
)
1024 def FixChromiumRevForBlink(revisions_final
, revisions
, self
, rev
):
1025 """Returns the chromium revision that has the correct blink revision
1026 for blink bisect, DEPS and REVISIONS file might not match since
1027 blink snapshots point to tip of tree blink.
1028 Note: The revisions_final variable might get modified to include
1029 additional revisions."""
1030 blink_deps_rev
= GetBlinkDEPSRevisionForChromiumRevision(self
, rev
)
1032 while (GetBlinkRevisionForChromiumRevision(self
, rev
) > blink_deps_rev
):
1033 idx
= revisions
.index(rev
)
1035 rev
= revisions
[idx
-1]
1036 if rev
not in revisions_final
:
1037 revisions_final
.insert(0, rev
)
1039 revisions_final
.sort()
1043 def GetChromiumRevision(context
, url
):
1044 """Returns the chromium revision read from given URL."""
1046 # Location of the latest build revision number
1047 latest_revision
= urllib
.urlopen(url
).read()
1048 if latest_revision
.isdigit():
1049 return int(latest_revision
)
1050 return context
.GetSVNRevisionFromGitHash(latest_revision
)
1052 print 'Could not determine latest revision. This could be bad...'
1055 def GetGitHashFromSVNRevision(svn_revision
):
1056 crrev_url
= CRREV_URL
+ str(svn_revision
)
1057 url
= urllib
.urlopen(crrev_url
)
1058 if url
.getcode() == 200:
1059 data
= json
.loads(url
.read())
1060 if 'git_sha' in data
:
1061 return data
['git_sha']
1063 def PrintChangeLog(min_chromium_rev
, max_chromium_rev
):
1064 """Prints the changelog URL."""
1066 print (' ' + CHANGELOG_URL
% (GetGitHashFromSVNRevision(min_chromium_rev
),
1067 GetGitHashFromSVNRevision(max_chromium_rev
)))
1071 usage
= ('%prog [options] [-- chromium-options]\n'
1072 'Perform binary search on the snapshot builds to find a minimal\n'
1073 'range of revisions where a behavior change happened. The\n'
1074 'behaviors are described as "good" and "bad".\n'
1075 'It is NOT assumed that the behavior of the later revision is\n'
1078 'Revision numbers should use\n'
1079 ' Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
1080 ' SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
1081 ' Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
1082 ' for earlier revs.\n'
1083 ' Chrome\'s about: build number and omahaproxy branch_revision\n'
1084 ' are incorrect, they are from branches.\n'
1086 'Tip: add "-- --no-first-run" to bypass the first run prompts.')
1087 parser
= optparse
.OptionParser(usage
=usage
)
1088 # Strangely, the default help output doesn't include the choice list.
1089 choices
= ['mac', 'mac64', 'win', 'win64', 'linux', 'linux64', 'linux-arm',
1091 parser
.add_option('-a', '--archive',
1093 help='The buildbot archive to bisect [%s].' %
1095 parser
.add_option('-o',
1096 action
='store_true',
1097 dest
='official_builds',
1098 help='Bisect across official Chrome builds (internal '
1099 'only) instead of Chromium archives.')
1100 parser
.add_option('-b', '--bad',
1102 help='A bad revision to start bisection. '
1103 'May be earlier or later than the good revision. '
1105 parser
.add_option('-f', '--flash_path',
1107 help='Absolute path to a recent Adobe Pepper Flash '
1108 'binary to be used in this bisection (e.g. '
1109 'on Windows C:\...\pepflashplayer.dll and on Linux '
1110 '/opt/google/chrome/PepperFlash/'
1111 'libpepflashplayer.so).')
1112 parser
.add_option('-g', '--good',
1114 help='A good revision to start bisection. ' +
1115 'May be earlier or later than the bad revision. ' +
1117 parser
.add_option('-p', '--profile', '--user-data-dir',
1120 help='Profile to use; this will not reset every run. '
1121 'Defaults to a clean profile.')
1122 parser
.add_option('-t', '--times',
1125 help='Number of times to run each build before asking '
1126 'if it\'s good or bad. Temporary profiles are reused.')
1127 parser
.add_option('-c', '--command',
1130 help='Command to execute. %p and %a refer to Chrome '
1131 'executable and specified extra arguments '
1132 'respectively. Use %s to specify all extra arguments '
1133 'as one string. Defaults to "%p %a". Note that any '
1134 'extra paths specified should be absolute.')
1135 parser
.add_option('-l', '--blink',
1136 action
='store_true',
1137 help='Use Blink bisect instead of Chromium. ')
1138 parser
.add_option('', '--not-interactive',
1139 action
='store_true',
1141 help='Use command exit code to tell good/bad revision.')
1142 parser
.add_option('--asan',
1144 action
='store_true',
1146 help='Allow the script to bisect ASAN builds')
1147 parser
.add_option('--use-local-cache',
1148 dest
='use_local_cache',
1149 action
='store_true',
1151 help='Use a local file in the current directory to cache '
1152 'a list of known revisions to speed up the '
1153 'initialization of this script.')
1155 (opts
, args
) = parser
.parse_args()
1157 if opts
.archive
is None:
1158 print 'Error: missing required parameter: --archive'
1164 supported_platforms
= ['linux', 'mac', 'win']
1165 if opts
.archive
not in supported_platforms
:
1166 print 'Error: ASAN bisecting only supported on these platforms: [%s].' % (
1167 '|'.join(supported_platforms
))
1169 if opts
.official_builds
:
1170 print 'Error: Do not yet support bisecting official ASAN builds.'
1174 base_url
= ASAN_BASE_URL
1176 base_url
= WEBKIT_BASE_URL
1178 base_url
= CHROMIUM_BASE_URL
1180 # Create the context. Initialize 0 for the revisions as they are set below.
1181 context
= PathContext(base_url
, opts
.archive
, opts
.good
, opts
.bad
,
1182 opts
.official_builds
, opts
.asan
, opts
.use_local_cache
,
1185 # Pick a starting point, try to get HEAD for this.
1187 context
.bad_revision
= '999.0.0.0'
1188 context
.bad_revision
= GetChromiumRevision(
1189 context
, context
.GetLastChangeURL())
1191 # Find out when we were good.
1193 context
.good_revision
= '0.0.0.0' if opts
.official_builds
else 0
1196 msg
= 'Could not find Flash binary at %s' % opts
.flash_path
1197 assert os
.path
.exists(opts
.flash_path
), msg
1199 if opts
.official_builds
:
1200 context
.good_revision
= LooseVersion(context
.good_revision
)
1201 context
.bad_revision
= LooseVersion(context
.bad_revision
)
1203 context
.good_revision
= int(context
.good_revision
)
1204 context
.bad_revision
= int(context
.bad_revision
)
1207 print('Number of times to run (%d) must be greater than or equal to 1.' %
1213 evaluator
= IsGoodASANBuild
1215 evaluator
= AskIsGoodBuild
1217 # Save these revision numbers to compare when showing the changelog URL
1219 good_rev
= context
.good_revision
1220 bad_rev
= context
.bad_revision
1222 (min_chromium_rev
, max_chromium_rev
, context
) = Bisect(
1223 context
, opts
.times
, opts
.command
, args
, opts
.profile
,
1224 not opts
.not_interactive
, evaluator
)
1226 # Get corresponding blink revisions.
1228 min_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1230 max_blink_rev
= GetBlinkRevisionForChromiumRevision(context
,
1233 # Silently ignore the failure.
1234 min_blink_rev
, max_blink_rev
= 0, 0
1237 # We're done. Let the user know the results in an official manner.
1238 if good_rev
> bad_rev
:
1239 print DONE_MESSAGE_GOOD_MAX
% (str(min_blink_rev
), str(max_blink_rev
))
1241 print DONE_MESSAGE_GOOD_MIN
% (str(min_blink_rev
), str(max_blink_rev
))
1243 print 'BLINK CHANGELOG URL:'
1244 print ' ' + BLINK_CHANGELOG_URL
% (max_blink_rev
, min_blink_rev
)
1247 # We're done. Let the user know the results in an official manner.
1248 if good_rev
> bad_rev
:
1249 print DONE_MESSAGE_GOOD_MAX
% (str(min_chromium_rev
),
1250 str(max_chromium_rev
))
1252 print DONE_MESSAGE_GOOD_MIN
% (str(min_chromium_rev
),
1253 str(max_chromium_rev
))
1254 if min_blink_rev
!= max_blink_rev
:
1255 print ('NOTE: There is a Blink roll in the range, '
1256 'you might also want to do a Blink bisect.')
1258 print 'CHANGELOG URL:'
1259 if opts
.official_builds
:
1260 print OFFICIAL_CHANGELOG_URL
% (min_chromium_rev
, max_chromium_rev
)
1262 PrintChangeLog(min_chromium_rev
, max_chromium_rev
)
1265 if __name__
== '__main__':