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