2010-04-09 Adam Barth <abarth@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / webkitpy / layout_tests / layout_package / dump_render_tree_thread.py
1 #!/usr/bin/env python
2 # Copyright (C) 2010 Google Inc. All rights reserved.
3 #
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7 #
8 #     * Redistributions of source code must retain the above copyright
9 # notice, this list of conditions and the following disclaimer.
10 #     * Redistributions in binary form must reproduce the above
11 # copyright notice, this list of conditions and the following disclaimer
12 # in the documentation and/or other materials provided with the
13 # distribution.
14 #     * Neither the name of Google Inc. nor the names of its
15 # contributors may be used to endorse or promote products derived from
16 # this software without specific prior written permission.
17 #
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30 """A Thread object for running DumpRenderTree and processing URLs from a
31 shared queue.
32
33 Each thread runs a separate instance of the DumpRenderTree binary and validates
34 the output.  When there are no more URLs to process in the shared queue, the
35 thread exits.
36 """
37
38 import copy
39 import logging
40 import os
41 import Queue
42 import signal
43 import sys
44 import thread
45 import threading
46 import time
47
48 import test_failures
49
50 _log = logging.getLogger("webkitpy.layout_tests.layout_package."
51                          "dump_render_tree_thread")
52
53
54 def process_output(port, test_info, test_types, test_args, configuration,
55                    output_dir, crash, timeout, test_run_time, actual_checksum,
56                    output, error):
57     """Receives the output from a DumpRenderTree process, subjects it to a number
58     of tests, and returns a list of failure types the test produced.
59
60     Args:
61       port: port-specific hooks
62       proc: an active DumpRenderTree process
63       test_info: Object containing the test filename, uri and timeout
64       test_types: list of test types to subject the output to
65       test_args: arguments to be passed to each test
66       configuration: Debug or Release
67       output_dir: directory to put crash stack traces into
68
69     Returns: a list of failure objects and times for the test being processed
70     """
71     failures = []
72
73     # Some test args, such as the image hash, may be added or changed on a
74     # test-by-test basis.
75     local_test_args = copy.copy(test_args)
76
77     local_test_args.hash = actual_checksum
78
79     if crash:
80         failures.append(test_failures.FailureCrash())
81     if timeout:
82         failures.append(test_failures.FailureTimeout())
83
84     if crash:
85         _log.debug("Stacktrace for %s:\n%s" % (test_info.filename, error))
86         # Strip off "file://" since RelativeTestFilename expects
87         # filesystem paths.
88         filename = os.path.join(output_dir, port.relative_test_filename(
89                                 test_info.filename))
90         filename = os.path.splitext(filename)[0] + "-stack.txt"
91         port.maybe_make_directory(os.path.split(filename)[0])
92         open(filename, "wb").write(error)
93     elif error:
94         _log.debug("Previous test output extra lines after dump:\n%s" %
95                    error)
96
97     # Check the output and save the results.
98     start_time = time.time()
99     time_for_diffs = {}
100     for test_type in test_types:
101         start_diff_time = time.time()
102         new_failures = test_type.compare_output(port, test_info.filename,
103                                                 output, local_test_args,
104                                                 configuration)
105         # Don't add any more failures if we already have a crash, so we don't
106         # double-report those tests. We do double-report for timeouts since
107         # we still want to see the text and image output.
108         if not crash:
109             failures.extend(new_failures)
110         time_for_diffs[test_type.__class__.__name__] = (
111             time.time() - start_diff_time)
112
113     total_time_for_all_diffs = time.time() - start_diff_time
114     return TestStats(test_info.filename, failures, test_run_time,
115         total_time_for_all_diffs, time_for_diffs)
116
117
118 class TestStats:
119
120     def __init__(self, filename, failures, test_run_time,
121                  total_time_for_all_diffs, time_for_diffs):
122         self.filename = filename
123         self.failures = failures
124         self.test_run_time = test_run_time
125         self.total_time_for_all_diffs = total_time_for_all_diffs
126         self.time_for_diffs = time_for_diffs
127
128
129 class SingleTestThread(threading.Thread):
130     """Thread wrapper for running a single test file."""
131
132     def __init__(self, port, image_path, shell_args, test_info,
133         test_types, test_args, configuration, output_dir):
134         """
135         Args:
136           port: object implementing port-specific hooks
137           test_info: Object containing the test filename, uri and timeout
138           output_dir: Directory to put crash stacks into.
139           See TestShellThread for documentation of the remaining arguments.
140         """
141
142         threading.Thread.__init__(self)
143         self._port = port
144         self._image_path = image_path
145         self._shell_args = shell_args
146         self._test_info = test_info
147         self._test_types = test_types
148         self._test_args = test_args
149         self._configuration = configuration
150         self._output_dir = output_dir
151
152     def run(self):
153         test_info = self._test_info
154         driver = self._port.start_driver(self._image_path, self._shell_args)
155         start = time.time()
156         crash, timeout, actual_checksum, output, error = \
157             driver.run_test(test_info.uri.strip(), test_info.timeout,
158                             test_info.image_hash)
159         end = time.time()
160         self._test_stats = process_output(self._port,
161             test_info, self._test_types, self._test_args,
162             self._configuration, self._output_dir, crash, timeout, end - start,
163             actual_checksum, output, error)
164         driver.stop()
165
166     def get_test_stats(self):
167         return self._test_stats
168
169
170 class TestShellThread(threading.Thread):
171
172     def __init__(self, port, filename_list_queue, result_queue,
173                  test_types, test_args, image_path, shell_args, options):
174         """Initialize all the local state for this DumpRenderTree thread.
175
176         Args:
177           port: interface to port-specific hooks
178           filename_list_queue: A thread safe Queue class that contains lists
179               of tuples of (filename, uri) pairs.
180           result_queue: A thread safe Queue class that will contain tuples of
181               (test, failure lists) for the test results.
182           test_types: A list of TestType objects to run the test output
183               against.
184           test_args: A TestArguments object to pass to each TestType.
185           shell_args: Any extra arguments to be passed to DumpRenderTree.
186           options: A property dictionary as produced by optparse. The
187               command-line options should match those expected by
188               run_webkit_tests; they are typically passed via the
189               run_webkit_tests.TestRunner class."""
190         threading.Thread.__init__(self)
191         self._port = port
192         self._filename_list_queue = filename_list_queue
193         self._result_queue = result_queue
194         self._filename_list = []
195         self._test_types = test_types
196         self._test_args = test_args
197         self._driver = None
198         self._image_path = image_path
199         self._shell_args = shell_args
200         self._options = options
201         self._canceled = False
202         self._exception_info = None
203         self._directory_timing_stats = {}
204         self._test_stats = []
205         self._num_tests = 0
206         self._start_time = 0
207         self._stop_time = 0
208
209         # Current directory of tests we're running.
210         self._current_dir = None
211         # Number of tests in self._current_dir.
212         self._num_tests_in_current_dir = None
213         # Time at which we started running tests from self._current_dir.
214         self._current_dir_start_time = None
215
216     def get_directory_timing_stats(self):
217         """Returns a dictionary mapping test directory to a tuple of
218         (number of tests in that directory, time to run the tests)"""
219         return self._directory_timing_stats
220
221     def get_individual_test_stats(self):
222         """Returns a list of (test_filename, time_to_run_test,
223         total_time_for_all_diffs, time_for_diffs) tuples."""
224         return self._test_stats
225
226     def cancel(self):
227         """Set a flag telling this thread to quit."""
228         self._canceled = True
229
230     def get_exception_info(self):
231         """If run() terminated on an uncaught exception, return it here
232         ((type, value, traceback) tuple).
233         Returns None if run() terminated normally. Meant to be called after
234         joining this thread."""
235         return self._exception_info
236
237     def get_total_time(self):
238         return max(self._stop_time - self._start_time, 0.0)
239
240     def get_num_tests(self):
241         return self._num_tests
242
243     def run(self):
244         """Delegate main work to a helper method and watch for uncaught
245         exceptions."""
246         self._start_time = time.time()
247         self._num_tests = 0
248         try:
249             _log.debug('%s starting' % (self.getName()))
250             self._run(test_runner=None, result_summary=None)
251             _log.debug('%s done (%d tests)' % (self.getName(),
252                        self.get_num_tests()))
253         except:
254             # Save the exception for our caller to see.
255             self._exception_info = sys.exc_info()
256             self._stop_time = time.time()
257             # Re-raise it and die.
258             _log.error('%s dying: %s' % (self.getName(),
259                        self._exception_info))
260             raise
261         self._stop_time = time.time()
262
263     def run_in_main_thread(self, test_runner, result_summary):
264         """This hook allows us to run the tests from the main thread if
265         --num-test-shells==1, instead of having to always run two or more
266         threads. This allows us to debug the test harness without having to
267         do multi-threaded debugging."""
268         self._run(test_runner, result_summary)
269
270     def _run(self, test_runner, result_summary):
271         """Main work entry point of the thread. Basically we pull urls from the
272         filename queue and run the tests until we run out of urls.
273
274         If test_runner is not None, then we call test_runner.UpdateSummary()
275         with the results of each test."""
276         batch_size = 0
277         batch_count = 0
278         if self._options.batch_size:
279             try:
280                 batch_size = int(self._options.batch_size)
281             except:
282                 _log.info("Ignoring invalid batch size '%s'" %
283                           self._options.batch_size)
284
285         # Append tests we're running to the existing tests_run.txt file.
286         # This is created in run_webkit_tests.py:_PrepareListsAndPrintOutput.
287         tests_run_filename = os.path.join(self._options.results_directory,
288                                           "tests_run.txt")
289         tests_run_file = open(tests_run_filename, "a")
290
291         while True:
292             if self._canceled:
293                 _log.info('Testing canceled')
294                 tests_run_file.close()
295                 return
296
297             if len(self._filename_list) is 0:
298                 if self._current_dir is not None:
299                     self._directory_timing_stats[self._current_dir] = \
300                         (self._num_tests_in_current_dir,
301                          time.time() - self._current_dir_start_time)
302
303                 try:
304                     self._current_dir, self._filename_list = \
305                         self._filename_list_queue.get_nowait()
306                 except Queue.Empty:
307                     self._kill_dump_render_tree()
308                     tests_run_file.close()
309                     return
310
311                 self._num_tests_in_current_dir = len(self._filename_list)
312                 self._current_dir_start_time = time.time()
313
314             test_info = self._filename_list.pop()
315
316             # We have a url, run tests.
317             batch_count += 1
318             self._num_tests += 1
319             if self._options.run_singly:
320                 failures = self._run_test_singly(test_info)
321             else:
322                 failures = self._run_test(test_info)
323
324             filename = test_info.filename
325             tests_run_file.write(filename + "\n")
326             if failures:
327                 # Check and kill DumpRenderTree if we need too.
328                 if len([1 for f in failures if f.should_kill_dump_render_tree()]):
329                     self._kill_dump_render_tree()
330                     # Reset the batch count since the shell just bounced.
331                     batch_count = 0
332                 # Print the error message(s).
333                 error_str = '\n'.join(['  ' + f.message() for f in failures])
334                 _log.debug("%s %s failed:\n%s" % (self.getName(),
335                            self._port.relative_test_filename(filename),
336                            error_str))
337             else:
338                 _log.debug("%s %s passed" % (self.getName(),
339                            self._port.relative_test_filename(filename)))
340             self._result_queue.put((filename, failures))
341
342             if batch_size > 0 and batch_count > batch_size:
343                 # Bounce the shell and reset count.
344                 self._kill_dump_render_tree()
345                 batch_count = 0
346
347             if test_runner:
348                 test_runner.update_summary(result_summary)
349
350     def _run_test_singly(self, test_info):
351         """Run a test in a separate thread, enforcing a hard time limit.
352
353         Since we can only detect the termination of a thread, not any internal
354         state or progress, we can only run per-test timeouts when running test
355         files singly.
356
357         Args:
358           test_info: Object containing the test filename, uri and timeout
359
360         Return:
361           A list of TestFailure objects describing the error.
362         """
363         worker = SingleTestThread(self._port, self._image_path,
364                                   self._shell_args,
365                                   test_info,
366                                   self._test_types,
367                                   self._test_args,
368                                   self._options.configuration,
369                                   self._options.results_directory)
370
371         worker.start()
372
373         # When we're running one test per DumpRenderTree process, we can enforce
374         # a hard timeout. the DumpRenderTree watchdog uses 2.5x the timeout
375         # We want to be larger than that.
376         worker.join(int(test_info.timeout) * 3.0 / 1000.0)
377         if worker.isAlive():
378             # If join() returned with the thread still running, the
379             # DumpRenderTree is completely hung and there's nothing
380             # more we can do with it.  We have to kill all the
381             # DumpRenderTrees to free it up. If we're running more than
382             # one DumpRenderTree thread, we'll end up killing the other
383             # DumpRenderTrees too, introducing spurious crashes. We accept that
384             # tradeoff in order to avoid losing the rest of this thread's
385             # results.
386             _log.error('Test thread hung: killing all DumpRenderTrees')
387             worker._driver.stop()
388
389         try:
390             stats = worker.get_test_stats()
391             self._test_stats.append(stats)
392             failures = stats.failures
393         except AttributeError, e:
394             failures = []
395             _log.error('Cannot get results of test: %s' %
396                        test_info.filename)
397
398         return failures
399
400     def _run_test(self, test_info):
401         """Run a single test file using a shared DumpRenderTree process.
402
403         Args:
404           test_info: Object containing the test filename, uri and timeout
405
406         Return:
407           A list of TestFailure objects describing the error.
408         """
409         self._ensure_dump_render_tree_is_running()
410         # The pixel_hash is used to avoid doing an image dump if the
411         # checksums match, so it should be set to a blank value if we
412         # are generating a new baseline.  (Otherwise, an image from a
413         # previous run will be copied into the baseline.)
414         image_hash = test_info.image_hash
415         if image_hash and self._test_args.new_baseline:
416             image_hash = ""
417         start = time.time()
418         crash, timeout, actual_checksum, output, error = \
419            self._driver.run_test(test_info.uri, test_info.timeout, image_hash)
420         end = time.time()
421
422         stats = process_output(self._port, test_info, self._test_types,
423                                self._test_args, self._options.configuration,
424                                self._options.results_directory, crash,
425                                timeout, end - start, actual_checksum,
426                                output, error)
427
428         self._test_stats.append(stats)
429         return stats.failures
430
431     def _ensure_dump_render_tree_is_running(self):
432         """Start the shared DumpRenderTree, if it's not running.  Not for use when
433         running tests singly, since those each start a separate DumpRenderTree in
434         their own thread.
435         """
436         if (not self._driver or self._driver.poll() is not None):
437             self._driver = self._port.start_driver(
438                 self._image_path, self._shell_args)
439
440     def _kill_dump_render_tree(self):
441         """Kill the DumpRenderTree process if it's running."""
442         if self._driver:
443             self._driver.stop()
444             self._driver = None