OLD | NEW |
---|---|
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """Snapshot Build Bisect Tool | 6 """Snapshot Build Bisect Tool |
7 | 7 |
8 This script bisects a snapshot archive using binary search. It starts at | 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 | 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, | 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, | 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. | 12 it will ask you whether it is good or bad before continuing the search. |
13 """ | 13 """ |
14 | 14 |
15 # The root URL for storage. | 15 # The root URL for storage. |
16 BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots' | 16 BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots' |
17 BASE_URL_RECENT = 'http://build.chromium.org/f/chromium/snapshots' | |
17 | 18 |
18 # URL to the ViewVC commit page. | 19 # URL to the ViewVC commit page. |
19 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d' | 20 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d' |
20 | 21 |
21 # Changelogs URL. | 22 # Changelogs URL. |
22 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \ | 23 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \ |
23 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d' | 24 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d' |
24 | 25 |
25 ############################################################################### | 26 ############################################################################### |
26 | 27 |
27 import math | 28 import math |
28 import optparse | 29 import optparse |
29 import os | 30 import os |
30 import pipes | 31 import pipes |
31 import re | 32 import re |
32 import shutil | 33 import shutil |
33 import sys | 34 import sys |
34 import tempfile | 35 import tempfile |
35 import urllib | 36 import urllib |
36 from xml.etree import ElementTree | 37 from xml.etree import ElementTree |
37 import zipfile | 38 import zipfile |
38 | 39 |
39 class PathContext(object): | 40 class PathContext(object): |
40 """A PathContext is used to carry the information used to construct URLs and | 41 """A PathContext is used to carry the information used to construct URLs and |
41 paths when dealing with the storage server and archives.""" | 42 paths when dealing with the storage server and archives.""" |
42 def __init__(self, platform, good_revision, bad_revision): | 43 def __init__(self, platform, good_revision, bad_revision, use_recent): |
43 super(PathContext, self).__init__() | 44 super(PathContext, self).__init__() |
44 # Store off the input parameters. | 45 # Store off the input parameters. |
45 self.platform = platform # What's passed in to the '-a/--archive' option. | 46 self.platform = platform # What's passed in to the '-a/--archive' option. |
46 self.good_revision = good_revision | 47 self.good_revision = good_revision |
47 self.bad_revision = bad_revision | 48 self.bad_revision = bad_revision |
49 self.use_recent = use_recent | |
48 | 50 |
49 # The name of the ZIP file in a revision directory on the server. | 51 # The name of the ZIP file in a revision directory on the server. |
50 self.archive_name = None | 52 self.archive_name = None |
51 | 53 |
52 # Set some internal members: | 54 # Set some internal members: |
53 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'. | 55 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'. |
54 # _archive_extract_dir = Uncompressed directory in the archive_name file. | 56 # _archive_extract_dir = Uncompressed directory in the archive_name file. |
55 # _binary_name = The name of the executable to run. | 57 # _binary_name = The name of the executable to run. |
56 if self.platform == 'linux' or self.platform == 'linux64': | 58 if self.platform == 'linux' or self.platform == 'linux64': |
57 self._listing_platform_dir = 'Linux/' | 59 self._listing_platform_dir = 'Linux/' |
(...skipping 17 matching lines...) Expand all Loading... | |
75 raise Exception("Invalid platform") | 77 raise Exception("Invalid platform") |
76 | 78 |
77 def GetListingURL(self, marker=None): | 79 def GetListingURL(self, marker=None): |
78 """Returns the URL for a directory listing, with an optional marker.""" | 80 """Returns the URL for a directory listing, with an optional marker.""" |
79 marker_param = '' | 81 marker_param = '' |
80 if marker: | 82 if marker: |
81 marker_param = '&marker=' + str(marker) | 83 marker_param = '&marker=' + str(marker) |
82 return BASE_URL + '/?delimiter=/&prefix=' + self._listing_platform_dir + \ | 84 return BASE_URL + '/?delimiter=/&prefix=' + self._listing_platform_dir + \ |
83 marker_param | 85 marker_param |
84 | 86 |
87 def GetListingURLRecent(self): | |
88 """Returns the URL for a directory listing of recent builds.""" | |
89 return BASE_URL_RECENT + '/' + self._listing_platform_dir | |
90 | |
85 def GetDownloadURL(self, revision): | 91 def GetDownloadURL(self, revision): |
86 """Gets the download URL for a build archive of a specific revision.""" | 92 """Gets the download URL for a build archive of a specific revision.""" |
87 return "%s/%s%d/%s" % ( | 93 if self.use_recent: |
88 BASE_URL, self._listing_platform_dir, revision, self.archive_name) | 94 return "%s/%s%d/%s" % ( |
95 BASE_URL_RECENT, self._listing_platform_dir, revision, | |
96 self.archive_name) | |
97 else: | |
98 return "%s/%s%d/%s" % ( | |
99 BASE_URL, self._listing_platform_dir, revision, self.archive_name) | |
89 | 100 |
90 def GetLastChangeURL(self): | 101 def GetLastChangeURL(self): |
91 """Returns a URL to the LAST_CHANGE file.""" | 102 """Returns a URL to the LAST_CHANGE file.""" |
92 return BASE_URL + '/' + self._listing_platform_dir + 'LAST_CHANGE' | 103 return BASE_URL + '/' + self._listing_platform_dir + 'LAST_CHANGE' |
93 | 104 |
94 def GetLaunchPath(self): | 105 def GetLaunchPath(self): |
95 """Returns a relative path (presumably from the archive extraction location) | 106 """Returns a relative path (presumably from the archive extraction location) |
96 that is used to run the executable.""" | 107 that is used to run the executable.""" |
97 return os.path.join(self._archive_extract_dir, self._binary_name) | 108 return os.path.join(self._archive_extract_dir, self._binary_name) |
98 | 109 |
(...skipping 75 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
174 # Fetch the first list of revisions. | 185 # Fetch the first list of revisions. |
175 (revisions, next_marker) = _FetchAndParse(context.GetListingURL()) | 186 (revisions, next_marker) = _FetchAndParse(context.GetListingURL()) |
176 # If the result list was truncated, refetch with the next marker. Do this | 187 # If the result list was truncated, refetch with the next marker. Do this |
177 # until an entire directory listing is done. | 188 # until an entire directory listing is done. |
178 while next_marker: | 189 while next_marker: |
179 (new_revisions, next_marker) = _FetchAndParse( | 190 (new_revisions, next_marker) = _FetchAndParse( |
180 context.GetListingURL(next_marker)) | 191 context.GetListingURL(next_marker)) |
181 revisions.extend(new_revisions) | 192 revisions.extend(new_revisions) |
182 | 193 |
183 return revisions | 194 return revisions |
184 | 195 |
Robert Sesek
2011/07/20 21:29:17
nit: two blank lines between top-level definitions
jbates
2011/07/20 21:32:10
Done.
| |
196 def ParseDirectoryIndexRecent(context): | |
197 """Parses the recent builds directory listing into a list of revision | |
198 numbers.""" | |
199 handle = urllib.urlopen(context.GetListingURLRecent()) | |
200 document = handle.read() | |
201 | |
202 # Looking for: <a href="92976/">92976/</a> | |
203 return re.findall(r"<a href=\"(\d+)/\">\1/</a>", document) | |
204 | |
185 | 205 |
186 def GetRevList(context): | 206 def GetRevList(context): |
187 """Gets the list of revision numbers between |good_revision| and | 207 """Gets the list of revision numbers between |good_revision| and |
188 |bad_revision| of the |context|.""" | 208 |bad_revision| of the |context|.""" |
189 # Download the revlist and filter for just the range between good and bad. | 209 # Download the revlist and filter for just the range between good and bad. |
190 rev_range = range(context.good_revision, context.bad_revision) | 210 rev_range = range(context.good_revision, context.bad_revision) |
191 revlist = map(int, ParseDirectoryIndex(context)) | 211 revisions = [] |
212 if context.use_recent: | |
213 revisions = ParseDirectoryIndexRecent(context) | |
214 else: | |
215 revisions = ParseDirectoryIndex(context) | |
216 revlist = map(int, revisions) | |
192 revlist = filter(lambda r: r in rev_range, revlist) | 217 revlist = filter(lambda r: r in rev_range, revlist) |
193 revlist.sort() | 218 revlist.sort() |
194 return revlist | 219 return revlist |
195 | 220 |
196 | 221 |
197 def TryRevision(context, rev, profile, args): | 222 def TryRevision(context, rev, profile, args): |
198 """Downloads revision |rev|, unzips it, and opens it for the user to test. | 223 """Downloads revision |rev|, unzips it, and opens it for the user to test. |
199 |profile| is the profile to use.""" | 224 |profile| is the profile to use.""" |
200 # Do this in a temp dir so we don't collide with user files. | 225 # Do this in a temp dir so we don't collide with user files. |
201 cwd = os.getcwd() | 226 cwd = os.getcwd() |
(...skipping 10 matching lines...) Expand all Loading... | |
212 size = min(totalsize, size) | 237 size = min(totalsize, size) |
213 progress = "Received %d of %d bytes, %.2f%%" % ( | 238 progress = "Received %d of %d bytes, %.2f%%" % ( |
214 size, totalsize, 100.0 * size / totalsize) | 239 size, totalsize, 100.0 * size / totalsize) |
215 # Send a \r to let all progress messages use just one line of output. | 240 # Send a \r to let all progress messages use just one line of output. |
216 sys.stdout.write("\r" + progress) | 241 sys.stdout.write("\r" + progress) |
217 sys.stdout.flush() | 242 sys.stdout.flush() |
218 try: | 243 try: |
219 print 'Fetching ' + download_url | 244 print 'Fetching ' + download_url |
220 urllib.urlretrieve(download_url, context.archive_name, _ReportHook) | 245 urllib.urlretrieve(download_url, context.archive_name, _ReportHook) |
221 print | 246 print |
247 # Throw an exception if the download was less than 1000 bytes. | |
248 if os.path.getsize(context.archive_name) < 1000: raise Exception() | |
222 except Exception, e: | 249 except Exception, e: |
223 print('Could not retrieve the download. Sorry.') | 250 print('Could not retrieve the download. Sorry.') |
224 sys.exit(-1) | 251 sys.exit(-1) |
225 | 252 |
226 # Unzip the file. | 253 # Unzip the file. |
227 print 'Unzipping ...' | 254 print 'Unzipping ...' |
228 UnzipFilenameToDir(context.archive_name, os.curdir) | 255 UnzipFilenameToDir(context.archive_name, os.curdir) |
229 | 256 |
230 # Tell the system to open the app. | 257 # Tell the system to open the app. |
231 args = ['--user-data-dir=%s' % profile] + args | 258 args = ['--user-data-dir=%s' % profile] + args |
(...skipping 12 matching lines...) Expand all Loading... | |
244 | 271 |
245 def AskIsGoodBuild(rev): | 272 def AskIsGoodBuild(rev): |
246 """Ask the user whether build |rev| is good or bad.""" | 273 """Ask the user whether build |rev| is good or bad.""" |
247 # Loop until we get a response that we can parse. | 274 # Loop until we get a response that we can parse. |
248 while True: | 275 while True: |
249 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % int(rev)) | 276 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % int(rev)) |
250 if response and response in ('g', 'b'): | 277 if response and response in ('g', 'b'): |
251 return response == 'g' | 278 return response == 'g' |
252 | 279 |
253 | 280 |
254 def Bisect(good, | 281 def Bisect(revlist, |
255 bad, | |
256 revlist, | |
257 context, | 282 context, |
258 try_args=(), | 283 try_args=(), |
259 profile='profile', | 284 profile='profile', |
260 predicate=AskIsGoodBuild): | 285 predicate=AskIsGoodBuild): |
261 """Tries to find the exact commit where a regression was introduced by | 286 """Tries to find the exact commit where a regression was introduced by |
262 running a binary search on all archived builds in a given revision range. | 287 running a binary search on all archived builds in a given revision range. |
263 | 288 |
264 @param good The index in revlist of the last known good revision. | |
265 @param bad The index in revlist of the first known bad revision. | |
266 @param revlist A list of chromium revision numbers to check. | 289 @param revlist A list of chromium revision numbers to check. |
267 @param context A PathContext object. | 290 @param context A PathContext object. |
268 @param try_args A tuple of arguments to pass to the predicate function. | 291 @param try_args A tuple of arguments to pass to the predicate function. |
269 @param profile The user profile with which to run chromium. | 292 @param profile The user profile with which to run chromium. |
270 @param predicate A predicate function which returns True iff the argument | 293 @param predicate A predicate function which returns True iff the argument |
271 chromium revision is good. | 294 chromium revision is good. |
272 """ | 295 """ |
273 | 296 |
297 good = 0 | |
298 bad = len(revlist) - 1 | |
274 last_known_good_rev = revlist[good] | 299 last_known_good_rev = revlist[good] |
275 first_known_bad_rev = revlist[bad] | 300 first_known_bad_rev = revlist[bad] |
276 | 301 |
277 # Binary search time! | 302 # Binary search time! |
278 while good < bad: | 303 while good < bad: |
279 candidates = revlist[good:bad] | 304 candidates = revlist[good:bad] |
280 num_poss = len(candidates) | 305 num_poss = len(candidates) |
281 if num_poss > 10: | 306 if num_poss > 10: |
282 print('%d candidates. %d tries left.' % | 307 print('%d candidates. %d tries left.' % |
283 (num_poss, round(math.log(num_poss, 2)))) | 308 (num_poss, round(math.log(num_poss, 2)))) |
284 else: | 309 else: |
285 print('Candidates: %s' % revlist[good:bad]) | 310 print('Candidates: %s' % revlist[good:bad]) |
286 | 311 |
287 # Cut the problem in half... | 312 # Cut the problem in half... |
288 test = int((bad - good) / 2) + good | 313 test = int((bad - good) / 2) + good |
289 test_rev = revlist[test] | 314 test_rev = revlist[test] |
290 | 315 |
291 # Let the user give this rev a spin (in her own profile, if she wants). | 316 # Let the user give this rev a spin (in her own profile, if she wants). |
292 TryRevision(context, test_rev, profile, try_args) | 317 TryRevision(context, test_rev, profile, try_args) |
293 if predicate(test_rev): | 318 if predicate(test_rev): |
294 last_known_good_rev = revlist[good] | 319 last_known_good_rev = test_rev |
295 good = test + 1 | 320 good = test + 1 |
296 else: | 321 else: |
297 bad = test | 322 bad = test |
298 | 323 |
299 return (last_known_good_rev, first_known_bad_rev) | 324 return (last_known_good_rev, first_known_bad_rev) |
300 | 325 |
301 | 326 |
302 def main(): | 327 def main(): |
303 usage = ('%prog [options] [-- chromium-options]\n' | 328 usage = ('%prog [options] [-- chromium-options]\n' |
304 'Perform binary search on the snapshot builds.\n' | 329 'Perform binary search on the snapshot builds.\n' |
305 '\n' | 330 '\n' |
306 'Tip: add "-- --no-first-run" to bypass the first run prompts.') | 331 'Tip: add "-- --no-first-run" to bypass the first run prompts.') |
307 parser = optparse.OptionParser(usage=usage) | 332 parser = optparse.OptionParser(usage=usage) |
308 # Strangely, the default help output doesn't include the choice list. | 333 # Strangely, the default help output doesn't include the choice list. |
309 choices = ['mac', 'win', 'linux', 'linux64'] | 334 choices = ['mac', 'win', 'linux', 'linux64'] |
310 # linux-chromiumos lacks a continuous archive http://crbug.com/78158 | 335 # linux-chromiumos lacks a continuous archive http://crbug.com/78158 |
311 parser.add_option('-a', '--archive', | 336 parser.add_option('-a', '--archive', |
312 choices = choices, | 337 choices = choices, |
313 help = 'The buildbot archive to bisect [%s].' % | 338 help = 'The buildbot archive to bisect [%s].' % |
314 '|'.join(choices)) | 339 '|'.join(choices)) |
315 parser.add_option('-b', '--bad', type = 'int', | 340 parser.add_option('-b', '--bad', type = 'int', |
316 help = 'The bad revision to bisect to.') | 341 help = 'The bad revision to bisect to.') |
317 parser.add_option('-g', '--good', type = 'int', | 342 parser.add_option('-g', '--good', type = 'int', |
318 help = 'The last known good revision to bisect from.') | 343 help = 'The last known good revision to bisect from.') |
319 parser.add_option('-p', '--profile', '--user-data-dir', type = 'str', | 344 parser.add_option('-p', '--profile', '--user-data-dir', type = 'str', |
320 help = 'Profile to use; this will not reset every run. ' + | 345 help = 'Profile to use; this will not reset every run. ' + |
321 'Defaults to a clean profile.', default = 'profile') | 346 'Defaults to a clean profile.', default = 'profile') |
347 parser.add_option('-r', '--recent', | |
348 dest = "recent", | |
349 default = False, | |
350 action = "store_true", | |
351 help = 'Use recent builds from about the last 2 months ' + | |
352 'for higher granularity bisecting.') | |
322 (opts, args) = parser.parse_args() | 353 (opts, args) = parser.parse_args() |
323 | 354 |
324 if opts.archive is None: | 355 if opts.archive is None: |
325 print 'Error: missing required parameter: --archive' | 356 print 'Error: missing required parameter: --archive' |
326 print | 357 print |
327 parser.print_help() | 358 parser.print_help() |
328 return 1 | 359 return 1 |
329 | 360 |
330 if opts.bad and opts.good and (opts.good > opts.bad): | 361 if opts.bad and opts.good and (opts.good > opts.bad): |
331 print ('The good revision (%d) must precede the bad revision (%d).\n' % | 362 print ('The good revision (%d) must precede the bad revision (%d).\n' % |
332 (opts.good, opts.bad)) | 363 (opts.good, opts.bad)) |
333 parser.print_help() | 364 parser.print_help() |
334 return 1 | 365 return 1 |
335 | 366 |
336 # Create the context. Initialize 0 for the revisions as they are set below. | 367 # Create the context. Initialize 0 for the revisions as they are set below. |
337 context = PathContext(opts.archive, 0, 0) | 368 context = PathContext(opts.archive, 0, 0, opts.recent) |
338 | 369 |
339 # Pick a starting point, try to get HEAD for this. | 370 # Pick a starting point, try to get HEAD for this. |
340 if opts.bad: | 371 if opts.bad: |
341 bad_rev = opts.bad | 372 bad_rev = opts.bad |
342 else: | 373 else: |
343 bad_rev = 0 | 374 bad_rev = 0 |
344 try: | 375 try: |
345 # Location of the latest build revision number | 376 # Location of the latest build revision number |
346 nh = urllib.urlopen(context.GetLastChangeURL()) | 377 nh = urllib.urlopen(context.GetLastChangeURL()) |
347 latest = int(nh.read()) | 378 latest = int(nh.read()) |
(...skipping 19 matching lines...) Expand all Loading... | |
367 # Set the input parameters now that they've been validated. | 398 # Set the input parameters now that they've been validated. |
368 context.good_revision = good_rev | 399 context.good_revision = good_rev |
369 context.bad_revision = bad_rev | 400 context.bad_revision = bad_rev |
370 | 401 |
371 # Get a list of revisions to bisect across. | 402 # Get a list of revisions to bisect across. |
372 revlist = GetRevList(context) | 403 revlist = GetRevList(context) |
373 if len(revlist) < 2: # Don't have enough builds to bisect | 404 if len(revlist) < 2: # Don't have enough builds to bisect |
374 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist | 405 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist |
375 sys.exit(1) | 406 sys.exit(1) |
376 | 407 |
377 # If we don't have a |good_rev|, set it to be the first revision possible. | |
378 if good_rev == 0: | |
379 good_rev = revlist[0] | |
380 | |
381 # These are indexes of |revlist|. | |
382 good = 0 | |
383 bad = len(revlist) - 1 | |
384 | |
385 (last_known_good_rev, first_known_bad_rev) = Bisect( | 408 (last_known_good_rev, first_known_bad_rev) = Bisect( |
386 good, bad, revlist, context, args, opts.profile) | 409 revlist, context, args, opts.profile) |
387 | 410 |
388 # We're done. Let the user know the results in an official manner. | 411 # We're done. Let the user know the results in an official manner. |
389 print('You are probably looking for build %d.' % first_known_bad_rev) | 412 print('You are probably looking for build %d.' % first_known_bad_rev) |
390 print('CHANGELOG URL:') | 413 print('CHANGELOG URL:') |
391 print(CHANGELOG_URL % (last_known_good_rev, first_known_bad_rev)) | 414 print(CHANGELOG_URL % (last_known_good_rev, first_known_bad_rev)) |
392 print('Built at revision:') | 415 print('Built at revision:') |
393 print(BUILD_VIEWVC_URL % first_known_bad_rev) | 416 print(BUILD_VIEWVC_URL % first_known_bad_rev) |
394 | 417 |
395 if __name__ == '__main__': | 418 if __name__ == '__main__': |
396 sys.exit(main()) | 419 sys.exit(main()) |
OLD | NEW |