Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
|
borenet
2016/03/30 14:02:09
This file can be deleted entirely once download_sk
| |
| 2 # | 2 # |
| 3 # Copyright 2016 Google Inc. | 3 # Copyright 2016 Google Inc. |
| 4 # | 4 # |
| 5 # Use of this source code is governed by a BSD-style license that can be | 5 # Use of this source code is governed by a BSD-style license that can be |
| 6 # found in the LICENSE file. | 6 # found in the LICENSE file. |
| 7 | 7 |
| 8 | 8 |
| 9 import contextlib | |
| 10 import glob | |
| 11 import math | |
| 12 import os | 9 import os |
| 13 import psutil | |
| 14 import shutil | 10 import shutil |
| 15 import socket | |
| 16 import subprocess | 11 import subprocess |
| 17 import sys | |
| 18 import time | |
| 19 import urllib2 | |
| 20 | |
| 21 from flavor import android_flavor | |
| 22 from flavor import chromeos_flavor | |
| 23 from flavor import cmake_flavor | |
| 24 from flavor import coverage_flavor | |
| 25 from flavor import default_flavor | |
| 26 from flavor import ios_flavor | |
| 27 from flavor import valgrind_flavor | |
| 28 from flavor import xsan_flavor | |
| 29 | 12 |
| 30 | 13 |
| 31 CONFIG_COVERAGE = 'Coverage' | |
| 32 CONFIG_DEBUG = 'Debug' | |
| 33 CONFIG_RELEASE = 'Release' | |
| 34 VALID_CONFIGS = (CONFIG_COVERAGE, CONFIG_DEBUG, CONFIG_RELEASE) | |
| 35 | |
| 36 BUILD_PRODUCTS_WHITELIST = [ | |
| 37 'dm', | |
| 38 'dm.exe', | |
| 39 'nanobench', | |
| 40 'nanobench.exe', | |
| 41 '*.so', | |
| 42 '*.dll', | |
| 43 ] | |
| 44 | |
| 45 GM_ACTUAL_FILENAME = 'actual-results.json' | |
| 46 GM_EXPECTATIONS_FILENAME = 'expected-results.json' | |
| 47 GM_IGNORE_TESTS_FILENAME = 'ignored-tests.txt' | |
| 48 | |
| 49 GOLD_UNINTERESTING_HASHES_URL = 'https://gold.skia.org/_/hashes' | |
| 50 | |
| 51 GS_GM_BUCKET = 'chromium-skia-gm' | 14 GS_GM_BUCKET = 'chromium-skia-gm' |
| 52 GS_SUMMARIES_BUCKET = 'chromium-skia-gm-summaries' | |
| 53 | 15 |
| 54 GS_SUBDIR_TMPL_SK_IMAGE = 'skimage/v%s' | 16 GS_SUBDIR_TMPL_SK_IMAGE = 'skimage/v%s' |
| 55 GS_SUBDIR_TMPL_SKP = 'playback_%s/skps' | 17 GS_SUBDIR_TMPL_SKP = 'playback_%s/skps' |
| 56 | 18 |
| 57 SKIA_REPO = 'https://skia.googlesource.com/skia.git' | |
| 58 INFRA_REPO = 'https://skia.googlesource.com/buildbot.git' | |
| 59 | |
| 60 SERVICE_ACCOUNT_FILE = 'service-account-skia.json' | |
| 61 SERVICE_ACCOUNT_INTERNAL_FILE = 'service-account-skia-internal.json' | |
| 62 | |
| 63 VERSION_FILE_SK_IMAGE = 'SK_IMAGE_VERSION' | 19 VERSION_FILE_SK_IMAGE = 'SK_IMAGE_VERSION' |
| 64 VERSION_FILE_SKP = 'SKP_VERSION' | 20 VERSION_FILE_SKP = 'SKP_VERSION' |
| 65 | 21 |
| 66 | 22 |
| 67 def is_android(bot_cfg): | |
| 68 """Determine whether the given bot is an Android bot.""" | |
| 69 return ('Android' in bot_cfg.get('extra_config', '') or | |
| 70 bot_cfg.get('os') == 'Android') | |
| 71 | |
| 72 def is_chromeos(bot_cfg): | |
| 73 return ('CrOS' in bot_cfg.get('extra_config', '') or | |
| 74 bot_cfg.get('os') == 'ChromeOS') | |
| 75 | |
| 76 def is_cmake(bot_cfg): | |
| 77 return 'CMake' in bot_cfg.get('extra_config', '') | |
| 78 | |
| 79 def is_ios(bot_cfg): | |
| 80 return ('iOS' in bot_cfg.get('extra_config', '') or | |
| 81 bot_cfg.get('os') == 'iOS') | |
| 82 | |
| 83 | |
| 84 def is_valgrind(bot_cfg): | |
| 85 return 'Valgrind' in bot_cfg.get('extra_config', '') | |
| 86 | |
| 87 | |
| 88 def is_xsan(bot_cfg): | |
| 89 return (bot_cfg.get('extra_config') == 'ASAN' or | |
| 90 bot_cfg.get('extra_config') == 'MSAN' or | |
| 91 bot_cfg.get('extra_config') == 'TSAN') | |
| 92 | |
| 93 | |
| 94 def download_dir(skia_dir, tmp_dir, version_file, gs_path_tmpl, dst_dir): | 23 def download_dir(skia_dir, tmp_dir, version_file, gs_path_tmpl, dst_dir): |
| 95 # Ensure that the tmp_dir exists. | 24 # Ensure that the tmp_dir exists. |
| 96 if not os.path.isdir(tmp_dir): | 25 if not os.path.isdir(tmp_dir): |
| 97 os.makedirs(tmp_dir) | 26 os.makedirs(tmp_dir) |
| 98 | 27 |
| 99 # Get the expected version. | 28 # Get the expected version. |
| 100 with open(os.path.join(skia_dir, version_file)) as f: | 29 with open(os.path.join(skia_dir, version_file)) as f: |
| 101 expected_version = f.read().rstrip() | 30 expected_version = f.read().rstrip() |
| 102 | 31 |
| 103 print 'Expected %s = %s' % (version_file, expected_version) | 32 print 'Expected %s = %s' % (version_file, expected_version) |
| (...skipping 13 matching lines...) Expand all Loading... | |
| 117 if actual_version != -1: | 46 if actual_version != -1: |
| 118 os.remove(actual_version_file) | 47 os.remove(actual_version_file) |
| 119 if os.path.isdir(dst_dir): | 48 if os.path.isdir(dst_dir): |
| 120 shutil.rmtree(dst_dir) | 49 shutil.rmtree(dst_dir) |
| 121 os.makedirs(dst_dir) | 50 os.makedirs(dst_dir) |
| 122 gs_path = 'gs://%s/%s/*' % (GS_GM_BUCKET, gs_path_tmpl % expected_version) | 51 gs_path = 'gs://%s/%s/*' % (GS_GM_BUCKET, gs_path_tmpl % expected_version) |
| 123 print 'Downloading from %s' % gs_path | 52 print 'Downloading from %s' % gs_path |
| 124 subprocess.check_call(['gsutil', 'cp', '-R', gs_path, dst_dir]) | 53 subprocess.check_call(['gsutil', 'cp', '-R', gs_path, dst_dir]) |
| 125 with open(actual_version_file, 'w') as f: | 54 with open(actual_version_file, 'w') as f: |
| 126 f.write(expected_version) | 55 f.write(expected_version) |
| 127 | |
| 128 | |
| 129 def get_uninteresting_hashes(hashes_file): | |
| 130 retries = 5 | |
| 131 timeout = 60 | |
| 132 wait_base = 15 | |
| 133 | |
| 134 socket.setdefaulttimeout(timeout) | |
| 135 for retry in range(retries): | |
| 136 try: | |
| 137 with contextlib.closing( | |
| 138 urllib2.urlopen(GOLD_UNINTERESTING_HASHES_URL, timeout=timeout)) as w: | |
| 139 hashes = w.read() | |
| 140 with open(hashes_file, 'w') as f: | |
| 141 f.write(hashes) | |
| 142 break | |
| 143 except Exception as e: | |
| 144 print >> sys.stderr, 'Failed to get uninteresting hashes from %s:\n%s' % ( | |
| 145 GOLD_UNINTERESTING_HASHES_URL, e) | |
| 146 if retry == retries: | |
| 147 raise | |
| 148 waittime = wait_base * math.pow(2, retry) | |
| 149 print 'Retry in %d seconds.' % waittime | |
| 150 time.sleep(waittime) | |
| 151 | |
| 152 | |
| 153 class BotInfo(object): | |
| 154 def __init__(self, bot_name, swarm_out_dir): | |
| 155 """Initialize the bot, given its name. | |
| 156 | |
| 157 Assumes that CWD is the directory containing this file. | |
| 158 """ | |
| 159 self.name = bot_name | |
| 160 self.skia_dir = os.path.abspath(os.path.join( | |
| 161 os.path.dirname(os.path.realpath(__file__)), | |
| 162 os.pardir, os.pardir)) | |
| 163 self.swarm_out_dir = swarm_out_dir | |
| 164 os.chdir(self.skia_dir) | |
| 165 self.build_dir = os.path.abspath(os.path.join(self.skia_dir, os.pardir)) | |
| 166 self.infrabots_dir = os.path.join(self.skia_dir, 'infra', 'bots') | |
| 167 self.home_dir = os.path.expanduser('~') | |
| 168 | |
| 169 self.spec = self.get_bot_spec(bot_name) | |
| 170 self.bot_cfg = self.spec['builder_cfg'] | |
| 171 self.out_dir = os.path.join(os.pardir, 'out') | |
| 172 self.configuration = self.spec['configuration'] | |
| 173 self.default_env = { | |
| 174 'CHROME_HEADLESS': '1', | |
| 175 'SKIA_OUT': self.out_dir, | |
| 176 'BUILDTYPE': self.configuration, | |
| 177 'PATH': os.environ['PATH'], | |
| 178 } | |
| 179 if 'Win' in self.bot_cfg['os']: | |
| 180 self.default_env['SystemRoot'] = 'C:\\Windows' | |
| 181 self.default_env['TEMP'] = os.path.join( | |
| 182 self.home_dir, 'AppData', 'Local', 'Temp') | |
| 183 self.default_env['TMP'] = self.default_env['TEMP'] | |
| 184 self.default_env.update(self.spec['env']) | |
| 185 self.build_targets = [str(t) for t in self.spec['build_targets']] | |
| 186 self.is_trybot = self.bot_cfg['is_trybot'] | |
| 187 self.upload_dm_results = self.spec['upload_dm_results'] | |
| 188 self.upload_perf_results = self.spec['upload_perf_results'] | |
| 189 self.perf_data_dir = os.path.join(self.swarm_out_dir, 'perfdata', | |
| 190 self.name, 'data') | |
| 191 self.resource_dir = os.path.join(self.skia_dir, 'resources') | |
| 192 self.images_dir = os.path.join(self.build_dir, 'images') | |
| 193 self.local_skp_dir = os.path.join(self.build_dir, 'playback', 'skps') | |
| 194 self.dm_flags = self.spec['dm_flags'] | |
| 195 self.nanobench_flags = self.spec['nanobench_flags'] | |
| 196 self._ccache = None | |
| 197 self._checked_for_ccache = False | |
| 198 self._already_ran = {} | |
| 199 self.tmp_dir = os.path.join(self.build_dir, 'tmp') | |
| 200 self.flavor = self.get_flavor(self.bot_cfg) | |
| 201 | |
| 202 # These get filled in during subsequent steps. | |
| 203 self.device_dirs = None | |
| 204 self.build_number = None | |
| 205 self.got_revision = None | |
| 206 self.master_name = None | |
| 207 self.slave_name = None | |
| 208 | |
| 209 @property | |
| 210 def ccache(self): | |
| 211 if not self._checked_for_ccache: | |
| 212 self._checked_for_ccache = True | |
| 213 if sys.platform != 'win32': | |
| 214 try: | |
| 215 result = subprocess.check_output(['which', 'ccache']) | |
| 216 self._ccache = result.rstrip() | |
| 217 except subprocess.CalledProcessError: | |
| 218 pass | |
| 219 | |
| 220 return self._ccache | |
| 221 | |
| 222 def get_bot_spec(self, bot_name): | |
| 223 """Retrieve the bot spec for this bot.""" | |
| 224 sys.path.append(self.skia_dir) | |
| 225 from tools import buildbot_spec | |
| 226 return buildbot_spec.get_builder_spec(bot_name) | |
| 227 | |
| 228 def get_flavor(self, bot_cfg): | |
| 229 """Return a flavor utils object specific to the given bot.""" | |
| 230 if is_android(bot_cfg): | |
| 231 return android_flavor.AndroidFlavorUtils(self) | |
| 232 elif is_chromeos(bot_cfg): | |
| 233 return chromeos_flavor.ChromeOSFlavorUtils(self) | |
| 234 elif is_cmake(bot_cfg): | |
| 235 return cmake_flavor.CMakeFlavorUtils(self) | |
| 236 elif is_ios(bot_cfg): | |
| 237 return ios_flavor.iOSFlavorUtils(self) | |
| 238 elif is_valgrind(bot_cfg): | |
| 239 return valgrind_flavor.ValgrindFlavorUtils(self) | |
| 240 elif is_xsan(bot_cfg): | |
| 241 return xsan_flavor.XSanFlavorUtils(self) | |
| 242 elif bot_cfg.get('configuration') == CONFIG_COVERAGE: | |
| 243 return coverage_flavor.CoverageFlavorUtils(self) | |
| 244 else: | |
| 245 return default_flavor.DefaultFlavorUtils(self) | |
| 246 | |
| 247 def run(self, cmd, env=None, cwd=None): | |
| 248 _env = {} | |
| 249 _env.update(self.default_env) | |
| 250 _env.update(env or {}) | |
| 251 cwd = cwd or self.skia_dir | |
| 252 print '============' | |
| 253 print 'CMD: %s' % cmd | |
| 254 print 'CWD: %s' % cwd | |
| 255 print 'ENV: %s' % _env | |
| 256 print '============' | |
| 257 subprocess.check_call(cmd, env=_env, cwd=cwd) | |
| 258 | |
| 259 def compile_steps(self): | |
| 260 for t in self.build_targets: | |
| 261 self.flavor.compile(t) | |
| 262 dst = os.path.join(self.swarm_out_dir, 'out', self.configuration) | |
| 263 os.makedirs(dst) | |
| 264 for pattern in BUILD_PRODUCTS_WHITELIST: | |
| 265 path = os.path.join(self.out_dir, self.configuration, pattern) | |
| 266 for f in glob.glob(path): | |
| 267 print 'Copying build product %s' % f | |
| 268 shutil.copy(f, dst) | |
| 269 self.cleanup() | |
| 270 | |
| 271 def _run_once(self, fn, *args, **kwargs): | |
| 272 if not fn.__name__ in self._already_ran: | |
| 273 self._already_ran[fn.__name__] = True | |
| 274 fn(*args, **kwargs) | |
| 275 | |
| 276 def install(self): | |
| 277 """Copy the required executables and files to the device.""" | |
| 278 self.device_dirs = self.flavor.get_device_dirs() | |
| 279 | |
| 280 # Run any device-specific installation. | |
| 281 self.flavor.install() | |
| 282 | |
| 283 # TODO(borenet): Only copy files which have changed. | |
| 284 # Resources | |
| 285 self.flavor.copy_directory_contents_to_device(self.resource_dir, | |
| 286 self.device_dirs.resource_dir) | |
| 287 | |
| 288 def _key_params(self): | |
| 289 """Build a unique key from the builder name (as a list). | |
| 290 | |
| 291 E.g. arch x86 gpu GeForce320M mode MacMini4.1 os Mac10.6 | |
| 292 """ | |
| 293 # Don't bother to include role, which is always Test. | |
| 294 # TryBots are uploaded elsewhere so they can use the same key. | |
| 295 blacklist = ['role', 'is_trybot'] | |
| 296 | |
| 297 flat = [] | |
| 298 for k in sorted(self.bot_cfg.keys()): | |
| 299 if k not in blacklist: | |
| 300 flat.append(k) | |
| 301 flat.append(self.bot_cfg[k]) | |
| 302 return flat | |
| 303 | |
| 304 def test_steps(self, got_revision, master_name, slave_name, build_number, | |
| 305 issue=None, patchset=None): | |
| 306 """Run the DM test.""" | |
| 307 self.build_number = build_number | |
| 308 self.got_revision = got_revision | |
| 309 self.master_name = master_name | |
| 310 self.slave_name = slave_name | |
| 311 self._run_once(self.install) | |
| 312 | |
| 313 use_hash_file = False | |
| 314 if self.upload_dm_results: | |
| 315 # This must run before we write anything into self.device_dirs.dm_dir | |
| 316 # or we may end up deleting our output on machines where they're the same. | |
| 317 host_dm_dir = os.path.join(self.swarm_out_dir, 'dm') | |
| 318 print 'host dm dir: %s' % host_dm_dir | |
| 319 self.flavor.create_clean_host_dir(host_dm_dir) | |
| 320 if str(host_dm_dir) != str(self.device_dirs.dm_dir): | |
| 321 self.flavor.create_clean_device_dir(self.device_dirs.dm_dir) | |
| 322 | |
| 323 # Obtain the list of already-generated hashes. | |
| 324 if not os.path.isdir(self.tmp_dir): | |
| 325 os.makedirs(self.tmp_dir) | |
| 326 hash_filename = 'uninteresting_hashes.txt' | |
| 327 host_hashes_file = os.path.join(self.tmp_dir, hash_filename) | |
| 328 hashes_file = self.flavor.device_path_join( | |
| 329 self.device_dirs.tmp_dir, hash_filename) | |
| 330 | |
| 331 try: | |
| 332 get_uninteresting_hashes(host_hashes_file) | |
| 333 except Exception: | |
| 334 pass | |
| 335 | |
| 336 if os.path.exists(host_hashes_file): | |
| 337 self.flavor.copy_file_to_device(host_hashes_file, hashes_file) | |
| 338 use_hash_file = True | |
| 339 | |
| 340 # Run DM. | |
| 341 properties = [ | |
| 342 'gitHash', self.got_revision, | |
| 343 'master', self.master_name, | |
| 344 'builder', self.name, | |
| 345 'build_number', self.build_number, | |
| 346 ] | |
| 347 if self.is_trybot: | |
| 348 if not issue: | |
| 349 raise Exception('issue is required for trybots.') | |
| 350 if not patchset: | |
| 351 raise Exception('patchset is required for trybots.') | |
| 352 properties.extend([ | |
| 353 'issue', issue, | |
| 354 'patchset', patchset, | |
| 355 ]) | |
| 356 | |
| 357 args = [ | |
| 358 'dm', | |
| 359 '--undefok', # This helps branches that may not know new flags. | |
| 360 '--resourcePath', self.device_dirs.resource_dir, | |
| 361 '--skps', self.device_dirs.skp_dir, | |
| 362 '--images', self.flavor.device_path_join( | |
| 363 self.device_dirs.images_dir, 'dm'), | |
| 364 '--nameByHash', | |
| 365 '--properties' | |
| 366 ] + properties | |
| 367 | |
| 368 args.append('--key') | |
| 369 args.extend(self._key_params()) | |
| 370 if use_hash_file: | |
| 371 args.extend(['--uninterestingHashesFile', hashes_file]) | |
| 372 if self.upload_dm_results: | |
| 373 args.extend(['--writePath', self.device_dirs.dm_dir]) | |
| 374 | |
| 375 skip_flag = None | |
| 376 if self.bot_cfg.get('cpu_or_gpu') == 'CPU': | |
| 377 skip_flag = '--nogpu' | |
| 378 elif self.bot_cfg.get('cpu_or_gpu') == 'GPU': | |
| 379 skip_flag = '--nocpu' | |
| 380 if skip_flag: | |
| 381 args.append(skip_flag) | |
| 382 args.extend(self.dm_flags) | |
| 383 | |
| 384 self.flavor.run(args, env=self.default_env) | |
| 385 | |
| 386 if self.upload_dm_results: | |
| 387 # Copy images and JSON to host machine if needed. | |
| 388 self.flavor.copy_directory_contents_to_host(self.device_dirs.dm_dir, | |
| 389 host_dm_dir) | |
| 390 | |
| 391 # See skia:2789. | |
| 392 if ('Valgrind' in self.name and | |
| 393 self.bot_cfg.get('cpu_or_gpu') == 'GPU'): | |
| 394 abandonGpuContext = list(args) | |
| 395 abandonGpuContext.append('--abandonGpuContext') | |
| 396 self.flavor.run(abandonGpuContext) | |
| 397 preAbandonGpuContext = list(args) | |
| 398 preAbandonGpuContext.append('--preAbandonGpuContext') | |
| 399 self.flavor.run(preAbandonGpuContext) | |
| 400 | |
| 401 self.cleanup() | |
| 402 | |
| 403 def perf_steps(self, got_revision, master_name, slave_name, build_number, | |
| 404 issue=None, patchset=None): | |
| 405 """Run Skia benchmarks.""" | |
| 406 self.build_number = build_number | |
| 407 self.got_revision = got_revision | |
| 408 self.master_name = master_name | |
| 409 self.slave_name = slave_name | |
| 410 self._run_once(self.install) | |
| 411 if self.upload_perf_results: | |
| 412 self.flavor.create_clean_device_dir(self.device_dirs.perf_data_dir) | |
| 413 | |
| 414 # Run nanobench. | |
| 415 properties = [ | |
| 416 '--properties', | |
| 417 'gitHash', self.got_revision, | |
| 418 'build_number', self.build_number, | |
| 419 ] | |
| 420 if self.is_trybot: | |
| 421 if not issue: | |
| 422 raise Exception('issue is required for trybots.') | |
| 423 if not patchset: | |
| 424 raise Exception('patchset is required for trybots.') | |
| 425 properties.extend([ | |
| 426 'issue', issue, | |
| 427 'patchset', patchset, | |
| 428 ]) | |
| 429 | |
| 430 target = 'nanobench' | |
| 431 if 'VisualBench' in self.name: | |
| 432 target = 'visualbench' | |
| 433 args = [ | |
| 434 target, | |
| 435 '--undefok', # This helps branches that may not know new flags. | |
| 436 '-i', self.device_dirs.resource_dir, | |
| 437 '--skps', self.device_dirs.skp_dir, | |
| 438 '--images', self.flavor.device_path_join( | |
| 439 self.device_dirs.images_dir, 'dm'), # Using DM images for now. | |
| 440 ] | |
| 441 | |
| 442 skip_flag = None | |
| 443 if self.bot_cfg.get('cpu_or_gpu') == 'CPU': | |
| 444 skip_flag = '--nogpu' | |
| 445 elif self.bot_cfg.get('cpu_or_gpu') == 'GPU': | |
| 446 skip_flag = '--nocpu' | |
| 447 if skip_flag: | |
| 448 args.append(skip_flag) | |
| 449 args.extend(self.nanobench_flags) | |
| 450 | |
| 451 if self.upload_perf_results: | |
| 452 json_path = self.flavor.device_path_join( | |
| 453 self.device_dirs.perf_data_dir, | |
| 454 'nanobench_%s.json' % self.got_revision) | |
| 455 args.extend(['--outResultsFile', json_path]) | |
| 456 args.extend(properties) | |
| 457 | |
| 458 keys_blacklist = ['configuration', 'role', 'is_trybot'] | |
| 459 args.append('--key') | |
| 460 for k in sorted(self.bot_cfg.keys()): | |
| 461 if not k in keys_blacklist: | |
| 462 args.extend([k, self.bot_cfg[k]]) | |
| 463 | |
| 464 self.flavor.run(args, env=self.default_env) | |
| 465 | |
| 466 # See skia:2789. | |
| 467 if ('Valgrind' in self.name and | |
| 468 self.bot_cfg.get('cpu_or_gpu') == 'GPU'): | |
| 469 abandonGpuContext = list(args) | |
| 470 abandonGpuContext.extend(['--abandonGpuContext', '--nocpu']) | |
| 471 self.flavor.run(abandonGpuContext, env=self.default_env) | |
| 472 | |
| 473 # Copy results to host. | |
| 474 if self.upload_perf_results: | |
| 475 if not os.path.exists(self.perf_data_dir): | |
| 476 os.makedirs(self.perf_data_dir) | |
| 477 self.flavor.copy_directory_contents_to_host( | |
| 478 self.device_dirs.perf_data_dir, self.perf_data_dir) | |
| 479 | |
| 480 self.cleanup() | |
| 481 | |
| 482 def cleanup(self): | |
| 483 if sys.platform == 'win32': | |
| 484 # Kill mspdbsrv.exe, which tends to hang around after the build finishes. | |
| 485 for p in psutil.process_iter(): | |
| 486 try: | |
| 487 if p.name == 'mspdbsrv.exe': | |
| 488 p.kill() | |
| 489 except psutil._error.AccessDenied: | |
| 490 pass | |
| 491 self.flavor.cleanup_steps() | |
| OLD | NEW |